import type { SessionStore, SessionKey, SessionStoreEntry } from '@anthropic-ai/claude-agent-sdk'; import type { Sql } from '../../db.js'; /** * claude-sdk-sessionstore #9 (Part 1) — clean-room PostgresSessionStore. * * A Postgres-backed implementation of the Claude Agent SDK's `SessionStore` * adapter type. The SDK mirrors each transcript line (a JSON-safe POJO with a * `type` discriminant) to this store via `append`; on resume it calls `load` * to materialize the full transcript back. We treat entries as opaque blobs and * preserve append order via a BIGSERIAL `id` — `load` replays `ORDER BY id`. * * Storage shape: one row per entry in `claude_session_entries`, keyed by the * SDK's `SessionKey` (project_key, session_id, subpath). The SDK uses an * *undefined* subpath for the main transcript and disallows the empty string; * we collapse `undefined → ''` so the main transcript and subagent files share * one table, distinguished by the `subpath` column (`'' = main`). * * Clean-room: written against the SDK's published `SessionStore` type contract * and BooCode's existing SQL conventions (porsager tagged templates, `sql.json` * for JSONB). No SDK example/reference code was consulted. */ export class PostgresSessionStore implements SessionStore { constructor(private readonly sql: Sql) {} /** * Mirror a batch of transcript entries. No-op on an empty batch; otherwise a * single multi-row INSERT writes them in array order. Because `id` is a * monotonically-increasing BIGSERIAL, the insert order is the replay order * `load` reconstructs — entries within one call land in the order given. */ async append(key: SessionKey, entries: SessionStoreEntry[]): Promise { if (entries.length === 0) return; const subpath = key.subpath ?? ''; const rows = entries.map((entry) => ({ project_key: key.projectKey, session_id: key.sessionId, subpath, entry: this.sql.json(entry as never), })); await this.sql` INSERT INTO claude_session_entries ${this.sql(rows, 'project_key', 'session_id', 'subpath', 'entry')} `; } /** * Load a full transcript for resume. Returns the entries in append order, or * `null` for a (project_key, session_id, subpath) key that was never written. */ async load(key: SessionKey): Promise { const subpath = key.subpath ?? ''; const rows = await this.sql<{ entry: SessionStoreEntry }[]>` SELECT entry FROM claude_session_entries WHERE project_key = ${key.projectKey} AND session_id = ${key.sessionId} AND subpath = ${subpath} ORDER BY id `; if (rows.length === 0) return null; return rows.map((r) => r.entry); } /** * List the main transcripts for a project. `mtime` is the storage write time * (latest `created_at` for the session) in Unix epoch milliseconds; the SDK * sorts the result by mtime descending. */ async listSessions(projectKey: string): Promise> { const rows = await this.sql<{ session_id: string; mtime: string }[]>` SELECT session_id, extract(epoch FROM max(created_at)) * 1000 AS mtime FROM claude_session_entries WHERE project_key = ${projectKey} AND subpath = '' GROUP BY session_id `; return rows.map((r) => ({ sessionId: r.session_id, mtime: Number(r.mtime) })); } /** * Delete a session. With a `subpath` set, only that subpath's rows are * removed; with `subpath` omitted, every row for the session is removed * (all subpaths, including the main transcript). */ async delete(key: SessionKey): Promise { if (key.subpath !== undefined) { await this.sql` DELETE FROM claude_session_entries WHERE project_key = ${key.projectKey} AND session_id = ${key.sessionId} AND subpath = ${key.subpath} `; return; } await this.sql` DELETE FROM claude_session_entries WHERE project_key = ${key.projectKey} AND session_id = ${key.sessionId} `; } /** * List the distinct non-main subpaths under a session (e.g. subagent files). * Used during resume to discover and materialize subagent transcripts; the * main transcript (`subpath = ''`) is excluded. */ async listSubkeys(key: { projectKey: string; sessionId: string }): Promise { const rows = await this.sql<{ subpath: string }[]>` SELECT DISTINCT subpath FROM claude_session_entries WHERE project_key = ${key.projectKey} AND session_id = ${key.sessionId} AND subpath <> '' `; return rows.map((r) => r.subpath); } }