Database adapters

NotifyKit stores all state (recipients, notifications, inbox items, deliveries, preferences) in your database via a pluggable adapter. The memory adapter is great for development; Drizzle adapters provide persistent SQLite and PostgreSQL storage.

Which adapter?

Memory

Dev & tests. Zero config, instant startup, resets on restart. No database required — state lives in-process.

PersistenceNone (in-process)
ConcurrencySingle process
SetupZero — built into core

SQLite

Prototypes & single-server. File-based persistence without a running database process. Fast reads, simple deploys.

PersistenceFile on disk
ConcurrencySingle writer
Setupbetter-sqlite3

PostgreSQL

Persistent multi-instance deployments. Supports concurrent writers and uses your existing Postgres operations and backups.

PersistenceNetworked DB
ConcurrencyMulti-writer
Setuppostgres + connection URL
Start with memory, graduate to Postgres. The adapter swap is a one-line change in lib/notifykit.ts. All behavior stays identical — only the storage layer changes.
1
Your app calls notify.send()

All reads and writes go through the adapter interface — NotifyKit never touches your DB directly.

2
Adapter translates to queries

The adapter maps operations (create inbox item, update preference) to your ORM or raw SQL.

3
Data lives in your database

Same connection pool, same backup strategy, same access controls as the rest of your app.

Memory adapter

Zero-config, no database required. State lives in-process and resets on restart. Perfect for local dev and tests.

import { memoryAdapter } from "@notifykitjs/core"

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

Drizzle SQLite

npm install @notifykitjs/drizzle drizzle-orm better-sqlite3
lib/notifykit.ts
import Database from "better-sqlite3"
import { drizzle } from "drizzle-orm/better-sqlite3"
import { createSqliteTables, drizzleSqliteAdapter } from "@notifykitjs/drizzle"

const sqlite = new Database("app.db")
const db = drizzle(sqlite)

// Create tables (run once, or use drizzle-kit migrations in production)
await createSqliteTables(db)

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

Drizzle PostgreSQL

npm install @notifykitjs/drizzle drizzle-orm postgres
lib/notifykit.ts
import postgres from "postgres"
import { drizzle } from "drizzle-orm/postgres-js"
import { createPgTables, drizzlePostgresAdapter } from "@notifykitjs/drizzle"

const client = postgres(process.env.DATABASE_URL!)
const db = drizzle(client)

// Create tables (run once, or use drizzle-kit migrations)
await createPgTables(db)

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

Connection pool sizing

NotifyKit uses your existing database connection — it doesn't open its own pool. But notification sends add database load, especially under burst traffic. Size the pool from measurements in your deployment, not a generic sends-per-second table.

SignalWhat to inspectPossible response
Connections wait during send burstsPool wait time and pg_stat_activityReduce concurrency, increase the pool within database limits, or queue work
Many application instances exhaust total connectionsAggregate pool size across every instanceUse a pooler and set a smaller per-instance maximum
Queries are slow without pool waitsEXPLAIN ANALYZE, locks, and database I/OTune the query/index or reduce contention before adding connections
// postgres.js — set pool size explicitly
const client = postgres(process.env.DATABASE_URL!, {
  max: 10, // connections in the pool
  idle_timeout: 20, // seconds before idle connections close
  connect_timeout: 10, // seconds to wait for a connection
})
Load test the real pipeline. Query count varies with channels, preferences, rate limits, digests, and fallbacks. Measure the exact notification mix you expect at peak rather than relying on a fixed pool recommendation.

Production migrations

The createPgTables / createSqliteTables helpers are fine for quick setup. For production, export the schema and use drizzle-kit for versioned migrations:

# Generate a migration from the NotifyKit schema
npx drizzle-kit generate

# Apply it (same as your app migrations)
npx drizzle-kit migrate
Why versioned migrations? Future NotifyKit versions may add columns or tables. With drizzle-kit, you get a diff you can review before applying — no surprise schema changes in production.
The preview does not yet ship a versioned migration history. Generate and commit migrations in your application, review the diff on every NotifyKit upgrade, and do not run table-creation helpers during production startup.

Joining NotifyKit tables with your app

The Drizzle adapter exports the full schema so you can join NotifyKit tables against your own:

