{"version":3,"file":"pg.mjs","names":[],"sources":["../../../../../../../../ai/src/human/stores/pg.ts"],"sourcesContent":["import type {\n  InterruptStore,\n  PendingInterrupt,\n  PgClientLike,\n} from \"../contracts/interrupt-store.contract\";\n\n/**\n * Options for the Postgres {@link InterruptStore}.\n *\n * Two mutually-supportive ways to supply the connection:\n * - **`client`** — pass an already-built `pg.Pool` / `pg.Client` (anything\n *   satisfying {@link PgClientLike}). The store only ever calls `query`\n *   and never opens or closes it; a single pool can back both an\n *   orchestrator's checkpoint/snapshot stores and this interrupt table.\n * - **`connectionString`** — let the store lazily `import(\"pg\")` and build\n *   its own `Pool`. `@warlock.js/ai` takes **no** hard dependency on\n *   `pg` (it is an optional peer); when it is absent the store throws a\n *   curated install string at first use, never a raw module-resolution\n *   stack trace at import.\n *\n * Exactly one of the two must be present.\n */\nexport interface PgInterruptOptions {\n  /** An already-built `pg.Pool` / `pg.Client` — anything matching {@link PgClientLike}. */\n  client?: PgClientLike;\n\n  /** Connection string the store passes to a lazily-imported `pg.Pool`. */\n  connectionString?: string;\n\n  /**\n   * Backing table name. Defaults to `warlock_ai_human_interrupts`. Must be\n   * a safe SQL identifier — it is interpolated into DDL/DML.\n   */\n  table?: string;\n}\n\n/**\n * Default backing table — provisions the store with no extra config when\n * the dev runs {@link InterruptStore.schema} through their migration tool.\n */\nconst DEFAULT_TABLE = \"warlock_ai_human_interrupts\";\n\n/**\n * Allowed characters in a Postgres identifier (table name). The table name\n * is interpolated into DDL/DML, so anything outside this conservative\n * ASCII subset is rejected — interpolating an arbitrary string would be a\n * SQL-injection footgun (mirrors `@warlock.js/ai`'s pg stores).\n */\nconst SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\n/**\n * Module specifier for the optional `pg` driver. Held in a `string`\n * variable so the dynamic `import()` is not statically resolved at\n * compile time — `pg` is an optional peer that need not be installed for\n * this package to type-check or for a memory-only consumer to run.\n */\nconst PG_MODULE = \"pg\";\n\n/**\n * Curated install string surfaced (at use time) when a `connectionString`\n * is configured but the optional `pg` driver is absent. Never thrown at\n * import — a memory-only consumer must be able to load this module.\n */\nconst PG_INSTALL_INSTRUCTIONS = `\nThe @warlock.js/ai Postgres interrupt store requires the pg package.\nInstall it with:\n\n  npm install pg\n\nOr with your preferred package manager:\n\n  pnpm add pg\n  yarn add pg\n`.trim();\n\n/**\n * Minimal structural view of a `pg.Pool` constructor — just enough of the\n * `pg` module surface for the store to build a client when handed a\n * `connectionString`. Declared locally (rather than `typeof import(\"pg\")`)\n * so this module type-checks even when `pg` is not installed.\n */\ninterface PgModuleLike {\n  Pool: new (config: { connectionString: string }) => PgClientLike;\n}\n\n/**\n * Lazily import `pg` and return a `Pool` built from `connectionString`. A\n * bare `catch` rethrows the curated install string — a missing optional\n * peer surfaces as actionable guidance, never a raw resolution error.\n */\nasync function buildPgClient(connectionString: string): Promise<PgClientLike> {\n  let sdk: PgModuleLike;\n\n  try {\n    sdk = (await import(PG_MODULE)) as unknown as PgModuleLike;\n  } catch {\n    throw new Error(PG_INSTALL_INSTRUCTIONS);\n  }\n\n  return new sdk.Pool({ connectionString });\n}\n\n/**\n * Coerce a Postgres timestamp/text column to an ISO string. `pg` returns\n * `TIMESTAMPTZ` as a `Date`; normalize to the ISO wire shape the\n * {@link PendingInterrupt} 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 * Map a raw DB row to a {@link PendingInterrupt}. Column names match the\n * reference DDL 1:1; the `request` JSONB rides one column, so it is parsed\n * defensively (node-postgres parses `JSONB` already, but some pool\n * wrappers hand back the raw string).\n */\nfunction rowToRecord(row: Record<string, unknown>): PendingInterrupt {\n  const request =\n    typeof row.request === \"string\" ? JSON.parse(row.request) : row.request;\n\n  return {\n    interruptId: row.interrupt_id as string,\n    request: request as PendingInterrupt[\"request\"],\n    status: row.status as PendingInterrupt[\"status\"],\n    savedAt: toIso(row.saved_at),\n  };\n}\n\n/**\n * Postgres-backed {@link InterruptStore} — one durable row per pending\n * interrupt, keyed by `interrupt_id`.\n *\n * Owns: durable round-tripping of the {@link PendingInterrupt} envelope so\n * a reviewer can rule out-of-process (a webhook approves hours later, in a\n * different process), the reference DDL via {@link PgInterruptStore.schema},\n * and prefix-filtered enumeration. Does NOT own: the connection lifecycle\n * (a dev-supplied client is never closed; a store-built `Pool` from a\n * `connectionString` is also left open for the process to reuse) or schema\n * migration (the dev runs `schema()` through their own tool — never\n * auto-migrated).\n *\n * Like the snapshot store, a call has exactly one live interrupt, so\n * `save()` upserts on the `interrupt_id` primary key.\n *\n * Front it with the {@link pg} factory — callers never `new` it.\n */\nclass PgInterruptStore implements InterruptStore {\n  /** Validated backing table name, safe to interpolate into SQL. */\n  private readonly table: string;\n\n  /**\n   * A ready client, or a promise resolving to one the store builds lazily\n   * from a `connectionString`. Resolved once and memoized so the optional\n   * `pg` import happens at most once.\n   */\n  private clientPromise: Promise<PgClientLike>;\n\n  public constructor(options: PgInterruptOptions) {\n    const table = options.table ?? DEFAULT_TABLE;\n\n    if (!SAFE_IDENTIFIER.test(table)) {\n      throw new TypeError(\n        `ai.human.interrupt.pg: invalid table name '${table}'. Allowed: [A-Za-z_][A-Za-z0-9_]*.`,\n      );\n    }\n\n    this.table = table;\n\n    if (options.client) {\n      if (typeof options.client.query !== \"function\") {\n        throw new TypeError(\n          \"ai.human.interrupt.pg requires a 'client' option implementing { query(text, params) } — pass a pg.Pool or pg.Client.\",\n        );\n      }\n\n      this.clientPromise = Promise.resolve(options.client);\n\n      return;\n    }\n\n    if (options.connectionString) {\n      // Defer the optional `pg` import to first use — a curated install\n      // string surfaces from `buildPgClient`, not at construction.\n      this.clientPromise = buildPgClient(options.connectionString);\n\n      return;\n    }\n\n    throw new TypeError(\n      \"ai.human.interrupt.pg requires either a 'client' or a 'connectionString' option.\",\n    );\n  }\n\n  /**\n   * Resolve the backing client, surfacing the lazy `pg` import's curated\n   * install string on the first call that needs it.\n   */\n  private client(): Promise<PgClientLike> {\n    return this.clientPromise;\n  }\n\n  /**\n   * Persist a pending interrupt, keyed by its own `interrupt_id`. Upserts\n   * — a call has exactly one live interrupt, so a second save for the same\n   * id overwrites the payload rather than appending.\n   */\n  public async save(record: PendingInterrupt): Promise<void> {\n    const client = await this.client();\n\n    await client.query(\n      `INSERT INTO ${this.table} (interrupt_id, request, status, saved_at)\n       VALUES ($1, $2::jsonb, $3, $4)\n       ON CONFLICT (interrupt_id) DO UPDATE\n         SET request = EXCLUDED.request,\n             status = EXCLUDED.status,\n             saved_at = EXCLUDED.saved_at`,\n      [\n        record.interruptId,\n        JSON.stringify(record.request),\n        record.status,\n        record.savedAt,\n      ],\n    );\n  }\n\n  /**\n   * Load the interrupt for an `interruptId`, or `undefined` when none is\n   * recorded.\n   */\n  public async load(\n    interruptId: string,\n  ): Promise<PendingInterrupt | undefined> {\n    const client = await this.client();\n\n    const { rows } = await client.query(\n      `SELECT interrupt_id, request, status, saved_at\n       FROM ${this.table}\n       WHERE interrupt_id = $1`,\n      [interruptId],\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   * Drop the interrupt for an `interruptId`. Idempotent — deleting an\n   * absent id deletes zero rows.\n   */\n  public async delete(interruptId: string): Promise<void> {\n    const client = await this.client();\n\n    await client.query(\n      `DELETE FROM ${this.table} WHERE interrupt_id = $1`,\n      [interruptId],\n    );\n  }\n\n  /**\n   * List the interrupt ids known to the store, optionally filtered by a\n   * prefix. The `_` and `%` LIKE wildcards in the prefix are escaped so an\n   * opaque interruptId that happens to contain them is matched literally.\n   */\n  public async list(prefix?: string): Promise<string[]> {\n    const client = await this.client();\n\n    if (prefix === undefined) {\n      const { rows } = await client.query(\n        `SELECT interrupt_id FROM ${this.table}`,\n      );\n\n      return rows.map(\n        (row) => (row as Record<string, unknown>).interrupt_id as string,\n      );\n    }\n\n    const escaped = prefix\n      .replace(/\\\\/g, \"\\\\\\\\\")\n      .replace(/_/g, \"\\\\_\")\n      .replace(/%/g, \"\\\\%\");\n\n    const { rows } = await client.query(\n      `SELECT interrupt_id FROM ${this.table}\n       WHERE interrupt_id LIKE $1 ESCAPE '\\\\'`,\n      [`${escaped}%`],\n    );\n\n    return rows.map(\n      (row) => (row as Record<string, unknown>).interrupt_id as string,\n    );\n  }\n\n  /**\n   * Return the reference DDL for this store's backing table, interpolating\n   * the configured table name. The dev runs it through their migration\n   * tool — the framework never auto-migrates.\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      `  interrupt_id  TEXT PRIMARY KEY,`,\n      `  request       JSONB NOT NULL,`,\n      `  status        TEXT NOT NULL,`,\n      `  saved_at      TIMESTAMPTZ NOT NULL DEFAULT now()`,\n      `);`,\n      `CREATE INDEX IF NOT EXISTS idx_${this.table}_saved_at`,\n      `  ON ${this.table} (saved_at);`,\n    ].join(\"\\n\");\n  }\n}\n\n/**\n * Create a Postgres-backed {@link InterruptStore}. Either pass a live\n * `pg.Pool` / `pg.Client` (`{ client }`) — `@warlock.js/ai` never\n * imports `pg` in that case — or a `{ connectionString }` and let the\n * store lazily `import(\"pg\")` to build its own pool. When `pg` is not\n * installed, the curated install string surfaces on first use, never at\n * import. Run {@link InterruptStore.schema} through your migration tool\n * once before use; the store never 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.human.interrupt.pg({ client: pool });\n *\n * // Once, via your migration tooling:\n * // await pool.query(store.schema());\n *\n * @example\n * // Let the store build its own pool from a connection string:\n * const store = ai.human.interrupt.pg({\n *   connectionString: process.env.DATABASE_URL,\n * });\n */\nexport function pg(options: PgInterruptOptions): InterruptStore {\n  return new PgInterruptStore(options);\n}\n"],"mappings":"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"}