Aviary
State stores

Drizzle

The Drizzle StateStore adapter for SQLite / libSQL (Turso, edge). Build your drizzle db with the package's schema and pass it to DrizzleStateStore. Schema is owned by drizzle-kit — no auto-schema.

@dudousxd/nestjs-durable-store-drizzle persists runs and step checkpoints through Drizzle on SQLite / libSQL — including Turso and edge runtimes. Timestamps and wakeAt are stored as epoch-ms integers (SQLite has no native date type).

pnpm add @dudousxd/nestjs-durable-store-drizzle

It declares drizzle-orm as a peer and rides the drizzle db you build.

1. Build your db with the durable schema

The package exports the durable tables and a durableSchema bundle of all of them. Include it in the schema you pass to drizzle(...) so the adapter's queries resolve:

db.ts
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import { durableSchema } from '@dudousxd/nestjs-durable-store-drizzle';

const client = createClient({ url: process.env.DATABASE_URL! }); // file: or Turso libsql://
export const db = drizzle(client, { schema: { ...durableSchema, /* your own tables */ } });

durableSchema is { workflowRuns, stepCheckpoints, runAttributes, signalWaiters, bufferedSignals, bufferedEvents } (all mapped to the durable_* tables). You can also import any of those tables individually — e.g. to reference them in a drizzle-kit migration. durableManagedTables() derives the same six table names from that bundle, for a migration tool's exclude list.

2. Wire the store into the module

DrizzleStateStore takes the drizzle db:

app.module.ts
import { DrizzleStateStore } from '@dudousxd/nestjs-durable-store-drizzle';
import { db } from './db';

DurableModule.forRoot({
  store: new DrizzleStateStore(db),
  transport,
});

Schema is owned by drizzle-kit

Like the Prisma adapter, this one has no auto-schemadrizzle-kit owns your migrations. The autoSchema option is a no-op here. Generate and apply the durable tables alongside your own:

npx drizzle-kit generate
npx drizzle-kit migrate

Point your drizzle.config.ts at a schema file that re-exports durableSchema (or the individual tables) so the generated SQL includes durable_workflow_runs, durable_step_checkpoints, durable_run_attributes, durable_signal_waiters, durable_buffered_signals and durable_buffered_events.

An upgrade that adds a durable column is a migration you have to run — and skipping it breaks every query against that table, not just the new field. Drizzle SELECTs every column the schema declares, so the moment the package declares a new one an un-migrated database answers getRun/listRuns with no such column. The MikroORM and TypeORM adapters heal this on boot and Prisma emits it through Migrate; this one has no auto-schema, so re-run drizzle-kit generate + drizzle-kit migrate after every upgrade. That diff — your database against the package's durableSchema — is the authority on what a given upgrade needs; the section below explains the two shapes it will hand you, but don't treat it as a hand-maintained list of columns to chase. Nor is the trap confined to durable_workflow_runs: parallel_group landed on durable_step_checkpoints and durable_signal_waiters the same way.

The two shapes an added column takes

Nullable, no defaultpriority, origin:

ALTER TABLE durable_workflow_runs ADD COLUMN origin TEXT;

Deliberate, not an oversight: an existing row's origin (which library registered its workflow) cannot be reconstructed after the fact, so it stays NULL and reads back as undefined, meaning unknown, rather than as a plausible-looking package name in the dashboard's facet.

NOT NULL with a default, plus an index — the shape namespace took, and the one with the worst failure mode, because it is the tenant boundary rather than a dashboard facet:

ALTER TABLE durable_workflow_runs ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default';
CREATE INDEX IF NOT EXISTS durable_workflow_runs_namespace_status_idx
  ON durable_workflow_runs (namespace, status, created_at);

SQLite applies the DEFAULT to every existing row as part of ADD COLUMN, so old runs land in 'default' — the namespace they really did execute in — and stay visible to an unscoped worker. Run a bare ADD COLUMN namespace TEXT instead and those rows are NULL; this store reads NULL as 'default' so they aren't orphaned, but the NOT NULL form above is the one to run.

The index is not the optional half. origin gets none because only the dashboard's listRuns filters by it, whereas the pending pick-up (listPendingRuns) and the timer resume (listDueTimers) filter on namespace and status on every poll tick — and this is the only index the schema declares on durable_workflow_runs. created_at is third because the pending scan orders by it (FIFO); crash recovery (listIncompleteRuns) uses the same two leading columns, though only at boot. The index name is pinned to match the MikroORM adapter's so a store swap doesn't drop and rebuild it.

Tag filtering is a substring match, not a JSON containment check. tags is stored as JSON text, so listRuns({ tag }) compiles to LIKE '%"tag"%' against that column (quoted, so filtering by etl doesn't also match a tag like etl-foo). This is a different mechanism from the MikroORM/ TypeORM/Prisma adapters' native JSON containment operators — functionally equivalent for typical tags, but worth knowing if you're auditing query plans or comparing adapters.

Sync drivers reject ctx.transaction. better-sqlite3 is synchronous under the hood, and its db.transaction() throws if you hand it an async callback — which is exactly what DrizzleStateStore.transaction() does. The package's own conformance suite runs with supportsAsyncTransaction: false against better-sqlite3 and skips the transaction case entirely for that driver. If you need ctx.transaction (exactly-once DB steps), use an async driver — libSQL (@libsql/client, including local file: databases and Turso) supports it; plain better-sqlite3 does not.

On this page