Skip to content

Package reference

Mirrors the package README (single source). Install @basaltkit/events-sqlite v1.2.0 — npm · source.

<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>

@basaltkit/events-sqlite ​

Durable, SQLite-backed implementation of the @basaltkit/events OutboxStore (the transactional outbox), on Node's built-in node:sqlite. Zero external dependencies.

The whole point of the transactional outbox is to survive a crash between "committed" and "delivered" — so its store has to be durable. @basaltkit/events ships MemoryOutboxStore by default, which loses every un-relayed event when the process exits. This package is the drop-in durable replacement for a single node; the production, multi-instance counterpart is @basaltkit/events-prisma.

Installation ​

bash
pnpm add @basaltkit/events @basaltkit/events-sqlite

Requires Node 22.5+ (node:sqlite is stable and flag-free on Node 24; on Node 22.x run with --experimental-sqlite).

Usage ​

sqliteOutboxStore() opens (or creates) the database, applies an idempotent schema, and returns the store named to drop straight into outboxPlugin:

ts
import { outboxPlugin } from '@basaltkit/events'
import { sqliteOutboxStore } from '@basaltkit/events-sqlite'

const outbox = sqliteOutboxStore('./data/outbox.db') // ':memory:' by default

outboxPlugin({
  store: outbox.store,
  captureEvents: ['order.*', 'invoice.*'], // record these domain events durably
  dispatch: async (entry) => sendToWebhook(entry), // relay to the outside world
  intervalMs: 1000, // flush on a timer
})

To commit an event atomically with your data, keep your tables in the same database and pass the handle running the transaction as tx:

ts
const { db, store } = sqliteOutboxStore('./data/app.db')
const outbox = new Outbox(store) // or container.get(OUTBOX)

db.exec('BEGIN')
try {
  db.prepare(`UPDATE orders SET status = 'paid' WHERE id = ?`).run(id)
  await outbox.enqueue('order.paid', { id }, { tx: db })
  db.exec('COMMIT')
} catch (error) {
  db.exec('ROLLBACK') // the outbox row goes with it
  throw error
}

tx may be the store's own handle or another DatabaseSync connection to the same file (the app's).

Domain events matching captureEvents are written to SQLite as they fire; the relay delivers each at least once and marks it published. A crash between capture and delivery loses nothing — pending entries are still there on restart.

The model ​

One outbox table holds each entry: event, JSON payload, optional tenant_id, created_at, attempts, published_at, last_error, and the relay claim — locked_until / locked_by (added in place by migrate() on older databases). A partial index on un-published rows keeps the relay's "what's pending?" scan cheap no matter how much published history accumulates.

SqliteOutboxStore implements the full OutboxStore contract — enqueue (optionally on { tx }), claim (one conditional UPDATE … WHERE locked_until IS NULL OR locked_until <= now, atomic even across processes sharing the file — so several relays never dispatch the same entry), pending(limit, maxAttempts, filter?) (unpublished, below the attempt ceiling, oldest first; filter excludes tenants — NULL-safe, so tenant-less rows are only dropped by excludeGlobal — which lets the relay stay fair across tenants), markPublished, markFailed (increments attempts), all. Re-enqueuing the same id replaces the entry (INSERT OR REPLACE: attempts reset to 0, publish/error cleared), mirroring MemoryOutboxStore. sqliteOutboxStore() also exposes the raw db handle.

API reference ​

ExportSignaturePurpose
sqliteOutboxStore(dbOrLocation?: DatabaseSync | string) => { db, store }The one you want. Opens (or reuses) the database, migrates it, returns { db, store } — drop store into outboxPlugin({ store }).
openOutboxDatabase(location?: string) => DatabaseSyncOpens and migrates a database without building the store.
migrate(db: DatabaseSync) => voidApplies the idempotent schema to a DatabaseSync you already own. Safe to call repeatedly.
SqliteOutboxStorenew SqliteOutboxStore(db: DatabaseSync)The store itself, if you manage the handle. enqueue(entry, { tx }) writes on the given DatabaseSync (the one running your BEGIN … COMMIT).

Options ​

ParameterTypeDefaultPurpose
dbOrLocationstring':memory:'File path for the SQLite database — use a real path in production; the default is in-memory and therefore not durable, which defeats the point of the outbox.
dbOrLocationDatabaseSync—An open handle to reuse (it is migrated in place) so the outbox shares one connection with the rest of your app.
location (openOutboxDatabase)string':memory:'Same.

Pragmas the migration sets ​

PragmaValueWhy
journal_modeWALThe relay reads while your request handlers write; WAL lets both proceed.
busy_timeout5000Waits up to 5 s for a competing writer's lock instead of throwing database is locked immediately — smooths over dev reloads and concurrency.

Errors ​

ErrorCodeWhen
——This package throws no error classes of its own. Failures surface as node:sqlite errors from the underlying statement, and reach you through the outbox's onFlushError (store-level) or the entry's lastError (per-entry).
ERR_UNKNOWN_BUILTIN_MODULE / import failure—Node is older than 22.5, or 22.x without --experimental-sqlite. The node:sqlite import fails at module load.

Hooks & events ​

None — this package is a storage adapter. The outbox's callbacks (onDead, onFlushError) and its retry policy live on outboxPlugin / OutboxOptions in @basaltkit/events.

Which backend? ​

  • @basaltkit/events-sqlite — a single node, zero dependencies, the outbox in a local file.
  • @basaltkit/events-prisma — you already run Postgres/MySQL, or need the outbox in the same database (and transaction) as your business writes.

Both implement the identical OutboxStore contract, so switching is a one-line change.

Released under the MIT License.