import { notifyKitSchema } from "@notifykitjs/drizzle"
import { eq } from "drizzle-orm"

// Get all inbox items for a user with their notification details
const items = await db
  .select()
  .from(notifyKitSchema.inboxItems)
  .where(eq(notifyKitSchema.inboxItems.recipientId, user.id))
  .orderBy(notifyKitSchema.inboxItems.createdAt)

Common queries

Patterns you'll likely need when building admin dashboards, analytics, or custom UI on top of NotifyKit data:

QueryUse case
Unread count per userDashboard badges, nav indicators outside React hooks
Failed deliveries in last hourIncident detection, provider health checks
Recipients with no recent inbox itemsChurn detection, stale account cleanup
Delivery success rate by channelProvider monitoring, cost/reliability analysis
import { notifyKitSchema } from "@notifykitjs/drizzle"
import { eq, isNull, gt, sql, and, count } from "drizzle-orm"

const { inboxItems, deliveries, recipients } = notifyKitSchema

// Unread count for a specific user (server-side, outside hooks)
const [{ unread }] = await db
  .select({ unread: count() })
  .from(inboxItems)
  .where(and(
    eq(inboxItems.recipientId, userId),
    isNull(inboxItems.readAt),
    isNull(inboxItems.archivedAt),
  ))

// Failed deliveries in the last hour (incident detection)
const oneHourAgo = new Date(Date.now() - 60 * 60_000)
const failures = await db
  .select()
  .from(deliveries)
  .where(and(
    eq(deliveries.status, "failed"),
    gt(deliveries.failedAt, oneHourAgo),
  ))

// Delivery success rate by channel (last 24h)
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60_000)
const stats = await db
  .select({
    channel: deliveries.channel,
    total: count(),
    sent: sql`count(*) filter (where ${deliveries.status} = 'sent')`,
  })
  .from(deliveries)
  .where(gt(deliveries.createdAt, oneDayAgo))
  .groupBy(deliveries.channel)
Prefer the SDK for user-facing reads. Use direct queries for admin dashboards, analytics jobs, and monitoring — not for building inbox UIs. The SDK handles pagination, scoping, and realtime; raw queries bypass those guarantees.

Schema overview

TableGrowthRetention strategy
notifykit_recipients1 row per user (stable)None needed — grows with user base only
notifykit_notifications1 row per send (linear)Archive or delete after 90 days for analytics-only data
notifykit_inbox_items1 row per send × inbox channelUsers delete via UI. Optionally purge archived items older than 30 days.
notifykit_deliveries1 row per channel per sendUseful for debugging — prune status = 'sent' after 30 days, keep failures longer
notifykit_preferences1 row per (user, notification) pairNone needed — bounded by user count × notification count
notifykit_scheduled_sendsTransient — consumed on flushSelf-cleaning. Rows disappear after quiet hours end.
notifykit_rate_limit_eventsTransient — expires per windowSelf-cleaning. Purge events older than your longest window.
notifykit_digest_buffersTransient — consumed on digest flushSelf-cleaning. Rows disappear after digest window fires.
notifykit_timeline_events3–8 rows per send (fastest grower)pruneTimeline() on a cron — set timelineRetentionMs
Timeline is the table to watch. It grows 3–8× faster than notifications (multiple events per send). Set timelineRetentionMs to 7–14 days and run pruneTimeline() on a daily cron. Without pruning, a 100 sends/day app accumulates ~20k rows/month in this table alone.

Data retention automation

The schema overview above tells you what to prune — here's the how. Set up a daily cron job that cleans up old records without affecting active data:

scripts/prune-notifykit.ts
// Run daily: npx tsx scripts/prune-notifykit.ts
// Or schedule via cron: 0 3 * * * npx tsx scripts/prune-notifykit.ts
import { notify } from "@/lib/notifykit"
import { notifyKitSchema } from "@notifykitjs/drizzle"
import { lt, and, eq, isNotNull } from "drizzle-orm"
import { db } from "@/lib/db"

const { deliveries, inboxItems, timelineEvents } = notifyKitSchema

const RETENTION = {
  timeline: 14,            // days — fastest-growing table
  sentDeliveries: 30,      // days — keep failures longer
  archivedInbox: 30,       // days — user already dismissed these
}

