@nymbalhq/doc-store-redis 0.0.0-stage → 1.1.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,35 @@
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
+ /** Removes every document whose TTL has passed. Reads never return them in the meantime. */
27
+ sweep(): Promise<void>;
28
+ close(): Promise<void>;
29
+ }
30
+ declare function createRedisDocumentStore(options: RedisDocumentStoreOptions): Promise<RedisDocumentStore>;
31
+
32
+ /** The kernel's entry: `infrastructure.documentStore: 'redis'`, with `{ url, prefix }` from the `redis` block. */
33
+ declare function createAdapter(ctx: AdapterContext): Promise<DocumentStoreAdapter>;
34
+
35
+ export { RedisDocumentStore, type RedisDocumentStoreOptions, createAdapter, createRedisDocumentStore };
package/dist/index.js ADDED
@@ -0,0 +1,249 @@
1
+ // src/index.ts
2
+ import { ConfigError } from "@nymbalhq/types";
3
+
4
+ // src/redis-document-store.ts
5
+ import { Redis } from "ioredis";
6
+ import {
7
+ AdapterError,
8
+ boundedHealthCheck,
9
+ connectionSecrets,
10
+ HEALTH_CHECK_TIMEOUT_MS,
11
+ queryDocuments
12
+ } from "@nymbalhq/types";
13
+ var PUT = `
14
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
15
+ if ARGV[3] == '' then
16
+ redis.call('ZREM', KEYS[2], ARGV[1])
17
+ else
18
+ redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])
19
+ redis.call('SADD', KEYS[3], ARGV[4])
20
+ end
21
+ return 1
22
+ `;
23
+ var PUT_IF = `
24
+ local current = redis.call('HGET', KEYS[1], ARGV[1])
25
+ if not current then return 'absent' end
26
+ local stored = cjson.decode(current)
27
+ if stored.e ~= cjson.null and tonumber(stored.e) <= tonumber(ARGV[7]) then return 'absent' end
28
+ local actual = nil
29
+ if type(stored.d) == 'table' then actual = stored.d[ARGV[5]] end
30
+ if ARGV[6] == '' then
31
+ if actual ~= nil then return 'mismatch' end
32
+ else
33
+ local expected = cjson.decode(ARGV[6])
34
+ if type(expected) == 'table' or type(actual) == 'table' then return 'mismatch' end
35
+ if actual ~= expected then return 'mismatch' end
36
+ end
37
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
38
+ if ARGV[3] == '' then
39
+ redis.call('ZREM', KEYS[2], ARGV[1])
40
+ else
41
+ redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])
42
+ redis.call('SADD', KEYS[3], ARGV[4])
43
+ end
44
+ return 'ok'
45
+ `;
46
+ var SWEEP = `
47
+ local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
48
+ for _, key in ipairs(due) do
49
+ local current = redis.call('HGET', KEYS[1], key)
50
+ if current then
51
+ local stored = cjson.decode(current)
52
+ if stored.e ~= cjson.null and tonumber(stored.e) <= tonumber(ARGV[1]) then
53
+ redis.call('HDEL', KEYS[1], key)
54
+ end
55
+ end
56
+ redis.call('ZREM', KEYS[2], key)
57
+ end
58
+ return #due
59
+ `;
60
+ var SWEEP_BATCH = 500;
61
+ var RedisDocumentStore = class {
62
+ #client;
63
+ #url;
64
+ #prefix;
65
+ #now;
66
+ #logger;
67
+ #sweepTimer = null;
68
+ constructor(client, options) {
69
+ client.defineCommand("nymbalPut", { numberOfKeys: 3, lua: PUT });
70
+ client.defineCommand("nymbalPutIf", { numberOfKeys: 3, lua: PUT_IF });
71
+ client.defineCommand("nymbalSweep", { numberOfKeys: 2, lua: SWEEP });
72
+ this.#client = client;
73
+ this.#url = options.url;
74
+ this.#prefix = options.prefix;
75
+ this.#now = options.now ?? Date.now;
76
+ this.#logger = options.logger;
77
+ const interval = options.sweepIntervalMs ?? 6e4;
78
+ if (interval > 0) {
79
+ this.#sweepTimer = setInterval(() => void this.sweep(), interval);
80
+ this.#sweepTimer.unref();
81
+ }
82
+ }
83
+ capabilities() {
84
+ return ["get", "put", "delete", "query", "batch", "ttl", "conditional-put"];
85
+ }
86
+ healthCheck() {
87
+ return pingRedis(this.#url);
88
+ }
89
+ async get(collection, key) {
90
+ const raw = await this.#client.hget(this.#docs(collection), key);
91
+ return this.#live(raw);
92
+ }
93
+ async put(collection, key, document, options = {}) {
94
+ const expiresAt = options.ttl !== void 0 ? this.#now() + options.ttl * 1e3 : null;
95
+ const stored = JSON.stringify({ d: document, e: expiresAt });
96
+ const keys = [this.#docs(collection), this.#expiry(collection), this.#expiring()];
97
+ const args = [key, stored, expiresAt === null ? "" : String(expiresAt), collection];
98
+ if (options.conditionKey === void 0) {
99
+ await this.#client.nymbalPut(...keys, ...args);
100
+ return;
101
+ }
102
+ const expected = options.conditionValue === void 0 ? "" : JSON.stringify(options.conditionValue);
103
+ const outcome = await this.#client.nymbalPutIf(
104
+ ...keys,
105
+ ...args,
106
+ options.conditionKey,
107
+ expected,
108
+ this.#now()
109
+ );
110
+ if (outcome !== "ok") {
111
+ throw new AdapterError(
112
+ "condition-failed",
113
+ outcome === "absent" ? `Condition failed on ${collection}/${key}: document does not exist` : `Condition failed on ${collection}/${key}: ${options.conditionKey} mismatch`,
114
+ { context: { collection, key, conditionKey: options.conditionKey } }
115
+ );
116
+ }
117
+ }
118
+ async delete(collection, key) {
119
+ await this.#client.multi().hdel(this.#docs(collection), key).zrem(this.#expiry(collection), key).exec();
120
+ }
121
+ async query(collection, params) {
122
+ const all = await this.#client.hgetall(this.#docs(collection));
123
+ const live = [];
124
+ for (const [key, raw] of Object.entries(all)) {
125
+ const stored = this.#decode(raw);
126
+ if (stored !== null) live.push([key, stored.d]);
127
+ }
128
+ return queryDocuments(live, params);
129
+ }
130
+ async batchGet(collection, keys) {
131
+ const result = /* @__PURE__ */ new Map();
132
+ if (keys.length === 0) return result;
133
+ const values = await this.#client.hmget(this.#docs(collection), ...keys);
134
+ keys.forEach((key, i) => {
135
+ const doc = this.#live(values[i] ?? null);
136
+ if (doc !== null) result.set(key, doc);
137
+ });
138
+ return result;
139
+ }
140
+ async batchPut(collection, items) {
141
+ if (items.size === 0) return;
142
+ const fields = [];
143
+ for (const [key, doc] of items) fields.push(key, JSON.stringify({ d: doc, e: null }));
144
+ await this.#client.multi().hset(this.#docs(collection), ...fields).zrem(this.#expiry(collection), ...items.keys()).exec();
145
+ }
146
+ /** Removes every document whose TTL has passed. Reads never return them in the meantime. */
147
+ async sweep() {
148
+ try {
149
+ const collections = await this.#client.smembers(this.#expiring());
150
+ for (const collection of collections) {
151
+ const now = this.#now();
152
+ let removed;
153
+ do {
154
+ removed = Number(
155
+ await this.#client.nymbalSweep(
156
+ this.#docs(collection),
157
+ this.#expiry(collection),
158
+ now,
159
+ SWEEP_BATCH
160
+ )
161
+ );
162
+ } while (removed === SWEEP_BATCH);
163
+ }
164
+ } catch (err) {
165
+ this.#logger?.warn({ err }, "redis document store: expired documents were not swept");
166
+ }
167
+ }
168
+ async close() {
169
+ if (this.#sweepTimer !== null) clearInterval(this.#sweepTimer);
170
+ this.#sweepTimer = null;
171
+ await this.#client.quit().catch(() => this.#client.disconnect());
172
+ }
173
+ #docs(collection) {
174
+ return `${this.#prefix}:doc:${collection}`;
175
+ }
176
+ #expiry(collection) {
177
+ return `${this.#prefix}:exp:${collection}`;
178
+ }
179
+ #expiring() {
180
+ return `${this.#prefix}:expiring`;
181
+ }
182
+ #decode(raw) {
183
+ if (raw === null) return null;
184
+ const stored = JSON.parse(raw);
185
+ if (stored.e !== null && stored.e <= this.#now()) return null;
186
+ return stored;
187
+ }
188
+ #live(raw) {
189
+ const stored = this.#decode(raw);
190
+ return stored === null ? null : stored.d;
191
+ }
192
+ };
193
+ function pingRedis(url, timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
194
+ const client = new Redis(url, {
195
+ lazyConnect: true,
196
+ retryStrategy: () => null,
197
+ maxRetriesPerRequest: 0,
198
+ enableOfflineQueue: false,
199
+ connectTimeout: timeoutMs
200
+ });
201
+ client.on("error", () => void 0);
202
+ return boundedHealthCheck(
203
+ async () => {
204
+ await client.connect();
205
+ await client.ping();
206
+ return { status: "healthy" };
207
+ },
208
+ { timeoutMs, redact: connectionSecrets(url) }
209
+ ).finally(() => client.disconnect());
210
+ }
211
+ async function connectRedis(url, logger) {
212
+ const client = new Redis(url, { lazyConnect: true });
213
+ client.on("error", (err) => logger?.warn({ err }, "redis connection error"));
214
+ try {
215
+ await client.connect();
216
+ } catch (error) {
217
+ client.disconnect();
218
+ throw error;
219
+ }
220
+ return client;
221
+ }
222
+ async function createRedisDocumentStore(options) {
223
+ const client = await connectRedis(options.url, options.logger);
224
+ return new RedisDocumentStore(client, options);
225
+ }
226
+
227
+ // src/index.ts
228
+ async function createAdapter(ctx) {
229
+ if (ctx.slot !== "documentStore") {
230
+ throw new ConfigError(`@nymbalhq/doc-store-redis serves documentStore, not ${ctx.slot}`, {
231
+ code: "adapter.wrong_slot",
232
+ context: { slot: ctx.slot }
233
+ });
234
+ }
235
+ const { url, prefix } = ctx.config;
236
+ if (typeof url !== "string" || url === "" || typeof prefix !== "string" || prefix === "") {
237
+ throw new ConfigError("@nymbalhq/doc-store-redis needs a Redis url and a key prefix", {
238
+ code: "config.invalid",
239
+ context: { slot: ctx.slot }
240
+ });
241
+ }
242
+ return createRedisDocumentStore({ url, prefix, logger: ctx.logger });
243
+ }
244
+ export {
245
+ RedisDocumentStore,
246
+ createAdapter,
247
+ createRedisDocumentStore
248
+ };
249
+ //# 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 { 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\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 /** 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 const client = new Redis(url, { lazyConnect: true })\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,50 @@
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.1.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.1.0"
33
+ },
34
+ "peerDependencies": {
35
+ "ioredis": "^5.4.0 || ^6.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "fast-check": "^4.10.2",
39
+ "ioredis": "^6.0.0",
40
+ "tsup": "^8.3.5",
41
+ "typescript": "^5.6.3",
42
+ "vitest": "^3.0.0",
43
+ "@nymbalhq/contract-tests": "0.1.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup",
47
+ "dev": "tsup --watch",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit"
49
+ }
6
50
  }
@@ -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.