GNL 0.17: early and moving fast — APIs can change between releasesJoin us

GNL
Docs menu
Core · Free@gnldev/durable

Storage adapters

Pluggable storage backends for journal + memory + vector + work queue: Postgres, SQLite, MySQL/MariaDB, MongoDB, libSQL and in-memory, each measured by the same store contract kit; a capability matrix and composite() for mixed backends.

What it's for / when to use it#

GNL's durability core (run journal, exactly-once, replay) always speaks the same Storage contract; which backend you plug in depends on the environment. Use SqliteStorage while developing or setting up a single-file demo, PostgresStorage in prod, and InMemoryStorage for tests — none of them change your agent/tool/workflow code, only the storage field you pass to createGnl changes.

A single storage may not serve every port (runs/memory/vectors/work/cache) at the same quality — so each adapter carries a capability matrix and can be routed per-port to a different storage (e.g. thread memory on a local SQLite file) with composite() when needed.

Setup / import#

Concrete implementations live in sub-exports so they don't bloat the core package; the main @gnldev/durable entry only carries the interfaces and pure helpers like composite/toJournal.

import paths
import { SqliteStorage } from '@gnldev/durable/sqlite';
import { PostgresStorage } from '@gnldev/durable/postgres';
import { MysqlStorage } from '@gnldev/durable/mysql';     // npm i mysql2
import { MongoStorage } from '@gnldev/durable/mongo';     // npm i mongodb
import { LibsqlStorage } from '@gnldev/durable/libsql';   // npm i libsql
import { InMemoryStorage, toJournal, composite } from '@gnldev/durable';

Step-by-step usage#

1) Create a storage instance and pass it to the createGnl configuration (taken verbatim from the swapi-free example):

swapi-free/src/index.ts
import { SqliteStorage } from '@gnldev/durable/sqlite';
import { createGnl, toJournal } from '@gnldev/durable';
import type { CreateGnlConfig } from '@gnldev/durable';

const storage = new SqliteStorage(process.env.DB_PATH ?? 'swapi-free.db');
const config: CreateGnlConfig = {
  storage,
  agents: {
    starwars: { model: buildModel(), system: SYSTEM, tools: makeSwapiTools(), maxSteps: 4 },
  },
};

2) When Studio or another non-paged consumer expects Journal & JournalReader, bridge storage.runs with toJournal():

app.mount('/studio', createStudioApp({
  reader: toJournal(storage),
  apiBase: '/studio',
  gnl: createStudioRunner(gnl, config, { toJsonSchema: aiToolSchema }),
  auth,
}));

3) When moving to prod, only one line changes — SqliteStorage becomes PostgresStorage:

import { PostgresStorage } from '@gnldev/durable/postgres';

const storage = new PostgresStorage({ connectionString: process.env.DATABASE_URL });
// or inject an existing pg.Pool / pg-mem:
const storage2 = new PostgresStorage({ pool: myPgPool });

Three more servers carry every port with the same guarantees as Postgres, each in its own sub-export with its driver as an optional dependency: MysqlStorage (MySQL 8.4+ and MariaDB 11+), MongoStorage (MongoDB 8, a replica set — one node is enough; a standalone server is refused before anything is written) and LibsqlStorage (libSQL/Turso, a local file only — an embedded replica or a remote database is refused, because a promise cannot be kept on it). The database must exist and be named in the URL; the store creates its tables on first use.

import { MysqlStorage } from '@gnldev/durable/mysql';
import { MongoStorage } from '@gnldev/durable/mongo';
import { LibsqlStorage } from '@gnldev/durable/libsql';

// MySQL 8.4+ or MariaDB 11+
const mysql = new MysqlStorage({ connectionString: 'mysql://app:secret@db:3306/agents' });
// MongoDB 8 — a replica set (one node is enough); a standalone server is refused
const mongo = new MongoStorage({ connectionString: 'mongodb://app:secret@db:27017/agents?replicaSet=rs0' });
// libSQL — a local file only
const libsql = new LibsqlStorage('runs.db');

Every store is measured, not assumed: one conformance kit (@gnldev/durable/testing) runs the same scenarios — a claim with one winner, two processes racing, erasure, organization isolation — against each backend, and a store that does not provide the guarantees is refused where it enters. Server-specific requirements (short TCP keepalives on MySQL and MongoDB, MongoDB 8 on Linux 6.19–7.0.13) are in the package README.

4) If you don't want persistence in tests, use InMemoryStorage — it needs no setup/file and every port runs at full capacity (full):

import { InMemoryStorage } from '@gnldev/durable';

const storage = new InMemoryStorage();

There is no Redis store. The stores are InMemoryStorage, SqliteStorage and LibsqlStorage (one machine), and PostgresStorage, MysqlStorage and MongoStorage (several processes or machines); nothing else in GNL runs on Redis — the queue, events, schedules, streams and locks read the store's ports. Coming from RedisStorage on 0.10: a store's records are not carried across, so finish or cancel its open runs on 0.10, then point the processes at another store.

5) If you only want to hand off a single port (e.g. thread memory) to a different storage, use composite() — runs and meta always come from default (replay must stay on a single journal). Here everything stays on Postgres and thread memory goes to a local SQLite file, for a Postgres-compatible server without pg_advisory_xact_lock, where Postgres turns its memory port off (see its boot warning):

