@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 +11 -0
- package/dist/index.cjs +36 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -2
- package/dist/index.d.ts +26 -2
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -1
- package/package.json +16 -8
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
|
package/dist/index.cjs.map
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fieldnotes/sync-redis",
|
|
3
|
-
"version": "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
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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/
|
|
49
|
-
"@fieldnotes/
|
|
50
|
-
"@fieldnotes/
|
|
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
|
}
|