{"version":3,"file":"redis.mjs","names":[],"sources":["../../../../../../../../ai/src/human/stores/redis.ts"],"sourcesContent":["import type {\n  InterruptStore,\n  PendingInterrupt,\n  RedisClientLike,\n} from \"../contracts/interrupt-store.contract\";\n\n/**\n * Options for the Redis {@link InterruptStore}.\n *\n * Two mutually-supportive ways to supply the connection:\n * - **`client`** — pass an already-connected `redis` client (anything\n *   satisfying {@link RedisClientLike}). The store only calls\n *   `get` / `set` / `del` and never connects or quits it.\n * - **`url`** — let the store lazily `import(\"redis\")`, build a client\n *   from the url, and connect it. `@warlock.js/ai` takes **no** hard\n *   dependency on `redis` (it is an optional peer); when it is absent the\n *   store throws a curated install string at first use, never a raw\n *   module-resolution stack trace at import.\n *\n * Exactly one of the two must be present.\n */\nexport interface RedisInterruptOptions {\n  /** An already-connected `redis` client — anything matching {@link RedisClientLike}. */\n  client?: RedisClientLike;\n\n  /** Connection url the store passes to a lazily-imported `createClient`. */\n  url?: string;\n\n  /**\n   * Key prefix prepended to every key this store writes. Lets one Redis\n   * database back multiple stores without collision. Defaults to\n   * `warlock:ai-human:interrupt:`.\n   */\n  prefix?: string;\n}\n\n/**\n * Default key prefix — namespaces the store's keys inside a shared Redis\n * database so interrupt records coexist with other data without collision.\n */\nconst DEFAULT_PREFIX = \"warlock:ai-human:interrupt:\";\n\n/**\n * Index key (under the configured prefix) holding the JSON array of live\n * interrupt ids. The structural {@link RedisClientLike} surface exposes no\n * `SCAN` / `KEYS`, so enumeration for `list()` is self-maintained.\n */\nconst INDEX_SUFFIX = \"index\";\n\n/**\n * Module specifier for the optional `redis` driver. Held in a `string`\n * variable so the dynamic `import()` is not statically resolved at compile\n * time — `redis` is an optional peer that need not be installed for this\n * package to type-check or for a memory-only consumer to run.\n */\nconst REDIS_MODULE = \"redis\";\n\n/**\n * Curated install string surfaced (at use time) when a `url` is configured\n * but the optional `redis` driver is absent. Never thrown at import — a\n * memory-only consumer must be able to load this module.\n */\nconst REDIS_INSTALL_INSTRUCTIONS = `\nThe @warlock.js/ai Redis interrupt store requires the redis package.\nInstall it with:\n\n  npm install redis\n\nOr with your preferred package manager:\n\n  pnpm add redis\n  yarn add redis\n`.trim();\n\n/**\n * Minimal structural view of the `redis` module surface — just enough to\n * build and connect a client from a url. Declared locally (rather than\n * `typeof import(\"redis\")`) so this module type-checks even when `redis`\n * is not installed.\n */\ninterface RedisModuleLike {\n  createClient(config: {\n    url: string;\n  }): RedisClientLike & { connect(): Promise<unknown> };\n}\n\n/**\n * Lazily import `redis`, build a client from `url`, and connect it. A bare\n * `catch` rethrows the curated install string — a missing optional peer\n * surfaces as actionable guidance, never a raw resolution error.\n */\nasync function buildRedisClient(url: string): Promise<RedisClientLike> {\n  let sdk: RedisModuleLike;\n\n  try {\n    sdk = (await import(REDIS_MODULE)) as unknown as RedisModuleLike;\n  } catch {\n    throw new Error(REDIS_INSTALL_INSTRUCTIONS);\n  }\n\n  const client = sdk.createClient({ url });\n  await client.connect();\n\n  return client;\n}\n\n/**\n * Redis-backed {@link InterruptStore} — one JSON string value per pending\n * interrupt, under a namespaced key, plus a self-maintained id index so\n * `list()` works without `SCAN`/`KEYS`.\n *\n * Owns: durable round-tripping of the {@link PendingInterrupt} envelope so\n * a reviewer can rule out-of-process, the namespaced key layout, and the\n * per-store id index that backs enumeration. Does NOT own: durability\n * guarantees beyond Redis's own, the connection lifecycle (a dev-supplied\n * client is never disconnected; a store-built client from a `url` is left\n * connected for the process to reuse), or migration —\n * {@link RedisInterruptStore.schema} returns an empty string.\n *\n * A call has exactly one live interrupt, so `save()` overwrites the key.\n *\n * Front it with the {@link redis} factory — callers never `new` it.\n */\nclass RedisInterruptStore implements InterruptStore {\n  /** Key prefix namespacing every key this store writes. */\n  private readonly prefix: string;\n\n  /**\n   * A ready client, or a promise resolving to one the store builds lazily\n   * from a `url`. Resolved once and memoized so the optional `redis`\n   * import + connect happens at most once.\n   */\n  private clientPromise: Promise<RedisClientLike>;\n\n  public constructor(options: RedisInterruptOptions) {\n    this.prefix = options.prefix ?? DEFAULT_PREFIX;\n\n    if (options.client) {\n      if (\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.human.interrupt.redis requires a 'client' option implementing { get, set, del } — pass a connected redis client.\",\n        );\n      }\n\n      this.clientPromise = Promise.resolve(options.client);\n\n      return;\n    }\n\n    if (options.url) {\n      // Defer the optional `redis` import to first use — a curated install\n      // string surfaces from `buildRedisClient`, not at construction.\n      this.clientPromise = buildRedisClient(options.url);\n\n      return;\n    }\n\n    throw new TypeError(\n      \"ai.human.interrupt.redis requires either a 'client' or a 'url' option.\",\n    );\n  }\n\n  /**\n   * Resolve the backing client, surfacing the lazy `redis` import's\n   * curated install string on the first call that needs it.\n   */\n  private client(): Promise<RedisClientLike> {\n    return this.clientPromise;\n  }\n\n  /**\n   * Persist a pending interrupt, keyed by its own `interruptId`, and index\n   * the id for enumeration. Overwrites any prior record for the same id —\n   * a call has exactly one live interrupt.\n   */\n  public async save(record: PendingInterrupt): Promise<void> {\n    const client = await this.client();\n\n    await client.set(this.recordKey(record.interruptId), JSON.stringify(record));\n    await this.indexId(record.interruptId);\n  }\n\n  /**\n   * Load the interrupt for an `interruptId`, or `undefined` when the key is\n   * missing. Redis returns `null` for an absent key — converted to\n   * `undefined` at the boundary.\n   */\n  public async load(\n    interruptId: string,\n  ): Promise<PendingInterrupt | undefined> {\n    const client = await this.client();\n    const raw = await client.get(this.recordKey(interruptId));\n\n    if (raw === null) {\n      return undefined;\n    }\n\n    return JSON.parse(raw) as PendingInterrupt;\n  }\n\n  /**\n   * Drop the interrupt for an `interruptId` and de-index its id. Idempotent\n   * — deleting an absent id is a no-op.\n   */\n  public async delete(interruptId: string): Promise<void> {\n    const client = await this.client();\n\n    await client.del(this.recordKey(interruptId));\n    await this.deindexId(interruptId);\n  }\n\n  /**\n   * List the interrupt ids known to the store, optionally filtered by a\n   * prefix. Reads the self-maintained index document.\n   */\n  public async list(prefix?: string): Promise<string[]> {\n    const ids = await this.readIndex();\n\n    if (prefix === undefined) {\n      return ids;\n    }\n\n    return ids.filter((id) => id.startsWith(prefix));\n  }\n\n  /**\n   * Redis needs no relational table — there is nothing to migrate. Returns\n   * an empty string so callers can treat `schema()` uniformly across\n   * drivers.\n   */\n  public schema(): string {\n    return \"\";\n  }\n\n  /**\n   * Read and parse the id index, defaulting to an empty list when absent.\n   */\n  private async readIndex(): Promise<string[]> {\n    const client = await this.client();\n    const raw = await client.get(this.indexKey());\n\n    if (raw === null) {\n      return [];\n    }\n\n    return JSON.parse(raw) as string[];\n  }\n\n  /**\n   * Add an interrupt id to the index, no-op when already present.\n   */\n  private async indexId(interruptId: string): Promise<void> {\n    const ids = await this.readIndex();\n\n    if (ids.includes(interruptId)) {\n      return;\n    }\n\n    ids.push(interruptId);\n\n    const client = await this.client();\n    await client.set(this.indexKey(), JSON.stringify(ids));\n  }\n\n  /**\n   * Remove an interrupt id from the index, no-op when absent.\n   */\n  private async deindexId(interruptId: string): Promise<void> {\n    const ids = await this.readIndex();\n    const next = ids.filter((id) => id !== interruptId);\n\n    if (next.length === ids.length) {\n      return;\n    }\n\n    const client = await this.client();\n    await client.set(this.indexKey(), JSON.stringify(next));\n  }\n\n  /**\n   * Key for a single interrupt record — `<prefix><interruptId>`.\n   */\n  private recordKey(interruptId: string): string {\n    return `${this.prefix}${interruptId}`;\n  }\n\n  /**\n   * Key for the self-maintained id index — `<prefix>index`.\n   */\n  private indexKey(): string {\n    return `${this.prefix}${INDEX_SUFFIX}`;\n  }\n}\n\n/**\n * Create a Redis-backed {@link InterruptStore}. Either pass a connected\n * `redis` client (`{ client }`) — `@warlock.js/ai` never imports\n * `redis` in that case — or a `{ url }` and let the store lazily\n * `import(\"redis\")`, build, and connect a client. When `redis` is not\n * installed, the curated install string surfaces on first use, never at\n * import. {@link InterruptStore.schema} returns an empty string; Redis\n * needs no migration.\n *\n * @example\n * import { createClient } from \"redis\";\n * import { ai } from \"@warlock.js/ai\";\n *\n * const client = createClient({ url: process.env.REDIS_URL });\n * await client.connect();\n *\n * const store = ai.human.interrupt.redis({ client });\n *\n * @example\n * // Let the store build + connect its own client from a url:\n * const store = ai.human.interrupt.redis({ url: process.env.REDIS_URL });\n */\nexport function redis(options: RedisInterruptOptions): InterruptStore {\n  return new RedisInterruptStore(options);\n}\n"],"mappings":"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"}