import { composite } from '@gnldev/durable';
import { PostgresStorage } from '@gnldev/durable/postgres';
import { SqliteStorage } from '@gnldev/durable/sqlite';

const storage = composite({
  default: new PostgresStorage({ connectionString: process.env.DATABASE_URL }),
  overrides: { memory: new SqliteStorage('memory.db') },
});

If you need a persistent vector store for RAG, @gnldev/rag has four beside InMemoryVectorStore, with the exact same VectorStore interface: PostgresVectorStore (pgvector, HNSW/ivfflat index), QdrantVectorStore, ChromaVectorStore and WeaviateVectorStore — each plugs drop-in into composite()'s vectors port and passes the same vector kit (see the RAG pipeline page). Because they run durable-wrapped inside createRagTool, the query result is also journaled — resume doesn't re-issue the query:

import { PostgresVectorStore } from '@gnldev/rag';

const vectors = new PostgresVectorStore({ connectionString: process.env.DATABASE_URL, index: 'hnsw' });

Process liveness belongs to the store#

Whether another process is still alive decides who may take a dead process's run, job or fire over — and a frozen process keeps its work, a dead one releases it at once. That answer comes from one place: the store's process liveness (a Postgres session lock, a MySQL named lock, a MongoDB connection named for the process, a SQLite file lock). A store has one liveness in a process, and every component on it — the registry, a queue worker, the scheduler, a direct runDurable — uses that one.

So it is given to the storage, not to the components. On the five built-in stores, passing liveness to createGnl or to createWorker throws LivenessConflictError. The option stays for a custom Storage that has no processLiveness(). The storage never closes a liveness it was given: whoever made it closes it.

liveness on the storage
import { createGnl, postgresLiveness, sqliteLiveness } from '@gnldev/durable';
import { PostgresStorage } from '@gnldev/durable/postgres';
import { SqliteStorage } from '@gnldev/durable/sqlite';

// Postgres behind a pooler: the store stays on the pooled URL, its liveness takes the direct one
const liveness = await postgresLiveness({ connectionString: process.env.DIRECT_URL! });
const storage = new PostgresStorage({ connectionString: process.env.POOLED_URL!, liveness });
const gnl = createGnl({ storage /* no liveness here: it would throw LivenessConflictError */ });

// SQLite takes it as a second argument
const local = new SqliteStorage('runs.db', { liveness: sqliteLiveness('runs.db') });

The case that needs it is Postgres behind a transaction-mode pooler (PgBouncer in transaction mode, a managed "-pooler" endpoint): a session lock cannot live on a connection lent to other clients, so the store refuses it when it opens. Keep the store on the pooled URL and give the liveness the direct one.

API reference#

classSqliteStorage

node:sqlite-based implementation of every port — the dev/single-file default (@gnldev/durable/sqlite).

classPostgresStorage

pg-based prod storage; accepts a connectionString or an injected pool (including pg-mem) (@gnldev/durable/postgres).

typePostgresStorageOptions

{ connectionString?, pool?, liveness? } — PostgresStorage constructor options; liveness is the store's process liveness (see above).

classMysqlStorage

MySQL 8.4+ / MariaDB 11+ storage, every port; connectionString or an injected mysql2 pool (@gnldev/durable/mysql).

classMongoStorage

MongoDB 8 storage on a replica set, every port; vectors are scanned, not indexed (@gnldev/durable/mongo).

classLibsqlStorage

The SQLite store on the libSQL driver; a local file only (@gnldev/durable/libsql).

classInMemoryStorage

Test/example storage providing every port at 'full' capability without persistence.

typeStorage

The unified interface carrying every store port (runs/memory/vectors/work/cache/meta) plus capabilities.

fntoJournal

Wraps a RunJournal in the legacy Journal & JournalReader contract — a bridge for non-paged consumers (e.g. Studio).

fncomposite

Produces a Storage from a default storage plus per-port overrides; recomputes the capability matrix.

typeCompositeConfig

{ default, overrides? } — composite() input.

fnrequireCapability

Requires a port to be available at at least 'scan' level; throws CapabilityError otherwise.

typeCapabilityMatrix

{ runs, memory, vectors, work, cache } — 'full' | 'scan' | 'ttl' | 'none' for each port.

typeRunJournal

The PROTECTED append-only journal port — get/put/putIfAbsent (CAS)/listKeys/readRun/listRuns (paged).

typeMemoryStore

The thread/message/working-memory/observation port — conversation artifacts (not replay state).

typeVectorStore

The RAG corpus port — upsert/query (embedding-based similarity).

typeWorkStore

The queue/event/scheduler port — lives in its own namespace, doesn't pollute the RunJournal. A custom one must implement read, appendMany and the ready set (appendReady, takeReady, heldReady, deferReady, dropReady) — the fair take the queue is built on; listNamespaces and getMany are optional.

Warning
The runs and meta ports always come from default in composite() and cannot be overridden — replay/exactly-once must stay tied to a single RunJournal. Vector search is still at 'scan' (brute-force cosine) level on both concrete @gnldev/durable adapters (Sqlite/Postgres); for persistent/scaled RAG, @gnldev/rag's PostgresVectorStore (pgvector) is used.