async function prune() {
  const now = Date.now()

  // 1. Timeline events (3-8 rows per send — biggest growth)
  const timelineCutoff = new Date(now - RETENTION.timeline * 86_400_000)
  const timelineResult = await db
    .delete(timelineEvents)
    .where(lt(timelineEvents.createdAt, timelineCutoff))
  console.log(`Timeline: pruned ${timelineResult.rowCount ?? 0} rows (>${RETENTION.timeline}d)`)

  // 2. Successful deliveries (keep failures for debugging)
  const deliveryCutoff = new Date(now - RETENTION.sentDeliveries * 86_400_000)
  const deliveryResult = await db
    .delete(deliveries)
    .where(and(
      eq(deliveries.status, "sent"),
      lt(deliveries.createdAt, deliveryCutoff),
    ))
  console.log(`Deliveries (sent): pruned ${deliveryResult.rowCount ?? 0} rows (>${RETENTION.sentDeliveries}d)`)

  // 3. Archived inbox items (user already read + archived)
  const archiveCutoff = new Date(now - RETENTION.archivedInbox * 86_400_000)
  const archiveResult = await db
    .delete(inboxItems)
    .where(and(
      isNotNull(inboxItems.archivedAt),
      lt(inboxItems.archivedAt, archiveCutoff),
    ))
  console.log(`Archived inbox: pruned ${archiveResult.rowCount ?? 0} rows (>${RETENTION.archivedInbox}d)`)
}

prune().catch(err => {
  console.error("Prune failed:", err)
  process.exit(1)
})
TableWhat gets prunedSafe because
timeline_eventsAll events older than 14 daysTimeline is for debugging — old events have no runtime effect
deliveriesOnly status: "sent" older than 30 daysFailed deliveries stay for investigation; sent records are just audit trail
inbox_itemsOnly archived items older than 30 daysUser already dismissed them; active/unread items are never touched
Run during low-traffic hours. Large DELETEs can lock rows and spike I/O. Schedule the cron for 3 AM (or your quietest window). For tables with 100k+ rows to prune, batch with LIMIT 10000 in a loop to avoid long-running transactions.
Monitor what you prune. Log row counts to your metrics system. A sudden jump in pruned rows means either a traffic spike (normal) or a bug creating excessive records (investigate). Flat-lining at zero means the job stopped running.

Custom adapter

Implement the DatabaseAdapter interface to use any database (Prisma, Kysely, raw SQL, DynamoDB, etc). The interface is split into logical sections:

SectionMethodsPriority
Recipientsupsert, findByIdRequired
Notificationscreate, findByIdempotencyKey, clearIdempotencyKeyRequired
Inboxcreate, list/count, recipient-safe mutationsRequired
Deliveriescreate, findById, list, updateRequired
Preferencesget, list, upsertRequired
Rate limitsreserve, countRequired; reservation must be atomic
Digestsappend, take, restore, listRequired
Scheduledcreate, claim, complete, release, list methodsRequired
Dedupecheck, exists, pruneRequired; check-and-insert must be atomic
Timelineappend, list, prune methodsOptional; enables persisted diagnostics

Implementation approach

Import DatabaseAdapter and implement its nested stores: recipients, notifications, inbox, deliveries, preferences, digests, rateLimits, scheduledSends, and dedupe. The optional timeline store enables persisted diagnostics. TypeScript reports every missing method and its exact input/result contract.

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

export function customAdapter(): DatabaseAdapter {
  return {
    recipients: createRecipientStore(),
    notifications: createNotificationStore(),
    inbox: createInboxStore(),
    deliveries: createDeliveryStore(),
    preferences: createPreferenceStore(),
    digests: createDigestStore(),
    rateLimits: createRateLimitStore(),
    scheduledSends: createScheduledSendStore(),
    dedupe: createDedupeStore(),
    timeline: createTimelineStore(), // optional
  }
}
Use the shipped adapters as executable specifications. Copy memoryAdapter() for the simplest complete contract, or the SQLite/Postgres adapter matching your concurrency needs. Atomic dedupe, rate-limit reservations, digest take/restore, and scheduled-send claims are correctness boundaries.

Implementation tips

