@fieldnotes/sync-redis 0.9.0 → 0.11.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.
package/README.md CHANGED
@@ -164,3 +164,14 @@ fanout forwards live ops; the shared backend keeps snapshots consistent. A share
164
164
  backend leaves a new joiner's snapshot stale — it would catch up from whichever instance it happened to hit,
165
165
  missing edits applied elsewhere. Pair `RedisHubFanout` with `RedisHubBackend` for full multi-instance
166
166
  real-time sync.
167
+
168
+ ## Running the real-Redis fog tests
169
+
170
+ `src/redis-fog.integration.test.ts` runs the fog Lua scripts against a real server instead of a fake, so
171
+ it is skipped unless `REDIS_URL` is set. Point it at a scratch Redis — the suite writes under a random
172
+ `fieldnotes-it:<uuid>:room:` key prefix and deletes those keys afterwards, but it does use `KEYS`, so do
173
+ not aim it at a production instance:
174
+
175
+ ```bash
176
+ REDIS_URL=redis://localhost:6379 pnpm --filter @fieldnotes/sync-redis test
177
+ ```
package/dist/index.cjs CHANGED
@@ -21,12 +21,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  RedisHubBackend: () => RedisHubBackend,
24
- RedisHubFanout: () => RedisHubFanout
24
+ RedisHubFanout: () => RedisHubFanout,
25
+ createScriptRunner: () => createScriptRunner,
26
+ encodeRoomKey: () => encodeRoomKey
25
27
  });
26
28
  module.exports = __toCommonJS(index_exports);
27
29
 
28
30
  // src/redis-hub-backend.ts
29
31
  var import_sync = require("@fieldnotes/sync");
