Database
Persist notifications so you can show an in-app feed. A NotificationStore interface, a bundled in-memory store, and TypeORM / MikroORM / Prisma / Drizzle adapters.
The database channel writes a notification to a store so you can render an in-app feed — an unread
count, a notifications dropdown, a "mark all as read" button. It persists through a
NotificationStore abstraction: a bundled in-memory store for development, and ORM adapters
(TypeORM, MikroORM, Prisma, Drizzle) for production. The row shape mirrors Laravel's notifications table.
Install
pnpm add @dudousxd/nestjs-notifications-databasenpm install @dudousxd/nestjs-notifications-databaseRegister the channel
import { DatabaseChannelModule } from '@dudousxd/nestjs-notifications-database';
@Module({
imports: [
DatabaseChannelModule.forRoot(), // bundled in-memory store
],
})
export class AppModule {}DatabaseChannelModule.forRoot() takes:
| Option | Type | Default | Description |
|---|---|---|---|
store | Type<NotificationStore> | InMemoryStore | Store class to instantiate. |
imports | DynamicModule['imports'] | [] | Extra modules to import (e.g. the ORM module the store depends on). |
global | boolean | true | Register globally so the channel is discoverable app-wide. |
There's also DatabaseChannelModule.forFeature(), which registers only the channel and reuses a
NOTIFICATION_STORE token provided elsewhere — that's how the ORM adapter modules below wire in.
The notification side
Annotate the payload method with the @Database() handle and return a plain object — that object
becomes the persisted data:
import { type Notifiable, Notification } from '@dudousxd/nestjs-notifications-core';
import { Database } from '@dudousxd/nestjs-notifications-database';
@Notification()
export class InvoicePaid {
constructor(private invoiceId: string, private amount: number) {}
@Database()
toDatabase({ notifiable }: ChannelContext): Record<string, unknown> {
return { invoiceId: this.invoiceId, amount: this.amount };
}
}The Database handle also works as a via() token (via() { return [Database]; }) for
explicit routing. For
compile-time checks, implement DatabaseNotification alongside the decorator.
toDatabase() is optional when you route with an explicit via(). If it's absent the channel falls
back to toArray() (Laravel parity), and finally to a structural copy of the notification's own
properties.
The notifiable reference
Persisting needs to know whose notification this is, as a { type, id } pair. The channel resolves
it in two ways:
routeNotificationFor('database')may return a{ type, id }reference directly.- Otherwise it calls the notifiable's
toNotifiableRef().
If neither is available it throws, asking you to provide one:
export class User implements Notifiable {
constructor(public id: number) {}
routeNotificationFor() {
return undefined;
}
toNotifiableRef() {
return { type: 'User', id: this.id };
}
}Stored shape
Each persisted notification is a StoredNotification:
interface StoredNotification {
id: string;
type: string; // notification class name, e.g. "InvoicePaid"
notifiableType: string; // reference type, e.g. "User"
notifiableId: string; // reference id
data: Record<string, unknown>; // what toDatabase() returned
readAt: Date | null;
createdAt: Date;
updatedAt: Date;
}The stored type is the notification's notificationName(notification) — an instance-level
notificationType() override when implemented, else the class-level @Notification({ name }),
else the class name. See notificationType()
for when to reach for the instance override (e.g. one generic notification class carrying many
event names). This is also the value the ?type= inbox filter
matches against.
The store
A store implements NotificationStore. These are the methods you get for reading and managing a
feed:
| Method | Returns | Description |
|---|---|---|
save(notification) | Promise<StoredNotification> | Persist a new row (the channel calls this). |
markAsRead(id) | Promise<void> | Mark one notification read. |
markAllAsRead(notifiableType, notifiableId) | Promise<void> | Mark every unread row for a notifiable read. |
getForNotifiable(notifiableType, notifiableId, types?) | Promise<StoredNotification[]> | All notifications for a notifiable, newest first. Optional types: string[] restricts to matching type values. |
getUnread(notifiableType, notifiableId, types?) | Promise<StoredNotification[]> | Unread notifications for a notifiable. Same optional types filter. |
delete(id) | Promise<void> | Remove one notification. |
deleteOwned(id, owner) | Promise<boolean> | Optional. Remove one notification only when it belongs to owner ({ notifiableType, notifiableId, tenantId? }); true when a row matched. |
markAsReadOwned(id, owner) | Promise<boolean> | Optional. Mark one read only when it belongs to owner; true when a row matched, including an already-read one. |
findById(id) | Promise<StoredNotification | null> | Optional. Fetch one row — the ownership fallback when the two scoped mutations are absent. |
countUnread(notifiableType, notifiableId, tenantId?, types?) | Promise<number> | Optional. Count unread rows in the database (a COUNT, no rows loaded). unreadCount() uses it when present and otherwise counts the rows getUnread returns. |
types is absent or an empty array by default, matching every type (unchanged behavior) — it's
purely additive. PaginateForNotifiableOptions carries the same types?: string[] for the
paginated read path. Every bundled adapter (in-memory, TypeORM, MikroORM, Prisma, Drizzle) implements the
filter; see In-app notifications for the higher-level
NotificationsQueryService and REST ?type= query param this powers.
Every bundled store implements countUnread. A custom store should implement it too: the React
inbox polls the unread count, and without it each poll loads every unread row and its payload.
The store is bound to the NOTIFICATION_STORE token, so inject it to build your feed endpoints:
import { Inject } from '@nestjs/common';
import { NOTIFICATION_STORE, type NotificationStore } from '@dudousxd/nestjs-notifications-database';
constructor(@Inject(NOTIFICATION_STORE) private store: NotificationStore) {}The bundled InMemoryStore keeps rows in a Map. It's perfect for tests and prototyping — but it's
not durable, so use a persistence adapter in production.
Reading notifications in-app
You usually don't inject the raw store. The NotificationsQueryService (provided by the database
module) wraps it with the read/manage methods you reach for — all, unread, unreadCount,
paginate, markAsRead, markAllAsRead, delete — accepting a notifiable or a { type, id } ref.
There's also an optional REST controller you can mount in one call.
See In-app notifications for the full recipe.
Persistence adapters
For real persistence, pair the channel with an adapter. Each adapter provides a store bound to the
NOTIFICATION_STORE token, plus the schema it needs (a NotificationEntity for the ORM adapters, a
Notification model for Prisma). You register the adapter's store module alongside
DatabaseChannelModule.forFeature() — the adapter provides the store token, forFeature() registers
the channel that consumes it.
pnpm add @dudousxd/nestjs-notifications-database-typeorm @nestjs/typeorm typeormnpm install @dudousxd/nestjs-notifications-database-typeorm @nestjs/typeorm typeormAdd the adapter's NotificationEntity to your TypeOrmModule.forRoot() entities (or migrations),
then wire the store and channel:
import { DatabaseChannelModule } from '@dudousxd/nestjs-notifications-database';
import { TypeOrmNotificationStoreModule } from '@dudousxd/nestjs-notifications-database-typeorm';
@Module({
imports: [
// ...TypeOrmModule.forRoot({ entities: [NotificationEntity, ...] })
TypeOrmNotificationStoreModule.forFeature(),
DatabaseChannelModule.forFeature(),
],
})
export class AppModule {}TypeOrmNotificationStoreModule.forFeature() registers TypeOrmModule.forFeature([NotificationEntity])
internally and binds TypeOrmNotificationStore to NOTIFICATION_STORE. The entity (NotificationEntity)
is exported if you'd rather register it yourself.
pnpm add @dudousxd/nestjs-notifications-database-mikro-orm @mikro-orm/core @mikro-orm/decorators @mikro-orm/nestjsnpm install @dudousxd/nestjs-notifications-database-mikro-orm @mikro-orm/core @mikro-orm/decorators @mikro-orm/nestjsTargets MikroORM v7 (peers ^7). It needs @mikro-orm/decorators because v7 moved the
decorators out of @mikro-orm/core; the adapter's entity uses the legacy (TypeScript
experimental) decorators, matching the rest of a NestJS app.
Register the adapter's NotificationEntity with MikroORM, then wire the store and channel:
import { DatabaseChannelModule } from '@dudousxd/nestjs-notifications-database';
import { MikroOrmNotificationStoreModule } from '@dudousxd/nestjs-notifications-database-mikro-orm';
@Module({
imports: [
// ...MikroOrmModule.forRoot({ entities: [NotificationEntity, ...] })
MikroOrmNotificationStoreModule.forFeature(),
DatabaseChannelModule.forFeature(),
],
})
export class AppModule {}MikroOrmNotificationStoreModule.forFeature() registers MikroOrmModule.forFeature([NotificationEntity])
and binds MikroOrmNotificationStore to NOTIFICATION_STORE.
pnpm add @dudousxd/nestjs-notifications-database-prismanpm install @dudousxd/nestjs-notifications-database-prismaPrisma's client is app-owned, so you pass your instance in. Add a Notification model to your
schema.prisma:
model Notification {
id String @id
type String
notifiableType String
notifiableId String
data Json
tenantId String?
readAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Backs the inbox reads and the unread count (filter by notifiable, newest first).
@@index([notifiableType, notifiableId, createdAt])
}A real PrismaClient structurally satisfies the adapter's client type, so pass it directly to
forRoot():
import { DatabaseChannelModule } from '@dudousxd/nestjs-notifications-database';
import { PrismaNotificationStoreModule } from '@dudousxd/nestjs-notifications-database-prisma';
@Module({
imports: [
PrismaNotificationStoreModule.forRoot({ client: prisma }),
DatabaseChannelModule.forFeature(),
],
})
export class AppModule {}PrismaNotificationStoreModule.forRoot({ client }) binds the client to the PRISMA_CLIENT token
and PrismaNotificationStore to NOTIFICATION_STORE. If you already provide PRISMA_CLIENT
elsewhere (e.g. a shared PrismaModule), use PrismaNotificationStoreModule.forFeature() instead.
pnpm add @dudousxd/nestjs-notifications-database-drizzle drizzle-ormnpm install @dudousxd/nestjs-notifications-database-drizzle drizzle-ormPostgres only. Any Drizzle Postgres driver works (node-postgres, postgres-js, PGlite, Neon, and
so on). The Drizzle database belongs to your app, so you pass your instance in. The easiest way is
forRootAsync from the token your app already exposes:
import { DatabaseChannelModule } from '@dudousxd/nestjs-notifications-database';
import { DrizzleNotificationStoreModule } from '@dudousxd/nestjs-notifications-database-drizzle';
@Module({
imports: [
DrizzleNotificationStoreModule.forRootAsync({
inject: [DB], // your Drizzle database token
useFactory: (db: Db) => ({ db }),
}),
DatabaseChannelModule.forFeature(),
],
})
export class AppModule {}If the database is registered with @nestjs/drizzle's
DrizzleModule, name its connection instead ('default' for the unnamed one); the store injects
getDrizzleToken(connectionName):
DrizzleModule.forRoot({ drizzle, connection: process.env.DATABASE_URL }),
DrizzleNotificationStoreModule.forRoot({ connectionName: 'default' }),
// or async: the connection name is static, the factory resolves the rest
DrizzleNotificationStoreModule.forRootAsync({
connectionName: 'default',
inject: [ConfigService],
useFactory: (config: ConfigService) => ({ tables: tablesFor(config) }),
}),With connectionName, the factory returns { tables? } only: returning db as well throws.
DrizzleNotificationStoreModule binds DrizzleNotificationStore to NOTIFICATION_STORE. The
tables are plain pgTables from createNotificationTables(). Columns are snake_case, as in
Laravel's table. You can also pass { tableNames, schema }. If you already provide
DRIZZLE_NOTIFICATIONS_DB from a shared module, use forFeature(). upsert is a single
INSERT … ON CONFLICT, and the digest window lock is an ON CONFLICT DO NOTHING RETURNING.
Neither adapter fits your schema? Implement NotificationStore yourself and pass it as the store
option to DatabaseChannelModule.forRoot(). The interface is six methods.
Schema
By default the channel is self-contained: on boot it creates the notifications table if it's
missing, non-destructively — it only adds what's absent and never drops anything. That's
autoCreateSchema: true, the default on both DatabaseChannelModule.forRoot() and
DatabaseChannelModule.forFeature(). Install an adapter, register the module, and the feed works on
first run with no migration step.
Auto-create is non-destructive — it only creates missing tables, columns and indexes, so it's safe
to leave on. Turn it off (autoCreateSchema: false) for strict, migration-only environments where the app
must never touch DDL.
To own the schema with your ORM's migrations instead, disable auto-create and use the exported migration helpers:
DatabaseChannelModule.forFeature({ autoCreateSchema: false }),Call createNotificationsTable(queryRunner) inside a TypeORM migration. It's non-destructive
(creates the table only if it doesn't exist) and driver-portable — the column types come from the
entity metadata. It leaves an existing table alone; see the inbox index for
adding the index to one.
import { createNotificationsTable } from '@dudousxd/nestjs-notifications-database-typeorm';
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddNotifications1700000000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await createNotificationsTable(queryRunner);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('notifications', true);
}
}Get the non-destructive SQL with notificationsSchemaSql(em) and add it to a MikroORM
Migration. It returns only the statements needed to bring the notifications table up to date
(create it, add missing columns and indexes).
import { notificationsSchemaSql } from '@dudousxd/nestjs-notifications-database-mikro-orm';
import { Migration } from '@mikro-orm/migrations';
export class AddNotifications extends Migration {
async up(): Promise<void> {
this.addSql(await notificationsSchemaSql(this.getEntityManager()));
}
}Prisma is schema-first, so autoCreateSchema is a no-op for the Prisma adapter — it won't run
DDL. Add the Notification model to your schema.prisma and apply it
with prisma migrate:
npx prisma migrate dev --name add-notificationsSpread the tables into the schema file that drizzle.config.ts points at, and drizzle-kit
generates the migration. Give the store the same object, so the queries and the migration
come from one definition:
import { createNotificationTables } from '@dudousxd/nestjs-notifications-database-drizzle';
export const notificationTables = createNotificationTables(); // or { tableNames, schema }
export const { notifications, pendingDigests, digestWindows } = notificationTables;DrizzleNotificationStoreModule.forRootAsync({
inject: [DB],
useFactory: (db: Db) => ({ db, tables: notificationTables }),
}),
DatabaseChannelModule.forFeature({ autoCreateSchema: false }),If you don't use drizzle-kit, leave autoCreateSchema on. ensureSchema() runs
CREATE TABLE/INDEX IF NOT EXISTS. notificationsSchemaDdl() / pendingDigestSchemaDdl() give
you the same statements for a hand-written migration.
The inbox index
Every adapter declares notifications_notifiable_idx on (notifiable_type, notifiable_id, created_at). It serves the inbox reads (WHERE notifiable = ? ORDER BY created_at DESC) and the
unread count. The tenant column is not part of it, so reads without a tenant keep the
created_at ordering from the index.
How an existing notifications table gets it:
-
autoCreateSchemaon (TypeORM, MikroORM, Drizzle): the next boot creates it. TypeORM matches indexes by name and only adds missing ones; MikroORM's schema fingerprint changes, so the table is healed once; Drizzle'sensureSchema()runsCREATE INDEX IF NOT EXISTS. -
TypeORM migrations:
createNotificationsTable()does nothing when the table exists, so add the index in a new migration. Building it from the entity metadata keeps the column names right under any naming strategy:migrations/1730000000000-AddNotificationsInboxIndex.ts import { NotificationEntity } from '@dudousxd/nestjs-notifications-database-typeorm'; import { type MigrationInterface, type QueryRunner, TableIndex } from 'typeorm'; const INDEX = 'notifications_notifiable_idx'; export class AddNotificationsInboxIndex1730000000000 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise<void> { const metadata = queryRunner.connection.getMetadata(NotificationEntity); const index = metadata.indices.find((i) => i.name === INDEX); if (index) await queryRunner.createIndex('notifications', TableIndex.create(index)); } async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropIndex('notifications', INDEX); } } -
MikroORM migrations: a new migration with
this.addSql(await notificationsSchemaSql(this.getEntityManager()))emits theCREATE INDEX, as does MikroORM's ownmigration:createwhenNotificationEntityis discovered. -
Prisma: add the
@@indexline to the model above and runprisma migrate dev. -
Drizzle with drizzle-kit: the index is part of
createNotificationTables()(named<table>_notifiable_idxwhen you pass customtableNames), so drizzle-kit migrations include it.
Building the index locks writes on a large table for the duration of the build (a plain
CREATE INDEX on Postgres), and with autoCreateSchema on it runs during boot. On a big table,
create it yourself beforehand, for example with CREATE INDEX CONCURRENTLY on Postgres, using the
same name notifications_notifiable_idx and your table's actual column names; the boot then finds
it and skips it.
Managed tables (schema-diff tooling)
The MikroORM, TypeORM and Drizzle adapters export notificationsManagedTables() — the table name(s) this
store creates/manages (['notifications']), derived from the same set ensureSchema() scopes its
fingerprint to. Feed it to your ORM's schema-diff exclude list (e.g. MikroORM's
schemaGenerator.skipTables) so a consumer never hand-maintains this table name separately —
mirroring durableManagedTables() / telescopeManagedTables() from the sibling @dudousxd
ecosystem libs.
import { notificationsManagedTables } from '@dudousxd/nestjs-notifications-database-mikro-orm';
schemaGenerator: { skipTables: notificationsManagedTables() },Send email notifications. A fluent MailMessage builder and renderers (Markdown, React Email, MJML) on top of @nestjs/mail, which owns transports, retries and events.
Broadcast
Push notifications to the browser in real time over socket.io. Each notifiable gets its own room; pair it with the database channel for a live in-app feed.