ConcernGuidance
ID generationAdapter create methods receive inputs without generated IDs/timestamps. Generate them consistently with the reference adapters.
Scope filteringApply every supplied tenantId and workspaceId on reads and mutations. Scope handling is a security boundary.
Atomic operationsImplement dedupe.check, rateLimits.reserve, digests.take, and scheduledSends.claim atomically across workers.
Failure recoveryDigest restore must preserve payload order, and scheduled sends must be completed only after delivery succeeds.
TestingRun the same behavioral scenarios as memoryAdapter(), including concurrency, tenant isolation, and claim/release recovery.

Switching adapters by environment

A common pattern: use memory for tests (fast, isolated, no cleanup), SQLite for local dev (persistent between restarts), and Postgres in production.

import { memoryAdapter } from "@notifykitjs/core"
import { drizzleSqliteAdapter } from "@notifykitjs/drizzle"
import { drizzlePostgresAdapter } from "@notifykitjs/drizzle"

function getAdapter() {
  if (process.env.NODE_ENV === "test") return memoryAdapter()
  if (process.env.DATABASE_URL) return drizzlePostgresAdapter(pgDb)
  return drizzleSqliteAdapter(sqliteDb)
}

export const notify = createNotifyKit({
  notifications: [...] as const,
  database: getAdapter(),
})
Memory keeps unit tests isolated. There is no disk I/O, connection pool, or teardown. Each test gets a fresh memoryAdapter() with zero state — no cleanup needed between runs.

Performance at scale

NotifyKit queries are straightforward — but as volume grows, certain access patterns become slow without proper indexing or query tuning. Use this table to diagnose common performance symptoms:

SymptomLikely causeFix
Inbox list slow for high-volume usersMissing composite index on (recipientId, archivedAt, createdAt)Add the index — the Drizzle adapter creates it by default, but custom adapters may not
send() latency spikes during broadcastsConnection pool exhausted — too many concurrent writesBatch with Promise.allSettled in chunks of 10–20, or use an external queue
flushDigests() slows as the buffer growsLarge digest buffer table with no index on expiresAtAdd index on (expiresAt) — the flush query scans by expiry time
unreadCount() regresses as inbox history growsTable scan on inbox_items for users with 1000+ itemsThe default index covers this, but verify with EXPLAIN ANALYZE
Timeline queries slow after months of dataTimeline table grew unbounded — no pruning configuredRun pruneTimeline() on a cron and set timelineRetentionMs
Preference resolution dominates send latencyMultiple round-trips for global + category + notification preferencesThe Drizzle adapter fetches all preferences for a recipient in one query — verify your custom adapter does the same

Key indexes for PostgreSQL

The Drizzle adapter creates these automatically. If you're using a custom adapter, verify they exist:

-- Inbox queries (list by recipient, filter archived, sort by time)
CREATE INDEX idx_inbox_recipient_active
  ON notifykit_inbox_items (recipient_id, created_at DESC)
  WHERE archived_at IS NULL;

-- Delivery queries (find failures for incident investigation)
CREATE INDEX idx_deliveries_status_time
  ON notifykit_deliveries (status, created_at DESC)
  WHERE status = 'failed';

-- Timeline pruning (prune by age without full table scan)
CREATE INDEX idx_timeline_created
  ON notifykit_timeline_events (created_at);

-- Rate limit counting (sliding window lookups)
CREATE INDEX idx_rate_limits_key_time
  ON notifykit_rate_limit_events (key, created_at);
Measure before you optimize. Run EXPLAIN ANALYZE on slow queries before adding indexes. Establish a baseline with your data volume and alert on meaningful regression against that baseline.

Migration checklist

Moving from memory → production adapter? Walk through this:

StepActionVerify
1Install @notifykitjs/drizzle + your drivernpm ls @notifykitjs/drizzle shows it installed
2Run createPgTables(db) or generate a migrationAll notifykit_* tables exist in your database
3Swap memoryAdapter() for the Drizzle adapterApp boots without errors
4Send a test notificationRow appears in notifykit_notifications
5Open the inbox UIItem renders — confirms reads work too
Memory data doesn't migrate. When you switch adapters, all existing inbox items, preferences, and delivery records start fresh. Plan the switch before you have real user data — or export and replay via upsertRecipient() and preference writes.