32
+
33
+ // src/room-key.ts
34
+ function encodeRoomKey(room) {
35
+ return encodeURIComponent(room);
36
+ }
37
+
38
+ // src/redis-hub-backend.ts
30
39
  var RedisHubBackend = class {
31
40
  sharedAcrossInstances = true;
32
41
  client;
@@ -42,10 +51,10 @@ var RedisHubBackend = class {
42
51
  return this.services.get(key.id);
43
52
  }
44
53
  key(room) {
45
- return `${this.keyPrefix}${room}`;
54
+ return `${this.keyPrefix}${encodeRoomKey(room)}`;
46
55
  }
47
56
  layersKey(room) {
48
- return `${this.keyPrefix}${room}:layers`;
57
+ return `${this.key(room)}:layers`;
49
58
  }
50
59
  async snapshot(room) {
51
60
  const map = await this.client.hGetAll(this.key(room));
@@ -124,6 +133,7 @@ var RedisHubBackend = class {
124
133
  plugin.start({
125
134
  client: this.client,
126
135
  roomKeyPrefix: this.keyPrefix,
136
+ roomKey: (room) => this.key(room),
127
137
  registerService: (key, service) => {
128
138
  if (this.services.has(key.id)) {
129
139
  throw new Error(`Backend service "${key.name}" is already registered`);
@@ -156,6 +166,40 @@ function safelyDispose(dispose) {
156
166
  }
157
167
  }
158
168
 
169
+ // src/script-runner.ts
170
+ function isNoScriptError(error) {
171
+ return error instanceof Error && error.message.includes("NOSCRIPT");
172
+ }
173
+ function createScriptRunner(client) {
174
+ const evalScript = client.eval.bind(client);
175
+ const scriptLoad = client.scriptLoad?.bind(client);
176
+ const evalSha = client.evalSha?.bind(client);
177
+ const shaByScript = /* @__PURE__ */ new Map();
178
+ const load = (script, loader) => {
179
+ const cached = shaByScript.get(script);
180
+ if (cached) return cached;
181
+ const pending = loader(script);
182
+ shaByScript.set(script, pending);
183
+ pending.catch(() => {
184
+ if (shaByScript.get(script) === pending) shaByScript.delete(script);
185
+ });
186
+ return pending;
187
+ };
188
+ return async (script, options) => {
189
+ if (!scriptLoad || !evalSha) return evalScript(script, options);
190
+ const pending = load(script, scriptLoad);
191
+ const sha = await pending;
192
+ try {
193
+ return await evalSha(sha, options);
194
+ } catch (error) {
195
+ if (!isNoScriptError(error)) throw error;
196
+ if (shaByScript.get(script) === pending) shaByScript.delete(script);
197
+ const reloaded = await load(script, scriptLoad);
198
+ return await evalSha(reloaded, options);
199
+ }
200
+ };
201
+ }
202
+
159
203
  // src/redis-hub-fanout.ts
160
204
  var RedisHubFanout = class {
161
205
  publisher;
@@ -202,6 +246,8 @@ var RedisHubFanout = class {
202
246
  // Annotate the CommonJS export names for ESM import in node:
203
247
  0 && (module.exports = {
204
248
  RedisHubBackend,
205
- RedisHubFanout
249
+ RedisHubFanout,
250
+ createScriptRunner,
251
+ encodeRoomKey
206
252
  });
207
253
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/redis-hub-backend.ts","../src/redis-hub-fanout.ts"],"sourcesContent":["export { RedisHubBackend } from './redis-hub-backend';\nexport type { RedisHubBackendOptions } from './redis-hub-backend';\nexport type { RedisHashClient } from './redis-hash-client';\nexport { RedisHubFanout } from './redis-hub-fanout';\nexport type { RedisHubFanoutOptions } from './redis-hub-fanout';\nexport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\nexport type { BackendSyncPlugin, BackendPluginContext } from './sync-plugin';\n","import type { ServiceKey } from '@fieldnotes/core';\nimport {\n isValidWireElement,\n isValidLayerRecord,\n type LayerRecord,\n type WireSyncElement,\n type WireSyncOp,\n} from '@fieldnotes/sync';\nimport type { HubBackend } from '@fieldnotes/sync-server';\nimport type { RedisHashClient } from './redis-hash-client';\nimport type { BackendSyncPlugin } from './sync-plugin';\n\nexport interface RedisHubBackendOptions {\n keyPrefix?: string;\n plugins?: readonly BackendSyncPlugin[];\n}\n\nexport class RedisHubBackend implements HubBackend {\n readonly sharedAcrossInstances = true;\n private readonly client: RedisHashClient;\n private readonly keyPrefix: string;\n private readonly services = new Map<symbol, unknown>();\n private readonly pluginDisposers: (() => void)[] = [];\n\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\n this.client = client;\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\n this.installPlugins(options.plugins ?? []);\n }\n\n getService<T>(key: ServiceKey<T>): T | undefined {\n return this.services.get(key.id) as T | undefined;\n }\n\n private key(room: string): string {\n return `${this.keyPrefix}${room}`;\n }\n\n private layersKey(room: string): string {\n return `${this.keyPrefix}${room}:layers`;\n }\n\n async snapshot(room: string): Promise<WireSyncElement[]> {\n const map = await this.client.hGetAll(this.key(room));\n const out: WireSyncElement[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidWireElement(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the room snapshot.\n }\n }\n return out;\n }\n\n async get(room: string, id: string): Promise<WireSyncElement | undefined> {\n const value = await this.client.hGet(this.key(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidWireElement(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async apply(room: string, op: WireSyncOp): Promise<void> {\n const key = this.key(room);\n if (op.kind === 'upsert')\n await this.client.hSet(key, op.element.id, JSON.stringify(op.element));\n else if (op.kind === 'remove') await this.client.hDel(key, op.id);\n else if (op.kind === 'clear') await this.client.del(key);\n }\n\n async layerRecords(room: string): Promise<LayerRecord[]> {\n const map = await this.client.hGetAll(this.layersKey(room));\n const out: LayerRecord[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidLayerRecord(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the layer ledger.\n }\n }\n return out;\n }\n\n async getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined> {\n const value = await this.client.hGet(this.layersKey(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidLayerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async applyLayerRecord(room: string, record: LayerRecord): Promise<void> {\n await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));\n }\n\n dispose(): void {\n for (const dispose of [...this.pluginDisposers].reverse()) safelyDispose(dispose);\n this.pluginDisposers.length = 0;\n this.services.clear();\n }\n\n private installPlugins(plugins: readonly BackendSyncPlugin[]): void {\n const names = new Set<string>();\n const prefixes = new Set<string>();\n try {\n for (const plugin of plugins) {\n if (names.has(plugin.name))\n throw new Error(`Backend plugin \"${plugin.name}\" is duplicated`);\n if (prefixes.has(plugin.keyPrefix)) {\n throw new Error(`Backend plugin key prefix \"${plugin.keyPrefix}\" is duplicated`);\n }\n names.add(plugin.name);\n prefixes.add(plugin.keyPrefix);\n const localDisposers: (() => void)[] = [];\n const serviceKeys: symbol[] = [];\n try {\n plugin.start({\n client: this.client,\n roomKeyPrefix: this.keyPrefix,\n registerService: (key, service) => {\n if (this.services.has(key.id)) {\n throw new Error(`Backend service \"${key.name}\" is already registered`);\n }\n this.services.set(key.id, service);\n serviceKeys.push(key.id);\n },\n addDisposer: (dispose) => localDisposers.push(dispose),\n });\n } catch (error) {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n throw error;\n }\n this.pluginDisposers.push(() => {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n });\n }\n } catch (error) {\n this.dispose();\n throw error;\n }\n }\n}\n\nfunction safelyDispose(dispose: () => void): void {\n try {\n dispose();\n } catch {\n // Continue rolling back later resources.\n }\n}\n","import type { HubFanout } from '@fieldnotes/sync-server';\nimport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\n\nexport interface RedisHubFanoutOptions {\n channel?: string;\n onError?: (err: unknown) => void;\n}\n\nexport class RedisHubFanout implements HubFanout {\n private readonly publisher: RedisPublisher;\n private readonly subscriber: RedisSubscriber;\n private readonly channel: string;\n private readonly onError: (err: unknown) => void;\n private readonly handlers = new Set<(payload: string) => void>();\n private subscribed = false;\n\n constructor(\n publisher: RedisPublisher,\n subscriber: RedisSubscriber,\n options: RedisHubFanoutOptions = {},\n ) {\n this.publisher = publisher;\n this.subscriber = subscriber;\n this.channel = options.channel ?? 'fieldnotes:fanout';\n this.onError = options.onError ?? (() => undefined);\n }\n\n async publish(payload: string): Promise<void> {\n try {\n await this.publisher.publish(this.channel, payload);\n } catch (error) {\n try {\n this.onError(error);\n } catch {\n /* preserve the publication failure even when the observer throws */\n }\n throw error;\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n if (!this.subscribed) {\n this.subscribed = true;\n Promise.resolve(\n this.subscriber.subscribe(this.channel, (message) => {\n for (const h of this.handlers) {\n try {\n h(message);\n } catch {\n /* isolate handlers */\n }\n }\n }),\n ).catch(this.onError);\n }\n return () => this.handlers.delete(handler);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACCA,kBAMO;AAUA,IAAM,kBAAN,MAA4C;AAAA,EACxC,wBAAwB;AAAA,EAChB;AAAA,EACA;AAAA,EACA,WAAW,oBAAI,IAAqB;AAAA,EACpC,kBAAkC,CAAC;AAAA,EAEpD,YAAY,QAAyB,UAAkC,CAAC,GAAG;AACzE,SAAK,SAAS;AACd,SAAK,YAAY,QAAQ,aAAa;AACtC,SAAK,eAAe,QAAQ,WAAW,CAAC,CAAC;AAAA,EAC3C;AAAA,EAEA,WAAc,KAAmC;AAC/C,WAAO,KAAK,SAAS,IAAI,IAAI,EAAE;AAAA,EACjC;AAAA,EAEQ,IAAI,MAAsB;AAChC,WAAO,GAAG,KAAK,SAAS,GAAG,IAAI;AAAA,EACjC;AAAA,EAEQ,UAAU,MAAsB;AACtC,WAAO,GAAG,KAAK,SAAS,GAAG,IAAI;AAAA,EACjC;AAAA,EAEA,MAAM,SAAS,MAA0C;AACvD,UAAM,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,IAAI,IAAI,CAAC;AACpD,UAAM,MAAyB,CAAC;AAChC,eAAW,SAAS,OAAO,OAAO,GAAG,GAAG;AACtC,UAAI;AACF,cAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,gBAAI,gCAAmB,MAAM,EAAG,KAAI,KAAK,MAAM;AAAA,MACjD,QAAQ;AAAA,MAER;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,IAAI,MAAc,IAAkD;AACxE,UAAM,QAAQ,MAAM,KAAK,OAAO,KAAK,KAAK,IAAI,IAAI,GAAG,EAAE;AACvD,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI;AACF,YAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,iBAAO,gCAAmB,MAAM,IAAI,SAAS;AAAA,IAC/C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,MAAM,MAAc,IAA+B;AACvD,UAAM,MAAM,KAAK,IAAI,IAAI;AACzB,QAAI,GAAG,SAAS;AACd,YAAM,KAAK,OAAO,KAAK,KAAK,GAAG,QAAQ,IAAI,KAAK,UAAU,GAAG,OAAO,CAAC;AAAA,aAC9D,GAAG,SAAS,SAAU,OAAM,KAAK,OAAO,KAAK,KAAK,GAAG,EAAE;AAAA,aACvD,GAAG,SAAS,QAAS,OAAM,KAAK,OAAO,IAAI,GAAG;AAAA,EACzD;AAAA,EAEA,MAAM,aAAa,MAAsC;AACvD,UAAM,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,UAAU,IAAI,CAAC;AAC1D,UAAM,MAAqB,CAAC;AAC5B,eAAW,SAAS,OAAO,OAAO,GAAG,GAAG;AACtC,UAAI;AACF,cAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,gBAAI,gCAAmB,MAAM,EAAG,KAAI,KAAK,MAAM;AAAA,MACjD,QAAQ;AAAA,MAER;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,eAAe,MAAc,IAA8C;AAC/E,UAAM,QAAQ,MAAM,KAAK,OAAO,KAAK,KAAK,UAAU,IAAI,GAAG,EAAE;AAC7D,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI;AACF,YAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,iBAAO,gCAAmB,MAAM,IAAI,SAAS;AAAA,IAC/C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,iBAAiB,MAAc,QAAoC;AACvE,UAAM,KAAK,OAAO,KAAK,KAAK,UAAU,IAAI,GAAG,OAAO,IAAI,KAAK,UAAU,MAAM,CAAC;AAAA,EAChF;AAAA,EAEA,UAAgB;AACd,eAAW,WAAW,CAAC,GAAG,KAAK,eAAe,EAAE,QAAQ,EAAG,eAAc,OAAO;AAChF,SAAK,gBAAgB,SAAS;AAC9B,SAAK,SAAS,MAAM;AAAA,EACtB;AAAA,EAEQ,eAAe,SAA6C;AAClE,UAAM,QAAQ,oBAAI,IAAY;AAC9B,UAAM,WAAW,oBAAI,IAAY;AACjC,QAAI;AACF,iBAAW,UAAU,SAAS;AAC5B,YAAI,MAAM,IAAI,OAAO,IAAI;AACvB,gBAAM,IAAI,MAAM,mBAAmB,OAAO,IAAI,iBAAiB;AACjE,YAAI,SAAS,IAAI,OAAO,SAAS,GAAG;AAClC,gBAAM,IAAI,MAAM,8BAA8B,OAAO,SAAS,iBAAiB;AAAA,QACjF;AACA,cAAM,IAAI,OAAO,IAAI;AACrB,iBAAS,IAAI,OAAO,SAAS;AAC7B,cAAM,iBAAiC,CAAC;AACxC,cAAM,cAAwB,CAAC;AAC/B,YAAI;AACF,iBAAO,MAAM;AAAA,YACX,QAAQ,KAAK;AAAA,YACb,eAAe,KAAK;AAAA,YACpB,iBAAiB,CAAC,KAAK,YAAY;AACjC,kBAAI,KAAK,SAAS,IAAI,IAAI,EAAE,GAAG;AAC7B,sBAAM,IAAI,MAAM,oBAAoB,IAAI,IAAI,yBAAyB;AAAA,cACvE;AACA,mBAAK,SAAS,IAAI,IAAI,IAAI,OAAO;AACjC,0BAAY,KAAK,IAAI,EAAE;AAAA,YACzB;AAAA,YACA,aAAa,CAAC,YAAY,eAAe,KAAK,OAAO;AAAA,UACvD,CAAC;AAAA,QACH,SAAS,OAAO;AACd,qBAAW,WAAW,eAAe,QAAQ,EAAG,eAAc,OAAO;AACrE,qBAAW,OAAO,YAAa,MAAK,SAAS,OAAO,GAAG;AACvD,gBAAM;AAAA,QACR;AACA,aAAK,gBAAgB,KAAK,MAAM;AAC9B,qBAAW,WAAW,eAAe,QAAQ,EAAG,eAAc,OAAO;AACrE,qBAAW,OAAO,YAAa,MAAK,SAAS,OAAO,GAAG;AAAA,QACzD,CAAC;AAAA,MACH;AAAA,IACF,SAAS,OAAO;AACd,WAAK,QAAQ;AACb,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAEA,SAAS,cAAc,SAA2B;AAChD,MAAI;AACF,YAAQ;AAAA,EACV,QAAQ;AAAA,EAER;AACF;;;ACxJO,IAAM,iBAAN,MAA0C;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW,oBAAI,IAA+B;AAAA,EACvD,aAAa;AAAA,EAErB,YACE,WACA,YACA,UAAiC,CAAC,GAClC;AACA,SAAK,YAAY;AACjB,SAAK,aAAa;AAClB,SAAK,UAAU,QAAQ,WAAW;AAClC,SAAK,UAAU,QAAQ,YAAY,MAAM;AAAA,EAC3C;AAAA,EAEA,MAAM,QAAQ,SAAgC;AAC5C,QAAI;AACF,YAAM,KAAK,UAAU,QAAQ,KAAK,SAAS,OAAO;AAAA,IACpD,SAAS,OAAO;AACd,UAAI;AACF,aAAK,QAAQ,KAAK;AAAA,MACpB,QAAQ;AAAA,MAER;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,UAAU,SAAgD;AACxD,SAAK,SAAS,IAAI,OAAO;AACzB,QAAI,CAAC,KAAK,YAAY;AACpB,WAAK,aAAa;AAClB,cAAQ;AAAA,QACN,KAAK,WAAW,UAAU,KAAK,SAAS,CAAC,YAAY;AACnD,qBAAW,KAAK,KAAK,UAAU;AAC7B,gBAAI;AACF,gBAAE,OAAO;AAAA,YACX,QAAQ;AAAA,YAER;AAAA,UACF;AAAA,QACF,CAAC;AAAA,MACH,EAAE,MAAM,KAAK,OAAO;AAAA,IACtB;AACA,WAAO,MAAM,KAAK,SAAS,OAAO,OAAO;AAAA,EAC3C;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/redis-hub-backend.ts","../src/room-key.ts","../src/script-runner.ts","../src/redis-hub-fanout.ts"],"sourcesContent":["export { RedisHubBackend } from './redis-hub-backend';\nexport type { RedisHubBackendOptions } from './redis-hub-backend';\nexport type { RedisHashClient } from './redis-hash-client';\nexport { createScriptRunner } from './script-runner';\nexport type { ScriptRunner, ScriptRunOptions } from './script-runner';\nexport { RedisHubFanout } from './redis-hub-fanout';\nexport type { RedisHubFanoutOptions } from './redis-hub-fanout';\nexport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\nexport type { BackendSyncPlugin, BackendPluginContext } from './sync-plugin';\nexport { encodeRoomKey } from './room-key';\n","import type { ServiceKey } from '@fieldnotes/core';\nimport {\n isValidWireElement,\n isValidLayerRecord,\n type LayerRecord,\n type WireSyncElement,\n type WireSyncOp,\n} from '@fieldnotes/sync';\nimport type { HubBackend } from '@fieldnotes/sync-server';\nimport type { RedisHashClient } from './redis-hash-client';\nimport type { BackendSyncPlugin } from './sync-plugin';\nimport { encodeRoomKey } from './room-key';\n\nexport interface RedisHubBackendOptions {\n keyPrefix?: string;\n plugins?: readonly BackendSyncPlugin[];\n}\n\nexport class RedisHubBackend implements HubBackend {\n readonly sharedAcrossInstances = true;\n private readonly client: RedisHashClient;\n private readonly keyPrefix: string;\n private readonly services = new Map<symbol, unknown>();\n private readonly pluginDisposers: (() => void)[] = [];\n\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\n this.client = client;\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\n this.installPlugins(options.plugins ?? []);\n }\n\n getService<T>(key: ServiceKey<T>): T | undefined {\n return this.services.get(key.id) as T | undefined;\n }\n\n private key(room: string): string {\n return `${this.keyPrefix}${encodeRoomKey(room)}`;\n }\n\n private layersKey(room: string): string {\n return `${this.key(room)}:layers`;\n }\n\n async snapshot(room: string): Promise<WireSyncElement[]> {\n const map = await this.client.hGetAll(this.key(room));\n const out: WireSyncElement[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidWireElement(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the room snapshot.\n }\n }\n return out;\n }\n\n async get(room: string, id: string): Promise<WireSyncElement | undefined> {\n const value = await this.client.hGet(this.key(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidWireElement(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async apply(room: string, op: WireSyncOp): Promise<void> {\n const key = this.key(room);\n if (op.kind === 'upsert')\n await this.client.hSet(key, op.element.id, JSON.stringify(op.element));\n else if (op.kind === 'remove') await this.client.hDel(key, op.id);\n else if (op.kind === 'clear') await this.client.del(key);\n }\n\n async layerRecords(room: string): Promise<LayerRecord[]> {\n const map = await this.client.hGetAll(this.layersKey(room));\n const out: LayerRecord[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidLayerRecord(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the layer ledger.\n }\n }\n return out;\n }\n\n async getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined> {\n const value = await this.client.hGet(this.layersKey(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidLayerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async applyLayerRecord(room: string, record: LayerRecord): Promise<void> {\n await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));\n }\n\n dispose(): void {\n for (const dispose of [...this.pluginDisposers].reverse()) safelyDispose(dispose);\n this.pluginDisposers.length = 0;\n this.services.clear();\n }\n\n private installPlugins(plugins: readonly BackendSyncPlugin[]): void {\n const names = new Set<string>();\n const prefixes = new Set<string>();\n try {\n for (const plugin of plugins) {\n if (names.has(plugin.name))\n throw new Error(`Backend plugin \"${plugin.name}\" is duplicated`);\n if (prefixes.has(plugin.keyPrefix)) {\n throw new Error(`Backend plugin key prefix \"${plugin.keyPrefix}\" is duplicated`);\n }\n names.add(plugin.name);\n prefixes.add(plugin.keyPrefix);\n const localDisposers: (() => void)[] = [];\n const serviceKeys: symbol[] = [];\n try {\n plugin.start({\n client: this.client,\n roomKeyPrefix: this.keyPrefix,\n roomKey: (room) => this.key(room),\n registerService: (key, service) => {\n if (this.services.has(key.id)) {\n throw new Error(`Backend service \"${key.name}\" is already registered`);\n }\n this.services.set(key.id, service);\n serviceKeys.push(key.id);\n },\n addDisposer: (dispose) => localDisposers.push(dispose),\n });\n } catch (error) {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n throw error;\n }\n this.pluginDisposers.push(() => {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n });\n }\n } catch (error) {\n this.dispose();\n throw error;\n }\n }\n}\n\nfunction safelyDispose(dispose: () => void): void {\n try {\n dispose();\n } catch {\n // Continue rolling back later resources.\n }\n}\n","/**\n * Encodes a room name for use inside a Redis key. Room-scoped hashes are laid\n * out as `<prefix><room>` plus `<prefix><room>:<suffix>` sub-keys, so an\n * unescaped `:` in a room name lets `foo:layers` alias room `foo`'s layer\n * ledger. `encodeURIComponent` leaves the relay's valid room alphabet\n * (`[A-Za-z0-9_-]`) untouched, so existing keys keep their historical layout.\n */\nexport function encodeRoomKey(room: string): string {\n return encodeURIComponent(room);\n}\n","import type { RedisHashClient } from './redis-hash-client';\n\n/** Keys and arguments passed to a Lua script, matching node-redis' `eval` options. */\nexport interface ScriptRunOptions {\n keys: string[];\n arguments: string[];\n}\n\n/** Runs a Lua script against Redis, by SHA when the client supports it. */\nexport type ScriptRunner = (script: string, options: ScriptRunOptions) => Promise<unknown>;\n\nfunction isNoScriptError(error: unknown): boolean {\n return error instanceof Error && error.message.includes('NOSCRIPT');\n}\n\n/**\n * Builds a script runner bound to one client instance. When the client exposes\n * both `scriptLoad` and `evalSha`, each distinct script is loaded once and then\n * evaluated by SHA; a `NOSCRIPT` failure (the server's script cache was flushed\n * or the connection moved to another node) reloads the script and retries once.\n * Clients without the optional methods fall back to plain `EVAL`, so existing\n * implementations keep working unchanged.\n */\nexport function createScriptRunner(client: RedisHashClient): ScriptRunner {\n const evalScript = client.eval.bind(client);\n const scriptLoad = client.scriptLoad?.bind(client);\n const evalSha = client.evalSha?.bind(client);\n const shaByScript = new Map<string, Promise<string>>();\n\n const load = (script: string, loader: (source: string) => Promise<string>): Promise<string> => {\n const cached = shaByScript.get(script);\n if (cached) return cached;\n const pending = loader(script);\n shaByScript.set(script, pending);\n pending.catch(() => {\n if (shaByScript.get(script) === pending) shaByScript.delete(script);\n });\n return pending;\n };\n\n return async (script, options) => {\n if (!scriptLoad || !evalSha) return evalScript(script, options);\n const pending = load(script, scriptLoad);\n const sha = await pending;\n try {\n return await evalSha(sha, options);\n } catch (error) {\n if (!isNoScriptError(error)) throw error;\n // Evict only this call's load: a concurrent NOSCRIPT may already have\n // started the reload, and dropping it would load the script again.\n if (shaByScript.get(script) === pending) shaByScript.delete(script);\n const reloaded = await load(script, scriptLoad);\n return await evalSha(reloaded, options);\n }\n };\n}\n","import type { HubFanout } from '@fieldnotes/sync-server';\nimport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\n\nexport interface RedisHubFanoutOptions {\n channel?: string;\n onError?: (err: unknown) => void;\n}\n\nexport class RedisHubFanout implements HubFanout {\n private readonly publisher: RedisPublisher;\n private readonly subscriber: RedisSubscriber;\n private readonly channel: string;\n private readonly onError: (err: unknown) => void;\n private readonly handlers = new Set<(payload: string) => void>();\n private subscribed = false;\n\n constructor(\n publisher: RedisPublisher,\n subscriber: RedisSubscriber,\n options: RedisHubFanoutOptions = {},\n ) {\n this.publisher = publisher;\n this.subscriber = subscriber;\n this.channel = options.channel ?? 'fieldnotes:fanout';\n this.onError = options.onError ?? (() => undefined);\n }\n\n async publish(payload: string): Promise<void> {\n try {\n await this.publisher.publish(this.channel, payload);\n } catch (error) {\n try {\n this.onError(error);\n } catch {\n /* preserve the publication failure even when the observer throws */\n }\n throw error;\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n if (!this.subscribed) {\n this.subscribed = true;\n Promise.resolve(\n this.subscriber.subscribe(this.channel, (message) => {\n for (const h of this.handlers) {\n try {\n h(message);\n } catch {\n /* isolate handlers */\n }\n }\n }),\n ).catch(this.onError);\n }\n return () => this.handlers.delete(handler);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACCA,kBAMO;;;ACAA,SAAS,cAAc,MAAsB;AAClD,SAAO,mBAAmB,IAAI;AAChC;;;ADSO,IAAM,kBAAN,MAA4C;AAAA,EACxC,wBAAwB;AAAA,EAChB;AAAA,EACA;AAAA,EACA,WAAW,oBAAI,IAAqB;AAAA,EACpC,kBAAkC,CAAC;AAAA,EAEpD,YAAY,QAAyB,UAAkC,CAAC,GAAG;AACzE,SAAK,SAAS;AACd,SAAK,YAAY,QAAQ,aAAa;AACtC,SAAK,eAAe,QAAQ,WAAW,CAAC,CAAC;AAAA,EAC3C;AAAA,EAEA,WAAc,KAAmC;AAC/C,WAAO,KAAK,SAAS,IAAI,IAAI,EAAE;AAAA,EACjC;AAAA,EAEQ,IAAI,MAAsB;AAChC,WAAO,GAAG,KAAK,SAAS,GAAG,cAAc,IAAI,CAAC;AAAA,EAChD;AAAA,EAEQ,UAAU,MAAsB;AACtC,WAAO,GAAG,KAAK,IAAI,IAAI,CAAC;AAAA,EAC1B;AAAA,EAEA,MAAM,SAAS,MAA0C;AACvD,UAAM,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,IAAI,IAAI,CAAC;AACpD,UAAM,MAAyB,CAAC;AAChC,eAAW,SAAS,OAAO,OAAO,GAAG,GAAG;AACtC,UAAI;AACF,cAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,gBAAI,gCAAmB,MAAM,EAAG,KAAI,KAAK,MAAM;AAAA,MACjD,QAAQ;AAAA,MAER;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,IAAI,MAAc,IAAkD;AACxE,UAAM,QAAQ,MAAM,KAAK,OAAO,KAAK,KAAK,IAAI,IAAI,GAAG,EAAE;AACvD,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI;AACF,YAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,iBAAO,gCAAmB,MAAM,IAAI,SAAS;AAAA,IAC/C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,MAAM,MAAc,IAA+B;AACvD,UAAM,MAAM,KAAK,IAAI,IAAI;AACzB,QAAI,GAAG,SAAS;AACd,YAAM,KAAK,OAAO,KAAK,KAAK,GAAG,QAAQ,IAAI,KAAK,UAAU,GAAG,OAAO,CAAC;AAAA,aAC9D,GAAG,SAAS,SAAU,OAAM,KAAK,OAAO,KAAK,KAAK,GAAG,EAAE;AAAA,aACvD,GAAG,SAAS,QAAS,OAAM,KAAK,OAAO,IAAI,GAAG;AAAA,EACzD;AAAA,EAEA,MAAM,aAAa,MAAsC;AACvD,UAAM,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,UAAU,IAAI,CAAC;AAC1D,UAAM,MAAqB,CAAC;AAC5B,eAAW,SAAS,OAAO,OAAO,GAAG,GAAG;AACtC,UAAI;AACF,cAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,gBAAI,gCAAmB,MAAM,EAAG,KAAI,KAAK,MAAM;AAAA,MACjD,QAAQ;AAAA,MAER;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,eAAe,MAAc,IAA8C;AAC/E,UAAM,QAAQ,MAAM,KAAK,OAAO,KAAK,KAAK,UAAU,IAAI,GAAG,EAAE;AAC7D,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI;AACF,YAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,iBAAO,gCAAmB,MAAM,IAAI,SAAS;AAAA,IAC/C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,iBAAiB,MAAc,QAAoC;AACvE,UAAM,KAAK,OAAO,KAAK,KAAK,UAAU,IAAI,GAAG,OAAO,IAAI,KAAK,UAAU,MAAM,CAAC;AAAA,EAChF;AAAA,EAEA,UAAgB;AACd,eAAW,WAAW,CAAC,GAAG,KAAK,eAAe,EAAE,QAAQ,EAAG,eAAc,OAAO;AAChF,SAAK,gBAAgB,SAAS;AAC9B,SAAK,SAAS,MAAM;AAAA,EACtB;AAAA,EAEQ,eAAe,SAA6C;AAClE,UAAM,QAAQ,oBAAI,IAAY;AAC9B,UAAM,WAAW,oBAAI,IAAY;AACjC,QAAI;AACF,iBAAW,UAAU,SAAS;AAC5B,YAAI,MAAM,IAAI,OAAO,IAAI;AACvB,gBAAM,IAAI,MAAM,mBAAmB,OAAO,IAAI,iBAAiB;AACjE,YAAI,SAAS,IAAI,OAAO,SAAS,GAAG;AAClC,gBAAM,IAAI,MAAM,8BAA8B,OAAO,SAAS,iBAAiB;AAAA,QACjF;AACA,cAAM,IAAI,OAAO,IAAI;AACrB,iBAAS,IAAI,OAAO,SAAS;AAC7B,cAAM,iBAAiC,CAAC;AACxC,cAAM,cAAwB,CAAC;AAC/B,YAAI;AACF,iBAAO,MAAM;AAAA,YACX,QAAQ,KAAK;AAAA,YACb,eAAe,KAAK;AAAA,YACpB,SAAS,CAAC,SAAS,KAAK,IAAI,IAAI;AAAA,YAChC,iBAAiB,CAAC,KAAK,YAAY;AACjC,kBAAI,KAAK,SAAS,IAAI,IAAI,EAAE,GAAG;AAC7B,sBAAM,IAAI,MAAM,oBAAoB,IAAI,IAAI,yBAAyB;AAAA,cACvE;AACA,mBAAK,SAAS,IAAI,IAAI,IAAI,OAAO;AACjC,0BAAY,KAAK,IAAI,EAAE;AAAA,YACzB;AAAA,YACA,aAAa,CAAC,YAAY,eAAe,KAAK,OAAO;AAAA,UACvD,CAAC;AAAA,QACH,SAAS,OAAO;AACd,qBAAW,WAAW,eAAe,QAAQ,EAAG,eAAc,OAAO;AACrE,qBAAW,OAAO,YAAa,MAAK,SAAS,OAAO,GAAG;AACvD,gBAAM;AAAA,QACR;AACA,aAAK,gBAAgB,KAAK,MAAM;AAC9B,qBAAW,WAAW,eAAe,QAAQ,EAAG,eAAc,OAAO;AACrE,qBAAW,OAAO,YAAa,MAAK,SAAS,OAAO,GAAG;AAAA,QACzD,CAAC;AAAA,MACH;AAAA,IACF,SAAS,OAAO;AACd,WAAK,QAAQ;AACb,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAEA,SAAS,cAAc,SAA2B;AAChD,MAAI;AACF,YAAQ;AAAA,EACV,QAAQ;AAAA,EAER;AACF;;;AEvJA,SAAS,gBAAgB,OAAyB;AAChD,SAAO,iBAAiB,SAAS,MAAM,QAAQ,SAAS,UAAU;AACpE;AAUO,SAAS,mBAAmB,QAAuC;AACxE,QAAM,aAAa,OAAO,KAAK,KAAK,MAAM;AAC1C,QAAM,aAAa,OAAO,YAAY,KAAK,MAAM;AACjD,QAAM,UAAU,OAAO,SAAS,KAAK,MAAM;AAC3C,QAAM,cAAc,oBAAI,IAA6B;AAErD,QAAM,OAAO,CAAC,QAAgB,WAAiE;AAC7F,UAAM,SAAS,YAAY,IAAI,MAAM;AACrC,QAAI,OAAQ,QAAO;AACnB,UAAM,UAAU,OAAO,MAAM;AAC7B,gBAAY,IAAI,QAAQ,OAAO;AAC/B,YAAQ,MAAM,MAAM;AAClB,UAAI,YAAY,IAAI,MAAM,MAAM,QAAS,aAAY,OAAO,MAAM;AAAA,IACpE,CAAC;AACD,WAAO;AAAA,EACT;AAEA,SAAO,OAAO,QAAQ,YAAY;AAChC,QAAI,CAAC,cAAc,CAAC,QAAS,QAAO,WAAW,QAAQ,OAAO;AAC9D,UAAM,UAAU,KAAK,QAAQ,UAAU;AACvC,UAAM,MAAM,MAAM;AAClB,QAAI;AACF,aAAO,MAAM,QAAQ,KAAK,OAAO;AAAA,IACnC,SAAS,OAAO;AACd,UAAI,CAAC,gBAAgB,KAAK,EAAG,OAAM;AAGnC,UAAI,YAAY,IAAI,MAAM,MAAM,QAAS,aAAY,OAAO,MAAM;AAClE,YAAM,WAAW,MAAM,KAAK,QAAQ,UAAU;AAC9C,aAAO,MAAM,QAAQ,UAAU,OAAO;AAAA,IACxC;AAAA,EACF;AACF;;;AC/CO,IAAM,iBAAN,MAA0C;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW,oBAAI,IAA+B;AAAA,EACvD,aAAa;AAAA,EAErB,YACE,WACA,YACA,UAAiC,CAAC,GAClC;AACA,SAAK,YAAY;AACjB,SAAK,aAAa;AAClB,SAAK,UAAU,QAAQ,WAAW;AAClC,SAAK,UAAU,QAAQ,YAAY,MAAM;AAAA,EAC3C;AAAA,EAEA,MAAM,QAAQ,SAAgC;AAC5C,QAAI;AACF,YAAM,KAAK,UAAU,QAAQ,KAAK,SAAS,OAAO;AAAA,IACpD,SAAS,OAAO;AACd,UAAI;AACF,aAAK,QAAQ,KAAK;AAAA,MACpB,QAAQ;AAAA,MAER;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,UAAU,SAAgD;AACxD,SAAK,SAAS,IAAI,OAAO;AACzB,QAAI,CAAC,KAAK,YAAY;AACpB,WAAK,aAAa;AAClB,cAAQ;AAAA,QACN,KAAK,WAAW,UAAU,KAAK,SAAS,CAAC,YAAY;AACnD,qBAAW,KAAK,KAAK,UAAU;AAC7B,gBAAI;AACF,gBAAE,OAAO;AAAA,YACX,QAAQ;AAAA,YAER;AAAA,UACF;AAAA,QACF,CAAC;AAAA,MACH,EAAE,MAAM,KAAK,OAAO;AAAA,IACtB;AACA,WAAO,MAAM,KAAK,SAAS,OAAO,OAAO;AAAA,EAC3C;AACF;","names":[]}
package/dist/index.d.cts CHANGED
@@ -8,16 +8,30 @@ interface RedisHashClient {
8
8
  hSet(key: string, field: string, value: string): Promise<unknown>;
9
9
  hDel(key: string, field: string): Promise<unknown>;
10
10
  del(key: string): Promise<unknown>;
11
- /** Optional primitive used by backend plugins for atomic updates; node-redis conforms directly. */
11
+ /** Primitive used by backend plugins for atomic updates; node-redis conforms directly. */
12
12
  eval(script: string, options: {
13
13
  keys: string[];
14
14
  arguments: string[];
15
15
  }): Promise<unknown>;
16
+ /** Optional: caches a Lua script server-side and returns its SHA1. Enables EVALSHA. */
17
+ scriptLoad?(script: string): Promise<string>;
18
+ /** Optional: runs a cached Lua script by SHA1. Only used when `scriptLoad` is present too. */
19
+ evalSha?(sha: string, options: {
20
+ keys: string[];
21
+ arguments: string[];
22
+ }): Promise<unknown>;
16
23
  }
17
24
 
18
25
  interface BackendPluginContext {
19
26
  readonly client: RedisHashClient;
27
+ /** Raw key prefix shared by every room-scoped hash. Prefer `roomKey`. */
20
28
  readonly roomKeyPrefix: string;
29
+ /**
30
+ * The escaped base key for `room`; derive sub-keys as `${roomKey(room)}:<suffix>`.
31
+ * Building keys from `roomKeyPrefix` and the raw room name lets a hostile
32
+ * room name alias another room's sub-key hashes.
33
+ */
34
+ roomKey(room: string): string;
21
35
  registerService<T>(key: ServiceKey<T>, service: NoInfer<T>): void;
22
36
  addDisposer(dispose: () => void): void;
23
37
  }
@@ -52,6 +66,23 @@ declare class RedisHubBackend implements HubBackend {
52
66
  private installPlugins;
53
67
  }
54
68
 
69
+ /** Keys and arguments passed to a Lua script, matching node-redis' `eval` options. */
70
+ interface ScriptRunOptions {
71
+ keys: string[];
72
+ arguments: string[];
73
+ }
74
+ /** Runs a Lua script against Redis, by SHA when the client supports it. */
75
+ type ScriptRunner = (script: string, options: ScriptRunOptions) => Promise<unknown>;
76
+ /**
77
+ * Builds a script runner bound to one client instance. When the client exposes
78
+ * both `scriptLoad` and `evalSha`, each distinct script is loaded once and then
79
+ * evaluated by SHA; a `NOSCRIPT` failure (the server's script cache was flushed
80
+ * or the connection moved to another node) reloads the script and retries once.
81
+ * Clients without the optional methods fall back to plain `EVAL`, so existing
82
+ * implementations keep working unchanged.
83
+ */
84
+ declare function createScriptRunner(client: RedisHashClient): ScriptRunner;
85
+
55
86
  interface RedisPublisher {
56
87
  publish(channel: string, message: string): Promise<unknown> | unknown;
57
88
  }
@@ -75,4 +106,13 @@ declare class RedisHubFanout implements HubFanout {
75
106
  subscribe(handler: (payload: string) => void): () => void;
76
107
  }
77
108
 
78
- export { type BackendPluginContext, type BackendSyncPlugin, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber };
109
+ /**
110
+ * Encodes a room name for use inside a Redis key. Room-scoped hashes are laid
111
+ * out as `<prefix><room>` plus `<prefix><room>:<suffix>` sub-keys, so an
112
+ * unescaped `:` in a room name lets `foo:layers` alias room `foo`'s layer
113
+ * ledger. `encodeURIComponent` leaves the relay's valid room alphabet
114
+ * (`[A-Za-z0-9_-]`) untouched, so existing keys keep their historical layout.
115
+ */
116
+ declare function encodeRoomKey(room: string): string;
117
+
118
+ export { type BackendPluginContext, type BackendSyncPlugin, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber, type ScriptRunOptions, type ScriptRunner, createScriptRunner, encodeRoomKey };
package/dist/index.d.ts CHANGED
@@ -8,16 +8,30 @@ interface RedisHashClient {
8
8
  hSet(key: string, field: string, value: string): Promise<unknown>;
9
9
  hDel(key: string, field: string): Promise<unknown>;
10
10
  del(key: string): Promise<unknown>;
11
- /** Optional primitive used by backend plugins for atomic updates; node-redis conforms directly. */
11
+ /** Primitive used by backend plugins for atomic updates; node-redis conforms directly. */
12
12
  eval(script: string, options: {
13
13
  keys: string[];
14
14
  arguments: string[];
15
15
  }): Promise<unknown>;
16
+ /** Optional: caches a Lua script server-side and returns its SHA1. Enables EVALSHA. */
17
+ scriptLoad?(script: string): Promise<string>;
18
+ /** Optional: runs a cached Lua script by SHA1. Only used when `scriptLoad` is present too. */
19
+ evalSha?(sha: string, options: {
20
+ keys: string[];
21
+ arguments: string[];
22
+ }): Promise<unknown>;
16
23
  }
17
24
 
18
25
  interface BackendPluginContext {
19
26
  readonly client: RedisHashClient;
27
+ /** Raw key prefix shared by every room-scoped hash. Prefer `roomKey`. */
20
28
  readonly roomKeyPrefix: string;
29
+ /**
30
+ * The escaped base key for `room`; derive sub-keys as `${roomKey(room)}:<suffix>`.
31
+ * Building keys from `roomKeyPrefix` and the raw room name lets a hostile
32
+ * room name alias another room's sub-key hashes.
33
+ */
34
+ roomKey(room: string): string;
21
35
  registerService<T>(key: ServiceKey<T>, service: NoInfer<T>): void;
22
36
  addDisposer(dispose: () => void): void;
23
37
  }
@@ -52,6 +66,23 @@ declare class RedisHubBackend implements HubBackend {
52
66
  private installPlugins;
53
67
  }
54
68
 
69
+ /** Keys and arguments passed to a Lua script, matching node-redis' `eval` options. */
70
+ interface ScriptRunOptions {
71
+ keys: string[];
72
+ arguments: string[];
73
+ }
74
+ /** Runs a Lua script against Redis, by SHA when the client supports it. */
75
+ type ScriptRunner = (script: string, options: ScriptRunOptions) => Promise<unknown>;
76
+ /**
77
+ * Builds a script runner bound to one client instance. When the client exposes
78
+ * both `scriptLoad` and `evalSha`, each distinct script is loaded once and then
79
+ * evaluated by SHA; a `NOSCRIPT` failure (the server's script cache was flushed
80
+ * or the connection moved to another node) reloads the script and retries once.
81
+ * Clients without the optional methods fall back to plain `EVAL`, so existing
82
+ * implementations keep working unchanged.
83
+ */
84
+ declare function createScriptRunner(client: RedisHashClient): ScriptRunner;
85
+
55
86
  interface RedisPublisher {
56
87
  publish(channel: string, message: string): Promise<unknown> | unknown;
57
88
  }
@@ -75,4 +106,13 @@ declare class RedisHubFanout implements HubFanout {
75
106
  subscribe(handler: (payload: string) => void): () => void;
76
107
  }
77
108
 
78
- export { type BackendPluginContext, type BackendSyncPlugin, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber };
109
+ /**
110
+ * Encodes a room name for use inside a Redis key. Room-scoped hashes are laid
111
+ * out as `<prefix><room>` plus `<prefix><room>:<suffix>` sub-keys, so an
112
+ * unescaped `:` in a room name lets `foo:layers` alias room `foo`'s layer
113
+ * ledger. `encodeURIComponent` leaves the relay's valid room alphabet
114
+ * (`[A-Za-z0-9_-]`) untouched, so existing keys keep their historical layout.
115
+ */
116
+ declare function encodeRoomKey(room: string): string;
117
+
118
+ export { type BackendPluginContext, type BackendSyncPlugin, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber, type ScriptRunOptions, type ScriptRunner, createScriptRunner, encodeRoomKey };
package/dist/index.js CHANGED
@@ -3,6 +3,13 @@ import {
3
3
  isValidWireElement,
4
4
  isValidLayerRecord
5
5
  } from "@fieldnotes/sync";
6
+
7
+ // src/room-key.ts
8
+ function encodeRoomKey(room) {
9
+ return encodeURIComponent(room);
10
+ }
11
+
12
+ // src/redis-hub-backend.ts
6
13
  var RedisHubBackend = class {
7
14
  sharedAcrossInstances = true;
8
15
  client;
@@ -18,10 +25,10 @@ var RedisHubBackend = class {
18
25
  return this.services.get(key.id);
19
26
  }
20
27
  key(room) {
21
- return `${this.keyPrefix}${room}`;
28
+ return `${this.keyPrefix}${encodeRoomKey(room)}`;
22
29
  }
23
30
  layersKey(room) {
24
- return `${this.keyPrefix}${room}:layers`;
31
+ return `${this.key(room)}:layers`;
25
32
  }
26
33
  async snapshot(room) {
27
34
  const map = await this.client.hGetAll(this.key(room));
@@ -100,6 +107,7 @@ var RedisHubBackend = class {
100
107
  plugin.start({
101
108
  client: this.client,
102
109
  roomKeyPrefix: this.keyPrefix,
110
+ roomKey: (room) => this.key(room),
103
111
  registerService: (key, service) => {
104
112
  if (this.services.has(key.id)) {
105
113
  throw new Error(`Backend service "${key.name}" is already registered`);
@@ -132,6 +140,40 @@ function safelyDispose(dispose) {
132
140
  }
133
141
  }
134
142
 
143
+ // src/script-runner.ts
144
+ function isNoScriptError(error) {
145
+ return error instanceof Error && error.message.includes("NOSCRIPT");
146
+ }
147
+ function createScriptRunner(client) {
148
+ const evalScript = client.eval.bind(client);
149
+ const scriptLoad = client.scriptLoad?.bind(client);
150
+ const evalSha = client.evalSha?.bind(client);
151
+ const shaByScript = /* @__PURE__ */ new Map();
152
+ const load = (script, loader) => {
153
+ const cached = shaByScript.get(script);
154
+ if (cached) return cached;
155
+ const pending = loader(script);
156
+ shaByScript.set(script, pending);
157
+ pending.catch(() => {
158
+ if (shaByScript.get(script) === pending) shaByScript.delete(script);
159
+ });
160
+ return pending;
161
+ };
162
+ return async (script, options) => {
163
+ if (!scriptLoad || !evalSha) return evalScript(script, options);
164
+ const pending = load(script, scriptLoad);
165
+ const sha = await pending;
166
+ try {
167
+ return await evalSha(sha, options);
168
+ } catch (error) {
169
+ if (!isNoScriptError(error)) throw error;
170
+ if (shaByScript.get(script) === pending) shaByScript.delete(script);
171
+ const reloaded = await load(script, scriptLoad);
172
+ return await evalSha(reloaded, options);
173
+ }
174
+ };
175
+ }
176
+
135
177
  // src/redis-hub-fanout.ts
136
178
  var RedisHubFanout = class {
137
179
  publisher;
@@ -177,6 +219,8 @@ var RedisHubFanout = class {
177
219
  };
178
220
  export {
179
221
  RedisHubBackend,
180
- RedisHubFanout
222
+ RedisHubFanout,
223
+ createScriptRunner,
224
+ encodeRoomKey
181
225
  };
182
226
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/redis-hub-backend.ts","../src/redis-hub-fanout.ts"],"sourcesContent":["import type { ServiceKey } from '@fieldnotes/core';\nimport {\n isValidWireElement,\n isValidLayerRecord,\n type LayerRecord,\n type WireSyncElement,\n type WireSyncOp,\n} from '@fieldnotes/sync';\nimport type { HubBackend } from '@fieldnotes/sync-server';\nimport type { RedisHashClient } from './redis-hash-client';\nimport type { BackendSyncPlugin } from './sync-plugin';\n\nexport interface RedisHubBackendOptions {\n keyPrefix?: string;\n plugins?: readonly BackendSyncPlugin[];\n}\n\nexport class RedisHubBackend implements HubBackend {\n readonly sharedAcrossInstances = true;\n private readonly client: RedisHashClient;\n private readonly keyPrefix: string;\n private readonly services = new Map<symbol, unknown>();\n private readonly pluginDisposers: (() => void)[] = [];\n\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\n this.client = client;\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\n this.installPlugins(options.plugins ?? []);\n }\n\n getService<T>(key: ServiceKey<T>): T | undefined {\n return this.services.get(key.id) as T | undefined;\n }\n\n private key(room: string): string {\n return `${this.keyPrefix}${room}`;\n }\n\n private layersKey(room: string): string {\n return `${this.keyPrefix}${room}:layers`;\n }\n\n async snapshot(room: string): Promise<WireSyncElement[]> {\n const map = await this.client.hGetAll(this.key(room));\n const out: WireSyncElement[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidWireElement(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the room snapshot.\n }\n }\n return out;\n }\n\n async get(room: string, id: string): Promise<WireSyncElement | undefined> {\n const value = await this.client.hGet(this.key(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidWireElement(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async apply(room: string, op: WireSyncOp): Promise<void> {\n const key = this.key(room);\n if (op.kind === 'upsert')\n await this.client.hSet(key, op.element.id, JSON.stringify(op.element));\n else if (op.kind === 'remove') await this.client.hDel(key, op.id);\n else if (op.kind === 'clear') await this.client.del(key);\n }\n\n async layerRecords(room: string): Promise<LayerRecord[]> {\n const map = await this.client.hGetAll(this.layersKey(room));\n const out: LayerRecord[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidLayerRecord(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the layer ledger.\n }\n }\n return out;\n }\n\n async getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined> {\n const value = await this.client.hGet(this.layersKey(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidLayerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async applyLayerRecord(room: string, record: LayerRecord): Promise<void> {\n await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));\n }\n\n dispose(): void {\n for (const dispose of [...this.pluginDisposers].reverse()) safelyDispose(dispose);\n this.pluginDisposers.length = 0;\n this.services.clear();\n }\n\n private installPlugins(plugins: readonly BackendSyncPlugin[]): void {\n const names = new Set<string>();\n const prefixes = new Set<string>();\n try {\n for (const plugin of plugins) {\n if (names.has(plugin.name))\n throw new Error(`Backend plugin \"${plugin.name}\" is duplicated`);\n if (prefixes.has(plugin.keyPrefix)) {\n throw new Error(`Backend plugin key prefix \"${plugin.keyPrefix}\" is duplicated`);\n }\n names.add(plugin.name);\n prefixes.add(plugin.keyPrefix);\n const localDisposers: (() => void)[] = [];\n const serviceKeys: symbol[] = [];\n try {\n plugin.start({\n client: this.client,\n roomKeyPrefix: this.keyPrefix,\n registerService: (key, service) => {\n if (this.services.has(key.id)) {\n throw new Error(`Backend service \"${key.name}\" is already registered`);\n }\n this.services.set(key.id, service);\n serviceKeys.push(key.id);\n },\n addDisposer: (dispose) => localDisposers.push(dispose),\n });\n } catch (error) {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n throw error;\n }\n this.pluginDisposers.push(() => {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n });\n }\n } catch (error) {\n this.dispose();\n throw error;\n }\n }\n}\n\nfunction safelyDispose(dispose: () => void): void {\n try {\n dispose();\n } catch {\n // Continue rolling back later resources.\n }\n}\n","import type { HubFanout } from '@fieldnotes/sync-server';\nimport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\n\nexport interface RedisHubFanoutOptions {\n channel?: string;\n onError?: (err: unknown) => void;\n}\n\nexport class RedisHubFanout implements HubFanout {\n private readonly publisher: RedisPublisher;\n private readonly subscriber: RedisSubscriber;\n private readonly channel: string;\n private readonly onError: (err: unknown) => void;\n private readonly handlers = new Set<(payload: string) => void>();\n private subscribed = false;\n\n constructor(\n publisher: RedisPublisher,\n subscriber: RedisSubscriber,\n options: RedisHubFanoutOptions = {},\n ) {\n this.publisher = publisher;\n this.subscriber = subscriber;\n this.channel = options.channel ?? 'fieldnotes:fanout';\n this.onError = options.onError ?? (() => undefined);\n }\n\n async publish(payload: string): Promise<void> {\n try {\n await this.publisher.publish(this.channel, payload);\n } catch (error) {\n try {\n this.onError(error);\n } catch {\n /* preserve the publication failure even when the observer throws */\n }\n throw error;\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n if (!this.subscribed) {\n this.subscribed = true;\n Promise.resolve(\n this.subscriber.subscribe(this.channel, (message) => {\n for (const h of this.handlers) {\n try {\n h(message);\n } catch {\n /* isolate handlers */\n }\n }\n }),\n ).catch(this.onError);\n }\n return () => this.handlers.delete(handler);\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/redis-hub-backend.ts","../src/room-key.ts","../src/script-runner.ts","../src/redis-hub-fanout.ts"],"sourcesContent":["import type { ServiceKey } from '@fieldnotes/core';\nimport {\n isValidWireElement,\n isValidLayerRecord,\n type LayerRecord,\n type WireSyncElement,\n type WireSyncOp,\n} from '@fieldnotes/sync';\nimport type { HubBackend } from '@fieldnotes/sync-server';\nimport type { RedisHashClient } from './redis-hash-client';\nimport type { BackendSyncPlugin } from './sync-plugin';\nimport { encodeRoomKey } from './room-key';\n\nexport interface RedisHubBackendOptions {\n keyPrefix?: string;\n plugins?: readonly BackendSyncPlugin[];\n}\n\nexport class RedisHubBackend implements HubBackend {\n readonly sharedAcrossInstances = true;\n private readonly client: RedisHashClient;\n private readonly keyPrefix: string;\n private readonly services = new Map<symbol, unknown>();\n private readonly pluginDisposers: (() => void)[] = [];\n\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\n this.client = client;\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\n this.installPlugins(options.plugins ?? []);\n }\n\n getService<T>(key: ServiceKey<T>): T | undefined {\n return this.services.get(key.id) as T | undefined;\n }\n\n private key(room: string): string {\n return `${this.keyPrefix}${encodeRoomKey(room)}`;\n }\n\n private layersKey(room: string): string {\n return `${this.key(room)}:layers`;\n }\n\n async snapshot(room: string): Promise<WireSyncElement[]> {\n const map = await this.client.hGetAll(this.key(room));\n const out: WireSyncElement[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidWireElement(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the room snapshot.\n }\n }\n return out;\n }\n\n async get(room: string, id: string): Promise<WireSyncElement | undefined> {\n const value = await this.client.hGet(this.key(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidWireElement(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async apply(room: string, op: WireSyncOp): Promise<void> {\n const key = this.key(room);\n if (op.kind === 'upsert')\n await this.client.hSet(key, op.element.id, JSON.stringify(op.element));\n else if (op.kind === 'remove') await this.client.hDel(key, op.id);\n else if (op.kind === 'clear') await this.client.del(key);\n }\n\n async layerRecords(room: string): Promise<LayerRecord[]> {\n const map = await this.client.hGetAll(this.layersKey(room));\n const out: LayerRecord[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidLayerRecord(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the layer ledger.\n }\n }\n return out;\n }\n\n async getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined> {\n const value = await this.client.hGet(this.layersKey(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidLayerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async applyLayerRecord(room: string, record: LayerRecord): Promise<void> {\n await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));\n }\n\n dispose(): void {\n for (const dispose of [...this.pluginDisposers].reverse()) safelyDispose(dispose);\n this.pluginDisposers.length = 0;\n this.services.clear();\n }\n\n private installPlugins(plugins: readonly BackendSyncPlugin[]): void {\n const names = new Set<string>();\n const prefixes = new Set<string>();\n try {\n for (const plugin of plugins) {\n if (names.has(plugin.name))\n throw new Error(`Backend plugin \"${plugin.name}\" is duplicated`);\n if (prefixes.has(plugin.keyPrefix)) {\n throw new Error(`Backend plugin key prefix \"${plugin.keyPrefix}\" is duplicated`);\n }\n names.add(plugin.name);\n prefixes.add(plugin.keyPrefix);\n const localDisposers: (() => void)[] = [];\n const serviceKeys: symbol[] = [];\n try {\n plugin.start({\n client: this.client,\n roomKeyPrefix: this.keyPrefix,\n roomKey: (room) => this.key(room),\n registerService: (key, service) => {\n if (this.services.has(key.id)) {\n throw new Error(`Backend service \"${key.name}\" is already registered`);\n }\n this.services.set(key.id, service);\n serviceKeys.push(key.id);\n },\n addDisposer: (dispose) => localDisposers.push(dispose),\n });\n } catch (error) {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n throw error;\n }\n this.pluginDisposers.push(() => {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n });\n }\n } catch (error) {\n this.dispose();\n throw error;\n }\n }\n}\n\nfunction safelyDispose(dispose: () => void): void {\n try {\n dispose();\n } catch {\n // Continue rolling back later resources.\n }\n}\n","/**\n * Encodes a room name for use inside a Redis key. Room-scoped hashes are laid\n * out as `<prefix><room>` plus `<prefix><room>:<suffix>` sub-keys, so an\n * unescaped `:` in a room name lets `foo:layers` alias room `foo`'s layer\n * ledger. `encodeURIComponent` leaves the relay's valid room alphabet\n * (`[A-Za-z0-9_-]`) untouched, so existing keys keep their historical layout.\n */\nexport function encodeRoomKey(room: string): string {\n return encodeURIComponent(room);\n}\n","import type { RedisHashClient } from './redis-hash-client';\n\n/** Keys and arguments passed to a Lua script, matching node-redis' `eval` options. */\nexport interface ScriptRunOptions {\n keys: string[];\n arguments: string[];\n}\n\n/** Runs a Lua script against Redis, by SHA when the client supports it. */\nexport type ScriptRunner = (script: string, options: ScriptRunOptions) => Promise<unknown>;\n\nfunction isNoScriptError(error: unknown): boolean {\n return error instanceof Error && error.message.includes('NOSCRIPT');\n}\n\n/**\n * Builds a script runner bound to one client instance. When the client exposes\n * both `scriptLoad` and `evalSha`, each distinct script is loaded once and then\n * evaluated by SHA; a `NOSCRIPT` failure (the server's script cache was flushed\n * or the connection moved to another node) reloads the script and retries once.\n * Clients without the optional methods fall back to plain `EVAL`, so existing\n * implementations keep working unchanged.\n */\nexport function createScriptRunner(client: RedisHashClient): ScriptRunner {\n const evalScript = client.eval.bind(client);\n const scriptLoad = client.scriptLoad?.bind(client);\n const evalSha = client.evalSha?.bind(client);\n const shaByScript = new Map<string, Promise<string>>();\n\n const load = (script: string, loader: (source: string) => Promise<string>): Promise<string> => {\n const cached = shaByScript.get(script);\n if (cached) return cached;\n const pending = loader(script);\n shaByScript.set(script, pending);\n pending.catch(() => {\n if (shaByScript.get(script) === pending) shaByScript.delete(script);\n });\n return pending;\n };\n\n return async (script, options) => {\n if (!scriptLoad || !evalSha) return evalScript(script, options);\n const pending = load(script, scriptLoad);\n const sha = await pending;\n try {\n return await evalSha(sha, options);\n } catch (error) {\n if (!isNoScriptError(error)) throw error;\n // Evict only this call's load: a concurrent NOSCRIPT may already have\n // started the reload, and dropping it would load the script again.\n if (shaByScript.get(script) === pending) shaByScript.delete(script);\n const reloaded = await load(script, scriptLoad);\n return await evalSha(reloaded, options);\n }\n };\n}\n","import type { HubFanout } from '@fieldnotes/sync-server';\nimport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\n\nexport interface RedisHubFanoutOptions {\n channel?: string;\n onError?: (err: unknown) => void;\n}\n\nexport class RedisHubFanout implements HubFanout {\n private readonly publisher: RedisPublisher;\n private readonly subscriber: RedisSubscriber;\n private readonly channel: string;\n private readonly onError: (err: unknown) => void;\n private readonly handlers = new Set<(payload: string) => void>();\n private subscribed = false;\n\n constructor(\n publisher: RedisPublisher,\n subscriber: RedisSubscriber,\n options: RedisHubFanoutOptions = {},\n ) {\n this.publisher = publisher;\n this.subscriber = subscriber;\n this.channel = options.channel ?? 'fieldnotes:fanout';\n this.onError = options.onError ?? (() => undefined);\n }\n\n async publish(payload: string): Promise<void> {\n try {\n await this.publisher.publish(this.channel, payload);\n } catch (error) {\n try {\n this.onError(error);\n } catch {\n /* preserve the publication failure even when the observer throws */\n }\n throw error;\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n if (!this.subscribed) {\n this.subscribed = true;\n Promise.resolve(\n this.subscriber.subscribe(this.channel, (message) => {\n for (const h of this.handlers) {\n try {\n h(message);\n } catch {\n /* isolate handlers */\n }\n }\n }),\n ).catch(this.onError);\n }\n return () => this.handlers.delete(handler);\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fieldnotes/sync-redis",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Redis-backed HubBackend for Field Notes real-time sync relay",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -9,9 +9,14 @@
9
9
  "types": "./dist/index.d.ts",
10
10
  "exports": {
11
11
  ".": {
12
- "types": "./dist/index.d.ts",
13
- "import": "./dist/index.js",
14
- "require": "./dist/index.cjs"
12
+ "import": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "require": {
17
+ "types": "./dist/index.d.cts",
18
+ "default": "./dist/index.cjs"
19
+ }
15
20
  }
16
21
  },
17
22
  "files": [
@@ -36,22 +41,25 @@
36
41
  "fieldnotes"
37
42
  ],
38
43
  "dependencies": {
39
- "@fieldnotes/sync": "0.19.0"
44
+ "@fieldnotes/sync": "0.20.1"
40
45
  },
41
46
  "peerDependencies": {
42
47
  "@fieldnotes/core": ">=0.82.0 <1.0.0"
43
48
  },
44
49
  "devDependencies": {
45
50
  "@vitest/coverage-v8": "^4.1.0",
51
+ "redis": "^5.12.1",
46
52
  "tsup": "^8.5.1",
47
53
  "vitest": "^4.1.0",
48
- "@fieldnotes/core": "0.82.0",
49
- "@fieldnotes/vtt": "0.8.0",
50
- "@fieldnotes/sync-server": "0.18.0"
54
+ "@fieldnotes/core": "0.83.0",
55
+ "@fieldnotes/vtt": "0.10.0",
56
+ "@fieldnotes/sync-server": "0.19.1"
51
57
  },
52
58
  "scripts": {
53
59
  "build": "tsup",
60
+ "typecheck": "tsc --noEmit -p tsconfig.json",
54
61
  "test": "vitest run",
62
+ "test:coverage": "vitest run --coverage",
55
63
  "test:watch": "vitest"
56
64
  }
57
65
  }