BackProduction

Database adapters

Keep recipients, deliveries, inbox items, and preferences in memory, SQLite, or Postgres.

Choosing

Everything NotifyKit stores goes through the adapter you pass as database. Your definitions and client code are the same whichever you choose, so moving from one to another is a one-line change.

AdapterKeeps dataGood for
memoryAdapter()Until the process exitsTests and first prototypes; built into the core
drizzleSqliteAdapter(db)In a fileSingle-server apps and local development
drizzlePostgresAdapter(db)In PostgresProduction and anything with more than one server

Data does not move between adapters: switching from memory starts with an empty inbox. Make the switch before you have real users.

Setup

import { createNotifyKit, memoryAdapter } from "@notifykitjs/core"

export const notify = createNotifyKit({
	// ...
	database: memoryAdapter(),
})

NotifyKit uses the connection you give it and opens none of its own, so its queries share your pool. A send makes several queries, more with preferences, rate limits, digests, and fallbacks turned on; size the pool by measuring your real mix at peak, not from a rule of thumb.

Tables and Migrations

createSqliteTables(db) and createPgTables(db) create every table and index if they do not exist. They are fine for development and tests:

import { createPgTables } from "@notifykitjs/drizzle"

await createPgTables(db)

In production, manage the tables with drizzle-kit like the rest of your schema. Re-export the NotifyKit schema from a file your Drizzle config already reads, and its tables appear in your generated migrations:

db/schema.ts
export * from "@notifykitjs/drizzle/schema/postgres" // or /schema/sqlite
export * from "./app-tables"
npx drizzle-kit generate
npx drizzle-kit migrate

Schema

Every table is prefixed notifykit_, with the same names in SQLite and Postgres:

TableHoldsGrows
recipientsId, name, email, phone, and quiet hours with their time zoneWith your users
notificationsOne row per send, with its idempotency keyWith every send
inbox_itemsWhat the inbox showsWith every inbox send
deliveriesOne row per email, SMS, or webhook, with status pending, sent, failed, or skippedWith every send, per channel
preferencesChannel choices per recipient and notification, category, or globallyBounded
scheduled_sendsSends held for quiet hoursRows clear as they are sent
digest_buffersOpen digest batchesRows clear when a digest goes out
rate_limit_eventsSends counted toward a rate limitWithin the window
dedupe_recordsDeduplication keys, with an expiryWithin the window
timeline_eventsThe delivery timeline, several rows per sendFastest; pruned automatically

The timeline prunes itself: events older than timelineRetentionMs (7 days by default) are removed now and then as new ones are written. Set it to 0 to keep everything, and call notify.pruneTimeline(olderThan?) yourself when you want to clear it.

Inbox items are indexed on recipient with archived_at and created_at for the list, and on recipient with read_at and archived_at for the unread count. A custom adapter needs equivalents.

Querying the Tables

The tables are yours, so admin pages, analytics, and monitoring can query them directly. The schema is exported as notifyKitSchema for SQLite and notifyKitPgSchema for Postgres:

import { notifyKitPgSchema } from "@notifykitjs/drizzle"
import { and, count, eq, gt } from "drizzle-orm"

const { deliveries } = notifyKitPgSchema
const hourAgo = new Date(Date.now() - 60 * 60_000)

// Failed deliveries in the last hour
const failures = await db
	.select()
	.from(deliveries)
	.where(and(eq(deliveries.status, "failed"), gt(deliveries.failedAt, hourAgo)))

// Sends per channel and status over the last day
const byChannel = await db
	.select({ channel: deliveries.channel, status: deliveries.status, n: count() })
	.from(deliveries)
	.where(gt(deliveries.createdAt, new Date(Date.now() - 86_400_000)))
	.groupBy(deliveries.channel, deliveries.status)

For anything a user sees, use notify.inbox and notify.preferences instead. They apply tenant and workspace scoping and publish realtime events; raw queries do neither.

Pruning

Nothing but the timeline is pruned for you. A nightly job can remove sent deliveries and long-archived inbox items:

scripts/prune-notifykit.ts
const { deliveries, inboxItems } = notifyKitPgSchema
const days = (n: number) => new Date(Date.now() - n * 86_400_000)

await db.delete(deliveries)
	.where(and(eq(deliveries.status, "sent"), lt(deliveries.createdAt, days(30))))
await db.delete(inboxItems)
	.where(and(isNotNull(inboxItems.archivedAt), lt(inboxItems.archivedAt, days(30))))

Keep failed deliveries longer; they are what you debug with. On large tables, delete in batches during a quiet hour.

Custom Adapters

To store NotifyKit’s state somewhere else, such as Prisma, Kysely, or DynamoDB, implement DatabaseAdapter from the core. TypeScript lists every method you still owe and its exact contract.

import type { DatabaseAdapter } from "@notifykitjs/core"

export function myAdapter(): DatabaseAdapter {
	return {
		recipients, notifications, inbox, deliveries, preferences,
		digests, rateLimits, scheduledSends, dedupe,
		timeline, // optional: without it, the delivery timeline is not stored
	}
}
  • Apply the tenantId and workspaceId you are given on every read and write. Scoping is a security boundary, not a filter.
  • Four operations must be atomic across workers: dedupe.check, rateLimits.reserve, digests.take, and scheduledSends.claim. Otherwise two servers can both send.
  • Restoring a digest must keep its payloads in order, and a scheduled send is completed only after it is delivered.
  • Use memoryAdapter() as the reference for behaviour, and run your adapter through the same scenarios: concurrency, tenant isolation, and claim and release.