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,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>;
}