@fieldnotes/sync-redis 0.7.2 → 0.8.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 -4
- package/dist/index.cjs +55 -161
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -35
- package/dist/index.d.ts +24 -35
- package/dist/index.js +56 -172
- package/dist/index.js.map +1 -1
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ pnpm add ioredis
|
|
|
15
15
|
|
|
16
16
|
`@fieldnotes/sync-redis` has **no Redis dependency of its own** — you inject a client that satisfies a
|
|
17
17
|
minimal `RedisHashClient` interface (`hGetAll` / `hGet` / `hSet` / `hDel` / `del` / `eval`).
|
|
18
|
-
`eval` is
|
|
18
|
+
`eval` is optional for the generic element backend and is used by plugins that need atomic scripts.
|
|
19
19
|
|
|
20
20
|
## node-redis v4 (direct)
|
|
21
21
|
|
|
@@ -59,9 +59,10 @@ Each room is stored as a Redis **HASH** at `{keyPrefix}{room}` (default prefix `
|
|
|
59
59
|
- **field** = element id
|
|
60
60
|
- **value** = `JSON.stringify(element)`
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
`
|
|
64
|
-
|
|
62
|
+
Backend plugins own any additional key schemas and atomic scripts. For fog, pass
|
|
63
|
+
`createFogBackendPlugin()` from `@fieldnotes/vtt/redis`; it owns `${key}:fog:meta` and
|
|
64
|
+
`${key}:fog:tiles` and requires the node-redis-compatible
|
|
65
|
+
`eval(script, { keys, arguments })` shape shown above.
|
|
65
66
|
|
|
66
67
|
node-redis v4 conforms to `RedisHashClient` directly (it has `hGet`); `RedisHubBackend.get(room, id)`
|
|
67
68
|
(via `HGET`) powers the relay's ownership lookups for write authorization (D2).
|
|
@@ -72,6 +73,12 @@ The prefix is configurable:
|
|
|
72
73
|
new RedisHubBackend(client, { keyPrefix: 'myapp:room:' });
|
|
73
74
|
```
|
|
74
75
|
|
|
76
|
+
```ts
|
|
77
|
+
import { createFogBackendPlugin } from '@fieldnotes/vtt/redis';
|
|
78
|
+
|
|
79
|
+
new RedisHubBackend(client, { plugins: [createFogBackendPlugin()] });
|
|
80
|
+
```
|
|
81
|
+
|
|
75
82
|
## Single vs. multiple relay instances
|
|
76
83
|
|
|
77
84
|
Persistence and shared state work with any number of relay instances — every instance reads and writes the
|
package/dist/index.cjs
CHANGED
|
@@ -27,14 +27,19 @@ module.exports = __toCommonJS(index_exports);
|
|
|
27
27
|
|
|
28
28
|
// src/redis-hub-backend.ts
|
|
29
29
|
var import_sync = require("@fieldnotes/sync");
|
|
30
|
-
var import_vtt = require("@fieldnotes/vtt");
|
|
31
30
|
var RedisHubBackend = class {
|
|
32
31
|
sharedAcrossInstances = true;
|
|
33
32
|
client;
|
|
34
33
|
keyPrefix;
|
|
34
|
+
services = /* @__PURE__ */ new Map();
|
|
35
|
+
pluginDisposers = [];
|
|
35
36
|
constructor(client, options = {}) {
|
|
36
37
|
this.client = client;
|
|
37
38
|
this.keyPrefix = options.keyPrefix ?? "fieldnotes:room:";
|
|
39
|
+
this.installPlugins(options.plugins ?? []);
|
|
40
|
+
}
|
|
41
|
+
getService(key) {
|
|
42
|
+
return this.services.get(key.id);
|
|
38
43
|
}
|
|
39
44
|
key(room) {
|
|
40
45
|
return `${this.keyPrefix}${room}`;
|
|
@@ -46,13 +51,11 @@ var RedisHubBackend = class {
|
|
|
46
51
|
const map = await this.client.hGetAll(this.key(room));
|
|
47
52
|
const out = [];
|
|
48
53
|
for (const value of Object.values(map)) {
|
|
49
|
-
let parsed;
|
|
50
54
|
try {
|
|
51
|
-
parsed = JSON.parse(value);
|
|
55
|
+
const parsed = JSON.parse(value);
|
|
56
|
+
if ((0, import_sync.isValidElement)(parsed)) out.push(parsed);
|
|
52
57
|
} catch {
|
|
53
|
-
continue;
|
|
54
58
|
}
|
|
55
|
-
if ((0, import_sync.isValidElement)(parsed)) out.push(parsed);
|
|
56
59
|
}
|
|
57
60
|
return out;
|
|
58
61
|
}
|
|
@@ -77,13 +80,11 @@ var RedisHubBackend = class {
|
|
|
77
80
|
const map = await this.client.hGetAll(this.layersKey(room));
|
|
78
81
|
const out = [];
|
|
79
82
|
for (const value of Object.values(map)) {
|
|
80
|
-
let parsed;
|
|
81
83
|
try {
|
|
82
|
-
parsed = JSON.parse(value);
|
|
84
|
+
const parsed = JSON.parse(value);
|
|
85
|
+
if ((0, import_sync.isValidLayerRecord)(parsed)) out.push(parsed);
|
|
83
86
|
} catch {
|
|
84
|
-
continue;
|
|
85
87
|
}
|
|
86
|
-
if ((0, import_sync.isValidLayerRecord)(parsed)) out.push(parsed);
|
|
87
88
|
}
|
|
88
89
|
return out;
|
|
89
90
|
}
|
|
@@ -100,167 +101,60 @@ var RedisHubBackend = class {
|
|
|
100
101
|
async applyLayerRecord(room, record) {
|
|
101
102
|
await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));
|
|
102
103
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return `${this.keyPrefix}${room}:fog:tiles`;
|
|
104
|
+
dispose() {
|
|
105
|
+
for (const dispose of [...this.pluginDisposers].reverse()) safelyDispose(dispose);
|
|
106
|
+
this.pluginDisposers.length = 0;
|
|
107
|
+
this.services.clear();
|
|
108
108
|
}
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
let meta;
|
|
109
|
+
installPlugins(plugins) {
|
|
110
|
+
const names = /* @__PURE__ */ new Set();
|
|
111
|
+
const prefixes = /* @__PURE__ */ new Set();
|
|
113
112
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (!meta.definition) return { meta, tiles: [] };
|
|
120
|
-
const definition = meta.definition;
|
|
121
|
-
if (!definition) return { meta, tiles: [] };
|
|
122
|
-
const tileMap = await this.client.hGetAll(this.fogTilesKey(room));
|
|
123
|
-
const tiles = [];
|
|
124
|
-
const seen = /* @__PURE__ */ new Set();
|
|
125
|
-
for (const value of Object.values(tileMap)) {
|
|
126
|
-
let parsed;
|
|
127
|
-
try {
|
|
128
|
-
parsed = JSON.parse(value);
|
|
129
|
-
} catch {
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
if (!(0, import_sync.isValidFogTileRecord)(parsed)) continue;
|
|
133
|
-
if (parsed.generation !== definition.generation) continue;
|
|
134
|
-
if (!(0, import_vtt.tileIntersectsDefinition)(parsed.x, parsed.y, definition)) continue;
|
|
135
|
-
if (!(0, import_sync.isValidFogSnapshot)({ meta, tiles: [parsed] })) continue;
|
|
136
|
-
const key = `${parsed.x},${parsed.y}`;
|
|
137
|
-
if (seen.has(key)) continue;
|
|
138
|
-
seen.add(key);
|
|
139
|
-
tiles.push(parsed);
|
|
140
|
-
if (tiles.length === 256) break;
|
|
141
|
-
}
|
|
142
|
-
const snapshot = { meta, tiles };
|
|
143
|
-
return (0, import_sync.isValidFogSnapshot)(snapshot) ? snapshot : void 0;
|
|
144
|
-
}
|
|
145
|
-
async applyFogMeta(room, record) {
|
|
146
|
-
for (let attempt = 0; attempt < 4; attempt++) {
|
|
147
|
-
const metaKey = this.fogMetaKey(room);
|
|
148
|
-
const tilesKey = this.fogTilesKey(room);
|
|
149
|
-
const expectedMeta = await this.client.hGet(metaKey, "current");
|
|
150
|
-
const expectedTiles = await this.client.hGetAll(tilesKey);
|
|
151
|
-
const replacements = [];
|
|
152
|
-
let current;
|
|
153
|
-
if (expectedMeta !== null) {
|
|
154
|
-
try {
|
|
155
|
-
const parsed = JSON.parse(expectedMeta);
|
|
156
|
-
if ((0, import_sync.isValidFogMetaRecord)(parsed)) current = parsed;
|
|
157
|
-
} catch {
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (current?.definition && record.definition && current.definition.generation === record.definition.generation) {
|
|
161
|
-
for (const raw of Object.values(expectedTiles)) {
|
|
162
|
-
let tile;
|
|
163
|
-
try {
|
|
164
|
-
tile = JSON.parse(raw);
|
|
165
|
-
} catch {
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
if (!(0, import_sync.isValidFogSnapshot)({ meta: current, tiles: [tile] })) continue;
|
|
169
|
-
const valid = tile;
|
|
170
|
-
if (!(0, import_vtt.tileIntersectsDefinition)(valid.x, valid.y, record.definition)) continue;
|
|
171
|
-
if (valid.data === void 0) {
|
|
172
|
-
replacements.push(valid);
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
const canonical = (0, import_vtt.canonicalizeFogTile)(
|
|
176
|
-
{ x: valid.x, y: valid.y, data: valid.data },
|
|
177
|
-
record.definition
|
|
178
|
-
);
|
|
179
|
-
if (canonical) replacements.push({ ...valid, data: canonical.data });
|
|
113
|
+
for (const plugin of plugins) {
|
|
114
|
+
if (names.has(plugin.name))
|
|
115
|
+
throw new Error(`Backend plugin "${plugin.name}" is duplicated`);
|
|
116
|
+
if (prefixes.has(plugin.keyPrefix)) {
|
|
117
|
+
throw new Error(`Backend plugin key prefix "${plugin.keyPrefix}" is duplicated`);
|
|
180
118
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
JSON.stringify(replacements)
|
|
186
|
-
]);
|
|
187
|
-
if (Array.isArray(result) && result[0] === 2) continue;
|
|
188
|
-
return (0, import_vtt.parseFogRedisMetaResult)(result, import_sync.isValidFogMetaRecord);
|
|
189
|
-
}
|
|
190
|
-
throw new Error("Redis fog meta update did not converge after concurrent writes");
|
|
191
|
-
}
|
|
192
|
-
async applyFogTile(room, record) {
|
|
193
|
-
const result = await this.applyFogPatch(room, [record]);
|
|
194
|
-
if (result.accepted.length === 1) return { accepted: true };
|
|
195
|
-
return { accepted: false, correction: result.corrections[0] };
|
|
196
|
-
}
|
|
197
|
-
async applyFogPatch(room, records) {
|
|
198
|
-
for (let attempt = 0; attempt < 4; attempt++) {
|
|
199
|
-
const metaRaw = await this.client.hGet(this.fogMetaKey(room), "current");
|
|
200
|
-
if (metaRaw === null) return { accepted: [], corrections: [] };
|
|
201
|
-
let meta;
|
|
202
|
-
try {
|
|
203
|
-
meta = JSON.parse(metaRaw);
|
|
204
|
-
} catch {
|
|
205
|
-
return { accepted: [], corrections: [] };
|
|
206
|
-
}
|
|
207
|
-
if (!(0, import_sync.isValidFogMetaRecord)(meta) || !meta.definition) {
|
|
208
|
-
return { accepted: [], corrections: [] };
|
|
209
|
-
}
|
|
210
|
-
const definition = meta.definition;
|
|
211
|
-
const stored = await this.client.hGetAll(this.fogTilesKey(room));
|
|
212
|
-
const invalidStored = {};
|
|
213
|
-
for (const [field, raw] of Object.entries(stored)) {
|
|
214
|
-
let parsed;
|
|
119
|
+
names.add(plugin.name);
|
|
120
|
+
prefixes.add(plugin.keyPrefix);
|
|
121
|
+
const localDisposers = [];
|
|
122
|
+
const serviceKeys = [];
|
|
215
123
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
if (records.some((tile) => !(0, import_sync.isValidFogSnapshot)({ meta, tiles: [tile] }))) {
|
|
224
|
-
return {
|
|
225
|
-
accepted: [],
|
|
226
|
-
corrections: records.map((tile) => {
|
|
227
|
-
const raw = stored[`${tile.x},${tile.y}`];
|
|
228
|
-
if (raw) {
|
|
229
|
-
try {
|
|
230
|
-
const parsed = JSON.parse(raw);
|
|
231
|
-
if ((0, import_sync.isValidFogSnapshot)({ meta, tiles: [parsed] })) return parsed;
|
|
232
|
-
} catch {
|
|
124
|
+
plugin.start({
|
|
125
|
+
client: this.client,
|
|
126
|
+
roomKeyPrefix: this.keyPrefix,
|
|
127
|
+
registerService: (key, service) => {
|
|
128
|
+
if (this.services.has(key.id)) {
|
|
129
|
+
throw new Error(`Backend service "${key.name}" is already registered`);
|
|
233
130
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
131
|
+
this.services.set(key.id, service);
|
|
132
|
+
serviceKeys.push(key.id);
|
|
133
|
+
},
|
|
134
|
+
addDisposer: (dispose) => localDisposers.push(dispose)
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
for (const dispose of localDisposers.reverse()) safelyDispose(dispose);
|
|
138
|
+
for (const key of serviceKeys) this.services.delete(key);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
this.pluginDisposers.push(() => {
|
|
142
|
+
for (const dispose of localDisposers.reverse()) safelyDispose(dispose);
|
|
143
|
+
for (const key of serviceKeys) this.services.delete(key);
|
|
144
|
+
});
|
|
244
145
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
]);
|
|
249
|
-
if (Array.isArray(result) && result[0] === 2) continue;
|
|
250
|
-
return (0, import_vtt.parseFogRedisPatchResult)(result);
|
|
251
|
-
}
|
|
252
|
-
throw new Error("Redis fog patch did not converge after concurrent definition writes");
|
|
253
|
-
}
|
|
254
|
-
async evalFog(script, room, record, extraArguments = []) {
|
|
255
|
-
if (!this.client.eval) {
|
|
256
|
-
throw new Error("Redis fog persistence requires a Redis client with EVAL support");
|
|
146
|
+
} catch (error) {
|
|
147
|
+
this.dispose();
|
|
148
|
+
throw error;
|
|
257
149
|
}
|
|
258
|
-
return this.client.eval(script, {
|
|
259
|
-
keys: [this.fogMetaKey(room), this.fogTilesKey(room)],
|
|
260
|
-
arguments: [JSON.stringify(record), ...extraArguments]
|
|
261
|
-
});
|
|
262
150
|
}
|
|
263
151
|
};
|
|
152
|
+
function safelyDispose(dispose) {
|
|
153
|
+
try {
|
|
154
|
+
dispose();
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
}
|
|
264
158
|
|
|
265
159
|
// src/redis-hub-fanout.ts
|
|
266
160
|
var RedisHubFanout = class {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/redis-hub-backend.ts","../src/redis-hub-fanout.ts"],"sourcesContent":["export { RedisHubBackend } from './redis-hub-backend';\nexport type { RedisHubBackendOptions } from './redis-hub-backend';\nexport type { RedisHashClient } from './redis-hash-client';\nexport { RedisHubFanout } from './redis-hub-fanout';\nexport type { RedisHubFanoutOptions } from './redis-hub-fanout';\nexport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\nexport type {\n BackendSyncPlugin,\n ApplyResult,\n BackendOpContext,\n PluginSnapshot,\n} from './sync-plugin';\n","import {\n isValidElement,\n isValidLayerRecord,\n isValidFogMetaRecord,\n isValidFogTileRecord,\n isValidFogSnapshot,\n type LayerRecord,\n type SyncOp,\n type FogSnapshot,\n type FogMetaRecord,\n type FogTileRecord,\n} from '@fieldnotes/sync';\nimport {\n canonicalizeFogTile,\n tileIntersectsDefinition,\n parseFogRedisMetaResult,\n parseFogRedisPatchResult,\n FOG_META_LWW_SCRIPT,\n FOG_PATCH_LWW_SCRIPT,\n} from '@fieldnotes/vtt';\nimport type { CanvasElement } from '@fieldnotes/core';\nimport type { FogApplyResult, FogPatchApplyResult, HubBackend } from '@fieldnotes/sync-server';\nimport type { RedisHashClient } from './redis-hash-client';\n\nexport interface RedisHubBackendOptions {\n keyPrefix?: string; // default 'fieldnotes:room:'\n}\n\nexport class RedisHubBackend implements HubBackend {\n readonly sharedAcrossInstances = true;\n private readonly client: RedisHashClient;\n private readonly keyPrefix: string;\n\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\n this.client = client;\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\n }\n\n private key(room: string): string {\n return `${this.keyPrefix}${room}`;\n }\n\n private layersKey(room: string): string {\n return `${this.keyPrefix}${room}:layers`;\n }\n\n async snapshot(room: string): Promise<CanvasElement[]> {\n const map = await this.client.hGetAll(this.key(room));\n const out: CanvasElement[] = [];\n for (const value of Object.values(map)) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(value);\n } catch {\n continue; // skip a corrupt stored value rather than throwing the whole snapshot\n }\n if (isValidElement(parsed)) out.push(parsed);\n }\n return out;\n }\n\n async get(room: string, id: string): Promise<CanvasElement | 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 isValidElement(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async apply(room: string, op: SyncOp): 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 // 'clear' deletes elements only; the layer ledger is a separate hash and survives.\n else if (op.kind === 'clear') await this.client.del(key);\n // request-snapshot/snapshot never reach apply (the hub only applies data ops)\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 let parsed: unknown;\n try {\n parsed = JSON.parse(value);\n } catch {\n continue; // skip a corrupt stored value rather than throwing the whole ledger\n }\n if (isValidLayerRecord(parsed)) out.push(parsed);\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 private fogMetaKey(room: string): string {\n return `${this.keyPrefix}${room}:fog:meta`;\n }\n\n private fogTilesKey(room: string): string {\n return `${this.keyPrefix}${room}:fog:tiles`;\n }\n\n async fogSnapshot(room: string): Promise<FogSnapshot | undefined> {\n const metaStr = await this.client.hGet(this.fogMetaKey(room), 'current');\n if (metaStr == null) return undefined;\n let meta: unknown;\n try {\n meta = JSON.parse(metaStr);\n } catch {\n return undefined;\n }\n if (!isValidFogMetaRecord(meta)) return undefined;\n\n if (!(meta as FogMetaRecord).definition) return { meta: meta as FogMetaRecord, tiles: [] };\n\n const definition = (meta as FogMetaRecord).definition;\n if (!definition) return { meta: meta as FogMetaRecord, tiles: [] };\n const tileMap = await this.client.hGetAll(this.fogTilesKey(room));\n const tiles: FogTileRecord[] = [];\n const seen = new Set<string>();\n for (const value of Object.values(tileMap)) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(value);\n } catch {\n continue;\n }\n if (!isValidFogTileRecord(parsed)) continue;\n if (parsed.generation !== definition.generation) continue;\n if (!tileIntersectsDefinition(parsed.x, parsed.y, definition)) continue;\n if (!isValidFogSnapshot({ meta: meta as FogMetaRecord, tiles: [parsed] })) continue;\n const key = `${parsed.x},${parsed.y}`;\n if (seen.has(key)) continue;\n seen.add(key);\n tiles.push(parsed);\n if (tiles.length === 256) break;\n }\n\n const snapshot = { meta: meta as FogMetaRecord, tiles };\n return isValidFogSnapshot(snapshot) ? snapshot : undefined;\n }\n\n async applyFogMeta(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>> {\n for (let attempt = 0; attempt < 4; attempt++) {\n const metaKey = this.fogMetaKey(room);\n const tilesKey = this.fogTilesKey(room);\n const expectedMeta = await this.client.hGet(metaKey, 'current');\n const expectedTiles = await this.client.hGetAll(tilesKey);\n const replacements: FogTileRecord[] = [];\n let current: FogMetaRecord | undefined;\n if (expectedMeta !== null) {\n try {\n const parsed: unknown = JSON.parse(expectedMeta);\n if (isValidFogMetaRecord(parsed)) current = parsed;\n } catch {\n // Corrupt state is atomically replaced by a valid winning record.\n }\n }\n if (\n current?.definition &&\n record.definition &&\n current.definition.generation === record.definition.generation\n ) {\n for (const raw of Object.values(expectedTiles)) {\n let tile: unknown;\n try {\n tile = JSON.parse(raw);\n } catch {\n continue;\n }\n if (!isValidFogSnapshot({ meta: current, tiles: [tile] })) continue;\n const valid = tile as FogTileRecord;\n if (!tileIntersectsDefinition(valid.x, valid.y, record.definition)) continue;\n if (valid.data === undefined) {\n replacements.push(valid);\n continue;\n }\n const canonical = canonicalizeFogTile(\n { x: valid.x, y: valid.y, data: valid.data },\n record.definition,\n );\n if (canonical) replacements.push({ ...valid, data: canonical.data });\n }\n }\n const result = await this.evalFog(FOG_META_LWW_SCRIPT, room, record, [\n expectedMeta ?? '',\n JSON.stringify(expectedTiles),\n JSON.stringify(replacements),\n ]);\n if (Array.isArray(result) && result[0] === 2) continue;\n return parseFogRedisMetaResult(result, isValidFogMetaRecord);\n }\n throw new Error('Redis fog meta update did not converge after concurrent writes');\n }\n\n async applyFogTile(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>> {\n const result = await this.applyFogPatch(room, [record]);\n if (result.accepted.length === 1) return { accepted: true };\n return { accepted: false, correction: result.corrections[0] };\n }\n\n async applyFogPatch(\n room: string,\n records: readonly FogTileRecord[],\n ): Promise<FogPatchApplyResult> {\n for (let attempt = 0; attempt < 4; attempt++) {\n const metaRaw = await this.client.hGet(this.fogMetaKey(room), 'current');\n if (metaRaw === null) return { accepted: [], corrections: [] };\n let meta: unknown;\n try {\n meta = JSON.parse(metaRaw);\n } catch {\n return { accepted: [], corrections: [] };\n }\n if (!isValidFogMetaRecord(meta) || !meta.definition) {\n return { accepted: [], corrections: [] };\n }\n const definition = meta.definition;\n const stored = await this.client.hGetAll(this.fogTilesKey(room));\n const invalidStored: Record<string, string> = {};\n for (const [field, raw] of Object.entries(stored)) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n invalidStored[field] = raw;\n continue;\n }\n if (!isValidFogSnapshot({ meta, tiles: [parsed] })) invalidStored[field] = raw;\n }\n if (records.some((tile) => !isValidFogSnapshot({ meta, tiles: [tile] }))) {\n return {\n accepted: [],\n corrections: records.map((tile) => {\n const raw = stored[`${tile.x},${tile.y}`];\n if (raw) {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (isValidFogSnapshot({ meta, tiles: [parsed] })) return parsed as FogTileRecord;\n } catch {\n // Fall through to an authoritative tombstone.\n }\n }\n return {\n generation: definition.generation,\n x: tile.x,\n y: tile.y,\n version: 1,\n editor: 'hub',\n };\n }),\n };\n }\n const result = await this.evalFog(FOG_PATCH_LWW_SCRIPT, room, records, [\n metaRaw,\n JSON.stringify(invalidStored),\n ]);\n if (Array.isArray(result) && result[0] === 2) continue;\n return parseFogRedisPatchResult(result);\n }\n throw new Error('Redis fog patch did not converge after concurrent definition writes');\n }\n\n private async evalFog(\n script: string,\n room: string,\n record: object,\n extraArguments: string[] = [],\n ): Promise<unknown> {\n if (!this.client.eval) {\n throw new Error('Redis fog persistence requires a Redis client with EVAL support');\n }\n return this.client.eval(script, {\n keys: [this.fogMetaKey(room), this.fogTilesKey(room)],\n arguments: [JSON.stringify(record), ...extraArguments],\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":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/redis-hub-backend.ts","../src/redis-hub-fanout.ts"],"sourcesContent":["export { RedisHubBackend } from './redis-hub-backend';\nexport type { RedisHubBackendOptions } from './redis-hub-backend';\nexport type { RedisHashClient } from './redis-hash-client';\nexport { RedisHubFanout } from './redis-hub-fanout';\nexport type { RedisHubFanoutOptions } from './redis-hub-fanout';\nexport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\nexport type { BackendSyncPlugin, BackendPluginContext } from './sync-plugin';\n","import type { CanvasElement, ServiceKey } from '@fieldnotes/core';\nimport {\n isValidElement,\n isValidLayerRecord,\n type LayerRecord,\n type SyncOp,\n} from '@fieldnotes/sync';\nimport type { HubBackend } from '@fieldnotes/sync-server';\nimport type { RedisHashClient } from './redis-hash-client';\nimport type { BackendSyncPlugin } from './sync-plugin';\n\nexport interface RedisHubBackendOptions {\n keyPrefix?: string;\n plugins?: readonly BackendSyncPlugin[];\n}\n\nexport class RedisHubBackend implements HubBackend {\n readonly sharedAcrossInstances = true;\n private readonly client: RedisHashClient;\n private readonly keyPrefix: string;\n private readonly services = new Map<symbol, unknown>();\n private readonly pluginDisposers: (() => void)[] = [];\n\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\n this.client = client;\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\n this.installPlugins(options.plugins ?? []);\n }\n\n getService<T>(key: ServiceKey<T>): T | undefined {\n return this.services.get(key.id) as T | undefined;\n }\n\n private key(room: string): string {\n return `${this.keyPrefix}${room}`;\n }\n\n private layersKey(room: string): string {\n return `${this.keyPrefix}${room}:layers`;\n }\n\n async snapshot(room: string): Promise<CanvasElement[]> {\n const map = await this.client.hGetAll(this.key(room));\n const out: CanvasElement[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidElement(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<CanvasElement | 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 isValidElement(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async apply(room: string, op: SyncOp): Promise<void> {\n const key = this.key(room);\n if (op.kind === 'upsert')\n await this.client.hSet(key, op.element.id, JSON.stringify(op.element));\n else if (op.kind === 'remove') await this.client.hDel(key, op.id);\n else if (op.kind === 'clear') await this.client.del(key);\n }\n\n async layerRecords(room: string): Promise<LayerRecord[]> {\n const map = await this.client.hGetAll(this.layersKey(room));\n const out: LayerRecord[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidLayerRecord(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the layer ledger.\n }\n }\n return out;\n }\n\n async getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined> {\n const value = await this.client.hGet(this.layersKey(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidLayerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async applyLayerRecord(room: string, record: LayerRecord): Promise<void> {\n await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));\n }\n\n dispose(): void {\n for (const dispose of [...this.pluginDisposers].reverse()) safelyDispose(dispose);\n this.pluginDisposers.length = 0;\n this.services.clear();\n }\n\n private installPlugins(plugins: readonly BackendSyncPlugin[]): void {\n const names = new Set<string>();\n const prefixes = new Set<string>();\n try {\n for (const plugin of plugins) {\n if (names.has(plugin.name))\n throw new Error(`Backend plugin \"${plugin.name}\" is duplicated`);\n if (prefixes.has(plugin.keyPrefix)) {\n throw new Error(`Backend plugin key prefix \"${plugin.keyPrefix}\" is duplicated`);\n }\n names.add(plugin.name);\n prefixes.add(plugin.keyPrefix);\n const localDisposers: (() => void)[] = [];\n const serviceKeys: symbol[] = [];\n try {\n plugin.start({\n client: this.client,\n roomKeyPrefix: this.keyPrefix,\n registerService: (key, service) => {\n if (this.services.has(key.id)) {\n throw new Error(`Backend service \"${key.name}\" is already registered`);\n }\n this.services.set(key.id, service);\n serviceKeys.push(key.id);\n },\n addDisposer: (dispose) => localDisposers.push(dispose),\n });\n } catch (error) {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n throw error;\n }\n this.pluginDisposers.push(() => {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n });\n }\n } catch (error) {\n this.dispose();\n throw error;\n }\n }\n}\n\nfunction safelyDispose(dispose: () => void): void {\n try {\n dispose();\n } catch {\n // Continue rolling back later resources.\n }\n}\n","import type { HubFanout } from '@fieldnotes/sync-server';\nimport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\n\nexport interface RedisHubFanoutOptions {\n channel?: string;\n onError?: (err: unknown) => void;\n}\n\nexport class RedisHubFanout implements HubFanout {\n private readonly publisher: RedisPublisher;\n private readonly subscriber: RedisSubscriber;\n private readonly channel: string;\n private readonly onError: (err: unknown) => void;\n private readonly handlers = new Set<(payload: string) => void>();\n private subscribed = false;\n\n constructor(\n publisher: RedisPublisher,\n subscriber: RedisSubscriber,\n options: RedisHubFanoutOptions = {},\n ) {\n this.publisher = publisher;\n this.subscriber = subscriber;\n this.channel = options.channel ?? 'fieldnotes:fanout';\n this.onError = options.onError ?? (() => undefined);\n }\n\n async publish(payload: string): Promise<void> {\n try {\n await this.publisher.publish(this.channel, payload);\n } catch (error) {\n try {\n this.onError(error);\n } catch {\n /* preserve the publication failure even when the observer throws */\n }\n throw error;\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n if (!this.subscribed) {\n this.subscribed = true;\n Promise.resolve(\n this.subscriber.subscribe(this.channel, (message) => {\n for (const h of this.handlers) {\n try {\n h(message);\n } catch {\n /* isolate handlers */\n }\n }\n }),\n ).catch(this.onError);\n }\n return () => this.handlers.delete(handler);\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { HubBackend,
|
|
1
|
+
import { ServiceKey, CanvasElement } from '@fieldnotes/core';
|
|
2
|
+
import { SyncOp, LayerRecord } from '@fieldnotes/sync';
|
|
3
|
+
import { HubBackend, HubFanout } from '@fieldnotes/sync-server';
|
|
4
4
|
|
|
5
5
|
interface RedisHashClient {
|
|
6
6
|
hGetAll(key: string): Promise<Record<string, string>>;
|
|
@@ -8,21 +8,38 @@ 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
|
+
/** Optional 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
16
|
}
|
|
17
17
|
|
|
18
|
+
interface BackendPluginContext {
|
|
19
|
+
readonly client: RedisHashClient;
|
|
20
|
+
readonly roomKeyPrefix: string;
|
|
21
|
+
registerService<T>(key: ServiceKey<T>, service: NoInfer<T>): void;
|
|
22
|
+
addDisposer(dispose: () => void): void;
|
|
23
|
+
}
|
|
24
|
+
interface BackendSyncPlugin {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly keyPrefix: string;
|
|
27
|
+
readonly scripts?: Readonly<Record<string, string>>;
|
|
28
|
+
start(context: BackendPluginContext): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
interface RedisHubBackendOptions {
|
|
19
32
|
keyPrefix?: string;
|
|
33
|
+
plugins?: readonly BackendSyncPlugin[];
|
|
20
34
|
}
|
|
21
35
|
declare class RedisHubBackend implements HubBackend {
|
|
22
36
|
readonly sharedAcrossInstances = true;
|
|
23
37
|
private readonly client;
|
|
24
38
|
private readonly keyPrefix;
|
|
39
|
+
private readonly services;
|
|
40
|
+
private readonly pluginDisposers;
|
|
25
41
|
constructor(client: RedisHashClient, options?: RedisHubBackendOptions);
|
|
42
|
+
getService<T>(key: ServiceKey<T>): T | undefined;
|
|
26
43
|
private key;
|
|
27
44
|
private layersKey;
|
|
28
45
|
snapshot(room: string): Promise<CanvasElement[]>;
|
|
@@ -31,13 +48,8 @@ declare class RedisHubBackend implements HubBackend {
|
|
|
31
48
|
layerRecords(room: string): Promise<LayerRecord[]>;
|
|
32
49
|
getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
|
|
33
50
|
applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
fogSnapshot(room: string): Promise<FogSnapshot | undefined>;
|
|
37
|
-
applyFogMeta(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>>;
|
|
38
|
-
applyFogTile(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>>;
|
|
39
|
-
applyFogPatch(room: string, records: readonly FogTileRecord[]): Promise<FogPatchApplyResult>;
|
|
40
|
-
private evalFog;
|
|
51
|
+
dispose(): void;
|
|
52
|
+
private installPlugins;
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
interface RedisPublisher {
|
|
@@ -63,27 +75,4 @@ declare class RedisHubFanout implements HubFanout {
|
|
|
63
75
|
subscribe(handler: (payload: string) => void): () => void;
|
|
64
76
|
}
|
|
65
77
|
|
|
66
|
-
|
|
67
|
-
accepted: SyncOp | null;
|
|
68
|
-
corrections: SyncOp[];
|
|
69
|
-
broadcast?: SyncOp[];
|
|
70
|
-
locality?: 'shared' | 'local';
|
|
71
|
-
}
|
|
72
|
-
interface BackendOpContext {
|
|
73
|
-
readonly room: string;
|
|
74
|
-
}
|
|
75
|
-
interface PluginSnapshot {
|
|
76
|
-
readonly pluginName: string;
|
|
77
|
-
readonly version: number;
|
|
78
|
-
readonly data: unknown;
|
|
79
|
-
}
|
|
80
|
-
interface BackendSyncPlugin {
|
|
81
|
-
readonly name: string;
|
|
82
|
-
readonly keyPrefix: string;
|
|
83
|
-
readonly scripts?: Record<string, string>;
|
|
84
|
-
snapshot?(room: string): Promise<PluginSnapshot>;
|
|
85
|
-
applyOp?(op: SyncOp, ctx: BackendOpContext): Promise<ApplyResult>;
|
|
86
|
-
dispose?(): void;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export { type ApplyResult, type BackendOpContext, type BackendSyncPlugin, type PluginSnapshot, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber };
|
|
78
|
+
export { type BackendPluginContext, type BackendSyncPlugin, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { HubBackend,
|
|
1
|
+
import { ServiceKey, CanvasElement } from '@fieldnotes/core';
|
|
2
|
+
import { SyncOp, LayerRecord } from '@fieldnotes/sync';
|
|
3
|
+
import { HubBackend, HubFanout } from '@fieldnotes/sync-server';
|
|
4
4
|
|
|
5
5
|
interface RedisHashClient {
|
|
6
6
|
hGetAll(key: string): Promise<Record<string, string>>;
|
|
@@ -8,21 +8,38 @@ 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
|
+
/** Optional 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
16
|
}
|
|
17
17
|
|
|
18
|
+
interface BackendPluginContext {
|
|
19
|
+
readonly client: RedisHashClient;
|
|
20
|
+
readonly roomKeyPrefix: string;
|
|
21
|
+
registerService<T>(key: ServiceKey<T>, service: NoInfer<T>): void;
|
|
22
|
+
addDisposer(dispose: () => void): void;
|
|
23
|
+
}
|
|
24
|
+
interface BackendSyncPlugin {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly keyPrefix: string;
|
|
27
|
+
readonly scripts?: Readonly<Record<string, string>>;
|
|
28
|
+
start(context: BackendPluginContext): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
interface RedisHubBackendOptions {
|
|
19
32
|
keyPrefix?: string;
|
|
33
|
+
plugins?: readonly BackendSyncPlugin[];
|
|
20
34
|
}
|
|
21
35
|
declare class RedisHubBackend implements HubBackend {
|
|
22
36
|
readonly sharedAcrossInstances = true;
|
|
23
37
|
private readonly client;
|
|
24
38
|
private readonly keyPrefix;
|
|
39
|
+
private readonly services;
|
|
40
|
+
private readonly pluginDisposers;
|
|
25
41
|
constructor(client: RedisHashClient, options?: RedisHubBackendOptions);
|
|
42
|
+
getService<T>(key: ServiceKey<T>): T | undefined;
|
|
26
43
|
private key;
|
|
27
44
|
private layersKey;
|
|
28
45
|
snapshot(room: string): Promise<CanvasElement[]>;
|
|
@@ -31,13 +48,8 @@ declare class RedisHubBackend implements HubBackend {
|
|
|
31
48
|
layerRecords(room: string): Promise<LayerRecord[]>;
|
|
32
49
|
getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
|
|
33
50
|
applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
fogSnapshot(room: string): Promise<FogSnapshot | undefined>;
|
|
37
|
-
applyFogMeta(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>>;
|
|
38
|
-
applyFogTile(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>>;
|
|
39
|
-
applyFogPatch(room: string, records: readonly FogTileRecord[]): Promise<FogPatchApplyResult>;
|
|
40
|
-
private evalFog;
|
|
51
|
+
dispose(): void;
|
|
52
|
+
private installPlugins;
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
interface RedisPublisher {
|
|
@@ -63,27 +75,4 @@ declare class RedisHubFanout implements HubFanout {
|
|
|
63
75
|
subscribe(handler: (payload: string) => void): () => void;
|
|
64
76
|
}
|
|
65
77
|
|
|
66
|
-
|
|
67
|
-
accepted: SyncOp | null;
|
|
68
|
-
corrections: SyncOp[];
|
|
69
|
-
broadcast?: SyncOp[];
|
|
70
|
-
locality?: 'shared' | 'local';
|
|
71
|
-
}
|
|
72
|
-
interface BackendOpContext {
|
|
73
|
-
readonly room: string;
|
|
74
|
-
}
|
|
75
|
-
interface PluginSnapshot {
|
|
76
|
-
readonly pluginName: string;
|
|
77
|
-
readonly version: number;
|
|
78
|
-
readonly data: unknown;
|
|
79
|
-
}
|
|
80
|
-
interface BackendSyncPlugin {
|
|
81
|
-
readonly name: string;
|
|
82
|
-
readonly keyPrefix: string;
|
|
83
|
-
readonly scripts?: Record<string, string>;
|
|
84
|
-
snapshot?(room: string): Promise<PluginSnapshot>;
|
|
85
|
-
applyOp?(op: SyncOp, ctx: BackendOpContext): Promise<ApplyResult>;
|
|
86
|
-
dispose?(): void;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export { type ApplyResult, type BackendOpContext, type BackendSyncPlugin, type PluginSnapshot, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber };
|
|
78
|
+
export { type BackendPluginContext, type BackendSyncPlugin, type RedisHashClient, RedisHubBackend, type RedisHubBackendOptions, RedisHubFanout, type RedisHubFanoutOptions, type RedisPublisher, type RedisSubscriber };
|
package/dist/index.js
CHANGED
|
@@ -1,26 +1,21 @@
|
|
|
1
1
|
// src/redis-hub-backend.ts
|
|
2
2
|
import {
|
|
3
3
|
isValidElement,
|
|
4
|
-
isValidLayerRecord
|
|
5
|
-
isValidFogMetaRecord,
|
|
6
|
-
isValidFogTileRecord,
|
|
7
|
-
isValidFogSnapshot
|
|
4
|
+
isValidLayerRecord
|
|
8
5
|
} from "@fieldnotes/sync";
|
|
9
|
-
import {
|
|
10
|
-
canonicalizeFogTile,
|
|
11
|
-
tileIntersectsDefinition,
|
|
12
|
-
parseFogRedisMetaResult,
|
|
13
|
-
parseFogRedisPatchResult,
|
|
14
|
-
FOG_META_LWW_SCRIPT,
|
|
15
|
-
FOG_PATCH_LWW_SCRIPT
|
|
16
|
-
} from "@fieldnotes/vtt";
|
|
17
6
|
var RedisHubBackend = class {
|
|
18
7
|
sharedAcrossInstances = true;
|
|
19
8
|
client;
|
|
20
9
|
keyPrefix;
|
|
10
|
+
services = /* @__PURE__ */ new Map();
|
|
11
|
+
pluginDisposers = [];
|
|
21
12
|
constructor(client, options = {}) {
|
|
22
13
|
this.client = client;
|
|
23
14
|
this.keyPrefix = options.keyPrefix ?? "fieldnotes:room:";
|
|
15
|
+
this.installPlugins(options.plugins ?? []);
|
|
16
|
+
}
|
|
17
|
+
getService(key) {
|
|
18
|
+
return this.services.get(key.id);
|
|
24
19
|
}
|
|
25
20
|
key(room) {
|
|
26
21
|
return `${this.keyPrefix}${room}`;
|
|
@@ -32,13 +27,11 @@ var RedisHubBackend = class {
|
|
|
32
27
|
const map = await this.client.hGetAll(this.key(room));
|
|
33
28
|
const out = [];
|
|
34
29
|
for (const value of Object.values(map)) {
|
|
35
|
-
let parsed;
|
|
36
30
|
try {
|
|
37
|
-
parsed = JSON.parse(value);
|
|
31
|
+
const parsed = JSON.parse(value);
|
|
32
|
+
if (isValidElement(parsed)) out.push(parsed);
|
|
38
33
|
} catch {
|
|
39
|
-
continue;
|
|
40
34
|
}
|
|
41
|
-
if (isValidElement(parsed)) out.push(parsed);
|
|
42
35
|
}
|
|
43
36
|
return out;
|
|
44
37
|
}
|
|
@@ -63,13 +56,11 @@ var RedisHubBackend = class {
|
|
|
63
56
|
const map = await this.client.hGetAll(this.layersKey(room));
|
|
64
57
|
const out = [];
|
|
65
58
|
for (const value of Object.values(map)) {
|
|
66
|
-
let parsed;
|
|
67
59
|
try {
|
|
68
|
-
parsed = JSON.parse(value);
|
|
60
|
+
const parsed = JSON.parse(value);
|
|
61
|
+
if (isValidLayerRecord(parsed)) out.push(parsed);
|
|
69
62
|
} catch {
|
|
70
|
-
continue;
|
|
71
63
|
}
|
|
72
|
-
if (isValidLayerRecord(parsed)) out.push(parsed);
|
|
73
64
|
}
|
|
74
65
|
return out;
|
|
75
66
|
}
|
|
@@ -86,167 +77,60 @@ var RedisHubBackend = class {
|
|
|
86
77
|
async applyLayerRecord(room, record) {
|
|
87
78
|
await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));
|
|
88
79
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return `${this.keyPrefix}${room}:fog:tiles`;
|
|
80
|
+
dispose() {
|
|
81
|
+
for (const dispose of [...this.pluginDisposers].reverse()) safelyDispose(dispose);
|
|
82
|
+
this.pluginDisposers.length = 0;
|
|
83
|
+
this.services.clear();
|
|
94
84
|
}
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
let meta;
|
|
85
|
+
installPlugins(plugins) {
|
|
86
|
+
const names = /* @__PURE__ */ new Set();
|
|
87
|
+
const prefixes = /* @__PURE__ */ new Set();
|
|
99
88
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!meta.definition) return { meta, tiles: [] };
|
|
106
|
-
const definition = meta.definition;
|
|
107
|
-
if (!definition) return { meta, tiles: [] };
|
|
108
|
-
const tileMap = await this.client.hGetAll(this.fogTilesKey(room));
|
|
109
|
-
const tiles = [];
|
|
110
|
-
const seen = /* @__PURE__ */ new Set();
|
|
111
|
-
for (const value of Object.values(tileMap)) {
|
|
112
|
-
let parsed;
|
|
113
|
-
try {
|
|
114
|
-
parsed = JSON.parse(value);
|
|
115
|
-
} catch {
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (!isValidFogTileRecord(parsed)) continue;
|
|
119
|
-
if (parsed.generation !== definition.generation) continue;
|
|
120
|
-
if (!tileIntersectsDefinition(parsed.x, parsed.y, definition)) continue;
|
|
121
|
-
if (!isValidFogSnapshot({ meta, tiles: [parsed] })) continue;
|
|
122
|
-
const key = `${parsed.x},${parsed.y}`;
|
|
123
|
-
if (seen.has(key)) continue;
|
|
124
|
-
seen.add(key);
|
|
125
|
-
tiles.push(parsed);
|
|
126
|
-
if (tiles.length === 256) break;
|
|
127
|
-
}
|
|
128
|
-
const snapshot = { meta, tiles };
|
|
129
|
-
return isValidFogSnapshot(snapshot) ? snapshot : void 0;
|
|
130
|
-
}
|
|
131
|
-
async applyFogMeta(room, record) {
|
|
132
|
-
for (let attempt = 0; attempt < 4; attempt++) {
|
|
133
|
-
const metaKey = this.fogMetaKey(room);
|
|
134
|
-
const tilesKey = this.fogTilesKey(room);
|
|
135
|
-
const expectedMeta = await this.client.hGet(metaKey, "current");
|
|
136
|
-
const expectedTiles = await this.client.hGetAll(tilesKey);
|
|
137
|
-
const replacements = [];
|
|
138
|
-
let current;
|
|
139
|
-
if (expectedMeta !== null) {
|
|
140
|
-
try {
|
|
141
|
-
const parsed = JSON.parse(expectedMeta);
|
|
142
|
-
if (isValidFogMetaRecord(parsed)) current = parsed;
|
|
143
|
-
} catch {
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
if (current?.definition && record.definition && current.definition.generation === record.definition.generation) {
|
|
147
|
-
for (const raw of Object.values(expectedTiles)) {
|
|
148
|
-
let tile;
|
|
149
|
-
try {
|
|
150
|
-
tile = JSON.parse(raw);
|
|
151
|
-
} catch {
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (!isValidFogSnapshot({ meta: current, tiles: [tile] })) continue;
|
|
155
|
-
const valid = tile;
|
|
156
|
-
if (!tileIntersectsDefinition(valid.x, valid.y, record.definition)) continue;
|
|
157
|
-
if (valid.data === void 0) {
|
|
158
|
-
replacements.push(valid);
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
const canonical = canonicalizeFogTile(
|
|
162
|
-
{ x: valid.x, y: valid.y, data: valid.data },
|
|
163
|
-
record.definition
|
|
164
|
-
);
|
|
165
|
-
if (canonical) replacements.push({ ...valid, data: canonical.data });
|
|
89
|
+
for (const plugin of plugins) {
|
|
90
|
+
if (names.has(plugin.name))
|
|
91
|
+
throw new Error(`Backend plugin "${plugin.name}" is duplicated`);
|
|
92
|
+
if (prefixes.has(plugin.keyPrefix)) {
|
|
93
|
+
throw new Error(`Backend plugin key prefix "${plugin.keyPrefix}" is duplicated`);
|
|
166
94
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
JSON.stringify(replacements)
|
|
172
|
-
]);
|
|
173
|
-
if (Array.isArray(result) && result[0] === 2) continue;
|
|
174
|
-
return parseFogRedisMetaResult(result, isValidFogMetaRecord);
|
|
175
|
-
}
|
|
176
|
-
throw new Error("Redis fog meta update did not converge after concurrent writes");
|
|
177
|
-
}
|
|
178
|
-
async applyFogTile(room, record) {
|
|
179
|
-
const result = await this.applyFogPatch(room, [record]);
|
|
180
|
-
if (result.accepted.length === 1) return { accepted: true };
|
|
181
|
-
return { accepted: false, correction: result.corrections[0] };
|
|
182
|
-
}
|
|
183
|
-
async applyFogPatch(room, records) {
|
|
184
|
-
for (let attempt = 0; attempt < 4; attempt++) {
|
|
185
|
-
const metaRaw = await this.client.hGet(this.fogMetaKey(room), "current");
|
|
186
|
-
if (metaRaw === null) return { accepted: [], corrections: [] };
|
|
187
|
-
let meta;
|
|
188
|
-
try {
|
|
189
|
-
meta = JSON.parse(metaRaw);
|
|
190
|
-
} catch {
|
|
191
|
-
return { accepted: [], corrections: [] };
|
|
192
|
-
}
|
|
193
|
-
if (!isValidFogMetaRecord(meta) || !meta.definition) {
|
|
194
|
-
return { accepted: [], corrections: [] };
|
|
195
|
-
}
|
|
196
|
-
const definition = meta.definition;
|
|
197
|
-
const stored = await this.client.hGetAll(this.fogTilesKey(room));
|
|
198
|
-
const invalidStored = {};
|
|
199
|
-
for (const [field, raw] of Object.entries(stored)) {
|
|
200
|
-
let parsed;
|
|
95
|
+
names.add(plugin.name);
|
|
96
|
+
prefixes.add(plugin.keyPrefix);
|
|
97
|
+
const localDisposers = [];
|
|
98
|
+
const serviceKeys = [];
|
|
201
99
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
if (records.some((tile) => !isValidFogSnapshot({ meta, tiles: [tile] }))) {
|
|
210
|
-
return {
|
|
211
|
-
accepted: [],
|
|
212
|
-
corrections: records.map((tile) => {
|
|
213
|
-
const raw = stored[`${tile.x},${tile.y}`];
|
|
214
|
-
if (raw) {
|
|
215
|
-
try {
|
|
216
|
-
const parsed = JSON.parse(raw);
|
|
217
|
-
if (isValidFogSnapshot({ meta, tiles: [parsed] })) return parsed;
|
|
218
|
-
} catch {
|
|
100
|
+
plugin.start({
|
|
101
|
+
client: this.client,
|
|
102
|
+
roomKeyPrefix: this.keyPrefix,
|
|
103
|
+
registerService: (key, service) => {
|
|
104
|
+
if (this.services.has(key.id)) {
|
|
105
|
+
throw new Error(`Backend service "${key.name}" is already registered`);
|
|
219
106
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
107
|
+
this.services.set(key.id, service);
|
|
108
|
+
serviceKeys.push(key.id);
|
|
109
|
+
},
|
|
110
|
+
addDisposer: (dispose) => localDisposers.push(dispose)
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
for (const dispose of localDisposers.reverse()) safelyDispose(dispose);
|
|
114
|
+
for (const key of serviceKeys) this.services.delete(key);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
this.pluginDisposers.push(() => {
|
|
118
|
+
for (const dispose of localDisposers.reverse()) safelyDispose(dispose);
|
|
119
|
+
for (const key of serviceKeys) this.services.delete(key);
|
|
120
|
+
});
|
|
230
121
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
]);
|
|
235
|
-
if (Array.isArray(result) && result[0] === 2) continue;
|
|
236
|
-
return parseFogRedisPatchResult(result);
|
|
237
|
-
}
|
|
238
|
-
throw new Error("Redis fog patch did not converge after concurrent definition writes");
|
|
239
|
-
}
|
|
240
|
-
async evalFog(script, room, record, extraArguments = []) {
|
|
241
|
-
if (!this.client.eval) {
|
|
242
|
-
throw new Error("Redis fog persistence requires a Redis client with EVAL support");
|
|
122
|
+
} catch (error) {
|
|
123
|
+
this.dispose();
|
|
124
|
+
throw error;
|
|
243
125
|
}
|
|
244
|
-
return this.client.eval(script, {
|
|
245
|
-
keys: [this.fogMetaKey(room), this.fogTilesKey(room)],
|
|
246
|
-
arguments: [JSON.stringify(record), ...extraArguments]
|
|
247
|
-
});
|
|
248
126
|
}
|
|
249
127
|
};
|
|
128
|
+
function safelyDispose(dispose) {
|
|
129
|
+
try {
|
|
130
|
+
dispose();
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
}
|
|
250
134
|
|
|
251
135
|
// src/redis-hub-fanout.ts
|
|
252
136
|
var RedisHubFanout = class {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/redis-hub-backend.ts","../src/redis-hub-fanout.ts"],"sourcesContent":["import {\n isValidElement,\n isValidLayerRecord,\n isValidFogMetaRecord,\n isValidFogTileRecord,\n isValidFogSnapshot,\n type LayerRecord,\n type SyncOp,\n type FogSnapshot,\n type FogMetaRecord,\n type FogTileRecord,\n} from '@fieldnotes/sync';\nimport {\n canonicalizeFogTile,\n tileIntersectsDefinition,\n parseFogRedisMetaResult,\n parseFogRedisPatchResult,\n FOG_META_LWW_SCRIPT,\n FOG_PATCH_LWW_SCRIPT,\n} from '@fieldnotes/vtt';\nimport type { CanvasElement } from '@fieldnotes/core';\nimport type { FogApplyResult, FogPatchApplyResult, HubBackend } from '@fieldnotes/sync-server';\nimport type { RedisHashClient } from './redis-hash-client';\n\nexport interface RedisHubBackendOptions {\n keyPrefix?: string; // default 'fieldnotes:room:'\n}\n\nexport class RedisHubBackend implements HubBackend {\n readonly sharedAcrossInstances = true;\n private readonly client: RedisHashClient;\n private readonly keyPrefix: string;\n\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\n this.client = client;\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\n }\n\n private key(room: string): string {\n return `${this.keyPrefix}${room}`;\n }\n\n private layersKey(room: string): string {\n return `${this.keyPrefix}${room}:layers`;\n }\n\n async snapshot(room: string): Promise<CanvasElement[]> {\n const map = await this.client.hGetAll(this.key(room));\n const out: CanvasElement[] = [];\n for (const value of Object.values(map)) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(value);\n } catch {\n continue; // skip a corrupt stored value rather than throwing the whole snapshot\n }\n if (isValidElement(parsed)) out.push(parsed);\n }\n return out;\n }\n\n async get(room: string, id: string): Promise<CanvasElement | 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 isValidElement(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async apply(room: string, op: SyncOp): 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 // 'clear' deletes elements only; the layer ledger is a separate hash and survives.\n else if (op.kind === 'clear') await this.client.del(key);\n // request-snapshot/snapshot never reach apply (the hub only applies data ops)\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 let parsed: unknown;\n try {\n parsed = JSON.parse(value);\n } catch {\n continue; // skip a corrupt stored value rather than throwing the whole ledger\n }\n if (isValidLayerRecord(parsed)) out.push(parsed);\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 private fogMetaKey(room: string): string {\n return `${this.keyPrefix}${room}:fog:meta`;\n }\n\n private fogTilesKey(room: string): string {\n return `${this.keyPrefix}${room}:fog:tiles`;\n }\n\n async fogSnapshot(room: string): Promise<FogSnapshot | undefined> {\n const metaStr = await this.client.hGet(this.fogMetaKey(room), 'current');\n if (metaStr == null) return undefined;\n let meta: unknown;\n try {\n meta = JSON.parse(metaStr);\n } catch {\n return undefined;\n }\n if (!isValidFogMetaRecord(meta)) return undefined;\n\n if (!(meta as FogMetaRecord).definition) return { meta: meta as FogMetaRecord, tiles: [] };\n\n const definition = (meta as FogMetaRecord).definition;\n if (!definition) return { meta: meta as FogMetaRecord, tiles: [] };\n const tileMap = await this.client.hGetAll(this.fogTilesKey(room));\n const tiles: FogTileRecord[] = [];\n const seen = new Set<string>();\n for (const value of Object.values(tileMap)) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(value);\n } catch {\n continue;\n }\n if (!isValidFogTileRecord(parsed)) continue;\n if (parsed.generation !== definition.generation) continue;\n if (!tileIntersectsDefinition(parsed.x, parsed.y, definition)) continue;\n if (!isValidFogSnapshot({ meta: meta as FogMetaRecord, tiles: [parsed] })) continue;\n const key = `${parsed.x},${parsed.y}`;\n if (seen.has(key)) continue;\n seen.add(key);\n tiles.push(parsed);\n if (tiles.length === 256) break;\n }\n\n const snapshot = { meta: meta as FogMetaRecord, tiles };\n return isValidFogSnapshot(snapshot) ? snapshot : undefined;\n }\n\n async applyFogMeta(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>> {\n for (let attempt = 0; attempt < 4; attempt++) {\n const metaKey = this.fogMetaKey(room);\n const tilesKey = this.fogTilesKey(room);\n const expectedMeta = await this.client.hGet(metaKey, 'current');\n const expectedTiles = await this.client.hGetAll(tilesKey);\n const replacements: FogTileRecord[] = [];\n let current: FogMetaRecord | undefined;\n if (expectedMeta !== null) {\n try {\n const parsed: unknown = JSON.parse(expectedMeta);\n if (isValidFogMetaRecord(parsed)) current = parsed;\n } catch {\n // Corrupt state is atomically replaced by a valid winning record.\n }\n }\n if (\n current?.definition &&\n record.definition &&\n current.definition.generation === record.definition.generation\n ) {\n for (const raw of Object.values(expectedTiles)) {\n let tile: unknown;\n try {\n tile = JSON.parse(raw);\n } catch {\n continue;\n }\n if (!isValidFogSnapshot({ meta: current, tiles: [tile] })) continue;\n const valid = tile as FogTileRecord;\n if (!tileIntersectsDefinition(valid.x, valid.y, record.definition)) continue;\n if (valid.data === undefined) {\n replacements.push(valid);\n continue;\n }\n const canonical = canonicalizeFogTile(\n { x: valid.x, y: valid.y, data: valid.data },\n record.definition,\n );\n if (canonical) replacements.push({ ...valid, data: canonical.data });\n }\n }\n const result = await this.evalFog(FOG_META_LWW_SCRIPT, room, record, [\n expectedMeta ?? '',\n JSON.stringify(expectedTiles),\n JSON.stringify(replacements),\n ]);\n if (Array.isArray(result) && result[0] === 2) continue;\n return parseFogRedisMetaResult(result, isValidFogMetaRecord);\n }\n throw new Error('Redis fog meta update did not converge after concurrent writes');\n }\n\n async applyFogTile(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>> {\n const result = await this.applyFogPatch(room, [record]);\n if (result.accepted.length === 1) return { accepted: true };\n return { accepted: false, correction: result.corrections[0] };\n }\n\n async applyFogPatch(\n room: string,\n records: readonly FogTileRecord[],\n ): Promise<FogPatchApplyResult> {\n for (let attempt = 0; attempt < 4; attempt++) {\n const metaRaw = await this.client.hGet(this.fogMetaKey(room), 'current');\n if (metaRaw === null) return { accepted: [], corrections: [] };\n let meta: unknown;\n try {\n meta = JSON.parse(metaRaw);\n } catch {\n return { accepted: [], corrections: [] };\n }\n if (!isValidFogMetaRecord(meta) || !meta.definition) {\n return { accepted: [], corrections: [] };\n }\n const definition = meta.definition;\n const stored = await this.client.hGetAll(this.fogTilesKey(room));\n const invalidStored: Record<string, string> = {};\n for (const [field, raw] of Object.entries(stored)) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n invalidStored[field] = raw;\n continue;\n }\n if (!isValidFogSnapshot({ meta, tiles: [parsed] })) invalidStored[field] = raw;\n }\n if (records.some((tile) => !isValidFogSnapshot({ meta, tiles: [tile] }))) {\n return {\n accepted: [],\n corrections: records.map((tile) => {\n const raw = stored[`${tile.x},${tile.y}`];\n if (raw) {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (isValidFogSnapshot({ meta, tiles: [parsed] })) return parsed as FogTileRecord;\n } catch {\n // Fall through to an authoritative tombstone.\n }\n }\n return {\n generation: definition.generation,\n x: tile.x,\n y: tile.y,\n version: 1,\n editor: 'hub',\n };\n }),\n };\n }\n const result = await this.evalFog(FOG_PATCH_LWW_SCRIPT, room, records, [\n metaRaw,\n JSON.stringify(invalidStored),\n ]);\n if (Array.isArray(result) && result[0] === 2) continue;\n return parseFogRedisPatchResult(result);\n }\n throw new Error('Redis fog patch did not converge after concurrent definition writes');\n }\n\n private async evalFog(\n script: string,\n room: string,\n record: object,\n extraArguments: string[] = [],\n ): Promise<unknown> {\n if (!this.client.eval) {\n throw new Error('Redis fog persistence requires a Redis client with EVAL support');\n }\n return this.client.eval(script, {\n keys: [this.fogMetaKey(room), this.fogTilesKey(room)],\n arguments: [JSON.stringify(record), ...extraArguments],\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":[]}
|
|
1
|
+
{"version":3,"sources":["../src/redis-hub-backend.ts","../src/redis-hub-fanout.ts"],"sourcesContent":["import type { CanvasElement, ServiceKey } from '@fieldnotes/core';\nimport {\n isValidElement,\n isValidLayerRecord,\n type LayerRecord,\n type SyncOp,\n} from '@fieldnotes/sync';\nimport type { HubBackend } from '@fieldnotes/sync-server';\nimport type { RedisHashClient } from './redis-hash-client';\nimport type { BackendSyncPlugin } from './sync-plugin';\n\nexport interface RedisHubBackendOptions {\n keyPrefix?: string;\n plugins?: readonly BackendSyncPlugin[];\n}\n\nexport class RedisHubBackend implements HubBackend {\n readonly sharedAcrossInstances = true;\n private readonly client: RedisHashClient;\n private readonly keyPrefix: string;\n private readonly services = new Map<symbol, unknown>();\n private readonly pluginDisposers: (() => void)[] = [];\n\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\n this.client = client;\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\n this.installPlugins(options.plugins ?? []);\n }\n\n getService<T>(key: ServiceKey<T>): T | undefined {\n return this.services.get(key.id) as T | undefined;\n }\n\n private key(room: string): string {\n return `${this.keyPrefix}${room}`;\n }\n\n private layersKey(room: string): string {\n return `${this.keyPrefix}${room}:layers`;\n }\n\n async snapshot(room: string): Promise<CanvasElement[]> {\n const map = await this.client.hGetAll(this.key(room));\n const out: CanvasElement[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidElement(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<CanvasElement | 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 isValidElement(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async apply(room: string, op: SyncOp): Promise<void> {\n const key = this.key(room);\n if (op.kind === 'upsert')\n await this.client.hSet(key, op.element.id, JSON.stringify(op.element));\n else if (op.kind === 'remove') await this.client.hDel(key, op.id);\n else if (op.kind === 'clear') await this.client.del(key);\n }\n\n async layerRecords(room: string): Promise<LayerRecord[]> {\n const map = await this.client.hGetAll(this.layersKey(room));\n const out: LayerRecord[] = [];\n for (const value of Object.values(map)) {\n try {\n const parsed: unknown = JSON.parse(value);\n if (isValidLayerRecord(parsed)) out.push(parsed);\n } catch {\n // Corrupt fields are isolated from the rest of the layer ledger.\n }\n }\n return out;\n }\n\n async getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined> {\n const value = await this.client.hGet(this.layersKey(room), id);\n if (value == null) return undefined;\n try {\n const parsed: unknown = JSON.parse(value);\n return isValidLayerRecord(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n }\n\n async applyLayerRecord(room: string, record: LayerRecord): Promise<void> {\n await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));\n }\n\n dispose(): void {\n for (const dispose of [...this.pluginDisposers].reverse()) safelyDispose(dispose);\n this.pluginDisposers.length = 0;\n this.services.clear();\n }\n\n private installPlugins(plugins: readonly BackendSyncPlugin[]): void {\n const names = new Set<string>();\n const prefixes = new Set<string>();\n try {\n for (const plugin of plugins) {\n if (names.has(plugin.name))\n throw new Error(`Backend plugin \"${plugin.name}\" is duplicated`);\n if (prefixes.has(plugin.keyPrefix)) {\n throw new Error(`Backend plugin key prefix \"${plugin.keyPrefix}\" is duplicated`);\n }\n names.add(plugin.name);\n prefixes.add(plugin.keyPrefix);\n const localDisposers: (() => void)[] = [];\n const serviceKeys: symbol[] = [];\n try {\n plugin.start({\n client: this.client,\n roomKeyPrefix: this.keyPrefix,\n registerService: (key, service) => {\n if (this.services.has(key.id)) {\n throw new Error(`Backend service \"${key.name}\" is already registered`);\n }\n this.services.set(key.id, service);\n serviceKeys.push(key.id);\n },\n addDisposer: (dispose) => localDisposers.push(dispose),\n });\n } catch (error) {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n throw error;\n }\n this.pluginDisposers.push(() => {\n for (const dispose of localDisposers.reverse()) safelyDispose(dispose);\n for (const key of serviceKeys) this.services.delete(key);\n });\n }\n } catch (error) {\n this.dispose();\n throw error;\n }\n }\n}\n\nfunction safelyDispose(dispose: () => void): void {\n try {\n dispose();\n } catch {\n // Continue rolling back later resources.\n }\n}\n","import type { HubFanout } from '@fieldnotes/sync-server';\nimport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\n\nexport interface RedisHubFanoutOptions {\n channel?: string;\n onError?: (err: unknown) => void;\n}\n\nexport class RedisHubFanout implements HubFanout {\n private readonly publisher: RedisPublisher;\n private readonly subscriber: RedisSubscriber;\n private readonly channel: string;\n private readonly onError: (err: unknown) => void;\n private readonly handlers = new Set<(payload: string) => void>();\n private subscribed = false;\n\n constructor(\n publisher: RedisPublisher,\n subscriber: RedisSubscriber,\n options: RedisHubFanoutOptions = {},\n ) {\n this.publisher = publisher;\n this.subscriber = subscriber;\n this.channel = options.channel ?? 'fieldnotes:fanout';\n this.onError = options.onError ?? (() => undefined);\n }\n\n async publish(payload: string): Promise<void> {\n try {\n await this.publisher.publish(this.channel, payload);\n } catch (error) {\n try {\n this.onError(error);\n } catch {\n /* preserve the publication failure even when the observer throws */\n }\n throw error;\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n if (!this.subscribed) {\n this.subscribed = true;\n Promise.resolve(\n this.subscriber.subscribe(this.channel, (message) => {\n for (const h of this.handlers) {\n try {\n h(message);\n } catch {\n /* isolate handlers */\n }\n }\n }),\n ).catch(this.onError);\n }\n return () => this.handlers.delete(handler);\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fieldnotes/sync-redis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Redis-backed HubBackend for Field Notes real-time sync relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -36,15 +36,18 @@
|
|
|
36
36
|
"fieldnotes"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@fieldnotes/sync": "0.
|
|
40
|
-
|
|
39
|
+
"@fieldnotes/sync": "0.18.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@fieldnotes/core": ">=0.81.0 <1.0.0"
|
|
41
43
|
},
|
|
42
44
|
"devDependencies": {
|
|
43
45
|
"@vitest/coverage-v8": "^4.1.0",
|
|
44
46
|
"tsup": "^8.5.1",
|
|
45
47
|
"vitest": "^4.1.0",
|
|
46
|
-
"@fieldnotes/
|
|
47
|
-
"@fieldnotes/core": "0.
|
|
48
|
+
"@fieldnotes/vtt": "0.7.0",
|
|
49
|
+
"@fieldnotes/core": "0.81.0",
|
|
50
|
+
"@fieldnotes/sync-server": "0.17.0"
|
|
48
51
|
},
|
|
49
52
|
"scripts": {
|
|
50
53
|
"build": "tsup",
|