@fieldnotes/sync-redis 0.10.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
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  RedisHubBackend: () => RedisHubBackend,
24
24
  RedisHubFanout: () => RedisHubFanout,
25
+ createScriptRunner: () => createScriptRunner,
25
26
  encodeRoomKey: () => encodeRoomKey
26
27
  });
27
28
  module.exports = __toCommonJS(index_exports);
@@ -165,6 +166,40 @@ function safelyDispose(dispose) {
165
166
  }
166
167
  }
167
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
+
168
203
  // src/redis-hub-fanout.ts
169
204
  var RedisHubFanout = class {
170
205
  publisher;
@@ -212,6 +247,7 @@ var RedisHubFanout = class {
212
247
  0 && (module.exports = {
213
248
  RedisHubBackend,
214
249
  RedisHubFanout,
250
+ createScriptRunner,
215
251
  encodeRoomKey
216
252
  });
217
253
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/redis-hub-backend.ts","../src/room-key.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';\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 { 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/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":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -8,11 +8,18 @@ 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 {
@@ -59,6 +66,23 @@ declare class RedisHubBackend implements HubBackend {
59
66
  private installPlugins;
60
67
  }
61
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
+
62
86
  interface RedisPublisher {
63
87
  publish(channel: string, message: string): Promise<unknown> | unknown;
64
88
  }
@@ -91,4 +115,4 @@ declare class RedisHubFanout implements HubFanout {
91
115
  */
92
116
  declare function encodeRoomKey(room: string): string;
93
117
 
94
- export { type BackendPluginContext, type BackendSyncPlugin, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber, encodeRoomKey };
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,11 +8,18 @@ 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 {
@@ -59,6 +66,23 @@ declare class RedisHubBackend implements HubBackend {
59
66
  private installPlugins;
60
67
  }
61
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
+
62
86
  interface RedisPublisher {
63
87
  publish(channel: string, message: string): Promise<unknown> | unknown;
64
88
  }
@@ -91,4 +115,4 @@ declare class RedisHubFanout implements HubFanout {
91
115
  */
92
116
  declare function encodeRoomKey(room: string): string;
93
117
 
94
- export { type BackendPluginContext, type BackendSyncPlugin, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber, encodeRoomKey };
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
@@ -140,6 +140,40 @@ function safelyDispose(dispose) {
140
140
  }
141
141
  }
142
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
+
143
177
  // src/redis-hub-fanout.ts
144
178
  var RedisHubFanout = class {
145
179
  publisher;
@@ -186,6 +220,7 @@ var RedisHubFanout = class {
186
220
  export {
187
221
  RedisHubBackend,
188
222
  RedisHubFanout,
223
+ createScriptRunner,
189
224
  encodeRoomKey
190
225
  };
191
226
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/redis-hub-backend.ts","../src/room-key.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 { 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":";AACA;AAAA,EACE;AAAA,EACA;AAAA,OAIK;;;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,YAAI,mBAAmB,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,aAAO,mBAAmB,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,YAAI,mBAAmB,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,aAAO,mBAAmB,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fieldnotes/sync-redis",
3
- "version": "0.10.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.20.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/vtt": "0.9.0",
49
- "@fieldnotes/sync-server": "0.19.0",
50
- "@fieldnotes/core": "0.82.1"
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
  }