{"version":3,"file":"redis.mjs","names":[],"sources":["../../../../../../../ai/src/checkpoint/redis.ts"],"sourcesContent":["import type {\n  CheckpointRecord,\n  CheckpointStore,\n} from \"../contracts/orchestrator/checkpoint-store.contract\";\nimport type { RedisClientLike } from \"../contracts/orchestrator/snapshot-store.contract\";\n\n/**\n * Options for the Redis {@link CheckpointStore} (orchestrator.md §8.3).\n *\n * The dev owns the connection — `@warlock.js/ai` takes no peer dep on\n * `redis` and never opens or closes the client.\n */\nexport type RedisCheckpointOptions = {\n  /** An already-connected `redis` client — anything matching {@link RedisClientLike}. */\n  client: RedisClientLike;\n  /**\n   * Key prefix for every key this store writes. Lets one Redis database\n   * back multiple stores without collision. Defaults to\n   * `warlock:orchestrator`.\n   */\n  prefix?: string;\n  /** Idle-key TTL in seconds. When set, every written key expires after the TTL. */\n  ttl?: number;\n};\n\n/**\n * Default key prefix — namespaces the store's keys inside a shared\n * Redis database.\n */\nconst DEFAULT_PREFIX = \"warlock:orchestrator\";\n\n/**\n * Per-session document persisted under one Redis key: the append-only\n * list of {@link CheckpointRecord} rows in turn order, mirroring the\n * Postgres append-only PK shape (§8.6) inside a single JSON value so\n * the store needs only `get`/`set`/`del` from {@link RedisClientLike}.\n */\ntype SessionDocument = {\n  rows: CheckpointRecord[];\n};\n\n/**\n * Per-orchestrator index document: the set of live session ids. Kept as\n * a JSON array because {@link RedisClientLike} exposes no `keys` / `scan`\n * — enumeration for the §9.3 boot drain must be self-maintained.\n */\ntype IndexDocument = {\n  sessionIds: string[];\n};\n\n/**\n * Redis-backed {@link CheckpointStore} (orchestrator.md §8.2).\n *\n * Owns: the per-session append-only document, a per-orchestrator\n * session-id index (so {@link RedisCheckpointStore.list} works without\n * `KEYS`/`SCAN`), the \"latest turn wins\" load, and the §4-Phase-6\n * retention prune. Does NOT own: durability guarantees beyond Redis's\n * own, the connection lifecycle (the dev passes a client), or the\n * `keepSnapshots` policy (that lives on the orchestrator config).\n *\n * Because {@link RedisClientLike} is intentionally minimal (`get` /\n * `set` / `del` only — §8.4), the store models a session as a single\n * JSON document rather than one Redis key per turn. This keeps every\n * operation a single round-trip and avoids depending on key scanning,\n * at the cost of read-modify-write on `save`. Callers must serialize\n * traffic per `sessionId` anyway (§17 \"two turns racing\"), so the\n * read-modify-write is safe under that contract.\n *\n * Front it with the {@link redis} factory — callers never `new` it.\n */\nclass RedisCheckpointStore implements CheckpointStore {\n  /** The dev-supplied redis client. Never disconnected by the store. */\n  private readonly client: RedisClientLike;\n\n  /** Key prefix namespacing every key this store writes. */\n  private readonly prefix: string;\n\n  /** Idle-key TTL in seconds, or `undefined` for no expiry. */\n  private ttl?: number;\n\n  public constructor(options: RedisCheckpointOptions) {\n    if (\n      !options ||\n      typeof options.client?.get !== \"function\" ||\n      typeof options.client?.set !== \"function\" ||\n      typeof options.client?.del !== \"function\"\n    ) {\n      throw new TypeError(\n        \"ai.checkpoint.redis requires a 'client' option implementing { get, set, del } — pass a connected redis client.\",\n      );\n    }\n\n    this.client = options.client;\n    this.prefix = options.prefix ?? DEFAULT_PREFIX;\n    this.ttl = options.ttl;\n  }\n\n  /**\n   * Return the latest checkpoint (highest `turn_index`) for a session,\n   * or `undefined` when the session has no document. Rows are appended\n   * in turn order, so the last element is the latest.\n   */\n  public async load(\n    orchestratorName: string,\n    sessionId: string,\n  ): Promise<CheckpointRecord | undefined> {\n    const document = await this.readSession(orchestratorName, sessionId);\n\n    if (!document || document.rows.length === 0) {\n      return undefined;\n    }\n\n    return document.rows[document.rows.length - 1];\n  }\n\n  /**\n   * Append a checkpoint row to its session document, creating the\n   * document and indexing the session id on first write. Append-only —\n   * an existing `turn_index` is never overwritten; a fresh row is\n   * pushed (§4 Phase 6, Q15).\n   */\n  public async save(record: CheckpointRecord): Promise<void> {\n    const { orchestrator_name, session_id } = record;\n\n    const document =\n      (await this.readSession(orchestrator_name, session_id)) ?? { rows: [] };\n\n    document.rows.push(record);\n\n    await this.writeSession(orchestrator_name, session_id, document);\n    await this.indexSession(orchestrator_name, session_id);\n  }\n\n  /**\n   * Drop a session document and de-index its session id.\n   */\n  public async delete(\n    orchestratorName: string,\n    sessionId: string,\n  ): Promise<void> {\n    await this.client.del(this.sessionKey(orchestratorName, sessionId));\n    await this.deindexSession(orchestratorName, sessionId);\n  }\n\n  /**\n   * List the session ids known for an orchestrator, optionally filtered\n   * by a session-id prefix. Reads the self-maintained index document\n   * (§9.3 boot drain).\n   */\n  public async list(\n    orchestratorName: string,\n    prefix?: string,\n  ): Promise<string[]> {\n    const index = await this.readIndex(orchestratorName);\n\n    if (prefix === undefined) {\n      return [...index.sessionIds];\n    }\n\n    return index.sessionIds.filter((sessionId) =>\n      sessionId.startsWith(prefix),\n    );\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). Drops\n   * every row whose `turn_index` is below `(max_turn_index -\n   * keepSnapshots)`. The orchestrator calls this synchronously after a\n   * successful {@link save} when `keepSnapshots` is a finite number;\n   * `\"all\"` retention skips the call. Additive to the\n   * {@link CheckpointStore} contract — the policy stays on the\n   * orchestrator and the store only executes the bounded trim.\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    const document = await this.readSession(orchestratorName, sessionId);\n\n    if (!document || document.rows.length === 0) {\n      return;\n    }\n\n    const maxTurnIndex = document.rows[document.rows.length - 1].turn_index;\n    const threshold = maxTurnIndex - keepSnapshots;\n\n    const kept = document.rows.filter((row) => row.turn_index >= threshold);\n\n    if (kept.length === document.rows.length) {\n      return;\n    }\n\n    await this.writeSession(orchestratorName, sessionId, { rows: kept });\n  }\n\n  /**\n   * The Redis store has no relational table — there is nothing to\n   * migrate. Returns an empty string so callers can treat `schema()`\n   * uniformly across drivers (mirrors the memory store).\n   */\n  public schema(): string {\n    return \"\";\n  }\n\n  /**\n   * Set the idle-key TTL (§8.2). Applied on every subsequent write; 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   * Read and parse a session document, or `undefined` when the key is\n   * absent.\n   */\n  private async readSession(\n    orchestratorName: string,\n    sessionId: string,\n  ): Promise<SessionDocument | undefined> {\n    const raw = await this.client.get(\n      this.sessionKey(orchestratorName, sessionId),\n    );\n\n    if (raw === null) {\n      return undefined;\n    }\n\n    return JSON.parse(raw) as SessionDocument;\n  }\n\n  /**\n   * Serialize and persist a session document, honoring the configured\n   * idle TTL when set.\n   */\n  private async writeSession(\n    orchestratorName: string,\n    sessionId: string,\n    document: SessionDocument,\n  ): Promise<void> {\n    await this.write(\n      this.sessionKey(orchestratorName, sessionId),\n      JSON.stringify(document),\n    );\n  }\n\n  /**\n   * Read and parse the per-orchestrator index document, defaulting to an\n   * empty index when absent.\n   */\n  private async readIndex(orchestratorName: string): Promise<IndexDocument> {\n    const raw = await this.client.get(this.indexKey(orchestratorName));\n\n    if (raw === null) {\n      return { sessionIds: [] };\n    }\n\n    return JSON.parse(raw) as IndexDocument;\n  }\n\n  /**\n   * Add a session id to the per-orchestrator index, no-op when already\n   * present.\n   */\n  private async indexSession(\n    orchestratorName: string,\n    sessionId: string,\n  ): Promise<void> {\n    const index = await this.readIndex(orchestratorName);\n\n    if (index.sessionIds.includes(sessionId)) {\n      return;\n    }\n\n    index.sessionIds.push(sessionId);\n\n    await this.write(this.indexKey(orchestratorName), JSON.stringify(index));\n  }\n\n  /**\n   * Remove a session id from the per-orchestrator index, no-op when\n   * absent.\n   */\n  private async deindexSession(\n    orchestratorName: string,\n    sessionId: string,\n  ): Promise<void> {\n    const index = await this.readIndex(orchestratorName);\n    const next = index.sessionIds.filter((id) => id !== sessionId);\n\n    if (next.length === index.sessionIds.length) {\n      return;\n    }\n\n    await this.write(\n      this.indexKey(orchestratorName),\n      JSON.stringify({ sessionIds: next }),\n    );\n  }\n\n  /**\n   * Write a key, attaching the `EX` expiry option when an idle TTL is\n   * configured. The TTL flows through {@link RedisClientLike.set}'s\n   * variadic args as node-redis's `{ EX }` option object.\n   */\n  private async write(key: string, value: string): Promise<void> {\n    if (this.ttl !== undefined && this.ttl > 0) {\n      await this.client.set(key, value, { EX: this.ttl });\n\n      return;\n    }\n\n    await this.client.set(key, value);\n  }\n\n  /**\n   * Key for a session document — `<prefix>:session:<name>:<sessionId>`.\n   */\n  private sessionKey(orchestratorName: string, sessionId: string): string {\n    return `${this.prefix}:session:${orchestratorName}:${sessionId}`;\n  }\n\n  /**\n   * Key for a per-orchestrator session-id index —\n   * `<prefix>:index:<name>`.\n   */\n  private indexKey(orchestratorName: string): string {\n    return `${this.prefix}:index:${orchestratorName}`;\n  }\n}\n\n/**\n * Create a Redis-backed {@link CheckpointStore} (orchestrator.md §8.3).\n * The dev installs `redis` and passes a connected client —\n * `@warlock.js/ai` never imports `redis`. {@link CheckpointStore.schema}\n * returns an empty string; Redis needs no migration.\n *\n * @example\n * import { createClient } from \"redis\";\n * import { ai } from \"@warlock.js/ai\";\n *\n * const client = createClient();\n * await client.connect();\n *\n * const store = ai.checkpoint.redis({ client });\n */\nexport function redis(options: RedisCheckpointOptions): CheckpointStore {\n  return new RedisCheckpointStore(options);\n}\n"],"mappings":"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"}