Files
dataroom/lib/kv/select.ts
777de736c2 KV-optional: run WITH and WITHOUT external Hanzo KV (#4)
* KV-optional: in-process backend when KV_URL unset

Make dataroom run WITH and WITHOUT external Hanzo KV, uniform with commerce
infra/kv.go. KV_URL unset -> in-process MemoryKV (single-replica correct: cache,
rate-limit, tus upload locks, export/download job stores, digest queues all work
with no external datastore). KV_URL set -> external Hanzo KV over ioredis
(multi-replica HA). Malformed KV_URL fails CLOSED (throws) -- never a silent
in-process fallback.

- lib/kv/url.ts: resolveKvUrl -- fail-closed parse, kv:// brand scheme -> redis://
  wire scheme, password redaction in errors.
- lib/kv/memory-kv.ts: in-process ioredis-compatible store (strings+TTL, zset,
  set, hash, list, pipeline) -- the exact surface dataroom uses.
- lib/kv/select.ts: the one backend selector (mirrors NewKVClient/FromURL).
- lib/redis.ts: use createKvClient() for redis + lockerRedisClient; drop the
  redis://localhost:6379 silent default (the WITHOUT-KV break) and vestigial
  REDIS_URL; Upstash-compat shims unchanged.
- Tests (node --test, dep-free): 25 pass proving selection + in-process
  correctness incl NX lock mutual-exclusion, TTL eviction, zset ordering,
  pipeline shape, fail-closed URL, cross-instance isolation (multi-replica
  split-brain contract).

* kv: warn once when falling back to in-process (non-silent split-brain)

Emit a one-time startup warning when KV_URL is unset so the WITHOUT-KV mode is
never silent — a multi-replica deploy with KV_URL unset splits sessions,
rate-limit windows and tus locks across replicas (single-replica only).

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-07 15:47:14 -07:00

46 lines
2.0 KiB
TypeScript

/**
* createKvClient — the ONE backend selector, mirroring commerce infra/kv.go's
* NewKVClient/NewKVClientFromURL split:
*
* KV_URL unset ⇒ MemoryKV (in-process, single-replica correct)
* KV_URL set ⇒ ioredis (external Hanzo KV, multi-replica HA)
* KV_URL bad ⇒ THROW (fail closed — resolveKvUrl never returns a
* silent in-process fallback for a malformed DSN)
*
* Kept SYNCHRONOUS so `lib/redis.ts` can export module-level singletons exactly
* as before. ioredis is a static import (a declared dependency, present in the
* built image); MemoryKV is the pure in-process leaf. The method surface either
* backend exposes is identical, so every call site and the Upstash-compat shims
* in `lib/redis.ts` are backend-agnostic.
*/
import IORedis, { type Redis } from "ioredis";
import { MemoryKV } from "./memory-kv";
import { resolveKvUrl } from "./url";
const IOREDIS_OPTS = { maxRetriesPerRequest: 3, lazyConnect: true } as const;
let warnedInProcess = false;
export function createKvClient(): Redis {
const url = resolveKvUrl(process.env.KV_URL); // throws on malformed → fail closed
if (url === null) {
// WITHOUT-KV: in-process backend. Warn ONCE per process so this mode is never
// SILENT — a multi-replica deploy with KV_URL unset would split sessions,
// rate-limit windows and tus locks across replicas (single-replica only).
if (!warnedInProcess) {
warnedInProcess = true;
console.warn(
"[kv] KV_URL is unset — using the in-process KV backend. This is correct " +
"for a SINGLE replica only; multi-replica HA requires KV_URL (external Hanzo KV).",
);
}
// `unknown as Redis` is a controlled cast — MemoryKV implements the exact
// ioredis subset dataroom uses (proven by lib/kv/memory-kv.test.ts), not the
// full ioredis type.
return new MemoryKV() as unknown as Redis;
}
// WITH-KV: external Hanzo KV over the RESP wire protocol.
return new IORedis(url, IOREDIS_OPTS);
}