Files
dataroom/lib/kv/select.test.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

55 lines
2.0 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
/**
* select.ts statically imports ioredis (a production dependency, parity with the
* original lib/redis.ts). In a bare checkout without node_modules the import
* can't resolve, so we dynamic-import and SKIP — the selection logic itself is
* proven dep-free by url.test.ts (fail-closed + normalization) and memory-kv.test.ts
* (in-process semantics). Where ioredis is installed (CI, image), these run.
*/
let createKvClient: (() => any) | undefined;
try {
({ createKvClient } = await import("./select.ts"));
} catch {
// ioredis not installed in this checkout — tests below skip.
}
const ready = createKvClient !== undefined;
function withEnv(value: string | undefined, fn: () => void | Promise<void>) {
const prev = process.env.KV_URL;
if (value === undefined) delete process.env.KV_URL;
else process.env.KV_URL = value;
const restore = () => {
if (prev === undefined) delete process.env.KV_URL;
else process.env.KV_URL = prev;
};
return Promise.resolve()
.then(fn)
.finally(restore);
}
test("KV_URL unset ⇒ in-process backend, fully functional", { skip: !ready }, async () => {
await withEnv(undefined, async () => {
const c = createKvClient!();
assert.equal(c.status, "ready"); // MemoryKV lifecycle stub — no connection
assert.equal(await c.set("k", "v"), "OK");
assert.equal(await c.get("k"), "v");
});
});
test("KV_URL malformed ⇒ THROWS (fail closed, no silent fallback)", { skip: !ready }, async () => {
await withEnv("http://:pw@hanzo-kv:6379", () => {
assert.throws(() => createKvClient!());
});
});
test("KV_URL set (kv://) ⇒ external ioredis client, lazy (does not connect)", { skip: !ready }, async () => {
await withEnv("kv://hanzo-kv:6379", () => {
const c = createKvClient!();
// ioredis with lazyConnect stays 'wait' until first command; MemoryKV is 'ready'.
assert.notEqual(c.status, "ready");
if (typeof c.disconnect === "function") c.disconnect();
});
});