/** * hosting/sqliteSessions — conversations in a file, so a restart is not an * amnesia event. * * `memorySessions()` keeps conversations in a `Map` and says what that costs in * its own docstring: restart the process and every conversation is gone. Until * now the next step up was "bring a Redis", and the step between those two — * *one machine, one file, nothing to install* — was a store every consumer had * to write for themselves. This is that store. * * A paused run was in the same position from the other direction. `agent.run()` * hands back a checkpoint that is documented as JSON you can keep anywhere, and * "anywhere" was the whole of the offer: the library named a shape and left the * keeping to you. Both halves land in the same table here, because * {@link CheckpointEnvelope} was already a union of the two and a session store * has no business caring which one it is holding. * * ── What this is, said plainly ────────────────────────────────────────────── * **One process on one machine, writing one file.** That is the whole claim, * and it is worth stating as a ceiling rather than leaving it to be discovered: * * • It survives a restart, a crash, a deploy — anything that ends the process * and leaves the disk alone. * • It is NOT a distributed store. Two machines do not share a session by * both opening this file over a network filesystem, and nothing here tries * to make that work. * • WAL lets readers and one writer run at once, and **one writer at a time * is the ceiling** — a second writer waits for the lock up to * `busyTimeoutMs` and then fails loudly rather than queueing forever. * Several processes on one box is fine at that scale; a fleet is not what * this is. * * When you outgrow it you swap the one argument to `standingAgent`, which is * the entire point of the port. * * ── Zero dependencies, and the version floor that buys ────────────────────── * SQLite is *inside Node* — `node:sqlite`, no install, no native build, no * peer dependency. The price is a version floor this package does not otherwise * have: the module ships with Node 22.5 and newer. Rather than raise `engines` * for one optional adapter and break every consumer on Node 20, the module is * loaded when you actually construct a store, and its absence is refused by * name with the version you are on and what to do about it — see * {@link SqliteUnavailableError}. There is deliberately **no fallback to * memory**: a store that silently forgot everything on restart is * indistinguishable, from the outside, from a brand-new user. * * ── The two laws it inherits rather than re-implements ────────────────────── * `checkEnvelope` is called on the way out AND on the way in, so an envelope * whose `format` this runtime does not know is refused by name, and a stored * session that is PRESENT but unreadable is refused by name too. Only a session * that was never written hydrates as `undefined`. Validating on the way in as * well is the cheap half of that promise: a row this store could not read back * never gets written in the first place. */ import type { SessionLifecycle, SessionSweep } from './types.js'; /** One prepared statement, as this adapter calls it. */ export interface SqliteStatementLike { run(...params: readonly unknown[]): unknown; get(...params: readonly unknown[]): unknown; all(...params: readonly unknown[]): unknown[]; } /** One open database, as this adapter calls it. */ export interface SqliteDatabaseLike { exec(sql: string): void; prepare(sql: string): SqliteStatementLike; close(): void; } /** * The shape of `node:sqlite` this adapter needs — declared locally, the same * way every other adapter here declares the slice of its backend it uses, so * nothing takes a hard import on a module that does not exist on every * supported Node. */ export interface SqliteModuleLike { new (path: string): SqliteDatabaseLike; } /** Options for {@link sqliteSessions}. */ export interface SqliteSessionsOptions { /** * The database file. Created if it does not exist, along with its parent * directory — "no infrastructure" would be a thin promise if you still had to * `mkdir` first. * * `':memory:'` is refused: it looks like a file, keeps nothing across a * restart, and a store that quietly forgets is the exact failure this adapter * exists to remove. Use `memorySessions()` when that is what you want — it * says so in its name. */ readonly file: string; /** * How long a write waits for another writer's lock before failing, in * milliseconds. Default 5000. * * It fails rather than waits forever on purpose: a request hung on a lock * looks exactly like a slow model, and the two need different fixes. */ readonly busyTimeoutMs?: number; /** * @internal Test seam only — the `node:sqlite` module, injected. Lets the * suite exercise the refusal path on a Node that HAS the module, and the * failure paths without corrupting a real file. Not public API, not * supported, and not a place to plug in another SQLite driver. */ readonly _sqlite?: SqliteModuleLike; } /** * A session store in a file. * * It is a {@link SessionLifecycle} plus the three things a real store owns * beyond the port — closing, forgetting, and telling you what the file actually * got — because the port deliberately asks for only two methods and leaves the * rest to whoever implements it. */ export interface SqliteSessions extends SessionLifecycle { /** * The journalling mode the file **actually has**, read back from SQLite * rather than assumed from what was asked for. * * It is normally `'wal'`. It is something else when the file lives somewhere * WAL cannot work — a network filesystem is the usual reason — and that is a * fact worth being able to read: the store still works, with one writer *or* * one reader at a time instead of both. A silent downgrade is the kind of * thing that is only ever discovered under load. */ readonly journalMode: string; /** Forget one session. No-op if there is nothing stored for it. */ forget(sessionId: string): Promise; /** * How conversations here stop existing: **this store deletes them**, when a * job of yours asks it to (9.42.0). * * Narrowed from the port's optional member to a required one, and to the one * arm a file-backed store can be — a caller holding a `SqliteSessions` needs * no feature check, and reaching for a backend policy that does not exist * here is a compile error rather than a surprise at 3am. */ retention(): SessionSweep; /** * Close the file. Idempotent — a shutdown hook and an explicit close can * coexist. Reading or writing afterwards refuses by name rather than * reopening behind your back. */ close(): void; } /** * Raised when `node:sqlite` is not available in the running Node. * * The class moved to `lib/sqliteUnavailable.ts` in 8.9.0 so the SQLite vector * store raises the SAME error for the same missing module — a consumer * catching it should not have to know which of our stores was being built. * Constructed with no third argument it produces exactly the message it always * did, and it is re-exported here under its own name, so nothing that imports * it from this module changes. */ export { SqliteUnavailableError } from '../lib/sqliteUnavailable.js'; /** * Raised when the file exists but this runtime cannot use it as a session * store. * * The same law {@link UnreadableEnvelopeError} states for one stored value, * one level up: **an unreadable store and an empty one are different facts, and * only one of them is safe to answer with a fresh start.** A store that opened * a corrupt file as an empty database would hand every returning user a blank * slate and log nothing. * * `problem` is the fact to branch on — the three cases need different actions: * * - `'cannot-open'` — not a SQLite database, or not readable (permissions, a * directory, a truncated file). * - `'not-our-schema'` — a database whose `agent_sessions` table is somebody * else's table of that name. Point the store at its own file. * - `'newer-schema'` — written by a newer agentfootprint than this one. Same * answer the envelope `format` field gives: refuse, never half-read. */ export declare class UnreadableSessionFileError extends Error { readonly code: "ERR_UNREADABLE_SESSION_FILE"; /** The file that was refused. */ readonly file: string; /** Which of the three cases this is. */ readonly problem: 'cannot-open' | 'not-our-schema' | 'newer-schema'; constructor(file: string, problem: UnreadableSessionFileError['problem'], detail: string); } /** * A session store in one SQLite file — the battery-included place to keep a * conversation, or a run that stopped to ask a person something, so that a * restart does not lose it. * * @throws SqliteUnavailableError when the running Node has no `node:sqlite`. * @throws UnreadableSessionFileError when the file exists but cannot be used — * never answered with an empty store. * * @example A standing agent whose conversations survive a restart * import { standingAgent, nodeHost, sqliteSessions } from 'agentfootprint/hosting'; * * const handle = await standingAgent({ * agent, * sessions: sqliteSessions({ file: './sessions.db' }), * host: nodeHost({ port: 8080 }), * }); * * @example Keeping a paused run yourself, without the composer * const sessions = sqliteSessions({ file: './sessions.db' }); * const out = await agent.run({ message }); * if (isPaused(out)) { * await sessions.persist(sessionId, toPausedEnvelope({ * checkpoint: out.checkpoint, * conversation: agent.checkpoint()!, * pending: { pauseData: out.pauseData }, * })); * } * // …a deploy later, in a new process: * const paused = readPausedRun(await sessions.hydrate(sessionId)); * const answer = await agent.resume(paused.checkpoint, decision); */ export declare function sqliteSessions(options: SqliteSessionsOptions): SqliteSessions;