chore(vendor): import pi 0.85.1

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:48:47 -04:00
parent 5ebc7018c3
commit 4def357d09
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
# Changelog
## [0.85.1] - 2026-09-05
## [0.85.0] - 2026-09-04
## [0.84.4] - 2026-08-28
## [0.84.3] - 2026-08-24
## [0.84.2] - 2026-08-14
## [0.84.1] - 2026-08-07
### Added
- Added the composable, parameterized `sql` template tag for SQLite queries.
### Fixed
- Fixed SQLite branch queries to apply filters, cursors, and limits in SQL; bounded log reads; and added covering indexes for session, record, branch, and fact queries ([#7727](https://github.com/earendil-works/pi/pull/7727) by [@cristinaponcela](https://github.com/cristinaponcela)).
## [0.84.0] - 2026-08-06
### Breaking Changes
- Renamed the package from `@earendil-works/pi-storage-sqlite-node` to `@earendil-works/pi-session-backend-sqlite-node`.
- Replaced the legacy SQLite session schema and repository with the v4 lane-based `SessionRepo` contract. Existing work-in-progress databases are not migrated.
### Added
- Added bounded active-branch queries, durable operation records, global facts, shared sequence allocation, session statistics, and fenced writer leases to the SQLite backend.
### Fixed
- Fixed SQLite session listings to avoid acquiring writer claims and include current session names, allowing inventory reads while sessions have active writers ([#7655](https://github.com/earendil-works/pi/pull/7655)).
## [0.83.0] - 2026-07-29
## [0.82.1] - 2026-07-25
## [0.82.0] - 2026-07-24
## [0.81.1] - 2026-07-21
## [0.81.0] - 2026-07-21
### Added
- Added a Node.js SQLite storage backend for agent harness sessions, including migrations and materialized session views ([#6594](https://github.com/earendil-works/pi/pull/6594) by [@cristinaponcela](https://github.com/cristinaponcela)).

View File

@@ -0,0 +1,33 @@
# @earendil-works/pi-session-backend-sqlite-node
Node `node:sqlite` Session backend for `@earendil-works/pi-agent-core`.
```ts
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
import {
createNodeSqliteFactory,
SqliteSessionRepo,
} from "@earendil-works/pi-session-backend-sqlite-node";
const repository = new SqliteSessionRepo({
directory: "/var/lib/pi/sessions",
databaseFactory: createNodeSqliteFactory(),
});
const session = await repository.create({}, BACKGROUND_CONTEXT);
const main = await session.createBranch("main", null, BACKGROUND_CONTEXT);
await main.appendMessage(
{ role: "user", content: "hello", timestamp: Date.now() },
BACKGROUND_CONTEXT,
);
await session.close(BACKGROUND_CONTEXT);
await repository.close(BACKGROUND_CONTEXT);
```
The default layout creates one file per Session under `directory`. IDs containing only ASCII letters, digits, `_`, and `-` retain `{sessionId}.sqlite`; every other ID uses a `~`-prefixed base64url encoding of its UTF-16 code units. The durable ID is unchanged, and returned/listed metadata contains the canonical physical path. Pass `databasePath` to place multiple Sessions in one supported shared container; its parent is created when needed.
The database factory distinguishes intentional creation, no-create read-write open, and no-create read-only open. Session `open()` and deletion reject metadata outside the configured repository and never create a missing database. Listing is read-only and best-effort. Forking deliberately permits a foreign source metadata path: it reads that exact existing container read-only and never substitutes an active local Session with the same ID.
The host lifecycle, not this backend, guarantees one writable owner per Session. Directly opening the same Session for writes in another process is unsupported. The repository rejects overlapping local create/open/fork/delete ownership for one ID, but implements no cross-process lease, lock, fence, heartbeat, or takeover. The host must close a worker before deletion.
A fork of a source open in the same repository queues its snapshot on that source's commit queue. Any other source, including one held open by a live Session worker, uses an independent read-only connection and one deferred WAL transaction; later worker commits may complete while that snapshot remains open. Shared-container deletion removes only the selected Session's rows. Repository close waits for every open Session cleanup attempt before reporting errors. The package does not export a search service or FTS index; search is the separate S3 projection.

View File

@@ -0,0 +1,33 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT, type SessionRepo } from "@earendil-works/pi-agent-core";
import { createNodeSqliteFactory, SqliteSessionRepo } from "../../src/index.ts";
import type { BenchmarkTarget } from "../../../../agent/benchmark/session/benchmark.ts";
export interface SessionRepoBenchmarkFixture extends AsyncDisposable {
readonly repo: SessionRepo;
}
const NOW = 1_700_000_000_000;
export const sessionRepoBenchmarkTargets = [
{
name: "sqlite",
async createFixture() {
const directory = await mkdtemp(join(tmpdir(), "pi-sqlite-session-repo-benchmark-"));
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => NOW,
});
return {
repo,
async [Symbol.asyncDispose]() {
await repo.close(BACKGROUND_CONTEXT);
await rm(directory, { recursive: true, force: true });
},
};
},
},
] satisfies readonly BenchmarkTarget<SessionRepoBenchmarkFixture>[];

View File

@@ -0,0 +1,62 @@
import {
SESSION_REPO_CATALOG_BENCHMARK_DATASETS,
SESSION_REPO_CATALOG_READ_BENCHMARK_SCENARIOS,
SESSION_REPO_CATALOG_WRITE_BENCHMARK_SCENARIOS,
SESSION_REPO_FORK_BENCHMARK_DATASETS,
SESSION_REPO_FORK_WRITE_BENCHMARK_SCENARIOS,
seedSessionRepoCatalogBenchmark,
seedSessionRepoForkBenchmark,
} from "@earendil-works/pi-agent-core/harness/session/testing";
import { registerReadBenchmarks, registerWriteBenchmarks } from "../../../../agent/benchmark/session/benchmark.ts";
import { sessionRepoBenchmarkTargets } from "./session-repo-targets.ts";
await registerReadBenchmarks({
datasets: SESSION_REPO_CATALOG_BENCHMARK_DATASETS,
targets: sessionRepoBenchmarkTargets,
scenarios: SESSION_REPO_CATALOG_READ_BENCHMARK_SCENARIOS,
async prepare(fixture, dataset) {
await seedSessionRepoCatalogBenchmark(fixture.repo, dataset);
},
getSubject(fixture) {
return fixture.repo;
},
});
await registerWriteBenchmarks({
targets: sessionRepoBenchmarkTargets,
scenarios: SESSION_REPO_CATALOG_WRITE_BENCHMARK_SCENARIOS,
prepare(fixture, scenario) {
return scenario.prepare(fixture.repo);
},
expectedResult(scenario) {
return scenario.expectedResult;
},
run(operation) {
return operation.run();
},
});
const forkBenchmarks = SESSION_REPO_FORK_BENCHMARK_DATASETS.flatMap((dataset) =>
SESSION_REPO_FORK_WRITE_BENCHMARK_SCENARIOS.map((scenario) => ({
name: `${scenario.name} (${dataset.name})`,
dataset,
scenario,
})),
);
await registerWriteBenchmarks({
targets: sessionRepoBenchmarkTargets,
scenarios: forkBenchmarks,
async prepare(fixture, benchmark) {
return {
repo: fixture.repo,
source: await seedSessionRepoForkBenchmark(fixture.repo, benchmark.dataset),
};
},
expectedResult(benchmark) {
return benchmark.scenario.expectedResult(benchmark.dataset);
},
run(subject, benchmark) {
return benchmark.scenario.run(subject.repo, subject.source, benchmark.dataset);
},
});

View File

@@ -0,0 +1,48 @@
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
import type { StorageFixture } from "@earendil-works/pi-agent-core/harness/session/testing";
import { createNodeSqliteFactory, SQLITE_STORAGE_VERSION, SqliteStorage, sql } from "../../src/index.ts";
import { applyInitialSchema } from "../../src/sqlite/migrations.ts";
import type { BenchmarkTarget } from "../../../../agent/benchmark/session/benchmark.ts";
const SESSION_ID = "session";
const NOW = 1_700_000_000_000;
const EMPTY_USAGE = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
export const storageBenchmarkTargets = [
{
name: "sqlite",
async createFixture() {
const db = await createNodeSqliteFactory().open(":memory:");
try {
db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
await applyInitialSchema(db);
sql`INSERT INTO sessions
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
VALUES (${SESSION_ID}, ${NOW}, ${null}, ${SQLITE_STORAGE_VERSION}, ${null}, ${0}, ${JSON.stringify(EMPTY_USAGE)}, ${1})`.run(
db,
);
const storage = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => NOW });
return {
storage,
async [Symbol.asyncDispose]() {
try {
await storage.close(BACKGROUND_CONTEXT);
} finally {
db.close();
}
},
} satisfies StorageFixture;
} catch (error) {
db.close();
throw error;
}
},
},
] satisfies readonly BenchmarkTarget<StorageFixture>[];

View File

@@ -0,0 +1,35 @@
import {
STORAGE_BENCHMARK_DATASETS,
STORAGE_READ_BENCHMARK_SCENARIOS,
STORAGE_WRITE_BENCHMARK_SCENARIOS,
seedStorageBenchmark,
} from "@earendil-works/pi-agent-core/harness/session/testing";
import { registerReadBenchmarks, registerWriteBenchmarks } from "../../../../agent/benchmark/session/benchmark.ts";
import { storageBenchmarkTargets } from "./storage-targets.ts";
await registerReadBenchmarks({
datasets: STORAGE_BENCHMARK_DATASETS,
targets: storageBenchmarkTargets,
scenarios: STORAGE_READ_BENCHMARK_SCENARIOS,
prepare(fixture, dataset) {
return seedStorageBenchmark(fixture.storage, dataset);
},
getSubject(fixture) {
return fixture.storage;
},
});
await registerWriteBenchmarks({
targets: storageBenchmarkTargets,
scenarios: STORAGE_WRITE_BENCHMARK_SCENARIOS,
async prepare(fixture, scenario) {
await scenario.prepare?.(fixture.storage);
return fixture.storage;
},
expectedResult(scenario) {
return scenario.writeCount;
},
run(storage, scenario) {
return scenario.run(storage);
},
});

View File

@@ -0,0 +1,45 @@
{
"name": "@earendil-works/pi-session-backend-sqlite-node",
"version": "0.85.1",
"description": "Node sqlite session backend for @earendil-works/pi-agent-core sessions",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"clean": "node -e \"import('node:fs/promises').then(fs=>fs.rm('dist',{recursive:true,force:true}))\"",
"build": "tsgo -p tsconfig.build.json && node scripts/copy-migrations.mjs",
"bench:session:timing": "vitest bench --config vitest.benchmark.config.ts",
"test": "vitest --run",
"coverage": "vitest --run --coverage",
"prepublishOnly": "npm run clean && npm run build"
},
"author": "Earendil Works",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/session-backends/sqlite-node"
},
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.85.1",
"@earendil-works/pi-agent-core": "^0.85.1"
},
"devDependencies": {
"@vitest/coverage-v8": "4.1.9",
"vitest": "4.1.9"
}
}

View File

@@ -0,0 +1,7 @@
import { cp, mkdir } from "node:fs/promises";
const source = new URL("../src/sqlite/migrations/", import.meta.url);
const target = new URL("../dist/sqlite/migrations/", import.meta.url);
await mkdir(target, { recursive: true });
await cp(source, target, { recursive: true });

View File

@@ -0,0 +1,123 @@
import type { SQLInputValue } from "node:sqlite";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";
import { sql } from "./sqlite/sql.ts";
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteRunResult, SqliteStatement } from "./sqlite/types.ts";
function isNamedParameters(value: unknown): value is Record<string, SQLInputValue> {
if (value === null || typeof value !== "object") return false;
if (Array.isArray(value) || ArrayBuffer.isView(value)) return false;
return true;
}
function isAsyncResult(value: unknown): boolean {
return value !== null && (typeof value === "object" || typeof value === "function") && "then" in value;
}
class NodeSqliteStatement implements SqliteStatement {
private readonly statement: ReturnType<DatabaseSync["prepare"]>;
constructor(statement: ReturnType<DatabaseSync["prepare"]>) {
this.statement = statement;
}
run(...params: unknown[]): SqliteRunResult {
const [first, ...rest] = params;
const result = isNamedParameters(first)
? this.statement.run(first, ...(rest as SQLInputValue[]))
: this.statement.run(...(params as SQLInputValue[]));
return {
changes: Number(result.changes),
lastInsertRowid: result.lastInsertRowid === undefined ? undefined : Number(result.lastInsertRowid),
};
}
get<TRow extends object>(...params: unknown[]): TRow | undefined {
const [first, ...rest] = params;
return (
isNamedParameters(first)
? this.statement.get(first, ...(rest as SQLInputValue[]))
: this.statement.get(...(params as SQLInputValue[]))
) as TRow | undefined;
}
all<TRow extends object>(...params: unknown[]): TRow[] {
const [first, ...rest] = params;
return (
isNamedParameters(first)
? this.statement.all(first, ...(rest as SQLInputValue[]))
: this.statement.all(...(params as SQLInputValue[]))
) as TRow[];
}
iterate<TRow extends object>(...params: unknown[]): Iterable<TRow> {
const [first, ...rest] = params;
return (
isNamedParameters(first)
? this.statement.iterate(first, ...(rest as SQLInputValue[]))
: this.statement.iterate(...(params as SQLInputValue[]))
) as Iterable<TRow>;
}
}
class NodeSqliteDatabase implements SqliteDatabase {
private readonly db: DatabaseSync;
constructor(db: DatabaseSync) {
this.db = db;
}
exec(sql: string): void {
this.db.exec(sql);
}
prepare(sql: string): SqliteStatement {
return new NodeSqliteStatement(this.db.prepare(sql));
}
transaction<T>(fn: () => T): T {
sql`BEGIN IMMEDIATE`.exec(this);
try {
const result = fn();
if (isAsyncResult(result)) {
throw new TypeError("SQLite transaction callbacks must be synchronous");
}
sql`COMMIT`.exec(this);
return result;
} catch (error) {
try {
sql`ROLLBACK`.exec(this);
} catch {
// Ignore rollback errors to rethrow original error.
}
throw error;
}
}
close(): void {
this.db.close();
}
}
export function wrapNodeSqliteDatabase(db: DatabaseSync): SqliteDatabase {
return new NodeSqliteDatabase(db);
}
export function createNodeSqliteFactory(): SqliteDatabaseFactory {
return {
async open(path: string): Promise<SqliteDatabase> {
return new NodeSqliteDatabase(new DatabaseSync(path));
},
async openExisting(path: string): Promise<SqliteDatabase> {
const url = pathToFileURL(path);
url.searchParams.set("mode", "rw");
return new NodeSqliteDatabase(new DatabaseSync(url));
},
async openReadOnly(path: string): Promise<SqliteDatabase> {
return new NodeSqliteDatabase(new DatabaseSync(path, { readOnly: true }));
},
};
}
// Re-export the SQLite session backend and types so this package is a complete node-sqlite backend.
export * from "./sqlite/index.ts";

View File

@@ -0,0 +1,9 @@
export * from "./repo.ts";
export * from "./sql.ts";
export * from "./storage.ts";
export type {
SqliteDatabase,
SqliteDatabaseFactory,
SqliteRunResult,
SqliteStatement,
} from "./types.ts";

View File

@@ -0,0 +1,8 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { SqliteDatabase } from "./types.ts";
export async function applyInitialSchema(db: SqliteDatabase): Promise<void> {
const migration = await readFile(fileURLToPath(new URL("./migrations/001_initial.sql", import.meta.url)), "utf8");
db.exec(migration);
}

View File

