{"version":3,"file":"pg.mjs","names":[],"sources":["../../../../../../../ai/src/checkpoint/pg.ts"],"sourcesContent":["import type {\n  CheckpointRecord,\n  CheckpointStore,\n} from \"../contracts/orchestrator/checkpoint-store.contract\";\nimport type { PgClientLike } from \"../contracts/orchestrator/snapshot-store.contract\";\n\n/**\n * Options for the Postgres {@link CheckpointStore} (orchestrator.md §8.3).\n *\n * The dev owns the connection — `@warlock.js/ai` takes no peer dep on\n * `pg` and never opens or closes the client. A single `pg.Pool` can\n * back both the cache and the orchestrator stores.\n */\nexport type PgCheckpointOptions = {\n  /** An already-built `pg.Pool` / `pg.Client` — anything matching {@link PgClientLike}. */\n  client: PgClientLike;\n  /** Backing table name. Defaults to `warlock_orchestrator_sessions` (§8.6). Must be a safe SQL identifier. */\n  table?: string;\n  /** Idle-row TTL in seconds. When set, rows older than the TTL are eligible for cleanup on prune. */\n  ttl?: number;\n};\n\n/**\n * Default backing table — matches the §8.6 reference DDL verbatim so a\n * stock migration provisions the store with no extra config.\n */\nconst DEFAULT_TABLE = \"warlock_orchestrator_sessions\";\n\n/**\n * Allowed characters in a Postgres identifier (table name). The table\n * name is interpolated into DDL/DML, so anything outside this\n * conservative ASCII subset is rejected — interpolating an arbitrary\n * string would be a SQL-injection footgun (mirrors `PgCacheDriver`).\n */\nconst SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\n/**\n * Coerce a Postgres `INTEGER` column back to a number. `pg` hands back\n * `INTEGER` as a JS number already, but some pool wrappers surface it\n * as a string — normalize defensively so `turn_index` arithmetic and\n * the latest-turn ordering never compare strings.\n */\nfunction toNumber(value: unknown): number {\n  return typeof value === \"string\" ? Number(value) : (value as number);\n}\n\n/**\n * Coerce a nullable Postgres integer column to `number | null`.\n */\nfunction toNullableNumber(value: unknown): number | null {\n  return value === null || value === undefined ? null : toNumber(value);\n}\n\n/**\n * Coerce a Postgres timestamp/text column to an ISO string. `pg`\n * returns `TIMESTAMPTZ` as a `Date`; normalize to the ISO wire shape\n * the {@link CheckpointRecord} contract declares.\n */\nfunction toIso(value: unknown): string {\n  if (value instanceof Date) {\n    return value.toISOString();\n  }\n\n  return value as string;\n}\n\n/**\n * Coerce a nullable Postgres timestamp column to `string | null`.\n */\nfunction toNullableIso(value: unknown): string | null {\n  if (value === null || value === undefined) {\n    return null;\n  }\n\n  return toIso(value);\n}\n\n/**\n * Decode the single `last_route` `TEXT` column back to the\n * `string | string[] | null` shape the contract declares. A fan-out\n * array is written JSON-encoded (it starts with `[`), so a leading `[`\n * is the signal to parse; any other value is a single intent stored\n * verbatim. Symmetric with {@link PgCheckpointStore.serializeRoute}.\n */\nfunction deserializeRoute(value: unknown): string | string[] | null {\n  if (value === null || value === undefined) {\n    return null;\n  }\n\n  const route = value as string;\n\n  if (route.startsWith(\"[\")) {\n    return JSON.parse(route) as string[];\n  }\n\n  return route;\n}\n\n/**\n * Map a raw DB row to a {@link CheckpointRecord}. Column names match\n * the §8.6 DDL 1:1, so this is a typed projection plus the defensive\n * coercions a heterogeneous `pg` client population needs.\n */\nfunction rowToRecord(row: Record<string, unknown>): CheckpointRecord {\n  const state =\n    typeof row.state === \"string\" ? JSON.parse(row.state) : row.state;\n\n  return {\n    orchestrator_name: row.orchestrator_name as string,\n    session_id: row.session_id as string,\n    turn_index: toNumber(row.turn_index),\n    state,\n    last_route: deserializeRoute(row.last_route),\n    signature: row.signature as string,\n    version: (row.version as string | null) ?? null,\n    summarized_through: toNullableNumber(row.summarized_through),\n    lock_acquired_at: toNullableIso(row.lock_acquired_at),\n    lock_expires_at: toNullableIso(row.lock_expires_at),\n    saved_at: toIso(row.saved_at),\n  };\n}\n\n/**\n * Postgres-backed {@link CheckpointStore} (orchestrator.md §8.2, §8.6).\n *\n * Owns: append-only checkpoint rows keyed by\n * `(orchestrator_name, session_id, turn_index)`, the \"latest turn wins\"\n * load, the §8.6 DDL via {@link PgCheckpointStore.schema}, and the\n * §4-Phase-6 retention prune. Does NOT own: the connection lifecycle\n * (the dev passes a client and keeps it), schema migration (the dev\n * runs `schema()` through their own tool — never auto-migrated, §8.5),\n * or the `keepSnapshots` policy itself (that lives on the orchestrator\n * config; the orchestrator passes the resolved bound into\n * {@link PgCheckpointStore.prune}).\n *\n * Front it with the {@link pg} factory — callers never `new` it.\n */\nclass PgCheckpointStore implements CheckpointStore {\n  /** The dev-supplied `pg.Pool` / `pg.Client`. Never closed by the store. */\n  private readonly client: PgClientLike;\n\n  /** Validated backing table name, safe to interpolate into SQL. */\n  private readonly table: string;\n\n  /** Idle-row TTL in seconds, or `undefined` for no expiry. */\n  private ttl?: number;\n\n  public constructor(options: PgCheckpointOptions) {\n    if (!options || typeof options.client?.query !== \"function\") {\n      throw new TypeError(\n        \"ai.checkpoint.pg requires a 'client' option implementing { query(text, params) } — pass a pg.Pool or pg.Client.\",\n      );\n    }\n\n    const table = options.table ?? DEFAULT_TABLE;\n\n    if (!SAFE_IDENTIFIER.test(table)) {\n      throw new TypeError(\n        `ai.checkpoint.pg: invalid table name '${table}'. Allowed: [A-Za-z_][A-Za-z0-9_]*.`,\n      );\n    }\n\n    this.client = options.client;\n    this.table = table;\n    this.ttl = options.ttl;\n  }\n\n  /**\n   * Return the latest checkpoint (highest `turn_index`) for a session,\n   * or `undefined` when the store has never seen it. The `(name,\n   * session_id, turn_index DESC)` lookup index keeps this O(1) on the\n   * latest row (§8.6).\n   */\n  public async load(\n    orchestratorName: string,\n    sessionId: string,\n  ): Promise<CheckpointRecord | undefined> {\n    const { rows } = await this.client.query(\n      `SELECT * FROM ${this.table}\n       WHERE orchestrator_name = $1 AND session_id = $2\n       ORDER BY turn_index DESC\n       LIMIT 1`,\n      [orchestratorName, sessionId],\n    );\n\n    if (rows.length === 0) {\n      return undefined;\n    }\n\n    return rowToRecord(rows[0] as Record<string, unknown>);\n  }\n\n  /**\n   * Persist a fresh checkpoint row. Append-only — an existing\n   * `turn_index` is never overwritten; the PK collision surfaces as a\n   * Postgres error rather than a silent clobber (§4 Phase 6, Q15).\n   */\n  public async save(record: CheckpointRecord): Promise<void> {\n    await this.client.query(\n      `INSERT INTO ${this.table} (\n         orchestrator_name, session_id, turn_index, state, last_route,\n         signature, version, summarized_through, lock_acquired_at,\n         lock_expires_at, saved_at\n       )\n       VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8, $9, $10, $11)`,\n      [\n        record.orchestrator_name,\n        record.session_id,\n        record.turn_index,\n        JSON.stringify(record.state),\n        this.serializeRoute(record.last_route),\n        record.signature,\n        record.version,\n        record.summarized_through,\n        record.lock_acquired_at,\n        record.lock_expires_at,\n        record.saved_at,\n      ],\n    );\n  }\n\n  /**\n   * Delete every checkpoint row for a session, ending it.\n   */\n  public async delete(\n    orchestratorName: string,\n    sessionId: string,\n  ): Promise<void> {\n    await this.client.query(\n      `DELETE FROM ${this.table}\n       WHERE orchestrator_name = $1 AND session_id = $2`,\n      [orchestratorName, sessionId],\n    );\n  }\n\n  /**\n   * List the distinct session ids known for an orchestrator, optionally\n   * filtered by a session-id prefix. Used by the production boot-drain\n   * loop (§9.3). The prefix is matched with `LIKE`, escaping the SQL\n   * wildcards so a literal `_` or `%` in the prefix is not treated as a\n   * pattern.\n   */\n  public async list(\n    orchestratorName: string,\n    prefix?: string,\n  ): Promise<string[]> {\n    if (prefix === undefined) {\n      const { rows } = await this.client.query(\n        `SELECT DISTINCT session_id FROM ${this.table}\n         WHERE orchestrator_name = $1`,\n        [orchestratorName],\n      );\n\n      return rows.map((row) => (row as Record<string, unknown>).session_id as string);\n    }\n\n    const escaped = prefix\n      .replace(/\\\\/g, \"\\\\\\\\\")\n      .replace(/_/g, \"\\\\_\")\n      .replace(/%/g, \"\\\\%\");\n\n    const { rows } = await this.client.query(\n      `SELECT DISTINCT session_id FROM ${this.table}\n       WHERE orchestrator_name = $1 AND session_id LIKE $2 ESCAPE '\\\\'`,\n      [orchestratorName, `${escaped}%`],\n    );\n\n    return rows.map((row) => (row as Record<string, unknown>).session_id as string);\n  }\n\n  /**\n   * Prune retained turns for a session down to the most recent\n   * `keepSnapshots` rows (orchestrator.md §4 Phase 6 / §15.2). Deletes\n   * every row with `turn_index < (max_turn_index - keepSnapshots)`. The\n   * orchestrator calls this synchronously after a successful\n   * {@link save} when `keepSnapshots` is a finite number; `\"all\"`\n   * retention skips the call entirely. Additive to the\n   * {@link CheckpointStore} contract — the contract carries no prune\n   * hook, so the policy stays on the orchestrator and the store only\n   * executes the bounded delete.\n   */\n  public async prune(\n    orchestratorName: string,\n    sessionId: string,\n    keepSnapshots: number,\n  ): Promise<void> {\n    if (!Number.isFinite(keepSnapshots) || keepSnapshots < 0) {\n      return;\n    }\n\n    await this.client.query(\n      `DELETE FROM ${this.table}\n       WHERE orchestrator_name = $1\n         AND session_id = $2\n         AND turn_index < (\n           SELECT max(turn_index) - $3\n           FROM ${this.table}\n           WHERE orchestrator_name = $1 AND session_id = $2\n         )`,\n      [orchestratorName, sessionId, keepSnapshots],\n    );\n  }\n\n  /**\n   * Return the §8.6 reference DDL for this store's backing table,\n   * interpolating the configured table name. The dev runs it through\n   * their migration tool — the framework never auto-migrates (§8.5).\n   *\n   * @example\n   * await pool.query(store.schema());\n   */\n  public schema(): string {\n    return [\n      `CREATE TABLE IF NOT EXISTS ${this.table} (`,\n      `  orchestrator_name    TEXT NOT NULL,`,\n      `  session_id           TEXT NOT NULL,`,\n      `  turn_index           INTEGER NOT NULL,`,\n      `  state                JSONB NOT NULL,`,\n      `  last_route           TEXT,`,\n      `  signature            TEXT NOT NULL,`,\n      `  version              TEXT,`,\n      `  summarized_through   INTEGER,`,\n      `  lock_acquired_at     TIMESTAMPTZ,`,\n      `  lock_expires_at      TIMESTAMPTZ,`,\n      `  saved_at             TIMESTAMPTZ NOT NULL DEFAULT now(),`,\n      `  PRIMARY KEY (orchestrator_name, session_id, turn_index)`,\n      `);`,\n      `CREATE INDEX IF NOT EXISTS idx_${this.table}_saved_at`,\n      `  ON ${this.table} (saved_at);`,\n      `CREATE INDEX IF NOT EXISTS idx_${this.table}_lookup`,\n      `  ON ${this.table} (orchestrator_name, session_id, turn_index DESC);`,\n    ].join(\"\\n\");\n  }\n\n  /**\n   * Set the idle-row TTL (§8.2). Stored for prune-time cleanup; the\n   * store never opens a background timer.\n   */\n  public setOptions(options: { ttl?: number }): void {\n    this.ttl = options.ttl;\n  }\n\n  /**\n   * `last_route` rides a single `TEXT` column. A fan-out array is\n   * JSON-encoded so it round-trips through one column without a schema\n   * change; a single intent (an identifier — never starts with `[`) is\n   * stored verbatim. {@link deserializeRoute} reverses this on load.\n   */\n  private serializeRoute(route: string | string[] | null): string | null {\n    if (route === null) {\n      return null;\n    }\n\n    if (Array.isArray(route)) {\n      return JSON.stringify(route);\n    }\n\n    return route;\n  }\n}\n\n/**\n * Create a Postgres-backed {@link CheckpointStore} (orchestrator.md\n * §8.3). The dev installs `pg` and passes a `pg.Pool` / `pg.Client` —\n * `@warlock.js/ai` never imports `pg`. Run {@link CheckpointStore.schema}\n * through your migration tool once before use; the store never\n * auto-migrates.\n *\n * @example\n * import { Pool } from \"pg\";\n * import { ai } from \"@warlock.js/ai\";\n *\n * const pool = new Pool({ connectionString: process.env.DATABASE_URL });\n * const store = ai.checkpoint.pg({ client: pool });\n *\n * // Once, via your migration tooling:\n * // await pool.query(store.schema());\n */\nexport function pg(options: PgCheckpointOptions): CheckpointStore {\n  return new PgCheckpointStore(options);\n}\n"],"mappings":"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"}