@pai/storage-sqlite
SQLite-backed ThreadStore implementation for durable local and single-host agents.
Package: @pai/storage-sqlite
Persists PAI thread state in a local SQLite database. This provider is intended for durable local applications, scripts, and single-host services that need stronger transactional coordination than JSON files without operating a networked database.
The package requires Node 24 and uses the built-in node:sqlite driver, so it
has no native npm dependency or postinstall build.
Quick start
import { createAgentRuntime } from "@pai/core";
import { createSQLiteThreadStore } from "@pai/storage-sqlite";
const storage = createSQLiteThreadStore({
filename: "./.pai/threads.db",
});
const runtime = createAgentRuntime({
agent,
scopeKey: (identity) => identity.workspaceId,
storage,
});
// During application shutdown:
await storage.close();The factory opens and initializes the database immediately. Missing parent
directories are created automatically. Use :memory: for an ephemeral SQLite
database.
Options
type SQLiteThreadStoreOptions = {
/** SQLite file path, file URL, or `:memory:`. */
filename: string | URL;
/** Time to wait for another SQLite writer. Defaults to 5,000 ms. */
busyTimeoutMs?: number;
/** Override the provider clock in tests. */
now?: () => Date;
/** Persistent database journal mode. Omit to retain the current mode. */
journalMode?: "delete" | "wal";
};The store owns its connection. close() is safe to call repeatedly, and all
later store operations reject after it has closed.
Storage model
The provider owns a normalized schema with one metadata row and separate tables for thread roots, runs, queued items, and messages. Each logical record keeps a strict JSON payload alongside checked scalar columns used for keys, ordering, filters, visibility, and relationships. Transcript and queued messages share a table, which preserves message-id uniqueness across both locations. Foreign keys keep child records attached to their thread, run, or queued item.
The tables are SQLite STRICT and WITHOUT ROWID, with indexes for thread
listing, bounded child pages, stable-id reads, and relationship validation.
Opaque PAI string keys are encoded without imposing application-specific
syntax.
The current physical schema version is 1. On open, the provider creates its
namespaced objects only when none exist, then validates the complete owned
schema and metadata. It refuses partial, unversioned, colliding, or altered
provider objects and does not currently migrate other schema versions.
Thread listing reads only indexed root candidates. A selected thread read runs in one read transaction and queries only the requested IDs or a bounded page of the requested child families. It does not materialize unrelated transcript, run, or queue records.
Transactions and concurrency
Every commit starts a SQLite BEGIN IMMEDIATE transaction, reads the current
root under the write lock, compares the complete ThreadVersion, and applies
the write set atomically. Updates load and mutate the explicitly affected child
rows plus the indexed relationship projections needed to validate the result;
they do not load or rewrite the complete aggregate. Truncation work is
proportional to the affected suffix. Root-only updates and thread deletion do
not read child history.
Separate connections and processes using the same database file therefore cannot both accept the same observed version. SQLite still serializes writers.
Version and lease-precondition races return the ordinary conflict result and
write nothing. Invalid logical changes roll back the transaction. Commit times
are advanced past a transactionally maintained high-water mark while the write
lock is held, so already-open provider instances retain monotonic ordering,
including after deletion.
Page tokens are stateless, query-bound, and scope-bound. They remain usable after closing and reopening the provider against the same database.
Journal mode and maintenance
When journalMode is omitted, the provider leaves the database's current
journal mode unchanged. Setting it to "wal" or "delete" requests that mode
only after schema ownership and compatibility have been established. SQLite
stores this setting for the database, so it affects later connections as well
as the one opened by this provider. WAL can improve reader/writer concurrency
for a dedicated local database, but it does not permit concurrent writers.
Deleting records normally leaves reusable pages in the database file. Call
await storage.compact() during a quiescent maintenance window to run SQLite
VACUUM and return free pages to the filesystem. Do not call it concurrently
with application traffic.
Deployment boundary
node:sqlite exposes synchronous database calls. Those calls can block the
Node.js event loop while SQLite performs I/O or waits up to busyTimeoutMs for
another writer. Use a networked transactional provider for multi-host
deployments, high write contention, or latency-sensitive servers where
embedded database work must not share the application event loop.
Do not place the database on a network filesystem unless its locking and durability behavior is known to satisfy SQLite's requirements.
Conformance
createSQLiteThreadStore passes the complete @pai/storage/test conformance
suite, plus persistence, cross-connection and cross-process compare-and-swap,
restart-safe page tokens, bounded-query, schema integrity, journal-mode,
compaction, rollback, and lifecycle tests.