@@ -0,0 +1,121 @@
-- AgentHarness storage format 4 / storageVersion 1.
-- A SQLite database file is a session container. The current default repo
-- placement still creates one file per session, but the schema supports any
-- number of sessions per file by scoping every durable row with session_id.
-- Authoritative durable state is entries + scalar_values + list_values +
-- usage_ledger; branch_* and stats columns on sessions are maintained
-- projections/caches.
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
parent_session_id TEXT,
storage_version INTEGER NOT NULL,
metadata TEXT,
message_count INTEGER NOT NULL,
usage_payload TEXT NOT NULL,
next_seq INTEGER NOT NULL
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS entries (
session_id TEXT NOT NULL,
id TEXT NOT NULL,
parent_id TEXT,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
custom_type TEXT,
timestamp INTEGER NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (session_id, id)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS ix_entry_parent ON entries(session_id, parent_id);
CREATE INDEX IF NOT EXISTS ix_entry_seq ON entries(session_id, seq, type);
CREATE TABLE IF NOT EXISTS scalar_values (
session_id TEXT NOT NULL,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
seq INTEGER NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (session_id, namespace, key)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS list_values (
session_id TEXT NOT NULL,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
seq INTEGER NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (session_id, namespace, key, seq)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS usage_ledger (
session_id TEXT NOT NULL,
id TEXT NOT NULL,
seq INTEGER NOT NULL,
entry_id TEXT,
adjustment INTEGER NOT NULL,
usage TEXT NOT NULL,
details TEXT,
PRIMARY KEY (session_id, id)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS ix_usage_seq ON usage_ledger(session_id, seq);
-- Storage-level integrity that spans rows/tables. Primary keys enforce
-- same-table duplicate ids; these triggers enforce the shared entry/usage id
-- namespace and ordered parent insertion without a TypeScript preflight pass.
CREATE TRIGGER IF NOT EXISTS trg_entries_validate
BEFORE INSERT ON entries
BEGIN
SELECT RAISE(ABORT, 'missing parent entry')
WHERE NEW.parent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM entries WHERE session_id = NEW.session_id AND id = NEW.parent_id
);
SELECT RAISE(ABORT, 'duplicate entry or usage id')
WHERE EXISTS (
SELECT 1 FROM usage_ledger WHERE session_id = NEW.session_id AND id = NEW.id
);
END;
CREATE TRIGGER IF NOT EXISTS trg_usage_ledger_validate
BEFORE INSERT ON usage_ledger
BEGIN
SELECT RAISE(ABORT, 'duplicate entry or usage id')
WHERE EXISTS (
SELECT 1 FROM entries WHERE session_id = NEW.session_id AND id = NEW.id
);
END;
-- Private branch index. Not values/lists; no equivalent in the other backends.
CREATE TABLE IF NOT EXISTS branch_entries (
session_id TEXT NOT NULL,
branch_id TEXT NOT NULL,
entry_id TEXT NOT NULL,
entry_seq INTEGER NOT NULL,
entry_type TEXT NOT NULL,
PRIMARY KEY (session_id, branch_id, entry_id)
) WITHOUT ROWID;
-- Ordered scans. entry_seq must follow session_id, branch_id directly or ORDER
-- BY needs a temp b-tree; entry_id and entry_type trail so the index covers
-- id-only reads.
CREATE INDEX IF NOT EXISTS ix_be_seq ON branch_entries(session_id, branch_id, entry_seq, entry_id, entry_type);
-- Type-filtered scans.
CREATE INDEX IF NOT EXISTS ix_be_type ON branch_entries(session_id, branch_id, entry_type, entry_seq, entry_id);
CREATE INDEX IF NOT EXISTS ix_be_entry ON branch_entries(session_id, entry_id);
CREATE TABLE IF NOT EXISTS branch_meta (
session_id TEXT NOT NULL,
branch_id TEXT NOT NULL,
tip_entry_id TEXT NOT NULL,
tip_seq INTEGER NOT NULL,
base_branch_id TEXT,
base_seq INTEGER,
PRIMARY KEY (session_id, branch_id)
) WITHOUT ROWID;
CREATE UNIQUE INDEX IF NOT EXISTS ix_bm_tip ON branch_meta(session_id, tip_entry_id);

View File

@@ -0,0 +1,453 @@
import { mkdir, open as openFile, readdir, realpath, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { Context, Entry, ForkOptions, SessionCreateOptions, StoredValue } from "@earendil-works/pi-agent-core";
import { branchTip, createForkSnapshot, StorageBackedSession } from "@earendil-works/pi-agent-core";
import { uuidv7 } from "@earendil-works/pi-ai";
import { applyInitialSchema } from "./migrations.ts";
import { appendEntryToBranchIndex, scanBranchEntries } from "./session/branch-entries.ts";
import { decodeEntryRow, type EntryRow, EntryRowWriter } from "./session/entries.ts";
import {
deleteSessionRows,
hasSessionRow,
insertSessionRow,
metadataFromSessionRow,
readAllSessionRows,
readSessionRow,
type SqliteSessionMetadata,
} from "./session/session-row.ts";
import { readAllScalarValueRows, setScalarValueRow } from "./session/values.ts";
import { SqliteOpenSession } from "./session.ts";
import { sql } from "./sql.ts";
import { SqliteStorage, type SqliteStorageSnapshot } from "./storage.ts";
import type { SqliteDatabase, SqliteDatabaseFactory } from "./types.ts";
export const SQLITE_STORAGE_VERSION = 1;
export const SQLITE_SESSION_EXTENSION = ".sqlite";
const FIRST_AVAILABLE_COMMIT_SEQ = 1;
const SAFE_SESSION_FILE_ID = /^[A-Za-z0-9_-]+$/;
export type SqliteSessionCreateOptions = SessionCreateOptions;
export interface SqliteSessionRepoOptions {
directory: string;
/** Optional single container path. Defaults to one encoded `${id}.sqlite` file per session under directory. */
databasePath?: string;
databaseFactory: SqliteDatabaseFactory;
now?: () => number;
}
function sessionFileName(id: string): string {
if (SAFE_SESSION_FILE_ID.test(id)) return `${id}${SQLITE_SESSION_EXTENSION}`;
const encoded = Buffer.from(id, "utf16le").toString("base64url");
return `~${encoded}${SQLITE_SESSION_EXTENSION}`;
}
function sessionPath(directory: string, id: string): string {
return join(directory, sessionFileName(id));
}
function storageIdentity(path: string, sessionId: string): string {
return JSON.stringify([path, sessionId]);
}
function isErrorWithCode(error: unknown, code: string): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === code;
}
async function removeSessionFiles(path: string, options: { force: boolean }): Promise<void> {
await rm(path, { force: options.force });
await rm(`${path}-wal`, { force: true });
await rm(`${path}-shm`, { force: true });
}
function configureWritableConnection(db: SqliteDatabase): void {
db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
}
function configureReadOnlyConnection(db: SqliteDatabase): void {
db.exec("PRAGMA busy_timeout = 5000;");
}
interface ForkSnapshot {
entries: Entry[];
scalarValues: StoredValue<unknown>[];
messageCount: number;
nextSeq: number;
}
function readSourceEntries(db: SqliteDatabase, sessionId: string): Entry[] {
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
FROM entries WHERE session_id = ${sessionId} ORDER BY seq ASC`
.all<EntryRow>(db)
.map(decodeEntryRow);
}
function buildForkSnapshot(source: SqliteStorageSnapshot, options: ForkOptions): ForkSnapshot {
const snapshot = createForkSnapshot(
{
entries: source.entries,
scalarValues: source.scalarValues,
entriesComplete: source.entriesComplete,
},
options,
);
const entries = [...snapshot.entries.values()].sort((left, right) => left.seq - right.seq);
return {
entries,
scalarValues: snapshot.scalarValues,
messageCount: entries.filter((entry) => entry.type === "message").length,
nextSeq: snapshot.nextSeq,
};
}
// TODO(WP08): Remove this snapshot path when SQLite forks use streaming staging.
function readForkSourceEntries(
db: SqliteDatabase,
sessionId: string,
scalarValues: readonly StoredValue<unknown>[],
options: ForkOptions,
): Entry[] {
if (options.scope === "tree") return readSourceEntries(db, sessionId);
const sourceAddress = branchTip(options.branch);
const sourceTip = scalarValues.find(
(stored) => stored.address.namespace === sourceAddress.namespace && stored.address.key === sourceAddress.key,
) as StoredValue<string | null> | undefined;
if (sourceTip === undefined) throw new Error(`Unknown source branch: ${options.branch}`);
return sourceTip.value === null
? []
: scanBranchEntries(db, sessionId, { start: sourceTip.value, order: "oldestFirst" });
}
function createSqliteForkSnapshot(
sourceDb: SqliteDatabase,
source: SqliteSessionMetadata,
options: ForkOptions,
): ForkSnapshot {
sourceDb.exec("BEGIN");
let committed = false;
try {
metadataFromSessionRow(source.path, readSessionRow(sourceDb, source.id), SQLITE_STORAGE_VERSION);
const scalarValues = readAllScalarValueRows(sourceDb, source.id);
const snapshot = buildForkSnapshot(
{
entries: readForkSourceEntries(sourceDb, source.id, scalarValues, options),
scalarValues,
entriesComplete: options.scope === "tree",
},
options,
);
sourceDb.exec("COMMIT");
committed = true;
return snapshot;
} catch (error) {
if (!committed) sourceDb.exec("ROLLBACK");
throw error;
}
}
function insertForkValue(db: SqliteDatabase, sessionId: string, stored: StoredValue<unknown>): void {
setScalarValueRow(db, sessionId, stored.address.namespace, stored.address.key, stored.seq, stored.value);
}
function updateForkSessionStats(db: SqliteDatabase, sessionId: string, messageCount: number): void {
sql`UPDATE sessions SET message_count = ${messageCount} WHERE id = ${sessionId}`.run(db);
}
export class SqliteSessionRepo {
private readonly directory: string;
private readonly databasePath: string | undefined;
private readonly databaseFactory: SqliteDatabaseFactory;
private readonly now: () => number;
private readonly pendingIds = new Set<string>();
private readonly openStorages = new Map<string, SqliteStorage>();
private readonly openSessions = new Set<SqliteOpenSession>();
private closed = false;
private closePromise: Promise<void> | undefined;
constructor(options: SqliteSessionRepoOptions) {
this.directory = options.directory;
this.databasePath = options.databasePath;
this.databaseFactory = options.databaseFactory;
this.now = options.now ?? Date.now;
}
async create(options: SqliteSessionCreateOptions | undefined, _context: Context): Promise<SqliteOpenSession> {
this.assertOpen();
options ??= {};
const createdAt = this.now();
const id = options.id ?? uuidv7(createdAt);
this.reserveId(id);
const path = this.pathForSession(id);
let db: SqliteDatabase | undefined;
let reservedFile = false;
let initialized = false;
let session: SqliteOpenSession | undefined;
try {
await mkdir(dirname(path), { recursive: true });
if (!this.usesSharedDatabase()) {
const file = await openFile(path, "wx");
await file.close();
reservedFile = true;
}
const activeDb = await this.databaseFactory.open(path);
db = activeDb;
configureWritableConnection(activeDb);
await applyInitialSchema(activeDb);
const canonicalPath = await realpath(path);
const metadata: SqliteSessionMetadata = {
id,
createdAt,
storageVersion: SQLITE_STORAGE_VERSION,
...(options.parentSessionId === undefined ? {} : { parentSessionId: options.parentSessionId }),
path: canonicalPath,
};
activeDb.transaction(() => {
if (hasSessionRow(activeDb, id)) throw new Error(`SQLite session already exists: ${id}`);
insertSessionRow(activeDb, metadata, SQLITE_STORAGE_VERSION, FIRST_AVAILABLE_COMMIT_SEQ);
});
initialized = true;
session = this.openStorageBackedSession(metadata, activeDb);
return session;
} catch (error) {
if (reservedFile && !initialized) await removeSessionFiles(path, { force: true });
throw error;
} finally {
if (session === undefined) {
try {
db?.close();
} finally {
this.pendingIds.delete(id);
}
}
}
}
async open(metadata: SqliteSessionMetadata, _context: Context): Promise<SqliteOpenSession> {
this.assertOpen();
this.reserveId(metadata.id);
let db: SqliteDatabase | undefined;
let session: SqliteOpenSession | undefined;
try {
const path = await this.repositoryPathForMetadata(metadata);
const activeDb = await this.databaseFactory.openExisting(path);
db = activeDb;
configureWritableConnection(activeDb);
const stored = metadataFromSessionRow(path, readSessionRow(activeDb, metadata.id), SQLITE_STORAGE_VERSION);
session = this.openStorageBackedSession(stored, activeDb);
return session;
} finally {
if (session === undefined) {
try {
db?.close();
} finally {
this.pendingIds.delete(metadata.id);
}
}
}
}
async list(_options: undefined, _context: Context): Promise<SqliteSessionMetadata[]> {
this.assertOpen();
let paths: string[];
if (this.usesSharedDatabase()) {
paths = [this.databasePath!];
} else {
let names: string[];
try {
names = await readdir(this.directory);
} catch (error) {
if (isErrorWithCode(error, "ENOENT")) return [];
throw error;
}
paths = names
.filter((name) => name.endsWith(SQLITE_SESSION_EXTENSION))
.map((name) => join(this.directory, name));
}
const sessions: SqliteSessionMetadata[] = [];
for (const path of paths) {
let db: SqliteDatabase | undefined;
try {
const canonicalPath = await realpath(path);
db = await this.databaseFactory.openReadOnly(canonicalPath);
configureReadOnlyConnection(db);
for (const row of readAllSessionRows(db)) {
sessions.push(metadataFromSessionRow(canonicalPath, row, SQLITE_STORAGE_VERSION));
}
} catch {
// Discovery is best-effort: corrupt files, incompatible versions, and
// unrelated *.sqlite files are reported when explicitly opened.
} finally {
db?.close();
}
}
return sessions.sort((left, right) => right.createdAt - left.createdAt);
}
async delete(metadata: SqliteSessionMetadata, _context: Context): Promise<void> {
this.assertOpen();
this.reserveId(metadata.id);
try {
const path = await this.repositoryPathForMetadata(metadata);
const db = await this.databaseFactory.openExisting(path);
try {
configureWritableConnection(db);
if (this.usesSharedDatabase()) {
db.transaction(() => {
metadataFromSessionRow(path, readSessionRow(db, metadata.id), SQLITE_STORAGE_VERSION);
deleteSessionRows(db, metadata.id);
});
} else {
metadataFromSessionRow(path, readSessionRow(db, metadata.id), SQLITE_STORAGE_VERSION);
}
} finally {
db.close();
}
if (!this.usesSharedDatabase()) await removeSessionFiles(path, { force: false });
} finally {
this.pendingIds.delete(metadata.id);
}
}
async fork(source: SqliteSessionMetadata, options: ForkOptions, context: Context): Promise<SqliteOpenSession> {
this.assertOpen();
const createdAt = this.now();
const id = options.id ?? uuidv7(createdAt);
this.reserveId(id);
const sourceStorage = this.openStorages.get(storageIdentity(source.path, source.id));
const activeSourceSnapshot = sourceStorage?.snapshot(options, context);
void activeSourceSnapshot?.catch(() => undefined);
const path = this.pathForSession(id);
let db: SqliteDatabase | undefined;
let reservedFile = false;
let initialized = false;
let session: SqliteOpenSession | undefined;
try {
await mkdir(dirname(path), { recursive: true });
if (!this.usesSharedDatabase()) {
const file = await openFile(path, "wx");
await file.close();
reservedFile = true;
}
const snapshot =
activeSourceSnapshot === undefined
? await this.createForkSnapshotFromExternalSource(source, options)
: buildForkSnapshot(await activeSourceSnapshot, options);
const activeDb = await this.databaseFactory.open(path);
db = activeDb;
configureWritableConnection(activeDb);
await applyInitialSchema(activeDb);
const canonicalPath = await realpath(path);
const metadata: SqliteSessionMetadata = {
id,
createdAt,
storageVersion: SQLITE_STORAGE_VERSION,
parentSessionId: source.id,
path: canonicalPath,
};
activeDb.transaction(() => {
if (hasSessionRow(activeDb, id)) throw new Error(`SQLite session already exists: ${id}`);
insertSessionRow(activeDb, metadata, SQLITE_STORAGE_VERSION, snapshot.nextSeq);
const entryWriter = new EntryRowWriter(activeDb, id);
for (const entry of snapshot.entries) {
entryWriter.insert(entry);
appendEntryToBranchIndex(activeDb, id, entry);
}
for (const stored of snapshot.scalarValues) insertForkValue(activeDb, id, stored);
updateForkSessionStats(activeDb, id, snapshot.messageCount);
});
initialized = true;
session = this.openStorageBackedSession(metadata, activeDb);
return session;
} catch (error) {
if (reservedFile && !initialized) await removeSessionFiles(path, { force: true });
throw error;
} finally {
if (session === undefined) {
try {
db?.close();
} finally {
this.pendingIds.delete(id);
}
}
}
}
close(context: Context): Promise<void> {
if (this.closePromise !== undefined) return this.closePromise;
this.closed = true;
this.closePromise = this.closeOpenSessions(context);
return this.closePromise;
}
private async createForkSnapshotFromExternalSource(
source: SqliteSessionMetadata,
options: ForkOptions,
): Promise<ForkSnapshot> {
const path = await realpath(source.path);
const sourceDb = await this.databaseFactory.openReadOnly(path);
try {
configureReadOnlyConnection(sourceDb);
return createSqliteForkSnapshot(sourceDb, { ...source, path }, options);
} finally {
sourceDb.close();
}
}
private async closeOpenSessions(context: Context): Promise<void> {
const results = await Promise.allSettled([...this.openSessions].map((session) => session.close(context)));
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, "Failed to close SQLite Sessions");
}
private openStorageBackedSession(metadata: SqliteSessionMetadata, db: SqliteDatabase): SqliteOpenSession {
const key = storageIdentity(metadata.path, metadata.id);
const storage = new SqliteStorage(db, { sessionId: metadata.id, now: this.now });
this.openStorages.set(key, storage);
const session = new StorageBackedSession(metadata, storage);
const openSession = new SqliteOpenSession(session, {
onClose: () => {
try {
db.close();
} finally {
if (this.openStorages.get(key) === storage) this.openStorages.delete(key);
this.openSessions.delete(openSession);
this.pendingIds.delete(metadata.id);
}
},
});
this.openSessions.add(openSession);
return openSession;
}
private async repositoryPathForMetadata(metadata: SqliteSessionMetadata): Promise<string> {
const [expected, actual] = await Promise.all([
realpath(this.pathForSession(metadata.id)),
realpath(metadata.path),
]);
if (expected !== actual) {
throw new Error(`SQLite session metadata path is outside this repository: ${metadata.path}`);
}
return actual;
}
private reserveId(id: string): void {
if (this.pendingIds.has(id)) throw new Error(`Session is already open: ${id}`);
this.pendingIds.add(id);
}
private pathForSession(id: string): string {
return this.databasePath ?? sessionPath(this.directory, id);
}
private usesSharedDatabase(): boolean {
return this.databasePath !== undefined;
}
private assertOpen(): void {
if (this.closed) throw new Error("SqliteSessionRepo is closed");
}
}

View File

@@ -0,0 +1,213 @@
import type {
Branch,
Context,
Entry,
EntryQuery,
ListElement,
ListReadOptions,
Session,
SessionMutation,
SessionMutationCallback,
SessionStats,
StorageBranchScan,
StoredValue,
Value,
ValueList,
} from "@earendil-works/pi-agent-core";
import type { SqliteSessionMetadata } from "./session/session-row.ts";
export interface SqliteOpenSessionOptions {
onClose: () => void;
}
/** SQLite-specific open-session lifecycle wrapper. */
export class SqliteOpenSession implements Session<SqliteSessionMetadata> {
readonly metadata: SqliteSessionMetadata;
readonly idGenerator: Session<SqliteSessionMetadata>["idGenerator"];
private readonly session: Session<SqliteSessionMetadata>;
private readonly onClose: () => void;
private readonly admitted = new Set<Promise<unknown>>();
private readonly closedError = new Error("Session is closed");
private state: "open" | "closing" | "closed" = "open";
private closePromise: Promise<void> | undefined;
constructor(session: Session<SqliteSessionMetadata>, options: SqliteOpenSessionOptions) {
this.session = session;
this.metadata = session.metadata;
this.idGenerator = session.idGenerator;
this.onClose = options.onClose;
}
async beginMutation(context: Context): Promise<SessionMutation> {
let resolveFinished!: () => void;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
this.admitted.add(finished);
let source: SessionMutation;
try {
source = await this.admit(() => this.session.beginMutation(context));
} catch (error) {
this.admitted.delete(finished);
resolveFinished();
throw error;
}
if (this.state !== "open") {
await source.end(context);
this.admitted.delete(finished);
resolveFinished();
throw this.closedError;
}
let ended = false;
return {
commit: (writes, commitContext) => source.commit(writes, commitContext),
end: async (endContext) => {
try {
await source.end(endContext);
} finally {
if (!ended) {
ended = true;
this.admitted.delete(finished);
resolveFinished();
}
}
},
getEntries: (ids, readContext) => source.getEntries(ids, readContext),
getStats: (readContext) => source.getStats(readContext),
getValue: (address, readContext) => source.getValue(address, readContext),
scanValues: (prefix, readContext) => source.scanValues(prefix, readContext),
readList: (address, options, readContext) => source.readList(address, options, readContext),
scanBranch: (query, readContext) => source.scanBranch(query, readContext),
};
}
mutate<T>(mutation: SessionMutationCallback<T>, context: Context): Promise<T> {
return this.admit(() =>
this.session.mutate((mutator, mutationContext) => {
if (this.state !== "open") throw this.closedError;
return mutation(mutator, mutationContext);
}, context),
);
}
getEntries(ids: string[], context: Context): Promise<Map<string, Entry>> {
return this.admit(() => this.session.getEntries(ids, context));
}
getEntry(id: string, context: Context): Promise<Entry | undefined> {
return this.admit(() => this.session.getEntry(id, context));
}
getValue<T>(address: Value<T>, context: Context): Promise<StoredValue<T> | undefined> {
return this.admit(() => this.session.getValue(address, context));
}
scanValues<T>(prefix: Value<T>, context: Context): Promise<StoredValue<T>[]> {
return this.admit(() => this.session.scanValues(prefix, context));
}
readList<T>(
address: ValueList<T>,
options: ListReadOptions | undefined,
context: Context,
): Promise<ListElement<T>[]> {
return this.admit(() => this.session.readList(address, options, context));
}
scanBranch(query: StorageBranchScan, context: Context): Promise<Entry[]> {
return this.admit(() => this.session.scanBranch(query, context));
}
getStats(context: Context): Promise<SessionStats> {
return this.admit(() => this.session.getStats(context));
}
getName(context: Context): Promise<string | undefined> {
return this.admit(() => this.session.getName(context));
}
getLabel(targetId: string, context: Context): Promise<string | undefined> {
return this.admit(() => this.session.getLabel(targetId, context));
}
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]> {
return this.admit(() => this.session.findEntries(query, context));
}
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined> {
return this.admit(() => this.session.findEntry(query, context));
}
async branch(name: string, context: Context): Promise<Branch | undefined> {
const branch = await this.admit(() => this.session.branch(name, context));
return branch === undefined ? undefined : this.wrapBranch(branch);
}
async createBranch(name: string, at: string | null, context: Context): Promise<Branch> {
return this.wrapBranch(await this.admit(() => this.session.createBranch(name, at, context)));
}
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void> {
return this.admit(() => this.session.setValue(address, next, context));
}
deleteValue<T>(address: Value<T>, context: Context): Promise<void> {
return this.admit(() => this.session.deleteValue(address, context));
}
appendList<T>(address: ValueList<T>, element: NoInfer<T>, context: Context): Promise<void> {
return this.admit(() => this.session.appendList(address, element, context));
}
deleteList<T>(address: ValueList<T>, context: Context): Promise<void> {
return this.admit(() => this.session.deleteList(address, context));
}
setName(name: string | undefined, context: Context): Promise<void> {
return this.admit(() => this.session.setName(name, context));
}
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void> {
return this.admit(() => this.session.setLabel(targetId, label, context));
}
close(context: Context): Promise<void> {
if (this.closePromise !== undefined) return this.closePromise;
this.state = "closing";
this.closePromise = Promise.allSettled([...this.admitted])
.then(() => this.session.close(context))
.finally(() => {
this.state = "closed";
this.onClose();
});
return this.closePromise;
}
private wrapBranch(branch: Branch): Branch {
return {
name: branch.name,
getTipId: (context) => this.admit(() => branch.getTipId(context)),
findEntries: (query, context) => this.admit(() => branch.findEntries(query, context)),
findEntry: (query, context) => this.admit(() => branch.findEntry(query, context)),
appendMessage: (message, context) => this.admit(() => branch.appendMessage(message, context)),
appendCustomEntry: (customType, data, context) =>
this.admit(() => branch.appendCustomEntry(customType, data, context)),
};
}
private admit<T>(operation: () => Promise<T>): Promise<T> {
if (this.state !== "open") return Promise.reject(this.closedError);
let result: Promise<T>;
try {
result = operation();
} catch (error) {
result = Promise.reject(error);
}
this.admitted.add(result);
void result.then(
() => this.admitted.delete(result),
() => this.admitted.delete(result),
);
return result;
}
}

View File

@@ -0,0 +1,324 @@
import type { Entry, EntryStructure, StorageBranchScan } from "@earendil-works/pi-agent-core";
import { joinSqlFragments, type SqlQuery, sql } from "../sql.ts";
import type { SqliteDatabase } from "../types.ts";
import { decodeEntryRow, type EntryRow } from "./entries.ts";
interface BranchMembershipRow {
branch_id: string;
entry_seq: number;
}
interface BranchMetaRow {
branch_id: string;
tip_entry_id: string;
tip_seq: number;
base_branch_id: string | null;
base_seq: number | null;
}
interface BranchSegment {
branchId: string;
lowerSeq: number;
upperSeq: number;
}
interface StopSeqRow {
stop_seq: number | null;
}
interface EntryStructureRow {
id: string;
parent_id: string | null;
seq: number;
type: Entry["type"];
custom_type: string | null;
timestamp: number;
}
interface BranchTipRow {
branch_id: string;
}
interface CompactionBoundary {
branchId: string;
seq: number;
}
interface CompactionBoundaryRow {
entry_seq: number | null;
}
function readBranchMembership(db: SqliteDatabase, sessionId: string, entryId: string): BranchMembershipRow {
const row = sql`SELECT b.branch_id, b.entry_seq
FROM branch_entries b
JOIN branch_meta m ON m.session_id = b.session_id AND m.branch_id = b.branch_id
WHERE b.session_id = ${sessionId}
AND b.entry_id = ${entryId}
AND ((m.base_seq IS NULL AND b.entry_seq > 0) OR (m.base_seq IS NOT NULL AND b.entry_seq > m.base_seq))
AND b.entry_seq <= m.tip_seq
ORDER BY m.tip_seq DESC, b.branch_id
LIMIT 1`.get<BranchMembershipRow>(db);
if (row === undefined) throw new Error(`Branch cache missing entry ${entryId}`);
return row;
}
function readBranchMeta(db: SqliteDatabase, sessionId: string, branchId: string): BranchMetaRow {
const row = sql`SELECT branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq
FROM branch_meta
WHERE session_id = ${sessionId} AND branch_id = ${branchId}`.get<BranchMetaRow>(db);
if (row === undefined) throw new Error(`Branch metadata missing for branch ${branchId}`);
return row;
}
function insertBranchEntry(db: SqliteDatabase, sessionId: string, branchId: string, entry: Entry): void {
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
VALUES (${sessionId}, ${branchId}, ${entry.id}, ${entry.seq}, ${entry.type})`.run(db);
}
function readBranchTipForParent(db: SqliteDatabase, sessionId: string, parentId: string): BranchTipRow | undefined {
return sql`SELECT branch_id
FROM branch_meta
WHERE session_id = ${sessionId} AND tip_entry_id = ${parentId}`.get<BranchTipRow>(db);
}
function createRootBranchForEntry(db: SqliteDatabase, sessionId: string, entry: Entry): void {
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
VALUES (${sessionId}, ${entry.id}, ${entry.id}, ${entry.seq}, ${null}, ${null})`.run(db);
insertBranchEntry(db, sessionId, entry.id, entry);
}
function appendEntryToExistingBranch(db: SqliteDatabase, sessionId: string, branchId: string, entry: Entry): void {
insertBranchEntry(db, sessionId, branchId, entry);
const result = sql`UPDATE branch_meta
SET tip_entry_id = ${entry.id}, tip_seq = ${entry.seq}
WHERE session_id = ${sessionId} AND branch_id = ${branchId}`.run(db);
if (result.changes !== 1) throw new Error(`Expected to update branch ${branchId}, updated ${result.changes}`);
}
function readBranchSegmentsNewestFirst(db: SqliteDatabase, sessionId: string, start: string): BranchSegment[] {
let { branch_id: branchId, entry_seq: upperSeq } = readBranchMembership(db, sessionId, start);
const segments: BranchSegment[] = [];
while (true) {
const meta = readBranchMeta(db, sessionId, branchId);
const lowerSeq = meta.base_seq ?? 0;
segments.push({ branchId, lowerSeq, upperSeq });
if (meta.base_branch_id === null) break;
if (meta.base_seq === null) throw new Error(`Branch ${branchId} has base branch without base_seq`);
branchId = meta.base_branch_id;
upperSeq = meta.base_seq;
}
return segments;
}
function readNewestCompactionBoundary(
db: SqliteDatabase,
sessionId: string,
segmentsNewestFirst: readonly BranchSegment[],
): CompactionBoundary | undefined {
for (const segment of segmentsNewestFirst) {
const row = sql`SELECT MAX(entry_seq) AS entry_seq
FROM branch_entries
WHERE session_id = ${sessionId}
AND branch_id = ${segment.branchId}
AND entry_seq > ${segment.lowerSeq}
AND entry_seq <= ${segment.upperSeq}
AND entry_type = ${"compaction"}`.get<CompactionBoundaryRow>(db);
if (row?.entry_seq !== null && row?.entry_seq !== undefined)
return { branchId: segment.branchId, seq: row.entry_seq };
}
return undefined;
}
function copyBranchEntriesAfterSeqThroughParent(
db: SqliteDatabase,
sessionId: string,
targetBranchId: string,
segmentsNewestFirst: readonly BranchSegment[],
afterSeq: number,
): void {
for (const segment of [...segmentsNewestFirst].reverse()) {
const lowerSeq = Math.max(segment.lowerSeq, afterSeq);
if (segment.upperSeq <= lowerSeq) continue;
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
SELECT ${sessionId}, ${targetBranchId}, entry_id, entry_seq, entry_type
FROM branch_entries
WHERE session_id = ${sessionId}
AND branch_id = ${segment.branchId}
AND entry_seq > ${lowerSeq}
AND entry_seq <= ${segment.upperSeq}`.run(db);
}
}
function createDivergentBranchForEntry(db: SqliteDatabase, sessionId: string, entry: Entry): void {
if (entry.parentId === null) throw new Error("Root entries do not create divergent branches");
const segmentsNewestFirst = readBranchSegmentsNewestFirst(db, sessionId, entry.parentId);
const compaction = readNewestCompactionBoundary(db, sessionId, segmentsNewestFirst);
const branchId = entry.id;
// A null base means this segment stores its own root-through-parent prefix.
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
VALUES (${sessionId}, ${branchId}, ${entry.id}, ${entry.seq}, ${compaction?.branchId ?? null}, ${compaction?.seq ?? null})`.run(
db,
);
copyBranchEntriesAfterSeqThroughParent(db, sessionId, branchId, segmentsNewestFirst, compaction?.seq ?? 0);
insertBranchEntry(db, sessionId, branchId, entry);
}
export function appendEntryToBranchIndex(db: SqliteDatabase, sessionId: string, entry: Entry): void {
if (entry.parentId === null) {
createRootBranchForEntry(db, sessionId, entry);
return;
}
const branch = readBranchTipForParent(db, sessionId, entry.parentId);
if (branch === undefined) {
createDivergentBranchForEntry(db, sessionId, entry);
return;
}
appendEntryToExistingBranch(db, sessionId, branch.branch_id, entry);
}
function stopPredicates(query: StorageBranchScan): SqlQuery[] {
const predicates: SqlQuery[] = [];
if (query.stopAtType !== undefined) predicates.push(sql`b.entry_type = ${query.stopAtType}`);
if (query.stopAtId !== undefined) predicates.push(sql`b.entry_id = ${query.stopAtId}`);
return predicates;
}
function readStopSeq(
db: SqliteDatabase,
sessionId: string,
segment: BranchSegment,
query: StorageBranchScan,
oldestFirst: boolean,
): number | undefined {
const stop = stopPredicates(query);
if (stop.length === 0) return undefined;
const aggregate = oldestFirst ? sql`MIN(b.entry_seq)` : sql`MAX(b.entry_seq)`;
const row = sql`SELECT ${aggregate} AS stop_seq
FROM branch_entries b
WHERE b.session_id = ${sessionId}
AND b.branch_id = ${segment.branchId}
AND b.entry_seq > ${segment.lowerSeq}
AND b.entry_seq <= ${segment.upperSeq}
AND (${joinSqlFragments(stop, " OR ")})`.get<StopSeqRow>(db);
return row?.stop_seq ?? undefined;
}
function branchScanPredicates(
sessionId: string,
segment: BranchSegment,
query: StorageBranchScan,
oldestFirst: boolean,
stopSeq: number | undefined,
): SqlQuery[] {
const predicates: SqlQuery[] = [
sql`b.session_id = ${sessionId}`,
sql`b.branch_id = ${segment.branchId}`,
sql`b.entry_seq > ${segment.lowerSeq}`,
sql`b.entry_seq <= ${segment.upperSeq}`,
sql`e.session_id = b.session_id`,
];
if (stopSeq !== undefined)
predicates.push(oldestFirst ? sql`b.entry_seq <= ${stopSeq}` : sql`b.entry_seq >= ${stopSeq}`);
if (query.type !== undefined) predicates.push(sql`b.entry_type = ${query.type}`);
if (query.customType !== undefined) predicates.push(sql`e.custom_type = ${query.customType}`);
if (query.cursor !== undefined) {
predicates.push(oldestFirst ? sql`b.entry_seq > ${query.cursor.seq}` : sql`b.entry_seq < ${query.cursor.seq}`);
}
return predicates;
}
function limitSql(limit: number | undefined): SqlQuery {
return limit === undefined ? sql`` : sql`LIMIT ${Math.max(0, limit)}`;
}
function scanEntrySegmentRows(
db: SqliteDatabase,
sessionId: string,
segment: BranchSegment,
query: StorageBranchScan,
oldestFirst: boolean,
stopSeq: number | undefined,
limit: number | undefined,
): EntryRow[] {
const predicates = branchScanPredicates(sessionId, segment, query, oldestFirst, stopSeq);
const order = oldestFirst ? sql`ASC` : sql`DESC`;
return sql`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp, e.payload
FROM branch_entries b
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
WHERE ${joinSqlFragments(predicates, " AND ")}
ORDER BY b.entry_seq ${order} ${limitSql(limit)}`.all<EntryRow>(db);
}
function decodeEntryStructureRow(row: EntryStructureRow): EntryStructure {
return {
id: row.id,
parentId: row.parent_id,
seq: row.seq,
timestamp: row.timestamp,
type: row.type,
...(row.custom_type === null ? {} : { customType: row.custom_type }),
};
}
function scanStructureSegmentRows(
db: SqliteDatabase,
sessionId: string,
segment: BranchSegment,
query: StorageBranchScan,
oldestFirst: boolean,
stopSeq: number | undefined,
limit: number | undefined,
): EntryStructure[] {
const predicates = branchScanPredicates(sessionId, segment, query, oldestFirst, stopSeq);
const order = oldestFirst ? sql`ASC` : sql`DESC`;
const rows = sql`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp
FROM branch_entries b
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
WHERE ${joinSqlFragments(predicates, " AND ")}
ORDER BY b.entry_seq ${order} ${limitSql(limit)}`.all<EntryStructureRow>(db);
return rows.map(decodeEntryStructureRow);
}
function scanBranchSegments<T>(
db: SqliteDatabase,
sessionId: string,
query: StorageBranchScan,
readSegment: (
db: SqliteDatabase,
sessionId: string,
segment: BranchSegment,
query: StorageBranchScan,
oldestFirst: boolean,
stopSeq: number | undefined,
limit: number | undefined,
) => T[],
): T[] {
const oldestFirst = query.order === "oldestFirst";
const segmentsNewestFirst = readBranchSegmentsNewestFirst(db, sessionId, query.start);
const segments = oldestFirst ? [...segmentsNewestFirst].reverse() : segmentsNewestFirst;
const limit = query.limit === undefined ? undefined : Math.max(0, query.limit);
if (limit === 0) return [];
const rows: T[] = [];
for (const segment of segments) {
const remaining = limit === undefined ? undefined : limit - rows.length;
if (remaining !== undefined && remaining <= 0) break;
const stopSeq = readStopSeq(db, sessionId, segment, query, oldestFirst);
rows.push(...readSegment(db, sessionId, segment, query, oldestFirst, stopSeq, remaining));
if (stopSeq !== undefined) break;
}
return rows;
}
export function scanBranchEntries(db: SqliteDatabase, sessionId: string, query: StorageBranchScan): Entry[] {
return scanBranchSegments(db, sessionId, query, scanEntrySegmentRows).map(decodeEntryRow);
}
export function scanBranchEntryStructures(
db: SqliteDatabase,
sessionId: string,
query: StorageBranchScan,
): EntryStructure[] {
return scanBranchSegments(db, sessionId, query, scanStructureSegmentRows);
}

View File

@@ -0,0 +1,161 @@
import type {
BranchSummaryEntry,
CompactionEntry,
CustomEntry,
Entry,
EntryScan,
EntryStructure,
MessageEntry,
} from "@earendil-works/pi-agent-core";
import { joinSqlFragments, type SqlQuery, sql } from "../sql.ts";
import type { SqliteDatabase, SqliteStatement } from "../types.ts";
export interface EntryRow {
id: string;
parent_id: string | null;
seq: number;
type: Entry["type"];
custom_type: string | null;
timestamp: number;
payload: string;
}
type StoredEntryPayload<TEntry extends Entry> = Omit<
TEntry,
"id" | "parentId" | "seq" | "timestamp" | "type" | "customType"
>;
function entryPayload(entry: Entry): StoredEntryPayload<Entry> {
switch (entry.type) {
case "message": {
const payload: StoredEntryPayload<MessageEntry> = {
message: entry.message,
...(entry.terminate === undefined ? {} : { terminate: entry.terminate }),
};
return payload;
}
case "compaction": {
const payload: StoredEntryPayload<CompactionEntry> = {
summary: entry.summary,
retainedTail: entry.retainedTail,
tokensBefore: entry.tokensBefore,
...(entry.details === undefined ? {} : { details: entry.details }),
...(entry.usage === undefined ? {} : { usage: entry.usage }),
fromHook: entry.fromHook,
};
return payload;
}
case "branch_summary": {
const payload: StoredEntryPayload<BranchSummaryEntry> = {
fromId: entry.fromId,
summary: entry.summary,
...(entry.details === undefined ? {} : { details: entry.details }),
...(entry.usage === undefined ? {} : { usage: entry.usage }),
fromHook: entry.fromHook,
};
return payload;
}
case "custom": {
const payload: StoredEntryPayload<CustomEntry> = entry.data === undefined ? {} : { data: entry.data };
return payload;
}
}
}
function parsePayload<TEntry extends Entry>(row: EntryRow): StoredEntryPayload<TEntry> {
return JSON.parse(row.payload) as StoredEntryPayload<TEntry>;
}
const INSERT_ENTRY_SQL = `INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
function entryRowParams(sessionId: string, entry: Entry): unknown[] {
return [
sessionId,
entry.id,
entry.parentId,
entry.seq,
entry.type,
entry.type === "custom" ? entry.customType : null,
entry.timestamp,
JSON.stringify(entryPayload(entry)),
];
}
export class EntryRowWriter {
private readonly insertStatement: SqliteStatement;
private readonly sessionId: string;
constructor(db: SqliteDatabase, sessionId: string) {
this.insertStatement = db.prepare(INSERT_ENTRY_SQL);
this.sessionId = sessionId;
}
insert(entry: Entry): void {
this.insertStatement.run(...entryRowParams(this.sessionId, entry));
}
}
export function insertEntryRow(db: SqliteDatabase, sessionId: string, entry: Entry): void {
db.prepare(INSERT_ENTRY_SQL).run(...entryRowParams(sessionId, entry));
}
export function decodeEntryRow(row: EntryRow): Entry {
const base = {
id: row.id,
parentId: row.parent_id,
seq: row.seq,
timestamp: row.timestamp,
};
switch (row.type) {
case "message":
return { ...base, type: "message", ...parsePayload<MessageEntry>(row) };
case "compaction":
return { ...base, type: "compaction", ...parsePayload<CompactionEntry>(row) };
case "branch_summary":
return { ...base, type: "branch_summary", ...parsePayload<BranchSummaryEntry>(row) };
case "custom":
if (row.custom_type === null) throw new Error(`Custom entry ${row.id} is missing custom_type`);
return { ...base, type: "custom", customType: row.custom_type, ...parsePayload<CustomEntry>(row) };
}
}
export function entryStructureFromRow(row: EntryRow): EntryStructure {
return {
id: row.id,
parentId: row.parent_id,
seq: row.seq,
timestamp: row.timestamp,
type: row.type,
...(row.custom_type === null ? {} : { customType: row.custom_type }),
};
}
export function readEntryRows(db: SqliteDatabase, sessionId: string, ids: readonly string[]): EntryRow[] {
if (ids.length === 0) return [];
const placeholders = joinSqlFragments(
ids.map((id) => sql`${id}`),
", ",
);
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
FROM entries
WHERE session_id = ${sessionId} AND id IN (${placeholders})`.all<EntryRow>(db);
}
export function readAllEntryRows(db: SqliteDatabase, sessionId: string): EntryRow[] {
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
FROM entries WHERE session_id = ${sessionId} ORDER BY seq ASC`.all<EntryRow>(db);
}
export function scanEntryRows(db: SqliteDatabase, sessionId: string, query: EntryScan): EntryRow[] {
const filters: SqlQuery[] = [sql`session_id = ${sessionId}`];
if (query.type !== undefined) filters.push(sql`type = ${query.type}`);
if (query.customType !== undefined) filters.push(sql`custom_type = ${query.customType}`);
if (query.fromSeq !== undefined) filters.push(sql`seq >= ${query.fromSeq}`);
if (query.toSeq !== undefined) filters.push(sql`seq <= ${query.toSeq}`);
const order = query.order === "desc" ? sql`ORDER BY seq DESC` : sql`ORDER BY seq ASC`;
const limit = query.limit === undefined ? sql`` : sql`LIMIT ${Math.max(0, query.limit)}`;
return sql`SELECT id, parent_id, seq, type, custom_type, timestamp, payload
FROM entries WHERE ${joinSqlFragments(filters, " AND ")} ${order} ${limit}`.all<EntryRow>(db);
}

View File

@@ -0,0 +1,102 @@
import type { SessionMetadata } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai";
import { sql } from "../sql.ts";
import type { SqliteDatabase } from "../types.ts";
export interface SessionRow {
id: string;
created_at: number;
parent_session_id: string | null;
storage_version: number;
metadata: string | null;
message_count: number;
usage_payload: string;
next_seq: number;
}
export interface SqliteSessionMetadata extends SessionMetadata {
/** SQLite container/shard path containing this session. */
path: string;
}
function zeroUsage(): Usage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
export function readSessionRow(db: SqliteDatabase, sessionId: string): SessionRow {
const row = sql`SELECT id, created_at, parent_session_id, storage_version, metadata,
message_count, usage_payload, next_seq
FROM sessions
WHERE id = ${sessionId}`.get<SessionRow>(db);
if (row === undefined) throw new Error(`Unknown SQLite session: ${sessionId}`);
return row;
}
export function readAllSessionRows(db: SqliteDatabase): SessionRow[] {
return sql`SELECT id, created_at, parent_session_id, storage_version, metadata,
message_count, usage_payload, next_seq
FROM sessions`.all<SessionRow>(db);
}
export function hasSessionRow(db: SqliteDatabase, sessionId: string): boolean {
return sql`SELECT id FROM sessions WHERE id = ${sessionId}`.get<{ id: string }>(db) !== undefined;
}
export function metadataFromSessionRow(
path: string,
row: SessionRow,
currentStorageVersion: number,
): SqliteSessionMetadata {
if (row.storage_version > currentStorageVersion) {
throw new Error(`SQLite session storage version ${row.storage_version} is newer than ${currentStorageVersion}`);
}
if (row.storage_version < currentStorageVersion) {
throw new Error(`SQLite session storage version ${row.storage_version} requires migrations`);
}
return {
id: row.id,
createdAt: row.created_at,
storageVersion: row.storage_version,
...(row.parent_session_id === null ? {} : { parentSessionId: row.parent_session_id }),
path,
};
}
export function insertSessionRow(
db: SqliteDatabase,
metadata: SqliteSessionMetadata,
storageVersion: number,
nextSeq: number,
): void {
sql`INSERT INTO sessions
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
VALUES (
${metadata.id},
${metadata.createdAt},
${metadata.parentSessionId ?? null},
${storageVersion},
${null},
${0},
${JSON.stringify(zeroUsage())},
${nextSeq}
)`.run(db);
}
export function deleteSessionRows(db: SqliteDatabase, sessionId: string): void {
sql`DELETE FROM entries WHERE session_id = ${sessionId}`.run(db);
sql`DELETE FROM scalar_values WHERE session_id = ${sessionId}`.run(db);
sql`DELETE FROM list_values WHERE session_id = ${sessionId}`.run(db);
sql`DELETE FROM usage_ledger WHERE session_id = ${sessionId}`.run(db);
sql`DELETE FROM branch_entries WHERE session_id = ${sessionId}`.run(db);
sql`DELETE FROM branch_meta WHERE session_id = ${sessionId}`.run(db);
const result = sql`DELETE FROM sessions WHERE id = ${sessionId}`.run(db);
if (result.changes !== 1)
throw new Error(`Expected to delete one SQLite session ${sessionId}, deleted ${result.changes}`);
}

View File

@@ -0,0 +1,14 @@
import { sql } from "../sql.ts";
import type { SqliteDatabase } from "../types.ts";
export function readNextSeq(db: SqliteDatabase, sessionId: string): number {
const row = sql`SELECT next_seq FROM sessions WHERE id = ${sessionId}`.get<{ next_seq: number }>(db);
if (row === undefined) throw new Error(`Unknown SQLite session: ${sessionId}`);
return row.next_seq;
}
export function advanceNextSeq(db: SqliteDatabase, sessionId: string, nextSeq: number): void {
const result = sql`UPDATE sessions SET next_seq = ${nextSeq} WHERE id = ${sessionId}`.run(db);
if (result.changes !== 1)
throw new Error(`Expected to update one SQLite session ${sessionId}, updated ${result.changes}`);
}

View File

@@ -0,0 +1,45 @@
import type { SessionStats, UsageRow } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai";
import { sql } from "../sql.ts";
import type { SqliteDatabase } from "../types.ts";
import { readSessionRow } from "./session-row.ts";
function addUsage(left: Usage, right: Usage): Usage {
return {
input: left.input + right.input,
output: left.output + right.output,
cacheRead: left.cacheRead + right.cacheRead,
cacheWrite: left.cacheWrite + right.cacheWrite,
...(left.cacheWrite1h === undefined && right.cacheWrite1h === undefined
? {}
: { cacheWrite1h: (left.cacheWrite1h ?? 0) + (right.cacheWrite1h ?? 0) }),
...(left.reasoning === undefined && right.reasoning === undefined
? {}
: { reasoning: (left.reasoning ?? 0) + (right.reasoning ?? 0) }),
totalTokens: left.totalTokens + right.totalTokens,
cost: {
input: left.cost.input + right.cost.input,
output: left.cost.output + right.cost.output,
cacheRead: left.cost.cacheRead + right.cost.cacheRead,
cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
total: left.cost.total + right.cost.total,
},
};
}
export function readSessionStats(db: SqliteDatabase, sessionId: string): SessionStats {
const row = readSessionRow(db, sessionId);
return {
messageCount: row.message_count,
usage: JSON.parse(row.usage_payload) as Usage,
};
}
export function incrementMessageCount(db: SqliteDatabase, sessionId: string): void {
sql`UPDATE sessions SET message_count = message_count + 1 WHERE id = ${sessionId}`.run(db);
}
export function addUsageToSessionStats(db: SqliteDatabase, sessionId: string, usage: UsageRow["usage"]): void {
const current = readSessionStats(db, sessionId).usage;
sql`UPDATE sessions SET usage_payload = ${JSON.stringify(addUsage(current, usage))} WHERE id = ${sessionId}`.run(db);
}

View File

@@ -0,0 +1,67 @@
import type { UsageRow, UsageScan } from "@earendil-works/pi-agent-core";
import { joinSqlFragments, type SqlQuery, sql } from "../sql.ts";
import type { SqliteDatabase, SqliteStatement } from "../types.ts";
export interface UsageLedgerRow {
id: string;
seq: number;
entry_id: string | null;
adjustment: number;
usage: string;
details: string | null;
}
const INSERT_USAGE_LEDGER_SQL = `INSERT INTO usage_ledger (session_id, id, seq, entry_id, adjustment, usage, details)
VALUES (?, ?, ?, ?, ?, ?, ?)`;
function usageLedgerRowParams(sessionId: string, row: UsageRow): unknown[] {
return [
sessionId,
row.id,
row.seq,
row.entryId ?? null,
row.adjustment ? 1 : 0,
JSON.stringify(row.usage),
row.details === undefined ? null : JSON.stringify(row.details),
];
}
export class UsageLedgerRowWriter {
private readonly insertStatement: SqliteStatement;
private readonly sessionId: string;
constructor(db: SqliteDatabase, sessionId: string) {
this.insertStatement = db.prepare(INSERT_USAGE_LEDGER_SQL);
this.sessionId = sessionId;
}
insert(row: UsageRow): void {
this.insertStatement.run(...usageLedgerRowParams(this.sessionId, row));
}
}
export function insertUsageLedgerRow(db: SqliteDatabase, sessionId: string, row: UsageRow): void {
db.prepare(INSERT_USAGE_LEDGER_SQL).run(...usageLedgerRowParams(sessionId, row));
}
export function decodeUsageLedgerRow(row: UsageLedgerRow): UsageRow {
return {
id: row.id,
seq: row.seq,
usage: JSON.parse(row.usage) as UsageRow["usage"],
...(row.entry_id === null ? {} : { entryId: row.entry_id }),
adjustment: row.adjustment !== 0,
...(row.details === null ? {} : { details: JSON.parse(row.details) as UsageRow["details"] }),
};
}
export function scanUsageLedgerRows(db: SqliteDatabase, sessionId: string, query: UsageScan): UsageLedgerRow[] {
const filters: SqlQuery[] = [sql`session_id = ${sessionId}`];
if (query.fromSeq !== undefined) filters.push(sql`seq >= ${query.fromSeq}`);
if (query.toSeq !== undefined) filters.push(sql`seq <= ${query.toSeq}`);
const order = query.order === "desc" ? sql`ORDER BY seq DESC` : sql`ORDER BY seq ASC`;
const limit = query.limit === undefined ? sql`` : sql`LIMIT ${Math.max(0, query.limit)}`;
return sql`SELECT id, seq, entry_id, adjustment, usage, details
FROM usage_ledger WHERE ${joinSqlFragments(filters, " AND ")} ${order} ${limit}`.all<UsageLedgerRow>(db);
}

View File

@@ -0,0 +1,146 @@
import {
type ListElement,
type ListReadOptions,
resolveListReadOptions,
type StoredValue,
type Value,
type ValueList,
value,
} from "@earendil-works/pi-agent-core";
import { type SqlQuery, sql } from "../sql.ts";
import type { SqliteDatabase } from "../types.ts";
export interface ScalarValueRow {
namespace: string;
key: string;
seq: number;
value: string;
}
export interface ListValueRow {
seq: number;
value: string;
}
export function setScalarValueRow(
db: SqliteDatabase,
sessionId: string,
namespace: string,
key: string,
seq: number,
storedValue: unknown,
): void {
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
VALUES (${sessionId}, ${namespace}, ${key}, ${seq}, ${JSON.stringify(storedValue)})
ON CONFLICT(session_id, namespace, key) DO UPDATE SET seq = excluded.seq, value = excluded.value`.run(db);
}
export function deleteScalarValueRow(db: SqliteDatabase, sessionId: string, namespace: string, key: string): void {
sql`DELETE FROM scalar_values
WHERE session_id = ${sessionId} AND namespace = ${namespace} AND key = ${key}`.run(db);
}
export function appendListValueRow(
db: SqliteDatabase,
sessionId: string,
namespace: string,
key: string,
seq: number,
element: unknown,
): void {
sql`INSERT INTO list_values (session_id, namespace, key, seq, value)
VALUES (${sessionId}, ${namespace}, ${key}, ${seq}, ${JSON.stringify(element)})`.run(db);
}
export function deleteListValueRows(db: SqliteDatabase, sessionId: string, namespace: string, key: string): void {
sql`DELETE FROM list_values
WHERE session_id = ${sessionId} AND namespace = ${namespace} AND key = ${key}`.run(db);
}
function decodeScalarValueRow<T>(address: Value<T>, row: ScalarValueRow): StoredValue<T> {
if (row.namespace !== address.namespace || row.key !== address.key) {
throw new Error(`Expected value ${address.namespace}:${address.key}, found ${row.namespace}:${row.key}`);
}
return { address, seq: row.seq, value: JSON.parse(row.value) as T };
}
export function readScalarValueRow<T>(
db: SqliteDatabase,
sessionId: string,
address: Value<T>,
): StoredValue<T> | undefined {
const row = sql`SELECT namespace, key, seq, value FROM scalar_values
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key}`.get<ScalarValueRow>(
db,
);
return row === undefined ? undefined : decodeScalarValueRow(address, row);
}
export function readAllScalarValueRows(db: SqliteDatabase, sessionId: string): StoredValue<unknown>[] {
return sql`SELECT namespace, key, seq, value FROM scalar_values
WHERE session_id = ${sessionId} ORDER BY seq ASC`
.all<ScalarValueRow>(db)
.map((row) => ({
address: value<unknown>(row.namespace, row.key),
seq: row.seq,
value: JSON.parse(row.value),
}));
}
function nextPrefixBoundary(prefix: string): string | undefined {
if (prefix === "") return undefined;
const codePoints = Array.from(prefix);
for (let index = codePoints.length - 1; index >= 0; index--) {
const codePoint = codePoints[index]?.codePointAt(0);
if (codePoint === undefined) throw new Error("Invalid value key prefix");
if (codePoint < 0x10ffff) {
const nextCodePoint = codePoint >= 0xd7ff && codePoint < 0xe000 ? 0xe000 : codePoint + 1;
return `${codePoints.slice(0, index).join("")}${String.fromCodePoint(nextCodePoint)}`;
}
}
return undefined;
}
export function scanScalarValueRows<T>(db: SqliteDatabase, sessionId: string, prefix: Value<T>): StoredValue<T>[] {
const upperBound = nextPrefixBoundary(prefix.key);
const rows =
upperBound === undefined
? sql`SELECT namespace, key, seq, value FROM scalar_values
WHERE session_id = ${sessionId} AND namespace = ${prefix.namespace} AND key >= ${prefix.key}
ORDER BY key ASC`.all<ScalarValueRow>(db)
: sql`SELECT namespace, key, seq, value FROM scalar_values
WHERE session_id = ${sessionId} AND namespace = ${prefix.namespace} AND key >= ${prefix.key} AND key < ${upperBound}
ORDER BY key ASC`.all<ScalarValueRow>(db);
return rows.map((row) => decodeScalarValueRow(value<T>(row.namespace, row.key), row));
}
export function listValueReadQuery<T>(sessionId: string, address: ValueList<T>, options?: ListReadOptions): SqlQuery {
const resolved = resolveListReadOptions(options);
if (resolved.order === "asc") {
return resolved.cursor === undefined
? sql`SELECT seq, value FROM list_values
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key}
ORDER BY seq ASC LIMIT ${resolved.limit}`
: sql`SELECT seq, value FROM list_values
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key} AND seq > ${resolved.cursor.seq}
ORDER BY seq ASC LIMIT ${resolved.limit}`;
}
return resolved.cursor === undefined
? sql`SELECT seq, value FROM list_values
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key}
ORDER BY seq DESC LIMIT ${resolved.limit}`
: sql`SELECT seq, value FROM list_values
WHERE session_id = ${sessionId} AND namespace = ${address.namespace} AND key = ${address.key} AND seq < ${resolved.cursor.seq}
ORDER BY seq DESC LIMIT ${resolved.limit}`;
}
export function readListValueRows<T>(
db: SqliteDatabase,
sessionId: string,
address: ValueList<T>,
options?: ListReadOptions,
): ListElement<T>[] {
return listValueReadQuery(sessionId, address, options)
.all<ListValueRow>(db)
.map((row) => ({ seq: row.seq, value: JSON.parse(row.value) as T }));
}

View File

@@ -0,0 +1,66 @@
import type { SqliteDatabase, SqliteRunResult } from "./types.ts";
type SqlTemplateValue = unknown | SqlQuery;
/** A parameterized SQLite query produced by {@link sql}. */
export class SqlQuery {
readonly queryText: string;
readonly params: readonly unknown[];
constructor(queryText: string, params: readonly unknown[] = []) {
this.queryText = queryText;
this.params = params;
}
exec(db: SqliteDatabase): void {
if (this.params.length > 0) throw new TypeError("SQLite exec queries cannot have parameters");
db.exec(this.queryText);
}
run(db: SqliteDatabase): SqliteRunResult {
return db.prepare(this.queryText).run(...this.params);
}
get<TRow extends object>(db: SqliteDatabase): TRow | undefined {
return db.prepare(this.queryText).get<TRow>(...this.params);
}
all<TRow extends object>(db: SqliteDatabase): TRow[] {
return db.prepare(this.queryText).all<TRow>(...this.params);
}
iterate<TRow extends object>(db: SqliteDatabase): Iterable<TRow> {
return db.prepare(this.queryText).iterate<TRow>(...this.params);
}
}
/** Builds a parameterized query. Nested queries are inlined; other interpolations become `?` parameters. */
export function sql(strings: TemplateStringsArray, ...values: SqlTemplateValue[]): SqlQuery {
let queryText = strings[0] ?? "";
const params: unknown[] = [];
for (let index = 0; index < values.length; index++) {
const value = values[index];
if (value instanceof SqlQuery) {
queryText += value.queryText;
params.push(...value.params);
} else {
queryText += "?";
params.push(value);
}
queryText += strings[index + 1] ?? "";
}
return new SqlQuery(queryText, params);
}
/** Joins trusted query fragments while preserving their parameter order. */
export function joinSqlFragments(fragments: readonly SqlQuery[], separator: string): SqlQuery {
let queryText = "";
const params: unknown[] = [];
for (let index = 0; index < fragments.length; index++) {
if (index > 0) queryText += separator;
const fragment = fragments[index]!;
queryText += fragment.queryText;
params.push(...fragment.params);
}
return new SqlQuery(queryText, params);
}

View File

@@ -0,0 +1,211 @@
import type {
CommitResult,
Context,
Entry,
EntryScan,
EntryStructure,
ForkOptions,
ListElement,
ListReadOptions,
SessionStats,
Storage,
StorageBranchScan,
StoredValue,
UsageRow,
UsageScan,
Value,
ValueList,
Write,
} from "@earendil-works/pi-agent-core";
import { branchTip, prepareStorageCommit } from "@earendil-works/pi-agent-core";
import { appendEntryToBranchIndex, scanBranchEntries, scanBranchEntryStructures } from "./session/branch-entries.ts";
import { decodeEntryRow, EntryRowWriter, readAllEntryRows, readEntryRows, scanEntryRows } from "./session/entries.ts";
import { advanceNextSeq, readNextSeq } from "./session/session-sequences.ts";
import { addUsageToSessionStats, incrementMessageCount, readSessionStats } from "./session/session-stats.ts";
import { decodeUsageLedgerRow, scanUsageLedgerRows, UsageLedgerRowWriter } from "./session/usage-ledger.ts";
import {
appendListValueRow,
deleteListValueRows,
deleteScalarValueRow,
readAllScalarValueRows,
readListValueRows,
readScalarValueRow,
scanScalarValueRows,
setScalarValueRow,
} from "./session/values.ts";
import type { SqliteDatabase } from "./types.ts";
export interface SqliteStorageOptions {
sessionId: string;
now?: () => number;
}
export interface SqliteStorageSnapshot {
entries: Entry[];
scalarValues: StoredValue<unknown>[];
entriesComplete: boolean;
}
export class SqliteStorage implements Storage {
private readonly db: SqliteDatabase;
private readonly sessionId: string;
private readonly now: () => number;
private readonly entryWriter: EntryRowWriter;
private readonly usageWriter: UsageLedgerRowWriter;
private commitQueue: Promise<void> = Promise.resolve();
private state: "open" | "closing" | "closed" = "open";
private closePromise: Promise<void> | undefined;
constructor(db: SqliteDatabase, options: SqliteStorageOptions) {
this.db = db;
this.sessionId = options.sessionId;
this.now = options.now ?? Date.now;
this.entryWriter = new EntryRowWriter(db, this.sessionId);
this.usageWriter = new UsageLedgerRowWriter(db, this.sessionId);
}
async commit(writes: Write[], _context: Context): Promise<CommitResult> {
if (this.state !== "open") throw new Error("SqliteStorage is closed");
const result = this.commitQueue.then(() => this.applyCommit(writes));
this.commitQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}
getEntries(ids: string[], _context: Context): Promise<Map<string, Entry>> {
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
const rowsById = new Map(readEntryRows(this.db, this.sessionId, ids).map((row) => [row.id, row]));
const entries = new Map<string, Entry>();
for (const id of ids) {
const row = rowsById.get(id);
if (row !== undefined) entries.set(id, decodeEntryRow(row));
}
return Promise.resolve(entries);
}
getValue<T>(address: Value<T>, _context: Context): Promise<StoredValue<T> | undefined> {
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
return Promise.resolve(readScalarValueRow(this.db, this.sessionId, address));
}
scanValues<T>(prefix: Value<T>, _context: Context): Promise<StoredValue<T>[]> {
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
return Promise.resolve(scanScalarValueRows(this.db, this.sessionId, prefix));
}
async readList<T>(
address: ValueList<T>,
options: ListReadOptions | undefined,
_context: Context,
): Promise<ListElement<T>[]> {
if (this.state !== "open") throw new Error("SqliteStorage is closed");
return readListValueRows(this.db, this.sessionId, address, options);
}
scanBranch(query: StorageBranchScan, _context: Context): Promise<Entry[]> {
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
return Promise.resolve().then(() => scanBranchEntries(this.db, this.sessionId, query));
}
scanBranchStructure(query: StorageBranchScan, _context: Context): Promise<EntryStructure[]> {
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
return Promise.resolve().then(() => scanBranchEntryStructures(this.db, this.sessionId, query));
}
scanEntries(query: EntryScan, _context: Context): Promise<Entry[]> {
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
return Promise.resolve(scanEntryRows(this.db, this.sessionId, query).map(decodeEntryRow));
}
scanUsage(query: UsageScan, _context: Context): Promise<UsageRow[]> {
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
return Promise.resolve(scanUsageLedgerRows(this.db, this.sessionId, query).map(decodeUsageLedgerRow));
}
getStats(_context: Context): Promise<SessionStats> {
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
return Promise.resolve(readSessionStats(this.db, this.sessionId));
}
snapshot(options: ForkOptions, _context: Context): Promise<SqliteStorageSnapshot> {
if (this.state !== "open") return Promise.reject(new Error("SqliteStorage is closed"));
const result = this.commitQueue.then(() => this.readSnapshot(options));
this.commitQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}
private readSnapshot(options: ForkOptions): SqliteStorageSnapshot {
const scalarValues = readAllScalarValueRows(this.db, this.sessionId);
return {
entries: this.readSnapshotEntries(options, scalarValues),
scalarValues,
entriesComplete: options.scope === "tree",
};
}
private readSnapshotEntries(options: ForkOptions, scalarValues: readonly StoredValue<unknown>[]): Entry[] {
if (options.scope === "tree") return readAllEntryRows(this.db, this.sessionId).map(decodeEntryRow);
const sourceAddress = branchTip(options.branch);
const sourceTip = scalarValues.find(
(stored) => stored.address.namespace === sourceAddress.namespace && stored.address.key === sourceAddress.key,
) as StoredValue<string | null> | undefined;
if (sourceTip === undefined) throw new Error(`Unknown source branch: ${options.branch}`);
return sourceTip.value === null
? []
: scanBranchEntries(this.db, this.sessionId, { start: sourceTip.value, order: "oldestFirst" });
}
private applyCommit(writes: Write[]): CommitResult {
return this.db.transaction(() => {
const firstSeq = readNextSeq(this.db, this.sessionId);
const prepared = prepareStorageCommit(writes, firstSeq, this.now());
for (const write of prepared.writes) {
switch (write.kind) {
case "entry": {
const { kind: _kind, ...entry } = write;
this.entryWriter.insert(entry);
appendEntryToBranchIndex(this.db, this.sessionId, entry);
if (entry.type === "message") incrementMessageCount(this.db, this.sessionId);
break;
}
case "usage": {
const { kind: _kind, ...row } = write;
this.usageWriter.insert(row);
addUsageToSessionStats(this.db, this.sessionId, row.usage);
break;
}
case "value":
if (write.op === "delete") {
deleteScalarValueRow(this.db, this.sessionId, write.namespace, write.key);
} else {
setScalarValueRow(this.db, this.sessionId, write.namespace, write.key, write.seq, write.value);
}
break;
case "list":
if (write.op === "delete") {
deleteListValueRows(this.db, this.sessionId, write.namespace, write.key);
} else {
appendListValueRow(this.db, this.sessionId, write.namespace, write.key, write.seq, write.value);
}
break;
}
}
advanceNextSeq(this.db, this.sessionId, firstSeq + prepared.writes.length);
return { ...prepared.result, stats: readSessionStats(this.db, this.sessionId) };
});
}
close(_context: Context): Promise<void> {
if (this.closePromise !== undefined) return this.closePromise;
this.state = "closing";
this.closePromise = this.commitQueue.then(() => {
this.state = "closed";
});
return this.closePromise;
}
}

View File

@@ -0,0 +1,31 @@
/** Result of a prepared SQLite statement execution. */
export interface SqliteRunResult {
changes: number;
lastInsertRowid?: number;
}
/** Prepared SQLite statement capability used by the SQLite session backend. */
export interface SqliteStatement {
run(...params: unknown[]): SqliteRunResult;
get<TRow extends object>(...params: unknown[]): TRow | undefined;
all<TRow extends object>(...params: unknown[]): TRow[];
iterate<TRow extends object>(...params: unknown[]): Iterable<TRow>;
}
/** SQLite database capability used by the SQLite session backend. */
export interface SqliteDatabase {
exec(sql: string): void;
prepare(sql: string): SqliteStatement;
/** Runs a synchronous write transaction. The callback must not return a promise. */
transaction<T>(callback: () => T): T;
close(): void;
}
export interface SqliteDatabaseFactory {
/** Open a writable database, creating it when absent. */
open(path: string): Promise<SqliteDatabase>;
/** Open a writable database without creating it. */
openExisting(path: string): Promise<SqliteDatabase>;
/** Open an existing database read-only. */
openReadOnly(path: string): Promise<SqliteDatabase>;
}

View File

@@ -0,0 +1,102 @@
import { access, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createNodeSqliteFactory } from "../src/index.ts";
async function withTempDir<T>(run: (directory: string) => Promise<T>): Promise<T> {
const directory = await mkdtemp(join(tmpdir(), "pi-sqlite-adapter-"));
try {
return await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
describe("node:sqlite adapter", () => {
it("does not create files for existing or read-only opens", async () => {
await withTempDir(async (directory) => {
const path = join(directory, "missing % #.sqlite");
const factory = createNodeSqliteFactory();
await expect(factory.openExisting(path)).rejects.toThrow();
await expect(access(path)).rejects.toThrow();
await expect(factory.openReadOnly(path)).rejects.toThrow();
await expect(access(path)).rejects.toThrow();
});
});
it("opens an existing database read-write or read-only without changing its mode", async () => {
await withTempDir(async (directory) => {
const path = join(directory, "existing % #.sqlite");
const factory = createNodeSqliteFactory();
const created = await factory.open(path);
created.exec("CREATE TABLE values_table (value INTEGER NOT NULL)");
created.close();
const writable = await factory.openExisting(path);
writable.exec("INSERT INTO values_table (value) VALUES (1)");
writable.close();
const readOnly = await factory.openReadOnly(path);
try {
expect(readOnly.prepare("SELECT value FROM values_table").all()).toEqual([{ value: 1 }]);
expect(() => readOnly.exec("INSERT INTO values_table (value) VALUES (2)")).toThrow();
} finally {
readOnly.close();
}
});
});
it("commits a synchronous transaction and returns its result", async () => {
const db = await createNodeSqliteFactory().open(":memory:");
try {
db.exec("CREATE TABLE values_table (value INTEGER NOT NULL)");
const result = db.transaction(() => {
db.prepare("INSERT INTO values_table (value) VALUES (?)").run(42);
return "committed";
});
expect(result).toBe("committed");
expect(db.prepare("SELECT value FROM values_table").get()).toEqual({ value: 42 });
} finally {
db.close();
}
});
it("forwards positional and named statement parameters", async () => {
const db = await createNodeSqliteFactory().open(":memory:");
try {
db.exec("CREATE TABLE values_table (id INTEGER PRIMARY KEY, value TEXT NOT NULL)");
expect(db.prepare("INSERT INTO values_table (value) VALUES (?)").run("positional")).toEqual({
changes: 1,
lastInsertRowid: 1,
});
expect(db.prepare("INSERT INTO values_table (value) VALUES (:value)").run({ value: "named" })).toEqual({
changes: 1,
lastInsertRowid: 2,
});
expect(db.prepare("SELECT value FROM values_table WHERE id = ?").get(1)).toEqual({ value: "positional" });
expect(db.prepare("SELECT value FROM values_table WHERE id >= :id ORDER BY id").all({ id: 2 })).toEqual([
{ value: "named" },
]);
} finally {
db.close();
}
});
it("rejects asynchronous transaction callbacks", async () => {
const db = await createNodeSqliteFactory().open(":memory:");
try {
db.exec("CREATE TABLE values_table (value INTEGER NOT NULL)");
const asynchronous = async () => {
db.prepare("INSERT INTO values_table (value) VALUES (?)").run(42);
await Promise.resolve();
};
expect(() => db.transaction(asynchronous)).toThrow("SQLite transaction callbacks must be synchronous");
await Promise.resolve();
expect(db.prepare("SELECT value FROM values_table").all()).toEqual([]);
} finally {
db.close();
}
});
});

View File

@@ -0,0 +1,67 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
type ConformanceCase,
createSessionRepoConformance,
} from "@earendil-works/pi-agent-core/harness/session/testing";
import { describe, it } from "vitest";
import { createNodeSqliteFactory, SqliteSessionRepo } from "../src/index.ts";
const NOW = 1_700_000_000_000;
function registerConformance(name: string, cases: readonly ConformanceCase[]): void {
describe(name, () => {
for (const group of new Set(cases.map((testCase) => testCase.group))) {
describe(group, () => {
for (const testCase of cases.filter((candidate) => candidate.group === group)) {
it(testCase.name, () => testCase.run());
}
});
}
});
}
let currentDirectory: string | undefined;
let currentSharedDirectory: string | undefined;
async function createConformanceRepo() {
currentDirectory = await mkdtemp(join(tmpdir(), "pi-sqlite-session-repo-conformance-"));
return new SqliteSessionRepo({
directory: currentDirectory,
databaseFactory: createNodeSqliteFactory(),
now: () => NOW,
});
}
async function createSharedContainerConformanceRepo() {
currentSharedDirectory = await mkdtemp(join(tmpdir(), "pi-sqlite-session-repo-shared-conformance-"));
return new SqliteSessionRepo({
directory: currentSharedDirectory,
databasePath: join(currentSharedDirectory, "sessions.sqlite"),
databaseFactory: createNodeSqliteFactory(),
now: () => NOW,
});
}
async function cleanupConformanceRepo() {
if (currentDirectory === undefined) return;
await rm(currentDirectory, { recursive: true, force: true });
currentDirectory = undefined;
}
async function cleanupSharedContainerConformanceRepo() {
if (currentSharedDirectory === undefined) return;
await rm(currentSharedDirectory, { recursive: true, force: true });
currentSharedDirectory = undefined;
}
registerConformance(
"SqliteSessionRepo conformance",
createSessionRepoConformance(createConformanceRepo, cleanupConformanceRepo),
);
registerConformance(
"SqliteSessionRepo shared-container conformance",
createSessionRepoConformance(createSharedContainerConformanceRepo, cleanupSharedContainerConformanceRepo),
);

View File

@@ -0,0 +1,944 @@
import { access, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, relative } from "node:path";
import * as storedValues from "@earendil-works/pi-agent-core";
import * as sessionWrites from "@earendil-works/pi-agent-core";
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
import { describe, expect, it } from "vitest";
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteStatement } from "../src/index.ts";
import { createNodeSqliteFactory, SqliteSessionRepo, sql } from "../src/index.ts";
const TEST_LANE_CONFIGURATION = {
model: { provider: "test", modelId: "test" },
thinkingLevel: "off",
activeToolNames: [],
} satisfies storedValues.LaneConfiguration;
const IDLE_LANE_STATE = {
currentOperationId: null,
lastOperationId: null,
inbox: [],
} satisfies storedValues.LaneState;
async function withTempDir<T>(run: (directory: string) => Promise<T>): Promise<T> {
const directory = await mkdtemp(join(tmpdir(), "pi-sqlite-session-"));
try {
return await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
async function withDb<T>(path: string, run: (db: SqliteDatabase) => Promise<T> | T): Promise<T> {
const db = await createNodeSqliteFactory().open(path);
try {
return await run(db);
} finally {
db.close();
}
}
async function pathExists(path: string): Promise<boolean> {
return access(path).then(
() => true,
() => false,
);
}
class ForwardingDatabase implements SqliteDatabase {
readonly source: SqliteDatabase;
constructor(source: SqliteDatabase) {
this.source = source;
}
exec(query: string): void {
this.source.exec(query);
}
prepare(query: string): SqliteStatement {
return this.source.prepare(query);
}
transaction<T>(callback: () => T): T {
return this.source.transaction(callback);
}
close(): void {
this.source.close();
}
}
class SnapshotBoundaryStatement implements SqliteStatement {
readonly source: SqliteStatement;
readonly afterGet: () => void;
private called = false;
constructor(source: SqliteStatement, afterGet: () => void) {
this.source = source;
this.afterGet = afterGet;
}
run(...params: unknown[]) {
return this.source.run(...params);
}
get<TRow extends object>(...params: unknown[]): TRow | undefined {
const row = this.source.get<TRow>(...params);
if (!this.called) {
this.called = true;
this.afterGet();
}
return row;
}
all<TRow extends object>(...params: unknown[]): TRow[] {
return this.source.all<TRow>(...params);
}
iterate<TRow extends object>(...params: unknown[]): Iterable<TRow> {
return this.source.iterate<TRow>(...params);
}
}
class SnapshotBoundaryDatabase extends ForwardingDatabase {
readonly afterSnapshotEstablished: () => void;
constructor(source: SqliteDatabase, afterSnapshotEstablished: () => void) {
super(source);
this.afterSnapshotEstablished = afterSnapshotEstablished;
}
override prepare(query: string): SqliteStatement {
const statement = this.source.prepare(query);
return query.includes("FROM sessions")
? new SnapshotBoundaryStatement(statement, this.afterSnapshotEstablished)
: statement;
}
}
class SnapshotBoundaryFactory implements SqliteDatabaseFactory {
readonly source = createNodeSqliteFactory();
readonly afterSnapshotEstablished: () => void;
readOnlyOpenCount = 0;
constructor(afterSnapshotEstablished: () => void) {
this.afterSnapshotEstablished = afterSnapshotEstablished;
}
open(path: string): Promise<SqliteDatabase> {
return this.source.open(path);
}
openExisting(path: string): Promise<SqliteDatabase> {
return this.source.openExisting(path);
}
async openReadOnly(path: string): Promise<SqliteDatabase> {
this.readOnlyOpenCount++;
return new SnapshotBoundaryDatabase(await this.source.openReadOnly(path), this.afterSnapshotEstablished);
}
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
class GatedOpenExistingFactory implements SqliteDatabaseFactory {
readonly source = createNodeSqliteFactory();
readonly entered = deferred();
readonly release = deferred();
private gated = false;
arm(): void {
this.gated = true;
}
open(path: string): Promise<SqliteDatabase> {
return this.source.open(path);
}
async openExisting(path: string): Promise<SqliteDatabase> {
if (this.gated) {
this.gated = false;
this.entered.resolve();
await this.release.promise;
}
return this.source.openExisting(path);
}
openReadOnly(path: string): Promise<SqliteDatabase> {
return this.source.openReadOnly(path);
}
}
class CloseTrackingDatabase extends ForwardingDatabase {
closeAttempts = 0;
closeError: Error | undefined;
override close(): void {
this.closeAttempts++;
this.source.close();
if (this.closeError !== undefined) throw this.closeError;
}
}
class CloseTrackingFactory implements SqliteDatabaseFactory {
readonly source = createNodeSqliteFactory();
readonly writableConnections: CloseTrackingDatabase[] = [];
async open(path: string): Promise<SqliteDatabase> {
const db = new CloseTrackingDatabase(await this.source.open(path));
this.writableConnections.push(db);
return db;
}
async openExisting(path: string): Promise<SqliteDatabase> {
const db = new CloseTrackingDatabase(await this.source.openExisting(path));
this.writableConnections.push(db);
return db;
}
openReadOnly(path: string): Promise<SqliteDatabase> {
return this.source.openReadOnly(path);
}
}
function commitLaterSourceState(db: SqliteDatabase): void {
const tip = storedValues.branchTip("main");
const name = storedValues.sessionName;
const label = storedValues.entryLabel("root");
db.transaction(() => {
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
VALUES (${"source"}, ${"child"}, ${"root"}, ${7}, ${"message"}, ${null}, ${2}, ${JSON.stringify({ message: { role: "user", content: "after", timestamp: 2 } })})`.run(
db,
);
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
VALUES (${"source"}, ${"root"}, ${"child"}, ${7}, ${"message"})`.run(db);
sql`UPDATE branch_meta SET tip_entry_id = ${"child"}, tip_seq = ${7}
WHERE session_id = ${"source"} AND branch_id = ${"root"}`.run(db);
for (const [namespace, key, seq, value] of [
[tip.namespace, tip.key, 8, "child"],
[name.namespace, name.key, 9, "after"],
[label.namespace, label.key, 10, "after-label"],
] as const) {
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
VALUES (${"source"}, ${namespace}, ${key}, ${seq}, ${JSON.stringify(value)})
ON CONFLICT(session_id, namespace, key) DO UPDATE SET seq = excluded.seq, value = excluded.value`.run(db);
}
sql`UPDATE sessions SET message_count = ${1}, next_seq = ${11} WHERE id = ${"source"}`.run(db);
});
}
describe("SqliteSessionRepo", () => {
it("creates one branchless initialized session file", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1_700_000_000_000,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
const metadata = session.metadata;
expect(metadata).toMatchObject({
id: "session",
createdAt: 1_700_000_000_000,
storageVersion: 1,
});
expect(metadata.path).toBe(await realpath(join(directory, "session.sqlite")));
await withDb(metadata.path, (db) => {
expect(sql`SELECT COUNT(*) AS count FROM sessions`.get<{ count: number }>(db)).toEqual({ count: 1 });
expect(
sql`SELECT message_count, usage_payload, next_seq FROM sessions WHERE id = ${"session"}`.get(db),
).toEqual({
message_count: 0,
usage_payload: JSON.stringify({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
}),
next_seq: 1,
});
expect(
sql`SELECT namespace, key, seq, value FROM scalar_values WHERE session_id = ${"session"} ORDER BY seq`.all(
db,
),
).toEqual([]);
expect(sql`SELECT COUNT(*) AS count FROM list_values WHERE session_id = ${"session"}`.get(db)).toEqual({
count: 0,
});
});
await session.close(BACKGROUND_CONTEXT);
});
});
it("exposes explicit branch scans through the open-session facade", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1_700_000_000_000,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
await session.mutate(
(mutator) =>
mutator.commit(
[
sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }),
sessionWrites.insertEntry({ id: "child", parentId: "root", type: "custom", customType: "child" }),
],
BACKGROUND_CONTEXT,
),
BACKGROUND_CONTEXT,
);
expect(await session.scanBranch({ start: "child", order: "oldestFirst" }, BACKGROUND_CONTEXT)).toMatchObject([
{ id: "root" },
{ id: "child" },
]);
await session.close(BACKGROUND_CONTEXT);
await expect(session.scanBranch({ start: "child" }, BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed");
});
});
it("commits an explicit mutation through the open-session facade", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1_700_000_000_000,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
const mutation = await session.beginMutation(BACKGROUND_CONTEXT);
const result = await mutation.commit(
[storedValues.setValue(storedValues.sessionName, "explicit")],
BACKGROUND_CONTEXT,
);
expect(result.seqs).toHaveLength(1);
expect(await mutation.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toMatchObject({
value: "explicit",
});
await mutation.end(BACKGROUND_CONTEXT);
expect(await session.getName(BACKGROUND_CONTEXT)).toBe("explicit");
await session.close(BACKGROUND_CONTEXT);
});
});
it("rejects duplicate create without deleting the existing database", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
const { metadata } = session;
await expect(repo.create({ id: "session" }, BACKGROUND_CONTEXT)).rejects.toThrow();
await withDb(metadata.path, (db) => {
expect(sql`SELECT COUNT(*) AS count FROM sessions`.get<{ count: number }>(db)).toEqual({ count: 1 });
});
await session.close(BACKGROUND_CONTEXT);
});
});
it("lists an open session without storage-layer ownership state", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
const { metadata } = session;
await expect(repo.list(undefined, BACKGROUND_CONTEXT)).resolves.toMatchObject([
{ id: "session", path: metadata.path },
]);
await withDb(metadata.path, (db) => {
expect(
sql`SELECT name FROM sqlite_master WHERE type = ${"table"} AND name = ${"writer_lease"}`.get(db),
).toBeUndefined();
});
await session.close(BACKGROUND_CONTEXT);
});
});
it("ignores a stale writer_lease table from a pre-WP07 WIP database", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const created = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
await created.close(BACKGROUND_CONTEXT);
await withDb(created.metadata.path, (db) => {
db.exec(
"CREATE TABLE writer_lease (session_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, fence INTEGER NOT NULL, expires_at_ms INTEGER NOT NULL) WITHOUT ROWID",
);
sql`INSERT INTO writer_lease (session_id, owner_id, fence, expires_at_ms)
VALUES (${"session"}, ${"stale"}, ${7}, ${999})`.run(db);
});
const reopened = await repo.open(created.metadata, BACKGROUND_CONTEXT);
await reopened.setName("works", BACKGROUND_CONTEXT);
await reopened.close(BACKGROUND_CONTEXT);
await withDb(created.metadata.path, (db) => {
expect(sql`SELECT owner_id, fence FROM writer_lease WHERE session_id = ${"session"}`.get(db)).toEqual({
owner_id: "stale",
fence: 7,
});
});
});
});
it("skips corrupt and incompatible files during list discovery", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
await session.close(BACKGROUND_CONTEXT);
await writeFile(join(directory, "corrupt.sqlite"), "not a sqlite database");
await withDb(session.metadata.path, (db) => {
sql`UPDATE sessions SET storage_version = ${999} WHERE id = ${"session"}`.run(db);
});
expect(await repo.list(undefined, BACKGROUND_CONTEXT)).toEqual([]);
});
});
it("does not remove a pre-existing non-database file when create fails", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const path = join(directory, "session.sqlite");
await writeFile(path, "not a sqlite database");
await expect(repo.create({ id: "session" }, BACKGROUND_CONTEXT)).rejects.toThrow();
await expect(access(path)).resolves.toBeUndefined();
});
});
it("rejects delete for missing files and deletes a closed session", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
const { metadata } = session;
await session.close(BACKGROUND_CONTEXT);
await expect(
repo.delete({ ...metadata, path: join(directory, "missing.sqlite") }, BACKGROUND_CONTEXT),
).rejects.toThrow();
await repo.delete(metadata, BACKGROUND_CONTEXT);
await expect(access(metadata.path)).rejects.toThrow();
});
});
it("closes open sessions through repo close and rejects later operations", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
await repo.close(BACKGROUND_CONTEXT);
await expect(session.getStats(BACKGROUND_CONTEXT)).rejects.toThrow("Session is closed");
await expect(repo.list(undefined, BACKGROUND_CONTEXT)).rejects.toThrow("SqliteSessionRepo is closed");
await expect(repo.create({ id: "other" }, BACKGROUND_CONTEXT)).rejects.toThrow("SqliteSessionRepo is closed");
await expect(repo.close(BACKGROUND_CONTEXT)).resolves.toBeUndefined();
});
});
it("opens a session through the version gate and rejects a duplicate local handle", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const created = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
const { metadata } = created;
await created.close(BACKGROUND_CONTEXT);
const opened = await repo.open(metadata, BACKGROUND_CONTEXT);
expect(opened.metadata).toMatchObject({ id: "session", path: metadata.path });
await expect(repo.open(metadata, BACKGROUND_CONTEXT)).rejects.toThrow("already open");
await opened.close(BACKGROUND_CONTEXT);
});
});
it("isolates sessions stored in one shared SQLite container", async () => {
await withTempDir(async (directory) => {
const databasePath = join(directory, "sessions.sqlite");
const repo = new SqliteSessionRepo({
directory,
databasePath,
databaseFactory: createNodeSqliteFactory(),
now: () => 1_700_000_000_000,
});
const left = await repo.create({ id: "left" }, BACKGROUND_CONTEXT);
const right = await repo.create({ id: "right" }, BACKGROUND_CONTEXT);
expect(left.metadata.path).toBe(await realpath(databasePath));
expect(right.metadata.path).toBe(await realpath(databasePath));
await left.mutate(
(mutator) =>
mutator.commit(
[
sessionWrites.insertEntry({ id: "left-root", parentId: null, type: "custom", customType: "left" }),
storedValues.setValue(storedValues.sessionName, "left-name"),
],
BACKGROUND_CONTEXT,
),
BACKGROUND_CONTEXT,
);
await right.mutate(
(mutator) =>
mutator.commit(
[
sessionWrites.insertEntry({
id: "right-root",
parentId: null,
type: "custom",
customType: "right",
}),
storedValues.setValue(storedValues.sessionName, "right-name"),
],
BACKGROUND_CONTEXT,
),
BACKGROUND_CONTEXT,
);
expect((await repo.list(undefined, BACKGROUND_CONTEXT)).map((metadata) => metadata.id).sort()).toEqual([
"left",
"right",
]);
expect(await left.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toMatchObject({
value: "left-name",
});
expect(await right.getValue(storedValues.sessionName, BACKGROUND_CONTEXT)).toMatchObject({
value: "right-name",
});
expect((await left.getEntries(["left-root", "right-root"], BACKGROUND_CONTEXT)).has("right-root")).toBe(false);
expect((await right.getEntries(["left-root", "right-root"], BACKGROUND_CONTEXT)).has("left-root")).toBe(false);
await Promise.all([left.close(BACKGROUND_CONTEXT), right.close(BACKGROUND_CONTEXT)]);
});
});
it("does not copy usage ledger rows when forking", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1_700_000_000_000,
});
const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT);
await source.mutate(
(mutator) =>
mutator.commit(
[
sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }),
sessionWrites.insertEntry({
id: "child",
parentId: "root",
type: "message",
message: { role: "user", content: "child", timestamp: 1 },
}),
storedValues.setValue(storedValues.branchTip("main"), "child"),
storedValues.setValue(storedValues.laneConfig("main"), TEST_LANE_CONFIGURATION),
storedValues.setValue(storedValues.laneState("main"), IDLE_LANE_STATE),
sessionWrites.insertUsage({
id: "usage",
adjustment: false,
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
}),
],
BACKGROUND_CONTEXT,
),
BACKGROUND_CONTEXT,
);
await source.close(BACKGROUND_CONTEXT);
const fork = await repo.fork(
source.metadata,
{ id: "fork", scope: "branch", branch: "main", entryId: "child" },
BACKGROUND_CONTEXT,
);
await withDb(fork.metadata.path, (db) => {
expect(
sql`SELECT COUNT(*) AS count FROM usage_ledger WHERE session_id = ${"fork"}`.get<{ count: number }>(db),
).toEqual({ count: 0 });
});
await Promise.all([source.close(BACKGROUND_CONTEXT), fork.close(BACKGROUND_CONTEXT)]);
});
});
it("does not create databases for missing open, list, fork, or delete targets", async () => {
await withTempDir(async (root) => {
const directory = join(root, "missing-directory");
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const missing = {
id: "missing",
createdAt: 1,
storageVersion: 1,
path: join(directory, "missing.sqlite"),
};
await expect(repo.list(undefined, BACKGROUND_CONTEXT)).resolves.toEqual([]);
expect(await pathExists(directory)).toBe(false);
await expect(repo.open(missing, BACKGROUND_CONTEXT)).rejects.toThrow();
await expect(repo.delete(missing, BACKGROUND_CONTEXT)).rejects.toThrow();
await expect(repo.fork(missing, { id: "fork", scope: "tree" }, BACKGROUND_CONTEXT)).rejects.toThrow();
expect(await pathExists(missing.path)).toBe(false);
expect(await pathExists(join(directory, "fork.sqlite"))).toBe(false);
});
});
it("reserves deletion against local create, open, and fork destinations", async () => {
await withTempDir(async (directory) => {
const databaseFactory = new GatedOpenExistingFactory();
const repo = new SqliteSessionRepo({ directory, databaseFactory, now: () => 1 });
const target = await repo.create({ id: "target" }, BACKGROUND_CONTEXT);
const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT);
await expect(repo.delete(target.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("already open");
// The host closes a worker before deletion; only same-repository exclusion is promised here.
await target.close(BACKGROUND_CONTEXT);
databaseFactory.arm();
const deleting = repo.delete(target.metadata, BACKGROUND_CONTEXT);
await databaseFactory.entered.promise;
await expect(repo.create({ id: "target" }, BACKGROUND_CONTEXT)).rejects.toThrow("already open");
await expect(repo.open(target.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("already open");
await expect(repo.fork(source.metadata, { id: "target", scope: "tree" }, BACKGROUND_CONTEXT)).rejects.toThrow(
"already open",
);
databaseFactory.release.resolve();
await deleting;
await source.close(BACKGROUND_CONTEXT);
});
});
it("deletes only the selected Session from a shared container", async () => {
await withTempDir(async (directory) => {
const databasePath = join(directory, "shared.sqlite");
const repo = new SqliteSessionRepo({
directory,
databasePath,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const removed = await repo.create({ id: "removed" }, BACKGROUND_CONTEXT);
const retained = await repo.create({ id: "retained" }, BACKGROUND_CONTEXT);
await removed.setName("removed", BACKGROUND_CONTEXT);
await retained.setName("retained", BACKGROUND_CONTEXT);
await removed.close(BACKGROUND_CONTEXT);
await repo.delete(removed.metadata, BACKGROUND_CONTEXT);
expect((await repo.list(undefined, BACKGROUND_CONTEXT)).map(({ id }) => id)).toEqual(["retained"]);
expect(await retained.getName(BACKGROUND_CONTEXT)).toBe("retained");
await expect(repo.open(removed.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("Unknown SQLite session");
await retained.close(BACKGROUND_CONTEXT);
});
});
it("removes per-file WAL and SHM sidecars", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
await session.close(BACKGROUND_CONTEXT);
await writeFile(`${session.metadata.path}-wal`, "");
await writeFile(`${session.metadata.path}-shm`, "");
await repo.delete(session.metadata, BACKGROUND_CONTEXT);
await expect(access(session.metadata.path)).rejects.toThrow();
await expect(access(`${session.metadata.path}-wal`)).rejects.toThrow();
await expect(access(`${session.metadata.path}-shm`)).rejects.toThrow();
});
});
it("creates the parent of a custom shared-container path", async () => {
await withTempDir(async (directory) => {
const databasePath = join(directory, "nested", "containers", "sessions.sqlite");
const repo = new SqliteSessionRepo({
directory: join(directory, "unrelated"),
databasePath,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const session = await repo.create({ id: "session" }, BACKGROUND_CONTEXT);
expect(session.metadata.path).toBe(await realpath(databasePath));
await session.close(BACKGROUND_CONTEXT);
});
});
it("encodes unsafe explicit IDs inside the repository directory and round-trips them", async () => {
await withTempDir(async (directory) => {
const repo = new SqliteSessionRepo({
directory,
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const ids = ["../escape", "slash/id", "back\\slash", "percent%id", "..", "dots...id", "ユニコード"];
const metadata = [];
for (const id of ids) {
const session = await repo.create({ id }, BACKGROUND_CONTEXT);
metadata.push(session.metadata);
await session.close(BACKGROUND_CONTEXT);
}
const canonicalDirectory = await realpath(directory);
for (const stored of metadata) {
const fromDirectory = relative(canonicalDirectory, stored.path);
expect(isAbsolute(fromDirectory)).toBe(false);
expect(fromDirectory.startsWith(".."), stored.id).toBe(false);
expect(dirname(stored.path)).toBe(canonicalDirectory);
}
expect((await repo.list(undefined, BACKGROUND_CONTEXT)).map(({ id }) => id).sort()).toEqual([...ids].sort());
for (const stored of metadata) {
const reopened = await repo.open(stored, BACKGROUND_CONTEXT);
expect(reopened.metadata.id).toBe(stored.id);
await reopened.close(BACKGROUND_CONTEXT);
}
const fork = await repo.fork(metadata[0]!, { id: "fork/../%", scope: "tree" }, BACKGROUND_CONTEXT);
expect(fork.metadata).toMatchObject({ id: "fork/../%", parentSessionId: "../escape" });
expect(dirname(fork.metadata.path)).toBe(canonicalDirectory);
await fork.close(BACKGROUND_CONTEXT);
});
});
it("uses exact physical identity for foreign fork sources and rejects foreign writable metadata", async () => {
await withTempDir(async (root) => {
const leftRepo = new SqliteSessionRepo({
directory: join(root, "left"),
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const rightRepo = new SqliteSessionRepo({
directory: join(root, "right"),
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const left = await leftRepo.create({ id: "same" }, BACKGROUND_CONTEXT);
const right = await rightRepo.create({ id: "same" }, BACKGROUND_CONTEXT);
await left.mutate(
(mutator) =>
mutator.commit(
[
sessionWrites.insertEntry({ id: "left-root", parentId: null, type: "custom", customType: "left" }),
storedValues.setValue(storedValues.branchTip("main"), "left-root"),
storedValues.setValue(storedValues.laneConfig("main"), TEST_LANE_CONFIGURATION),
storedValues.setValue(storedValues.laneState("main"), IDLE_LANE_STATE),
storedValues.setValue(storedValues.sessionName, "left"),
],
BACKGROUND_CONTEXT,
),
BACKGROUND_CONTEXT,
);
await right.mutate(
(mutator) =>
mutator.commit(
[
sessionWrites.insertEntry({
id: "right-root",
parentId: null,
type: "custom",
customType: "right",
}),
storedValues.setValue(storedValues.branchTip("main"), "right-root"),
storedValues.setValue(storedValues.sessionName, "right"),
],
BACKGROUND_CONTEXT,
),
BACKGROUND_CONTEXT,
);
const fork = await rightRepo.fork(
left.metadata,
{ id: "fork-left", scope: "branch", branch: "main" },
BACKGROUND_CONTEXT,
);
expect((await fork.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => id)).toEqual([
"left-root",
]);
expect(await fork.getName(BACKGROUND_CONTEXT)).toBe("left");
await right.close(BACKGROUND_CONTEXT);
await expect(rightRepo.open(left.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("outside this repository");
await expect(rightRepo.delete(left.metadata, BACKGROUND_CONTEXT)).rejects.toThrow("outside this repository");
await Promise.all([left.close(BACKGROUND_CONTEXT), fork.close(BACKGROUND_CONTEXT)]);
});
});
for (const layout of ["per-file", "shared"] as const) {
it(`forks a live ${layout} source through one coherent read-only WAL snapshot`, async () => {
await withTempDir(async (directory) => {
const databasePath = layout === "shared" ? join(directory, "shared.sqlite") : undefined;
const workerRepo = new SqliteSessionRepo({
directory,
...(databasePath === undefined ? {} : { databasePath }),
databaseFactory: createNodeSqliteFactory(),
now: () => 1,
});
const source = await workerRepo.create({ id: "source" }, BACKGROUND_CONTEXT);
await source.mutate(
(mutator) =>
mutator.commit(
[
sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }),
storedValues.setValue(storedValues.branchTip("main"), "root"),
storedValues.setValue(storedValues.laneConfig("main"), TEST_LANE_CONFIGURATION),
storedValues.setValue(storedValues.laneState("main"), IDLE_LANE_STATE),
storedValues.setValue(storedValues.sessionName, "before"),
storedValues.setValue(storedValues.entryLabel("root"), "before-label"),
],
BACKGROUND_CONTEXT,
),
BACKGROUND_CONTEXT,
);
const writerDb = await createNodeSqliteFactory().openExisting(source.metadata.path);
writerDb.exec("PRAGMA busy_timeout = 5000;");
let laterCommitCompleted = false;
const databaseFactory = new SnapshotBoundaryFactory(() => {
if (laterCommitCompleted) return;
commitLaterSourceState(writerDb);
laterCommitCompleted = true;
});
const serverRepo = new SqliteSessionRepo({
directory,
...(databasePath === undefined ? {} : { databasePath }),
databaseFactory,
now: () => 2,
});
const first = await serverRepo.fork(
source.metadata,
{ id: "first-fork", scope: "branch", branch: "main" },
BACKGROUND_CONTEXT,
);
expect(laterCommitCompleted).toBe(true);
expect(databaseFactory.readOnlyOpenCount).toBe(1);
expect((await first.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => id)).toEqual([
"root",
]);
expect(await first.getName(BACKGROUND_CONTEXT)).toBe("before");
expect(await first.getLabel("root", BACKGROUND_CONTEXT)).toBe("before-label");
await expect((await first.branch("main", BACKGROUND_CONTEXT))?.getTipId(BACKGROUND_CONTEXT)).resolves.toBe(
"root",
);
expect((await first.getStats(BACKGROUND_CONTEXT)).messageCount).toBe(0);
const second = await serverRepo.fork(
source.metadata,
{ id: "second-fork", scope: "branch", branch: "main" },
BACKGROUND_CONTEXT,
);
expect(databaseFactory.readOnlyOpenCount).toBe(2);
expect((await second.findEntries({ order: "asc" }, BACKGROUND_CONTEXT)).map(({ id }) => id)).toEqual([
"root",
"child",
]);
expect(await second.getName(BACKGROUND_CONTEXT)).toBe("after");
expect(await second.getLabel("root", BACKGROUND_CONTEXT)).toBe("after-label");
await expect((await second.branch("main", BACKGROUND_CONTEXT))?.getTipId(BACKGROUND_CONTEXT)).resolves.toBe(
"child",
);
expect((await second.getStats(BACKGROUND_CONTEXT)).messageCount).toBe(1);
writerDb.close();
await Promise.all([
source.close(BACKGROUND_CONTEXT),
first.close(BACKGROUND_CONTEXT),
second.close(BACKGROUND_CONTEXT),
]);
});
});
}
it("waits for every Session close and reports one or multiple failures after settlement", async () => {
await withTempDir(async (directory) => {
const multipleFactory = new CloseTrackingFactory();
const multipleRepo = new SqliteSessionRepo({
directory: join(directory, "multiple"),
databaseFactory: multipleFactory,
now: () => 1,
});
await multipleRepo.create({ id: "first" }, BACKGROUND_CONTEXT);
await multipleRepo.create({ id: "second" }, BACKGROUND_CONTEXT);
await multipleRepo.create({ id: "third" }, BACKGROUND_CONTEXT);
const firstError = new Error("first close failed");
const secondError = new Error("second close failed");
multipleFactory.writableConnections[0]!.closeError = firstError;
multipleFactory.writableConnections[1]!.closeError = secondError;
const multipleClose = multipleRepo.close(BACKGROUND_CONTEXT);
expect(multipleRepo.close(BACKGROUND_CONTEXT)).toBe(multipleClose);
let multipleFailure: unknown;
try {
await multipleClose;
} catch (error) {
multipleFailure = error;
}
expect(multipleFailure).toBeInstanceOf(AggregateError);
if (!(multipleFailure instanceof AggregateError)) throw new Error("Expected AggregateError");
expect(multipleFailure.errors).toEqual([firstError, secondError]);
expect(multipleFactory.writableConnections.map(({ closeAttempts }) => closeAttempts)).toEqual([1, 1, 1]);
const singleFactory = new CloseTrackingFactory();
const singleRepo = new SqliteSessionRepo({
directory: join(directory, "single"),
databaseFactory: singleFactory,
now: () => 1,
});
await singleRepo.create({ id: "first" }, BACKGROUND_CONTEXT);
await singleRepo.create({ id: "second" }, BACKGROUND_CONTEXT);
const singleError = new Error("single close failed");
singleFactory.writableConnections[0]!.closeError = singleError;
await expect(singleRepo.close(BACKGROUND_CONTEXT)).rejects.toBe(singleError);
expect(singleFactory.writableConnections.map(({ closeAttempts }) => closeAttempts)).toEqual([1, 1]);
});
});
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { createNodeSqliteFactory, joinSqlFragments, sql } from "../src/index.ts";
describe("sql", () => {
it("composes SQLite queries without renumbering parameters", async () => {
const db = await createNodeSqliteFactory().open(":memory:");
try {
sql`CREATE TABLE entries (id TEXT PRIMARY KEY, kind TEXT NOT NULL, active INTEGER NOT NULL)`.exec(db);
sql`INSERT INTO entries (id, kind, active) VALUES (${"one"}, ${"message"}, ${1})`.run(db);
sql`INSERT INTO entries (id, kind, active) VALUES (${"two"}, ${"message"}, ${0})`.run(db);
const filters = joinSqlFragments([sql`kind = ${"message"}`, sql`active = ${1}`], " AND ");
expect(sql`SELECT id FROM entries WHERE ${filters} LIMIT ${10}`.all<{ id: string }>(db)).toEqual([
{ id: "one" },
]);
} finally {
db.close();
}
});
it("executes parameterized queries", async () => {
const db = await createNodeSqliteFactory().open(":memory:");
try {
sql`CREATE TABLE values_table (id INTEGER PRIMARY KEY, value TEXT NOT NULL)`.exec(db);
sql`INSERT INTO values_table (id, value) VALUES (${1}, ${"one"})`.run(db);
sql`INSERT INTO values_table (id, value) VALUES (${2}, ${"two"})`.run(db);
expect(sql`SELECT value FROM values_table WHERE id = ${1}`.get<{ value: string }>(db)).toEqual({
value: "one",
});
expect(sql`SELECT value FROM values_table ORDER BY id`.all<{ value: string }>(db)).toEqual([
{ value: "one" },
{ value: "two" },
]);
} finally {
db.close();
}
});
});

View File

@@ -0,0 +1,61 @@
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core";
import {
type ConformanceCase,
createStorageConformance,
type StorageFixture,
} from "@earendil-works/pi-agent-core/harness/session/testing";
import { describe, it } from "vitest";
import { createNodeSqliteFactory, SQLITE_STORAGE_VERSION, SqliteStorage, sql } from "../src/index.ts";
import { applyInitialSchema } from "../src/sqlite/migrations.ts";
const SESSION_ID = "session";
const NOW = 1_700_000_000_000;
const EMPTY_USAGE = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
function registerConformance(name: string, cases: readonly ConformanceCase[]): void {
describe(name, () => {
for (const group of new Set(cases.map((testCase) => testCase.group))) {
describe(group, () => {
for (const testCase of cases.filter((candidate) => candidate.group === group)) {
it(testCase.name, () => testCase.run());
}
});
}
});
}
registerConformance(
"SqliteStorage conformance",
createStorageConformance(async () => {
const db = await createNodeSqliteFactory().open(":memory:");
try {
await applyInitialSchema(db);
sql`INSERT INTO sessions
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
VALUES (${SESSION_ID}, ${NOW}, ${null}, ${SQLITE_STORAGE_VERSION}, ${null}, ${0}, ${JSON.stringify(EMPTY_USAGE)}, ${1})`.run(
db,
);
const storage = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => NOW });
return {
storage,
async [Symbol.asyncDispose]() {
try {
await storage.close(BACKGROUND_CONTEXT);
} finally {
db.close();
}
},
} satisfies StorageFixture;
} catch (error) {
db.close();
throw error;
}
}),
);

View File

@@ -0,0 +1,696 @@
import * as storedValues from "@earendil-works/pi-agent-core";
import * as sessionWrites from "@earendil-works/pi-agent-core";
import { BACKGROUND_CONTEXT, prepareStorageCommit } from "@earendil-works/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
createNodeSqliteFactory,
type SqliteDatabase,
type SqliteStatement,
SqliteStorage,
sql,
} from "../src/index.ts";
import { applyInitialSchema } from "../src/sqlite/migrations.ts";
import { advanceNextSeq, readNextSeq } from "../src/sqlite/session/session-sequences.ts";
import { listValueReadQuery } from "../src/sqlite/session/values.ts";
const SESSION_ID = "session";
class TransactionCountingDatabase implements SqliteDatabase {
readonly source: SqliteDatabase;
transactionCount = 0;
constructor(source: SqliteDatabase) {
this.source = source;
}
exec(query: string): void {
this.source.exec(query);
}
prepare(query: string): SqliteStatement {
return this.source.prepare(query);
}
transaction<T>(callback: () => T): T {
this.transactionCount++;
return this.source.transaction(callback);
}
close(): void {
this.source.close();
}
}
const ZERO_USAGE = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
async function withStorage<T>(run: (storage: SqliteStorage, db: SqliteDatabase) => Promise<T>): Promise<T> {
const db = await createNodeSqliteFactory().open(":memory:");
try {
await applyInitialSchema(db);
const storage = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => 1_700_000_000_000 });
return await run(storage, db);
} finally {
db.close();
}
}
function explainQueryPlan(db: SqliteDatabase, query: string, ...params: unknown[]): string[] {
return db
.prepare(`EXPLAIN QUERY PLAN ${query}`)
.all<{ detail: string }>(...params)
.map((row) => row.detail);
}
function expectBranchPlan(plan: string[]): void {
expect(plan.some((detail) => detail.includes("SEARCH b USING COVERING INDEX ix_be_seq"))).toBe(true);
expect(plan.some((detail) => detail.includes("SEARCH e USING PRIMARY KEY"))).toBe(true);
expect(plan.some((detail) => detail.includes("USE TEMP B-TREE"))).toBe(false);
expect(plan.some((detail) => detail.includes("SCAN e"))).toBe(false);
}
function insertCommitSessionRow(db: SqliteDatabase, nextSeq = 1): void {
sql`INSERT INTO sessions
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
VALUES (
${SESSION_ID},
${1},
${null},
${1},
${null},
${0},
${JSON.stringify(ZERO_USAGE)},
${nextSeq}
)`.run(db);
}
describe("SqliteStorage", () => {
it("uses one write transaction for an ordinary commit", async () => {
const db = new TransactionCountingDatabase(await createNodeSqliteFactory().open(":memory:"));
try {
await applyInitialSchema(db);
insertCommitSessionRow(db);
const storage = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => 1_700_000_000_000 });
await storage.commit([storedValues.setValue(storedValues.sessionName, "name")], BACKGROUND_CONTEXT);
expect(db.transactionCount).toBe(1);
await storage.close(BACKGROUND_CONTEXT);
} finally {
db.close();
}
});
it("commits root entries and append-to-tip entries into the branch index", async () => {
await withStorage(async (storage, db) => {
insertCommitSessionRow(db);
expect(
await storage.commit(
[
sessionWrites.insertEntry({
id: "root",
parentId: null,
type: "message",
message: { role: "user", content: "root", timestamp: 10 },
}),
],
BACKGROUND_CONTEXT,
),
).toEqual({
firstSeq: 1,
seqs: [1],
timestamp: 1_700_000_000_000,
stats: { messageCount: 1, usage: ZERO_USAGE },
});
expect(
await storage.commit(
[
sessionWrites.insertEntry({
id: "child",
parentId: "root",
type: "message",
message: { role: "user", content: "child", timestamp: 11 },
}),
],
BACKGROUND_CONTEXT,
),
).toEqual({
firstSeq: 2,
seqs: [2],
timestamp: 1_700_000_000_000,
stats: { messageCount: 2, usage: ZERO_USAGE },
});
expect(sql`SELECT branch_id, tip_entry_id, tip_seq FROM branch_meta`.all(db)).toEqual([
{ branch_id: "root", tip_entry_id: "child", tip_seq: 2 },
]);
expect(
sql`SELECT branch_id, entry_id, entry_seq, entry_type FROM branch_entries ORDER BY entry_seq`.all(db),
).toEqual([
{ branch_id: "root", entry_id: "root", entry_seq: 1, entry_type: "message" },
{ branch_id: "root", entry_id: "child", entry_seq: 2, entry_type: "message" },
]);
expect((await storage.scanBranch({ start: "child" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([
"child",
"root",
]);
});
});
it("commits divergent branch entries by materializing a new segment", async () => {
await withStorage(async (storage, db) => {
insertCommitSessionRow(db);
await storage.commit(
[
sessionWrites.insertEntry({
id: "root",
parentId: null,
type: "message",
message: { role: "user", content: "root", timestamp: 10 },
}),
sessionWrites.insertEntry({
id: "left",
parentId: "root",
type: "message",
message: { role: "user", content: "left", timestamp: 11 },
}),
sessionWrites.insertEntry({
id: "right",
parentId: "root",
type: "message",
message: { role: "user", content: "right", timestamp: 12 },
}),
],
BACKGROUND_CONTEXT,
);
expect(
sql`SELECT branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq FROM branch_meta ORDER BY branch_id`.all(
db,
),
).toEqual([
{ branch_id: "right", tip_entry_id: "right", tip_seq: 3, base_branch_id: null, base_seq: null },
{ branch_id: "root", tip_entry_id: "left", tip_seq: 2, base_branch_id: null, base_seq: null },
]);
expect(
sql`SELECT branch_id, entry_id, entry_seq, entry_type FROM branch_entries ORDER BY branch_id, entry_seq`.all(
db,
),
).toEqual([
{ branch_id: "right", entry_id: "root", entry_seq: 1, entry_type: "message" },
{ branch_id: "right", entry_id: "right", entry_seq: 3, entry_type: "message" },
{ branch_id: "root", entry_id: "root", entry_seq: 1, entry_type: "message" },
{ branch_id: "root", entry_id: "left", entry_seq: 2, entry_type: "message" },
]);
expect((await storage.scanBranch({ start: "right" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([
"right",
"root",
]);
expect((await storage.scanBranch({ start: "left" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([
"left",
"root",
]);
});
});
it("bases divergent branch segments at the newest compaction", async () => {
await withStorage(async (storage, db) => {
insertCommitSessionRow(db);
await storage.commit(
[
sessionWrites.insertEntry({
id: "root",
parentId: null,
type: "message",
message: { role: "user", content: "root", timestamp: 10 },
}),
sessionWrites.insertEntry({
id: "compact",
parentId: "root",
type: "compaction",
summary: "summary",
retainedTail: [],
tokensBefore: 1,
fromHook: false,
}),
sessionWrites.insertEntry({
id: "left",
parentId: "compact",
type: "message",
message: { role: "user", content: "left", timestamp: 11 },
}),
sessionWrites.insertEntry({
id: "leaf",
parentId: "left",
type: "message",
message: { role: "user", content: "leaf", timestamp: 12 },
}),
sessionWrites.insertEntry({
id: "right",
parentId: "left",
type: "message",
message: { role: "user", content: "right", timestamp: 13 },
}),
],
BACKGROUND_CONTEXT,
);
expect(
sql`SELECT branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq FROM branch_meta WHERE branch_id = ${"right"}`.get(
db,
),
).toEqual({
branch_id: "right",
tip_entry_id: "right",
tip_seq: 5,
base_branch_id: "root",
base_seq: 2,
});
expect(
sql`SELECT branch_id, entry_id, entry_seq, entry_type FROM branch_entries WHERE branch_id = ${"right"} ORDER BY entry_seq`.all(
db,
),
).toEqual([
{ branch_id: "right", entry_id: "left", entry_seq: 3, entry_type: "message" },
{ branch_id: "right", entry_id: "right", entry_seq: 5, entry_type: "message" },
]);
expect((await storage.scanBranch({ start: "right" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([
"right",
"left",
"compact",
"root",
]);
});
});
it("gets entries by requested id order", async () => {
await withStorage(async (storage, db) => {
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
VALUES
(${SESSION_ID}, ${"first"}, ${null}, ${1}, ${"custom"}, ${"note"}, ${10}, ${JSON.stringify({ data: { value: 1 } })}),
(${SESSION_ID}, ${"second"}, ${"first"}, ${2}, ${"message"}, ${null}, ${11}, ${JSON.stringify({ message: { role: "user", content: "hi", timestamp: 11 } })})`.run(
db,
);
const entries = await storage.getEntries(["second", "missing", "first"], BACKGROUND_CONTEXT);
expect([...entries.keys()]).toEqual(["second", "first"]);
expect(entries.get("second")).toMatchObject({ id: "second", type: "message", parentId: "first" });
expect(entries.get("first")).toMatchObject({ id: "first", type: "custom", customType: "note" });
});
});
it("scans decoded entries with filters and sequence bounds", async () => {
await withStorage(async (storage, db) => {
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
VALUES
(${SESSION_ID}, ${"one"}, ${null}, ${1}, ${"custom"}, ${"note"}, ${10}, ${JSON.stringify({ data: 1 })}),
(${SESSION_ID}, ${"two"}, ${"one"}, ${2}, ${"message"}, ${null}, ${11}, ${JSON.stringify({ message: { role: "user", content: "two", timestamp: 11 } })}),
(${SESSION_ID}, ${"three"}, ${"two"}, ${3}, ${"custom"}, ${"note"}, ${12}, ${JSON.stringify({ data: 3 })})`.run(
db,
);
expect(
(await storage.scanEntries({ order: "desc", type: "custom", fromSeq: 2 }, BACKGROUND_CONTEXT)).map(
(entry) => entry.id,
),
).toEqual(["three"]);
expect(
(await storage.scanEntries({ order: "asc", customType: "note", limit: 2 }, BACKGROUND_CONTEXT)).map(
(entry) => entry.id,
),
).toEqual(["one", "three"]);
});
});
it("scans branch entries through materialized branch segments", async () => {
await withStorage(async (storage, db) => {
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
VALUES
(${SESSION_ID}, ${"root"}, ${null}, ${1}, ${"message"}, ${null}, ${10}, ${JSON.stringify({ message: { role: "user", content: "root", timestamp: 10 } })}),
(${SESSION_ID}, ${"compact"}, ${"root"}, ${2}, ${"compaction"}, ${null}, ${11}, ${JSON.stringify({ summary: "s", retainedTail: [], tokensBefore: 1, fromHook: false })}),
(${SESSION_ID}, ${"old"}, ${"compact"}, ${3}, ${"message"}, ${null}, ${12}, ${JSON.stringify({ message: { role: "assistant", content: "old", timestamp: 12 } })}),
(${SESSION_ID}, ${"custom"}, ${"old"}, ${4}, ${"custom"}, ${"note"}, ${13}, ${JSON.stringify({ data: 4 })}),
(${SESSION_ID}, ${"leaf"}, ${"custom"}, ${5}, ${"message"}, ${null}, ${14}, ${JSON.stringify({ message: { role: "user", content: "leaf", timestamp: 14 } })})`.run(
db,
);
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
VALUES
(${SESSION_ID}, ${"base"}, ${"compact"}, ${2}, ${null}, ${null}),
(${SESSION_ID}, ${"new"}, ${"leaf"}, ${5}, ${"base"}, ${2})`.run(db);
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
VALUES
(${SESSION_ID}, ${"base"}, ${"root"}, ${1}, ${"message"}),
(${SESSION_ID}, ${"base"}, ${"compact"}, ${2}, ${"compaction"}),
(${SESSION_ID}, ${"new"}, ${"old"}, ${3}, ${"message"}),
(${SESSION_ID}, ${"new"}, ${"custom"}, ${4}, ${"custom"}),
(${SESSION_ID}, ${"new"}, ${"leaf"}, ${5}, ${"message"})`.run(db);
expect(
(await storage.scanBranch({ start: "leaf", stopAtType: "compaction", limit: 2 }, BACKGROUND_CONTEXT)).map(
(entry) => entry.id,
),
).toEqual(["leaf", "custom"]);
expect(
(
await storage.scanBranch(
{ start: "leaf", order: "oldestFirst", type: "message", cursor: { seq: 1 } },
BACKGROUND_CONTEXT,
)
).map((entry) => entry.id),
).toEqual(["old", "leaf"]);
});
});
it("resolves a branch segment that physically contains the start entry", async () => {
await withStorage(async (storage, db) => {
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
VALUES
(${SESSION_ID}, ${"root"}, ${null}, ${1}, ${"message"}, ${null}, ${10}, ${JSON.stringify({ message: { role: "user", content: "root", timestamp: 10 } })}),
(${SESSION_ID}, ${"base-tip"}, ${"root"}, ${2}, ${"message"}, ${null}, ${11}, ${JSON.stringify({ message: { role: "assistant", content: "base", timestamp: 11 } })}),
(${SESSION_ID}, ${"new-tip"}, ${"base-tip"}, ${3}, ${"message"}, ${null}, ${12}, ${JSON.stringify({ message: { role: "user", content: "new", timestamp: 12 } })})`.run(
db,
);
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
VALUES
(${SESSION_ID}, ${"aaa-new"}, ${"new-tip"}, ${3}, ${"zzz-base"}, ${2}),
(${SESSION_ID}, ${"zzz-base"}, ${"base-tip"}, ${2}, ${null}, ${null})`.run(db);
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
VALUES
(${SESSION_ID}, ${"aaa-new"}, ${"new-tip"}, ${3}, ${"message"}),
(${SESSION_ID}, ${"zzz-base"}, ${"root"}, ${1}, ${"message"}),
(${SESSION_ID}, ${"zzz-base"}, ${"base-tip"}, ${2}, ${"message"})`.run(db);
expect(
(await storage.scanBranch({ start: "base-tip", order: "oldestFirst" }, BACKGROUND_CONTEXT)).map(
(entry) => entry.id,
),
).toEqual(["root", "base-tip"]);
});
});
it("applies branch stop boundaries across base segments before filtering", async () => {
await withStorage(async (storage, db) => {
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
VALUES
(${SESSION_ID}, ${"root"}, ${null}, ${1}, ${"message"}, ${null}, ${10}, ${JSON.stringify({ message: { role: "user", content: "root", timestamp: 10 } })}),
(${SESSION_ID}, ${"compact"}, ${"root"}, ${2}, ${"compaction"}, ${null}, ${11}, ${JSON.stringify({ summary: "s", retainedTail: [], tokensBefore: 1, fromHook: false })}),
(${SESSION_ID}, ${"after"}, ${"compact"}, ${3}, ${"message"}, ${null}, ${12}, ${JSON.stringify({ message: { role: "assistant", content: "after", timestamp: 12 } })}),
(${SESSION_ID}, ${"leaf"}, ${"after"}, ${4}, ${"custom"}, ${"note"}, ${13}, ${JSON.stringify({ data: 4 })})`.run(
db,
);
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
VALUES
(${SESSION_ID}, ${"base"}, ${"compact"}, ${2}, ${null}, ${null}),
(${SESSION_ID}, ${"new"}, ${"leaf"}, ${4}, ${"base"}, ${2})`.run(db);
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
VALUES
(${SESSION_ID}, ${"base"}, ${"root"}, ${1}, ${"message"}),
(${SESSION_ID}, ${"base"}, ${"compact"}, ${2}, ${"compaction"}),
(${SESSION_ID}, ${"new"}, ${"after"}, ${3}, ${"message"}),
(${SESSION_ID}, ${"new"}, ${"leaf"}, ${4}, ${"custom"})`.run(db);
expect(
(await storage.scanBranch({ start: "leaf", stopAtType: "compaction" }, BACKGROUND_CONTEXT)).map(
(entry) => entry.id,
),
).toEqual(["leaf", "after", "compact"]);
expect(
(
await storage.scanBranch(
{ start: "leaf", stopAtType: "compaction", type: "message" },
BACKGROUND_CONTEXT,
)
).map((entry) => entry.id),
).toEqual(["after"]);
});
});
it("uses branch_entries as the outer scan for branch payload queries", async () => {
await withStorage(async (_storage, db) => {
const plan = explainQueryPlan(
db,
`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp, e.payload
FROM branch_entries b
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq > ? AND b.entry_seq <= ?
ORDER BY b.entry_seq DESC LIMIT ?`,
SESSION_ID,
"main",
0,
10,
2,
);
expectBranchPlan(plan);
});
});
it("scans branch structure without payloads", async () => {
await withStorage(async (storage, db) => {
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
VALUES
(${SESSION_ID}, ${"root"}, ${null}, ${1}, ${"message"}, ${null}, ${10}, ${JSON.stringify({ message: { role: "user", content: "root", timestamp: 10 } })}),
(${SESSION_ID}, ${"custom"}, ${"root"}, ${2}, ${"custom"}, ${"note"}, ${11}, ${JSON.stringify({ data: 2 })})`.run(
db,
);
sql`INSERT INTO branch_meta (session_id, branch_id, tip_entry_id, tip_seq, base_branch_id, base_seq)
VALUES (${SESSION_ID}, ${"main"}, ${"custom"}, ${2}, ${null}, ${null})`.run(db);
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
VALUES
(${SESSION_ID}, ${"main"}, ${"root"}, ${1}, ${"message"}),
(${SESSION_ID}, ${"main"}, ${"custom"}, ${2}, ${"custom"})`.run(db);
expect(await storage.scanBranchStructure({ start: "custom", customType: "note" }, BACKGROUND_CONTEXT)).toEqual(
[
{
id: "custom",
parentId: "root",
seq: 2,
timestamp: 11,
type: "custom",
customType: "note",
},
],
);
});
});
it("uses branch_entries as the outer scan for branch structure queries", async () => {
await withStorage(async (_storage, db) => {
const plan = explainQueryPlan(
db,
`SELECT e.id, e.parent_id, e.seq, e.type, e.custom_type, e.timestamp
FROM branch_entries b
CROSS JOIN entries e ON e.session_id = b.session_id AND e.id = b.entry_id
WHERE b.session_id = ? AND b.branch_id = ? AND b.entry_seq > ? AND b.entry_seq <= ?
ORDER BY b.entry_seq ASC LIMIT ?`,
SESSION_ID,
"main",
0,
10,
2,
);
expectBranchPlan(plan);
});
});
it("scans decoded usage rows with sequence bounds", async () => {
await withStorage(async (storage, db) => {
const usage = {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
};
sql`INSERT INTO usage_ledger (session_id, id, seq, entry_id, adjustment, usage, details)
VALUES
(${SESSION_ID}, ${"u1"}, ${1}, ${"e1"}, ${0}, ${JSON.stringify(usage)}, ${null}),
(${SESSION_ID}, ${"u2"}, ${2}, ${null}, ${1}, ${JSON.stringify(usage)}, ${JSON.stringify({ reason: "adjust" })}),
(${SESSION_ID}, ${"u3"}, ${3}, ${"e3"}, ${0}, ${JSON.stringify(usage)}, ${null})`.run(db);
expect(await storage.scanUsage({ fromSeq: 2, order: "asc", limit: 1 }, BACKGROUND_CONTEXT)).toEqual([
{ id: "u2", seq: 2, usage, adjustment: true, details: { reason: "adjust" } },
]);
expect(
(await storage.scanUsage({ toSeq: 2, order: "desc" }, BACKGROUND_CONTEXT)).map((row) => row.id),
).toEqual(["u2", "u1"]);
});
});
it("prepares committed writes with assigned sequences and timestamp", () => {
const prepared = prepareStorageCommit(
[
sessionWrites.insertEntry({
id: "entry",
parentId: null,
type: "message",
message: { role: "user", content: "hi", timestamp: 1 },
}),
sessionWrites.insertUsage({
id: "usage",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
adjustment: false,
}),
storedValues.setValue(storedValues.sessionName, "name"),
storedValues.deleteValue(storedValues.entryLabel("entry")),
],
7,
1_700_000_000_000,
);
expect(prepared.result).toEqual({ firstSeq: 7, seqs: [7, 8, 9, 10], timestamp: 1_700_000_000_000 });
expect(prepared.writes).toMatchObject([
{ kind: "entry", id: "entry", seq: 7, timestamp: 1_700_000_000_000 },
{ kind: "usage", id: "usage", seq: 8 },
storedValues.setValue(storedValues.sessionName, "name"),
storedValues.deleteValue(storedValues.entryLabel("entry")),
]);
});
it("reads and advances the next commit sequence", async () => {
await withStorage(async (_storage, db) => {
sql`INSERT INTO sessions
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
VALUES (${SESSION_ID}, ${1}, ${null}, ${1}, ${null}, ${0}, ${JSON.stringify({})}, ${7})`.run(db);
expect(readNextSeq(db, SESSION_ID)).toBe(7);
advanceNextSeq(db, SESSION_ID, 10);
expect(readNextSeq(db, SESSION_ID)).toBe(10);
});
});
it("gets maintained session stats", async () => {
await withStorage(async (storage, db) => {
const usage = {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
};
sql`INSERT INTO sessions
(id, created_at, parent_session_id, storage_version, metadata, message_count, usage_payload, next_seq)
VALUES (${SESSION_ID}, ${1}, ${null}, ${1}, ${null}, ${2}, ${JSON.stringify(usage)}, ${3})`.run(db);
expect(await storage.getStats(BACKGROUND_CONTEXT)).toEqual({ messageCount: 2, usage });
const next = await storage.commit(
[storedValues.setValue(storedValues.sessionName, "after-history")],
BACKGROUND_CONTEXT,
);
expect(next.stats).toEqual({ messageCount: 2, usage });
});
});
it("includes historical totals in the first commit after storage reopen", async () => {
await withStorage(async (storage, db) => {
insertCommitSessionRow(db);
const usage = {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
await storage.commit(
[
sessionWrites.insertEntry({
id: "history",
parentId: null,
type: "message",
message: { role: "user", content: "history", timestamp: 1 },
}),
sessionWrites.insertUsage({ id: "usage", usage, adjustment: false }),
],
BACKGROUND_CONTEXT,
);
await storage.close(BACKGROUND_CONTEXT);
const reopened = new SqliteStorage(db, { sessionId: SESSION_ID, now: () => 1_700_000_000_000 });
const result = await reopened.commit(
[storedValues.setValue(storedValues.sessionName, "reopened")],
BACKGROUND_CONTEXT,
);
expect(result.stats).toEqual({ messageCount: 1, usage });
expect(result.stats).toEqual(await reopened.getStats(BACKGROUND_CONTEXT));
await reopened.close(BACKGROUND_CONTEXT);
});
});
it("gets a decoded scalar value by bound address", async () => {
await withStorage(async (storage, db) => {
const address = storedValues.value<{ ready: boolean }>("test.value", "state");
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
VALUES (${SESSION_ID}, ${address.namespace}, ${address.key}, ${1}, ${JSON.stringify({ ready: true })})`.run(
db,
);
expect(await storage.getValue(address, BACKGROUND_CONTEXT)).toEqual({
address,
seq: 1,
value: { ready: true },
});
expect(
await storage.getValue(storedValues.value<unknown>("test.value", "missing"), BACKGROUND_CONTEXT),
).toBeUndefined();
});
});
it("scans decoded scalar values by namespace and key prefix", async () => {
await withStorage(async (storage, db) => {
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
VALUES
(${SESSION_ID}, ${"test.value"}, ${"app:one"}, ${1}, ${JSON.stringify(1)}),
(${SESSION_ID}, ${"test.value"}, ${"app:two"}, ${2}, ${JSON.stringify(2)}),
(${SESSION_ID}, ${"test.value"}, ${"app:\ufffftail"}, ${3}, ${JSON.stringify(3)}),
(${SESSION_ID}, ${"test.value"}, ${"app;other"}, ${4}, ${JSON.stringify(4)}),
(${SESSION_ID}, ${"test.value"}, ${"other"}, ${5}, ${JSON.stringify(5)}),
(${SESSION_ID}, ${"other.value"}, ${""}, ${6}, ${JSON.stringify("name")})`.run(db);
expect(
await storage.scanValues(storedValues.value<unknown>("test.value", "app:"), BACKGROUND_CONTEXT),
).toEqual([
{ address: storedValues.value<unknown>("test.value", "app:one"), seq: 1, value: 1 },
{ address: storedValues.value<unknown>("test.value", "app:two"), seq: 2, value: 2 },
{ address: storedValues.value<unknown>("test.value", "app:\ufffftail"), seq: 3, value: 3 },
]);
});
});
it("uses the list primary key for ascending and descending cursor pages", async () => {
await withStorage(async (_storage, db) => {
const address = storedValues.list<unknown>("test.list", "events");
for (const options of [
{ order: "asc" as const },
{ order: "asc" as const, cursor: { seq: 4 } },
{ order: "desc" as const },
{ order: "desc" as const, cursor: { seq: 4 } },
]) {
const query = listValueReadQuery(SESSION_ID, address, options);
const plan = explainQueryPlan(db, query.queryText, ...query.params);
expect(plan.some((detail) => detail.includes("USING PRIMARY KEY"))).toBe(true);
expect(plan.some((detail) => detail.includes("SCAN list_values"))).toBe(false);
expect(plan.some((detail) => detail.includes("USE TEMP B-TREE"))).toBe(false);
}
});
});
});

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"emitDeclarationOnly": false,
"noEmit": false
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url));
const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url));
const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url));
const agentSessionTesting = fileURLToPath(
new URL("../../agent/src/harness/session/testing/index.ts", import.meta.url),
);
export default defineConfig({
test: {
environment: "node",
benchmark: {
include: ["benchmark/session/**/*.bench.ts"],
reporters: ["verbose"],
},
},
resolve: {
conditions: ["source"],
alias: [
{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex },
{ find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex },
{ find: /^@earendil-works\/pi-ai$/, replacement: aiIndex },
],
},
ssr: { resolve: { conditions: ["source"] } },
});

View File

@@ -0,0 +1,37 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", import.meta.url));
const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url));
const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url));
const agentNode = fileURLToPath(new URL("../../agent/src/node.ts", import.meta.url));
const agentSessionTesting = fileURLToPath(
new URL("../../agent/src/harness/session/testing/index.ts", import.meta.url),
);
export default defineConfig({
test: {
globals: true,
environment: "node",
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
coverage: {
provider: "v8",
all: true,
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts"],
reporter: ["text", "html", "lcov"],
reportsDirectory: "coverage",
},
},
resolve: {
conditions: ["source"],
alias: [
{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex },
{ find: /^@earendil-works\/pi-agent-core\/node$/, replacement: agentNode },
{ find: /^@earendil-works\/pi-agent-core\/harness\/session\/testing$/, replacement: agentSessionTesting },
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex },
{ find: /^@earendil-works\/pi-ai$/, replacement: aiIndex },
],
},
ssr: { resolve: { conditions: ["source"] } },
});