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.
| Adapter | Keeps data | Good for |
|---|---|---|
memoryAdapter() | Until the process exits | Tests and first prototypes; built into the core |
drizzleSqliteAdapter(db) | In a file | Single-server apps and local development |
drizzlePostgresAdapter(db) | In Postgres | Production 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(),
})npm install @notifykitjs/drizzle drizzle-orm better-sqlite3import Database from "better-sqlite3"
import { drizzle } from "drizzle-orm/better-sqlite3"
import { drizzleSqliteAdapter } from "@notifykitjs/drizzle"
const db = drizzle(new Database("app.db"))
export const notify = createNotifyKit({
// ...
database: drizzleSqliteAdapter(db),
})Any Drizzle SQLite database works, synchronous or not, including bun:sqlite and libSQL.
npm install @notifykitjs/drizzle drizzle-orm pgimport { Pool } from "pg"
import { drizzle } from "drizzle-orm/node-postgres"
import { drizzlePostgresAdapter } from "@notifykitjs/drizzle"
const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }))
export const notify = createNotifyKit({
// ...
database: drizzlePostgresAdapter(db),
})Any Drizzle Postgres database works, so postgres (postgres.js) with drizzle-orm/postgres-js is fine too.
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:
export * from "@notifykitjs/drizzle/schema/postgres" // or /schema/sqlite
export * from "./app-tables"npx drizzle-kit generate
npx drizzle-kit migrateSchema
Every table is prefixed notifykit_, with the same names in SQLite and Postgres:
| Table | Holds | Grows |
|---|---|---|
recipients | Id, name, email, phone, and quiet hours with their time zone | With your users |
notifications | One row per send, with its idempotency key | With every send |
inbox_items | What the inbox shows | With every inbox send |
deliveries | One row per email, SMS, or webhook, with status pending, sent, failed, or skipped | With every send, per channel |
preferences | Channel choices per recipient and notification, category, or globally | Bounded |
scheduled_sends | Sends held for quiet hours | Rows clear as they are sent |
digest_buffers | Open digest batches | Rows clear when a digest goes out |
rate_limit_events | Sends counted toward a rate limit | Within the window |
dedupe_records | Deduplication keys, with an expiry | Within the window |
timeline_events | The delivery timeline, several rows per send | Fastest; 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:
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
tenantIdandworkspaceIdyou 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, andscheduledSends.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.