nestjs-notifications
Channels

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-database
npm install @dudousxd/nestjs-notifications-database

Register the channel

app.module.ts
import { DatabaseChannelModule } from '@dudousxd/nestjs-notifications-database';

@Module({
  imports: [
    DatabaseChannelModule.forRoot(), // bundled in-memory store
  ],
})
export class AppModule {}

DatabaseChannelModule.forRoot() takes:

OptionTypeDefaultDescription
storeType<NotificationStore>InMemoryStoreStore class to instantiate.
importsDynamicModule['imports'][]Extra modules to import (e.g. the ORM module the store depends on).
globalbooleantrueRegister 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:

invoice-paid.notification.ts
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:

  1. routeNotificationFor('database') may return a { type, id } reference directly.
  2. Otherwise it calls the notifiable's toNotifiableRef().

If neither is available it throws, asking you to provide one:

user.ts
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:

MethodReturnsDescription
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 typeorm
npm install @dudousxd/nestjs-notifications-database-typeorm @nestjs/typeorm typeorm

Add the adapter's NotificationEntity to your TypeOrmModule.forRoot() entities (or migrations), then wire the store and channel:

app.module.ts
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/nestjs
npm install @dudousxd/nestjs-notifications-database-mikro-orm @mikro-orm/core @mikro-orm/decorators @mikro-orm/nestjs

Targets 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:

app.module.ts
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-prisma
npm install @dudousxd/nestjs-notifications-database-prisma

Prisma's client is app-owned, so you pass your instance in. Add a Notification model to your schema.prisma:

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():

app.module.ts
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-orm
npm install @dudousxd/nestjs-notifications-database-drizzle drizzle-orm

Postgres 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:

app.module.ts
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:

app.module.ts
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.

migrations/1700000000000-AddNotifications.ts
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).

migrations/AddNotifications.ts
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-notifications

Spread 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:

db/schema.ts
import { createNotificationTables } from '@dudousxd/nestjs-notifications-database-drizzle';

export const notificationTables = createNotificationTables(); // or { tableNames, schema }
export const { notifications, pendingDigests, digestWindows } = notificationTables;
app.module.ts
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:

  • autoCreateSchema on (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's ensureSchema() runs CREATE 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 the CREATE INDEX, as does MikroORM's own migration:create when NotificationEntity is discovered.

  • Prisma: add the @@index line to the model above and run prisma migrate dev.

  • Drizzle with drizzle-kit: the index is part of createNotificationTables() (named <table>_notifiable_idx when you pass custom tableNames), 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() },

On this page