@nymbalhq/doc-store-redis 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ import { DocumentStoreAdapter, Logger, HealthReport, PutOptions, QueryParams, QueryResult, AdapterContext } from '@nymbalhq/types';
2
+ import { Redis } from 'ioredis';
3
+
4
+ interface RedisDocumentStoreOptions {
5
+ /** `redis://[user:password@]host:port[/db]`; the db index in the path is honoured. */
6
+ url: string;
7
+ /** Namespaces every key the store writes: `<prefix>:doc:<collection>` and its expiry index. */
8
+ prefix: string;
9
+ logger?: Logger;
10
+ /** How often expired documents are removed (they are never returned once expired). 0 turns the sweep off. */
11
+ sweepIntervalMs?: number;
12
+ now?: () => number;
13
+ }
14
+ /** A document store on Redis: one hash per collection under the prefix, ordered and filtered by the reference. */
15
+ declare class RedisDocumentStore implements DocumentStoreAdapter {
16
+ #private;
17
+ constructor(client: Redis, options: RedisDocumentStoreOptions);
18
+ capabilities(): readonly string[];
19
+ healthCheck(): Promise<HealthReport>;
20
+ get<T>(collection: string, key: string): Promise<T | null>;
21
+ put<T>(collection: string, key: string, document: T, options?: PutOptions): Promise<void>;
22
+ delete(collection: string, key: string): Promise<void>;
23
+ query<T>(collection: string, params: QueryParams): Promise<QueryResult<T>>;
24
+ batchGet<T>(collection: string, keys: string[]): Promise<Map<string, T>>;
25
+ batchPut<T>(collection: string, items: Map<string, T>): Promise<void>;
26
+ /**
27
+ * Writes the new contents to a staging hash under the prefix, then swaps it in with RENAME in one MULTI, dropping the
28
+ * collection's expiry index with it (the new documents have no TTL). A source that throws deletes the staging hash.
29
+ */
30
+ replaceCollection<T>(collection: string, entries: AsyncIterable<[string, T]>): Promise<void>;
31
+ /** Removes every document whose TTL has passed. Reads never return them in the meantime. */
32
+ sweep(): Promise<void>;
33
+ close(): Promise<void>;
34
+ }
35
+ declare function createRedisDocumentStore(options: RedisDocumentStoreOptions): Promise<RedisDocumentStore>;
36
+
37
+ /** The kernel's entry: `infrastructure.documentStore: 'redis'`, with `{ url, prefix }` from the `redis` block. */
38
+ declare function createAdapter(ctx: AdapterContext): Promise<DocumentStoreAdapter>;
39
+
40
+ export { RedisDocumentStore, createAdapter, createRedisDocumentStore };
41
+ export type { RedisDocumentStoreOptions };
package/dist/index.js ADDED
@@ -0,0 +1,289 @@
1
+ // src/index.ts
2
+ import { ConfigError } from "@nymbalhq/types";
3
+
4
+ // src/redis-document-store.ts
5
+ import { randomUUID } from "crypto";
6
+ import { Redis } from "ioredis";
7
+ import {
8
+ AdapterError,
9
+ boundedHealthCheck,
10
+ connectionSecrets,
11
+ HEALTH_CHECK_TIMEOUT_MS,
12
+ queryDocuments
13
+ } from "@nymbalhq/types";
14
+ var PUT = `
15
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
16
+ if ARGV[3] == '' then
17
+ redis.call('ZREM', KEYS[2], ARGV[1])
18
+ else
19
+ redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])
20
+ redis.call('SADD', KEYS[3], ARGV[4])
21
+ end
22
+ return 1
23
+ `;
24
+ var PUT_IF = `
25
+ local current = redis.call('HGET', KEYS[1], ARGV[1])
26
+ if not current then return 'absent' end
27
+ local stored = cjson.decode(current)
28
+ if stored.e ~= cjson.null and tonumber(stored.e) <= tonumber(ARGV[7]) then return 'absent' end
29
+ local actual = nil
30
+ if type(stored.d) == 'table' then actual = stored.d[ARGV[5]] end
31
+ if ARGV[6] == '' then
32
+ if actual ~= nil then return 'mismatch' end
33
+ else
34
+ local expected = cjson.decode(ARGV[6])
35
+ if type(expected) == 'table' or type(actual) == 'table' then return 'mismatch' end
36
+ if actual ~= expected then return 'mismatch' end
37
+ end
38
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
39
+ if ARGV[3] == '' then
40
+ redis.call('ZREM', KEYS[2], ARGV[1])
41
+ else
42
+ redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])
43
+ redis.call('SADD', KEYS[3], ARGV[4])
44
+ end
45
+ return 'ok'
46
+ `;
47
+ var SWEEP = `
48
+ local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
49
+ for _, key in ipairs(due) do
50
+ local current = redis.call('HGET', KEYS[1], key)
51
+ if current then
52
+ local stored = cjson.decode(current)
53
+ if stored.e ~= cjson.null and tonumber(stored.e) <= tonumber(ARGV[1]) then
54
+ redis.call('HDEL', KEYS[1], key)
55
+ end
56
+ end
57
+ redis.call('ZREM', KEYS[2], key)
58
+ end
59
+ return #due
60
+ `;
61
+ var SWEEP_BATCH = 500;
62
+ var REPLACE_BATCH = 500;
63
+ var RedisDocumentStore = class {
64
+ #client;
65
+ #url;
66
+ #prefix;
67
+ #now;
68
+ #logger;
69
+ #sweepTimer = null;
70
+ constructor(client, options) {
71
+ client.defineCommand("nymbalPut", { numberOfKeys: 3, lua: PUT });
72
+ client.defineCommand("nymbalPutIf", { numberOfKeys: 3, lua: PUT_IF });
73
+ client.defineCommand("nymbalSweep", { numberOfKeys: 2, lua: SWEEP });
74
+ this.#client = client;
75
+ this.#url = options.url;
76
+ this.#prefix = options.prefix;
77
+ this.#now = options.now ?? Date.now;
78
+ this.#logger = options.logger;
79
+ const interval = options.sweepIntervalMs ?? 6e4;
80
+ if (interval > 0) {
81
+ this.#sweepTimer = setInterval(() => void this.sweep(), interval);
82
+ this.#sweepTimer.unref();
83
+ }
84
+ }
85
+ capabilities() {
86
+ return ["get", "put", "delete", "query", "batch", "ttl", "conditional-put"];
87
+ }
88
+ healthCheck() {
89
+ return pingRedis(this.#url);
90
+ }
91
+ async get(collection, key) {
92
+ const raw = await this.#client.hget(this.#docs(collection), key);
93
+ return this.#live(raw);
94
+ }
95
+ async put(collection, key, document, options = {}) {
96
+ const expiresAt = options.ttl !== void 0 ? this.#now() + options.ttl * 1e3 : null;
97
+ const stored = JSON.stringify({ d: document, e: expiresAt });
98
+ const keys = [this.#docs(collection), this.#expiry(collection), this.#expiring()];
99
+ const args = [key, stored, expiresAt === null ? "" : String(expiresAt), collection];
100
+ if (options.conditionKey === void 0) {
101
+ await this.#client.nymbalPut(...keys, ...args);
102
+ return;
103
+ }
104
+ const expected = options.conditionValue === void 0 ? "" : JSON.stringify(options.conditionValue);
105
+ const outcome = await this.#client.nymbalPutIf(
106
+ ...keys,
107
+ ...args,
108
+ options.conditionKey,
109
+ expected,
110
+ this.#now()
111
+ );
112
+ if (outcome !== "ok") {
113
+ throw new AdapterError(
114
+ "condition-failed",
115
+ outcome === "absent" ? `Condition failed on ${collection}/${key}: document does not exist` : `Condition failed on ${collection}/${key}: ${options.conditionKey} mismatch`,
116
+ { context: { collection, key, conditionKey: options.conditionKey } }
117
+ );
118
+ }
119
+ }
120
+ async delete(collection, key) {
121
+ await this.#client.multi().hdel(this.#docs(collection), key).zrem(this.#expiry(collection), key).exec();
122
+ }
123
+ async query(collection, params) {
124
+ const all = await this.#client.hgetall(this.#docs(collection));
125
+ const live = [];
126
+ for (const [key, raw] of Object.entries(all)) {
127
+ const stored = this.#decode(raw);
128
+ if (stored !== null) live.push([key, stored.d]);
129
+ }
130
+ return queryDocuments(live, params);
131
+ }
132
+ async batchGet(collection, keys) {
133
+ const result = /* @__PURE__ */ new Map();
134
+ if (keys.length === 0) return result;
135
+ const values = await this.#client.hmget(this.#docs(collection), ...keys);
136
+ keys.forEach((key, i) => {
137
+ const doc = this.#live(values[i] ?? null);
138
+ if (doc !== null) result.set(key, doc);
139
+ });
140
+ return result;
141
+ }
142
+ async batchPut(collection, items) {
143
+ if (items.size === 0) return;
144
+ const fields = [];
145
+ for (const [key, doc] of items) fields.push(key, JSON.stringify({ d: doc, e: null }));
146
+ await this.#client.multi().hset(this.#docs(collection), ...fields).zrem(this.#expiry(collection), ...items.keys()).exec();
147
+ }
148
+ /**
149
+ * Writes the new contents to a staging hash under the prefix, then swaps it in with RENAME in one MULTI, dropping the
150
+ * collection's expiry index with it (the new documents have no TTL). A source that throws deletes the staging hash.
151
+ */
152
+ async replaceCollection(collection, entries) {
153
+ const staging = `${this.#prefix}:stage:${randomUUID()}:doc:${collection}`;
154
+ let staged = 0;
155
+ try {
156
+ let fields = [];
157
+ const flush = async () => {
158
+ if (fields.length === 0) return;
159
+ await this.#client.hset(staging, ...fields);
160
+ fields = [];
161
+ };
162
+ for await (const [key, doc] of entries) {
163
+ fields.push(key, JSON.stringify({ d: doc, e: null }));
164
+ staged += 1;
165
+ if (fields.length >= REPLACE_BATCH * 2) await flush();
166
+ }
167
+ await flush();
168
+ } catch (error) {
169
+ await this.#client.del(staging).catch(
170
+ (err) => this.#logger?.warn({ err, staging }, "redis document store: a staging hash was left")
171
+ );
172
+ throw error;
173
+ }
174
+ const swap = this.#client.multi();
175
+ if (staged > 0) swap.rename(staging, this.#docs(collection));
176
+ else swap.del(this.#docs(collection));
177
+ const results = await swap.del(this.#expiry(collection)).exec();
178
+ const failed = results?.find(([err]) => err !== null)?.[0];
179
+ if (failed) throw failed;
180
+ }
181
+ /** Removes every document whose TTL has passed. Reads never return them in the meantime. */
182
+ async sweep() {
183
+ try {
184
+ const collections = await this.#client.smembers(this.#expiring());
185
+ for (const collection of collections) {
186
+ const now = this.#now();
187
+ let removed;
188
+ do {
189
+ removed = Number(
190
+ await this.#client.nymbalSweep(
191
+ this.#docs(collection),
192
+ this.#expiry(collection),
193
+ now,
194
+ SWEEP_BATCH
195
+ )
196
+ );
197
+ } while (removed === SWEEP_BATCH);
198
+ }
199
+ } catch (err) {
200
+ this.#logger?.warn({ err }, "redis document store: expired documents were not swept");
201
+ }
202
+ }
203
+ async close() {
204
+ if (this.#sweepTimer !== null) clearInterval(this.#sweepTimer);
205
+ this.#sweepTimer = null;
206
+ await this.#client.quit().catch(() => this.#client.disconnect());
207
+ }
208
+ #docs(collection) {
209
+ return `${this.#prefix}:doc:${collection}`;
210
+ }
211
+ #expiry(collection) {
212
+ return `${this.#prefix}:exp:${collection}`;
213
+ }
214
+ #expiring() {
215
+ return `${this.#prefix}:expiring`;
216
+ }
217
+ #decode(raw) {
218
+ if (raw === null) return null;
219
+ const stored = JSON.parse(raw);
220
+ if (stored.e !== null && stored.e <= this.#now()) return null;
221
+ return stored;
222
+ }
223
+ #live(raw) {
224
+ const stored = this.#decode(raw);
225
+ return stored === null ? null : stored.d;
226
+ }
227
+ };
228
+ function pingRedis(url, timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
229
+ const client = new Redis(url, {
230
+ lazyConnect: true,
231
+ retryStrategy: () => null,
232
+ maxRetriesPerRequest: 0,
233
+ enableOfflineQueue: false,
234
+ connectTimeout: timeoutMs
235
+ });
236
+ client.on("error", () => void 0);
237
+ return boundedHealthCheck(
238
+ async () => {
239
+ await client.connect();
240
+ await client.ping();
241
+ return { status: "healthy" };
242
+ },
243
+ { timeoutMs, redact: connectionSecrets(url) }
244
+ ).finally(() => client.disconnect());
245
+ }
246
+ async function connectRedis(url, logger) {
247
+ const client = new Redis(url, {
248
+ lazyConnect: true,
249
+ maxRetriesPerRequest: 1,
250
+ retryStrategy: (times) => Math.min(times * 200, 1e3),
251
+ connectTimeout: 2e3
252
+ });
253
+ client.on("error", (err) => logger?.warn({ err }, "redis connection error"));
254
+ try {
255
+ await client.connect();
256
+ } catch (error) {
257
+ client.disconnect();
258
+ throw error;
259
+ }
260
+ return client;
261
+ }
262
+ async function createRedisDocumentStore(options) {
263
+ const client = await connectRedis(options.url, options.logger);
264
+ return new RedisDocumentStore(client, options);
265
+ }
266
+
267
+ // src/index.ts
268
+ async function createAdapter(ctx) {
269
+ if (ctx.slot !== "documentStore") {
270
+ throw new ConfigError(`@nymbalhq/doc-store-redis serves documentStore, not ${ctx.slot}`, {
271
+ code: "adapter.wrong_slot",
272
+ context: { slot: ctx.slot }
273
+ });
274
+ }
275
+ const { url, prefix } = ctx.config;
276
+ if (typeof url !== "string" || url === "" || typeof prefix !== "string" || prefix === "") {
277
+ throw new ConfigError("@nymbalhq/doc-store-redis needs a Redis url and a key prefix", {
278
+ code: "config.invalid",
279
+ context: { slot: ctx.slot }
280
+ });
281
+ }
282
+ return createRedisDocumentStore({ url, prefix, logger: ctx.logger });
283
+ }
284
+ export {
285
+ RedisDocumentStore,
286
+ createAdapter,
287
+ createRedisDocumentStore
288
+ };
289
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/redis-document-store.ts"],"sourcesContent":["import { ConfigError, type AdapterContext, type DocumentStoreAdapter } from '@nymbalhq/types'\nimport {\n createRedisDocumentStore,\n RedisDocumentStore,\n type RedisDocumentStoreOptions,\n} from './redis-document-store.js'\n\nexport { createRedisDocumentStore, RedisDocumentStore, type RedisDocumentStoreOptions }\n\n/** The kernel's entry: `infrastructure.documentStore: 'redis'`, with `{ url, prefix }` from the `redis` block. */\nexport async function createAdapter(ctx: AdapterContext): Promise<DocumentStoreAdapter> {\n if (ctx.slot !== 'documentStore') {\n throw new ConfigError(`@nymbalhq/doc-store-redis serves documentStore, not ${ctx.slot}`, {\n code: 'adapter.wrong_slot',\n context: { slot: ctx.slot },\n })\n }\n const { url, prefix } = ctx.config\n if (typeof url !== 'string' || url === '' || typeof prefix !== 'string' || prefix === '') {\n throw new ConfigError('@nymbalhq/doc-store-redis needs a Redis url and a key prefix', {\n code: 'config.invalid',\n context: { slot: ctx.slot },\n })\n }\n return createRedisDocumentStore({ url, prefix, logger: ctx.logger })\n}\n","import { randomUUID } from 'node:crypto'\nimport { Redis } from 'ioredis'\nimport {\n AdapterError,\n boundedHealthCheck,\n connectionSecrets,\n HEALTH_CHECK_TIMEOUT_MS,\n queryDocuments,\n type DocumentStoreAdapter,\n type HealthReport,\n type Logger,\n type PutOptions,\n type QueryParams,\n type QueryResult,\n} from '@nymbalhq/types'\n\nexport interface RedisDocumentStoreOptions {\n /** `redis://[user:password@]host:port[/db]`; the db index in the path is honoured. */\n url: string\n /** Namespaces every key the store writes: `<prefix>:doc:<collection>` and its expiry index. */\n prefix: string\n logger?: Logger\n /** How often expired documents are removed (they are never returned once expired). 0 turns the sweep off. */\n sweepIntervalMs?: number\n now?: () => number\n}\n\n/** A stored document and when it expires (epoch ms), or null for never. */\ninterface Stored {\n d: unknown\n e: number | null\n}\n\n// Writes a document and keeps the collection's expiry index in step with it.\n// KEYS: documents hash, expiry zset, expiring-collections set. ARGV: key, stored JSON, expiresAt or '', collection.\nconst PUT = `\nredis.call('HSET', KEYS[1], ARGV[1], ARGV[2])\nif ARGV[3] == '' then\n redis.call('ZREM', KEYS[2], ARGV[1])\nelse\n redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])\n redis.call('SADD', KEYS[3], ARGV[4])\nend\nreturn 1\n`\n\n// The same write, only when the live document's field is strictly equal to the expected value (JavaScript `===`:\n// objects and arrays never are). ARGV[5]: the field; ARGV[6]: the expected value as JSON, or '' for undefined;\n// ARGV[7]: now (epoch ms).\nconst PUT_IF = `\nlocal current = redis.call('HGET', KEYS[1], ARGV[1])\nif not current then return 'absent' end\nlocal stored = cjson.decode(current)\nif stored.e ~= cjson.null and tonumber(stored.e) <= tonumber(ARGV[7]) then return 'absent' end\nlocal actual = nil\nif type(stored.d) == 'table' then actual = stored.d[ARGV[5]] end\nif ARGV[6] == '' then\n if actual ~= nil then return 'mismatch' end\nelse\n local expected = cjson.decode(ARGV[6])\n if type(expected) == 'table' or type(actual) == 'table' then return 'mismatch' end\n if actual ~= expected then return 'mismatch' end\nend\nredis.call('HSET', KEYS[1], ARGV[1], ARGV[2])\nif ARGV[3] == '' then\n redis.call('ZREM', KEYS[2], ARGV[1])\nelse\n redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])\n redis.call('SADD', KEYS[3], ARGV[4])\nend\nreturn 'ok'\n`\n\n// Removes up to ARGV[2] documents whose expiry has passed at ARGV[1], checking each again under the script, so a\n// document written again since it was indexed is kept.\nconst SWEEP = `\nlocal due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))\nfor _, key in ipairs(due) do\n local current = redis.call('HGET', KEYS[1], key)\n if current then\n local stored = cjson.decode(current)\n if stored.e ~= cjson.null and tonumber(stored.e) <= tonumber(ARGV[1]) then\n redis.call('HDEL', KEYS[1], key)\n end\n end\n redis.call('ZREM', KEYS[2], key)\nend\nreturn #due\n`\n\ntype Script = (...args: Array<string | number>) => Promise<unknown>\ninterface Scripted {\n nymbalPut: Script\n nymbalPutIf: Script\n nymbalSweep: Script\n}\n\nconst SWEEP_BATCH = 500\n/** Documents per HSET while a replace stages its new contents. */\nconst REPLACE_BATCH = 500\n\n/** A document store on Redis: one hash per collection under the prefix, ordered and filtered by the reference. */\nexport class RedisDocumentStore implements DocumentStoreAdapter {\n readonly #client: Redis & Scripted\n readonly #url: string\n readonly #prefix: string\n readonly #now: () => number\n readonly #logger: Logger | undefined\n #sweepTimer: NodeJS.Timeout | null = null\n\n constructor(client: Redis, options: RedisDocumentStoreOptions) {\n client.defineCommand('nymbalPut', { numberOfKeys: 3, lua: PUT })\n client.defineCommand('nymbalPutIf', { numberOfKeys: 3, lua: PUT_IF })\n client.defineCommand('nymbalSweep', { numberOfKeys: 2, lua: SWEEP })\n this.#client = client as Redis & Scripted\n this.#url = options.url\n this.#prefix = options.prefix\n this.#now = options.now ?? Date.now\n this.#logger = options.logger\n const interval = options.sweepIntervalMs ?? 60_000\n if (interval > 0) {\n this.#sweepTimer = setInterval(() => void this.sweep(), interval)\n this.#sweepTimer.unref()\n }\n }\n\n capabilities(): readonly string[] {\n return ['get', 'put', 'delete', 'query', 'batch', 'ttl', 'conditional-put']\n }\n\n healthCheck(): Promise<HealthReport> {\n return pingRedis(this.#url)\n }\n\n async get<T>(collection: string, key: string): Promise<T | null> {\n const raw = await this.#client.hget(this.#docs(collection), key)\n return this.#live<T>(raw)\n }\n\n async put<T>(\n collection: string,\n key: string,\n document: T,\n options: PutOptions = {},\n ): Promise<void> {\n const expiresAt = options.ttl !== undefined ? this.#now() + options.ttl * 1000 : null\n const stored = JSON.stringify({ d: document, e: expiresAt } satisfies Stored)\n const keys = [this.#docs(collection), this.#expiry(collection), this.#expiring()]\n const args = [key, stored, expiresAt === null ? '' : String(expiresAt), collection]\n if (options.conditionKey === undefined) {\n await this.#client.nymbalPut(...keys, ...args)\n return\n }\n const expected =\n options.conditionValue === undefined ? '' : JSON.stringify(options.conditionValue)\n const outcome = await this.#client.nymbalPutIf(\n ...keys,\n ...args,\n options.conditionKey,\n expected,\n this.#now(),\n )\n if (outcome !== 'ok') {\n throw new AdapterError(\n 'condition-failed',\n outcome === 'absent'\n ? `Condition failed on ${collection}/${key}: document does not exist`\n : `Condition failed on ${collection}/${key}: ${options.conditionKey} mismatch`,\n { context: { collection, key, conditionKey: options.conditionKey } },\n )\n }\n }\n\n async delete(collection: string, key: string): Promise<void> {\n await this.#client\n .multi()\n .hdel(this.#docs(collection), key)\n .zrem(this.#expiry(collection), key)\n .exec()\n }\n\n async query<T>(collection: string, params: QueryParams): Promise<QueryResult<T>> {\n const all = await this.#client.hgetall(this.#docs(collection))\n const live: Array<[string, unknown]> = []\n for (const [key, raw] of Object.entries(all)) {\n const stored = this.#decode(raw)\n if (stored !== null) live.push([key, stored.d])\n }\n return queryDocuments<T>(live, params)\n }\n\n async batchGet<T>(collection: string, keys: string[]): Promise<Map<string, T>> {\n const result = new Map<string, T>()\n if (keys.length === 0) return result\n const values = await this.#client.hmget(this.#docs(collection), ...keys)\n keys.forEach((key, i) => {\n const doc = this.#live<T>(values[i] ?? null)\n if (doc !== null) result.set(key, doc)\n })\n return result\n }\n\n async batchPut<T>(collection: string, items: Map<string, T>): Promise<void> {\n if (items.size === 0) return\n const fields: string[] = []\n for (const [key, doc] of items) fields.push(key, JSON.stringify({ d: doc, e: null }))\n await this.#client\n .multi()\n .hset(this.#docs(collection), ...fields)\n .zrem(this.#expiry(collection), ...items.keys())\n .exec()\n }\n\n /**\n * Writes the new contents to a staging hash under the prefix, then swaps it in with RENAME in one MULTI, dropping the\n * collection's expiry index with it (the new documents have no TTL). A source that throws deletes the staging hash.\n */\n async replaceCollection<T>(\n collection: string,\n entries: AsyncIterable<[string, T]>,\n ): Promise<void> {\n const staging = `${this.#prefix}:stage:${randomUUID()}:doc:${collection}`\n let staged = 0\n try {\n let fields: string[] = []\n const flush = async () => {\n if (fields.length === 0) return\n await this.#client.hset(staging, ...fields)\n fields = []\n }\n for await (const [key, doc] of entries) {\n fields.push(key, JSON.stringify({ d: doc, e: null } satisfies Stored))\n staged += 1\n if (fields.length >= REPLACE_BATCH * 2) await flush()\n }\n await flush()\n } catch (error) {\n await this.#client\n .del(staging)\n .catch((err: unknown) =>\n this.#logger?.warn({ err, staging }, 'redis document store: a staging hash was left'),\n )\n throw error\n }\n const swap = this.#client.multi()\n if (staged > 0) swap.rename(staging, this.#docs(collection))\n else swap.del(this.#docs(collection))\n const results = await swap.del(this.#expiry(collection)).exec()\n const failed = results?.find(([err]) => err !== null)?.[0]\n if (failed) throw failed\n }\n\n /** Removes every document whose TTL has passed. Reads never return them in the meantime. */\n async sweep(): Promise<void> {\n try {\n const collections = await this.#client.smembers(this.#expiring())\n for (const collection of collections) {\n const now = this.#now()\n let removed: number\n // A full batch may have left more behind.\n do {\n removed = Number(\n await this.#client.nymbalSweep(\n this.#docs(collection),\n this.#expiry(collection),\n now,\n SWEEP_BATCH,\n ),\n )\n } while (removed === SWEEP_BATCH)\n }\n } catch (err) {\n this.#logger?.warn({ err }, 'redis document store: expired documents were not swept')\n }\n }\n\n async close(): Promise<void> {\n if (this.#sweepTimer !== null) clearInterval(this.#sweepTimer)\n this.#sweepTimer = null\n await this.#client.quit().catch(() => this.#client.disconnect())\n }\n\n #docs(collection: string): string {\n return `${this.#prefix}:doc:${collection}`\n }\n\n #expiry(collection: string): string {\n return `${this.#prefix}:exp:${collection}`\n }\n\n #expiring(): string {\n return `${this.#prefix}:expiring`\n }\n\n #decode(raw: string | null): Stored | null {\n if (raw === null) return null\n const stored = JSON.parse(raw) as Stored\n if (stored.e !== null && stored.e <= this.#now()) return null\n return stored\n }\n\n #live<T>(raw: string | null): T | null {\n const stored = this.#decode(raw)\n return stored === null ? null : (stored.d as T)\n }\n}\n\n/**\n * PING on a client of its own that never retries and queues nothing, disconnected afterwards: the store's or bus's own\n * clients retry a closed port for as long as the process lives, so they cannot answer within the bound.\n */\nfunction pingRedis(url: string, timeoutMs = HEALTH_CHECK_TIMEOUT_MS): Promise<HealthReport> {\n const client = new Redis(url, {\n lazyConnect: true,\n retryStrategy: () => null,\n maxRetriesPerRequest: 0,\n enableOfflineQueue: false,\n connectTimeout: timeoutMs,\n })\n // connect() rejects with the same error, and the report carries it; without a listener ioredis would throw it.\n client.on('error', () => undefined)\n return boundedHealthCheck(\n async () => {\n await client.connect()\n await client.ping()\n return { status: 'healthy' }\n },\n { timeoutMs, redact: connectionSecrets(url) },\n ).finally(() => client.disconnect())\n}\n\n/** Connects to `url` and resolves once the connection is ready; a server that cannot be reached rejects. */\nexport async function connectRedis(url: string, logger?: Logger): Promise<Redis> {\n // While the server is gone a command waits out at most two reconnect attempts (each a connect of at most 2 s, a\n // second apart) and then rejects, rather than queueing until the server returns. The client keeps reconnecting for\n // as long as the store is open, so the commands after the server is back succeed.\n const client = new Redis(url, {\n lazyConnect: true,\n maxRetriesPerRequest: 1,\n retryStrategy: (times: number) => Math.min(times * 200, 1000),\n connectTimeout: 2000,\n })\n client.on('error', (err: Error) => logger?.warn({ err }, 'redis connection error'))\n try {\n await client.connect()\n } catch (error) {\n client.disconnect()\n throw error\n }\n return client\n}\n\nexport async function createRedisDocumentStore(\n options: RedisDocumentStoreOptions,\n): Promise<RedisDocumentStore> {\n const client = await connectRedis(options.url, options.logger)\n return new RedisDocumentStore(client, options)\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@nymbalhq/doc-store-redis",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.2.0",
4
+ "description": "The Redis document store for Nymbal: `infrastructure.documentStore: 'redis'`",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/nymbalhq/nymbal.git",
9
+ "directory": "packages/doc-store-redis"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ },
25
+ "./recipes/*": "./recipes/*"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "recipes"
30
+ ],
31
+ "dependencies": {
32
+ "@nymbalhq/types": "1.2.0"
33
+ },
34
+ "peerDependencies": {
35
+ "ioredis": "^5.4.0 || ^6.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@nymbalhq/contract-tests": "1.2.0",
39
+ "@typescript/native": "npm:typescript@^7.0.2",
40
+ "fast-check": "^4.10.2",
41
+ "ioredis": "^6.0.0",
42
+ "tsup": "^9.0.0",
43
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
44
+ "vitest": "^3.0.0"
45
+ },
46
+ "scripts": {
47
+ "build": "tsup",
48
+ "dev": "tsup --watch",
49
+ "typecheck": "tsc -p tsconfig.json --noEmit --rootDir ../.."
50
+ }
6
51
  }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "redis",
3
+ "summary": "Keep the read model in Redis and carry events on Redis Streams, so several API instances share one store.",
4
+ "install": ["@nymbalhq/doc-store-redis", "@nymbalhq/event-bus-redis-streams", "ioredis"],
5
+ "set": [
6
+ { "path": "infrastructure.documentStore", "value": "redis" },
7
+ { "path": "infrastructure.eventBus", "value": "redis-streams" }
8
+ ],
9
+ "env": [
10
+ {
11
+ "name": "REDIS_URL",
12
+ "example": "redis://localhost:6379",
13
+ "required": true,
14
+ "comment": "The Redis server the document store and the event bus connect to"
15
+ },
16
+ {
17
+ "name": "NYMBAL_REDIS_PREFIX",
18
+ "example": "nymbal",
19
+ "required": false,
20
+ "comment": "Starts every key and stream name, so stores can share one server (default nymbal)"
21
+ }
22
+ ],
23
+ "next": [
24
+ "Start a Redis server (docker run -p 6379:6379 redis:7-alpine) and point REDIS_URL at it.",
25
+ "Then nymbal dev: the API connects to Redis when it starts."
26
+ ]
27
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.