Files
1260996051 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

80 lines
3.1 KiB
TypeScript

/**
* KV_URL resolution — the ONE place that decides the KV backend.
*
* Contract (uniform across every Hanzo KV consumer; mirrors commerce infra/kv.go):
* - KV_URL unset/empty ⇒ returns null ⇒ caller uses the in-process backend
* (Base/in-memory). This is the WITHOUT-KV mode, which
* is single-replica correct.
* - KV_URL set ⇒ returns a normalized `redis://`/`rediss://` DSN for the
* external Hanzo KV instance (RESP wire protocol).
* - KV_URL malformed ⇒ THROWS. Fail CLOSED — never silently fall back to the
* in-process backend, which would mask a misconfigured
* external KV as a "working" single-replica cache
* (silent split-brain across replicas).
*
* Brand law: the env var is `KV_URL` and its canonical scheme is the Hanzo KV
* brand scheme `kv://` (`kvs://` for TLS). RESP is the wire protocol, so we map
* the brand scheme onto `redis://`/`rediss://` — the ONE allowed "redis" token,
* confined to the wire URL. A raw `redis://`/`rediss://` DSN (what commerce's
* KMS-synced secret carries) is accepted verbatim for cross-service uniformity.
*/
/**
* resolveKvUrl maps the raw KV_URL env value to either null (⇒ in-process) or a
* validated `redis://`/`rediss://` DSN (⇒ external Hanzo KV). Throws on a
* malformed URL — the fail-closed contract.
*/
export function resolveKvUrl(raw: string | undefined | null): string | null {
const value = (raw ?? "").trim();
if (value === "") {
return null;
}
// Brand scheme kv:// / kvs:// → RESP wire scheme redis:// / rediss://.
const normalized = value.startsWith("kv://")
? "redis://" + value.slice("kv://".length)
: value.startsWith("kvs://")
? "rediss://" + value.slice("kvs://".length)
: value;
// Parse to validate. `new URL` throws on a malformed URL → propagates as the
// fail-closed error (we never swallow it into a null/in-process fallback).
let parsed: URL;
try {
parsed = new URL(normalized);
} catch (cause) {
throw new Error(
`KV_URL is malformed and cannot be parsed as a redis:// DSN (got ${redact(value)})`,
{ cause },
);
}
if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
throw new Error(
`KV_URL has unsupported scheme '${parsed.protocol}' — expected kv://, kvs://, redis:// or rediss:// (got ${redact(value)})`,
);
}
if (parsed.hostname === "") {
throw new Error(
`KV_URL is missing a host — expected e.g. redis://:<password>@hanzo-kv:6379 (got ${redact(value)})`,
);
}
return normalized;
}
/**
* redact strips any userinfo (password) from a KV_URL before it appears in an
* error message or log line — a malformed DSN must never leak its credential.
*/
function redact(value: string): string {
const at = value.lastIndexOf("@");
if (at === -1) {
return value;
}
const schemeEnd = value.indexOf("://");
const schemePrefix = schemeEnd === -1 ? "" : value.slice(0, schemeEnd + 3);
return `${schemePrefix}<redacted>@${value.slice(at + 1)}`;
}