@fieldnotes/sync-redis 0.4.0 → 0.5.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/dist/index.cjs CHANGED
@@ -27,7 +27,9 @@ 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_core = require("@fieldnotes/core");
30
31
  var RedisHubBackend = class {
32
+ sharedAcrossInstances = true;
31
33
  client;
32
34
  keyPrefix;
33
35
  constructor(client, options = {}) {
@@ -98,7 +100,390 @@ var RedisHubBackend = class {
98
100
  async applyLayerRecord(room, record) {
99
101
  await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));
100
102
  }
103
+ fogMetaKey(room) {
104
+ return `${this.keyPrefix}${room}:fog:meta`;
105
+ }
106
+ fogTilesKey(room) {
107
+ return `${this.keyPrefix}${room}:fog:tiles`;
108
+ }
109
+ async fogSnapshot(room) {
110
+ const metaStr = await this.client.hGet(this.fogMetaKey(room), "current");
111
+ if (metaStr == null) return void 0;
112
+ let meta;
113
+ try {
114
+ meta = JSON.parse(metaStr);
115
+ } catch {
116
+ return void 0;
117
+ }
118
+ if (!(0, import_sync.isValidFogMetaRecord)(meta)) return void 0;
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 (!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 (!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_core.canonicalizeFogTile)(
176
+ { x: valid.x, y: valid.y, data: valid.data },
177
+ record.definition
178
+ );
179
+ if (canonical) replacements.push({ ...valid, data: canonical.data });
180
+ }
181
+ }
182
+ const result = await this.evalFog(FOG_META_LWW_SCRIPT, room, record, [
183
+ expectedMeta ?? "",
184
+ JSON.stringify(expectedTiles),
185
+ JSON.stringify(replacements)
186
+ ]);
187
+ if (Array.isArray(result) && result[0] === 2) continue;
188
+ return parseApplyResult(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;
215
+ try {
216
+ parsed = JSON.parse(raw);
217
+ } catch {
218
+ invalidStored[field] = raw;
219
+ continue;
220
+ }
221
+ if (!(0, import_sync.isValidFogSnapshot)({ meta, tiles: [parsed] })) invalidStored[field] = raw;
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 {
233
+ }
234
+ }
235
+ return {
236
+ generation: definition.generation,
237
+ x: tile.x,
238
+ y: tile.y,
239
+ version: 1,
240
+ editor: "hub"
241
+ };
242
+ })
243
+ };
244
+ }
245
+ const result = await this.evalFog(FOG_PATCH_LWW_SCRIPT, room, records, [
246
+ metaRaw,
247
+ JSON.stringify(invalidStored)
248
+ ]);
249
+ if (Array.isArray(result) && result[0] === 2) continue;
250
+ return parsePatchApplyResult(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");
257
+ }
258
+ return this.client.eval(script, {
259
+ keys: [this.fogMetaKey(room), this.fogTilesKey(room)],
260
+ arguments: [JSON.stringify(record), ...extraArguments]
261
+ });
262
+ }
101
263
  };
264
+ function tileIntersectsDefinition(x, y, definition) {
265
+ const tileWorldSize = 128 * definition.cellSize;
266
+ const tileWorldX = x * tileWorldSize;
267
+ const tileWorldY = y * tileWorldSize;
268
+ return !(tileWorldX + tileWorldSize <= definition.bounds.x || tileWorldY + tileWorldSize <= definition.bounds.y || tileWorldX >= definition.bounds.x + definition.bounds.w || tileWorldY >= definition.bounds.y + definition.bounds.h);
269
+ }
270
+ function parseApplyResult(raw, guard) {
271
+ if (!Array.isArray(raw) || raw[0] !== 0 && raw[0] !== 1) {
272
+ throw new Error("Redis returned an invalid fog apply result");
273
+ }
274
+ if (raw[0] === 1) return { accepted: true };
275
+ if (typeof raw[1] !== "string" || raw[1].length === 0) return { accepted: false };
276
+ let correction;
277
+ try {
278
+ correction = JSON.parse(raw[1]);
279
+ } catch {
280
+ throw new Error("Redis returned an invalid fog correction");
281
+ }
282
+ if (!guard(correction)) throw new Error("Redis returned an invalid fog correction");
283
+ return { accepted: false, correction };
284
+ }
285
+ function parsePatchApplyResult(raw) {
286
+ if (!Array.isArray(raw) || typeof raw[0] !== "number") {
287
+ throw new Error("Redis returned an invalid fog patch result");
288
+ }
289
+ const acceptedCount = raw[0];
290
+ if (!Number.isSafeInteger(acceptedCount) || acceptedCount < 0) {
291
+ throw new Error("Redis returned an invalid fog patch result");
292
+ }
293
+ const accepted = [];
294
+ let cursor = 1;
295
+ for (let i = 0; i < acceptedCount; i++, cursor++) {
296
+ const parsed = parseFogTileResultRecord(raw[cursor]);
297
+ accepted.push(parsed);
298
+ }
299
+ const correctionCount = raw[cursor++];
300
+ if (!Number.isSafeInteger(correctionCount) || correctionCount < 0) {
301
+ throw new Error("Redis returned an invalid fog patch result");
302
+ }
303
+ const corrections = [];
304
+ for (let i = 0; i < correctionCount; i++, cursor++) {
305
+ corrections.push(parseFogTileResultRecord(raw[cursor]));
306
+ }
307
+ if (cursor !== raw.length) throw new Error("Redis returned an invalid fog patch result");
308
+ return { accepted, corrections };
309
+ }
310
+ function parseFogTileResultRecord(raw) {
311
+ if (typeof raw !== "string") throw new Error("Redis returned an invalid fog patch record");
312
+ let parsed;
313
+ try {
314
+ parsed = JSON.parse(raw);
315
+ } catch {
316
+ throw new Error("Redis returned an invalid fog patch record");
317
+ }
318
+ if (!(0, import_sync.isValidFogTileRecord)(parsed)) throw new Error("Redis returned an invalid fog patch record");
319
+ return parsed;
320
+ }
321
+ var FOG_META_LWW_SCRIPT = `
322
+ local incomingRaw = ARGV[1]
323
+ local incoming = cjson.decode(incomingRaw)
324
+ local currentRaw = redis.call('HGET', KEYS[1], 'current')
325
+ local expectedMetaRaw = ARGV[2]
326
+ if (currentRaw or '') ~= expectedMetaRaw then return {2} end
327
+ local expectedTiles = cjson.decode(ARGV[3])
328
+ local expectedTileCount = 0
329
+ for field, raw in pairs(expectedTiles) do
330
+ expectedTileCount = expectedTileCount + 1
331
+ if redis.call('HGET', KEYS[2], field) ~= raw then return {2} end
332
+ end
333
+ if redis.call('HLEN', KEYS[2]) ~= expectedTileCount then return {2} end
334
+ local replacementTiles = cjson.decode(ARGV[4])
335
+ local function ascii(value)
336
+ if type(value) ~= 'string' or #value < 1 or #value > 128 then return false end
337
+ for i = 1, #value do
338
+ local b = string.byte(value, i)
339
+ if b < 32 or b > 126 then return false end
340
+ end
341
+ return true
342
+ end
343
+ local function integer(value)
344
+ return type(value) == 'number' and value >= 1 and value <= 9007199254740991
345
+ and value == math.floor(value)
346
+ end
347
+ local function validDef(def)
348
+ return type(def) == 'table' and def.version == 1 and ascii(def.generation)
349
+ and type(def.bounds) == 'table' and type(def.bounds.x) == 'number'
350
+ and type(def.bounds.y) == 'number' and type(def.bounds.w) == 'number' and def.bounds.w > 0
351
+ and type(def.bounds.h) == 'number' and def.bounds.h > 0 and type(def.cellSize) == 'number'
352
+ and def.cellSize > 0 and def.tileCells == 128 and (def.base == 'covered' or def.base == 'revealed')
353
+ end
354
+ local function validMeta(value)
355
+ return type(value) == 'table' and integer(value.version) and ascii(value.editor)
356
+ and (value.definition == nil or validDef(value.definition))
357
+ end
358
+ local current = nil
359
+ if currentRaw then
360
+ local ok, decoded = pcall(cjson.decode, currentRaw)
361
+ if ok and validMeta(decoded) then current = decoded else redis.call('HDEL', KEYS[1], 'current') end
362
+ end
363
+ local function newer(a, b)
364
+ return a.version > b.version or (a.version == b.version and a.editor > b.editor)
365
+ end
366
+ if current and not newer(incoming, current) then return {0, currentRaw} end
367
+ local oldDef = current and current.definition or nil
368
+ local newDef = incoming.definition
369
+ if oldDef and newDef and oldDef.generation == newDef.generation
370
+ and (oldDef.cellSize ~= newDef.cellSize or oldDef.tileCells ~= newDef.tileCells
371
+ or oldDef.base ~= newDef.base or newDef.bounds.x > oldDef.bounds.x
372
+ or newDef.bounds.y > oldDef.bounds.y
373
+ or newDef.bounds.x + newDef.bounds.w < oldDef.bounds.x + oldDef.bounds.w
374
+ or newDef.bounds.y + newDef.bounds.h < oldDef.bounds.y + oldDef.bounds.h) then
375
+ return {0, currentRaw}
376
+ end
377
+
378
+ redis.call('HSET', KEYS[1], 'current', incomingRaw)
379
+ redis.call('DEL', KEYS[2])
380
+ if newDef and oldDef and oldDef.generation == newDef.generation then
381
+ for i = 1, #replacementTiles do
382
+ local tile = replacementTiles[i]
383
+ redis.call('HSET', KEYS[2], tostring(tile.x) .. ',' .. tostring(tile.y), cjson.encode(tile))
384
+ end
385
+ end
386
+ return {1}
387
+ `;
388
+ var FOG_PATCH_LWW_SCRIPT = `
389
+ local incomingRaws = cjson.decode(ARGV[1])
390
+ local metaRaw = redis.call('HGET', KEYS[1], 'current')
391
+ if (metaRaw or '') ~= ARGV[2] then return {2} end
392
+ local invalidStored = cjson.decode(ARGV[3])
393
+ for field, raw in pairs(invalidStored) do
394
+ if redis.call('HGET', KEYS[2], field) == raw then redis.call('HDEL', KEYS[2], field) end
395
+ end
396
+ if not metaRaw then return {0, 0} end
397
+ local metaOk, meta = pcall(cjson.decode, metaRaw)
398
+ if not metaOk or type(meta) ~= 'table' or type(meta.definition) ~= 'table'
399
+ or type(meta.definition.generation) ~= 'string' then return {0, 0} end
400
+ local def = meta.definition
401
+ local tileSize = 128 * def.cellSize
402
+ local function intersects(tile)
403
+ local tx = tile.x * tileSize
404
+ local ty = tile.y * tileSize
405
+ return tx + tileSize > def.bounds.x and ty + tileSize > def.bounds.y
406
+ and tx < def.bounds.x + def.bounds.w and ty < def.bounds.y + def.bounds.h
407
+ end
408
+ local function newer(a, b)
409
+ return a.version > b.version or (a.version == b.version and a.editor > b.editor)
410
+ end
411
+ local function validTile(tile)
412
+ if type(tile) ~= 'table' then return false end
413
+ local function ascii(value)
414
+ if type(value) ~= 'string' or #value < 1 or #value > 128 then return false end
415
+ for i = 1, #value do
416
+ local b = string.byte(value, i)
417
+ if b < 32 or b > 126 then return false end
418
+ end
419
+ return true
420
+ end
421
+ local dataOk = tile.data == nil or (type(tile.data) == 'string' and #tile.data == 2732
422
+ and string.match(tile.data, '^[A-Za-z0-9+/]+[AEIMQUYcgkosw048]=$') ~= nil)
423
+ return ascii(tile.generation)
424
+ and type(tile.x) == 'number' and math.abs(tile.x) <= 9007199254740991
425
+ and tile.x == math.floor(tile.x)
426
+ and type(tile.y) == 'number' and math.abs(tile.y) <= 9007199254740991
427
+ and tile.y == math.floor(tile.y)
428
+ and type(tile.version) == 'number' and tile.version >= 1
429
+ and tile.version <= 9007199254740991 and tile.version == math.floor(tile.version)
430
+ and ascii(tile.editor) and dataOk
431
+ end
432
+ local stored = redis.call('HGETALL', KEYS[2])
433
+ local validCount = 0
434
+ for i = 1, #stored, 2 do
435
+ local ok, tile = pcall(cjson.decode, stored[i + 1])
436
+ if not ok or not validTile(tile) or stored[i] ~= tostring(tile.x) .. ',' .. tostring(tile.y)
437
+ or tile.generation ~= def.generation or not intersects(tile) then
438
+ redis.call('HDEL', KEYS[2], stored[i])
439
+ else
440
+ validCount = validCount + 1
441
+ end
442
+ end
443
+ local accepted = {}
444
+ local corrections = {}
445
+ local newCount = 0
446
+ local currents = {}
447
+ for i = 1, #incomingRaws do
448
+ local incomingRaw = cjson.encode(incomingRaws[i])
449
+ local incoming = incomingRaws[i]
450
+ local field = tostring(incoming.x) .. ',' .. tostring(incoming.y)
451
+ local currentRaw = redis.call('HGET', KEYS[2], field)
452
+ local current = nil
453
+ if currentRaw then
454
+ local ok, decoded = pcall(cjson.decode, currentRaw)
455
+ if ok and validTile(decoded) then current = decoded else currentRaw = nil end
456
+ end
457
+ currents[i] = currentRaw or false
458
+ if incoming.generation ~= def.generation or not intersects(incoming)
459
+ or (current and not newer(incoming, current)) then
460
+ corrections[#corrections + 1] = currentRaw or cjson.encode({generation=def.generation,
461
+ x=incoming.x, y=incoming.y, version=1, editor='hub'})
462
+ else
463
+ accepted[#accepted + 1] = incomingRaw
464
+ if not current then newCount = newCount + 1 end
465
+ end
466
+ end
467
+ if validCount + newCount > 256 then
468
+ accepted = {}
469
+ corrections = {}
470
+ for i = 1, #incomingRaws do
471
+ local incoming = incomingRaws[i]
472
+ corrections[#corrections + 1] = currents[i] or cjson.encode({generation=def.generation,
473
+ x=incoming.x, y=incoming.y, version=1, editor='hub'})
474
+ end
475
+ else
476
+ for i = 1, #accepted do
477
+ local record = cjson.decode(accepted[i])
478
+ redis.call('HSET', KEYS[2], tostring(record.x) .. ',' .. tostring(record.y), accepted[i])
479
+ end
480
+ end
481
+ local result = {#accepted}
482
+ for i = 1, #accepted do result[#result + 1] = accepted[i] end
483
+ result[#result + 1] = #corrections
484
+ for i = 1, #corrections do result[#result + 1] = corrections[i] end
485
+ return result
486
+ `;
102
487
 
103
488
  // src/redis-hub-fanout.ts
104
489
  var RedisHubFanout = class {
@@ -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';\n","import {\n isValidElement,\n isValidLayerRecord,\n type LayerRecord,\n type SyncOp,\n} from '@fieldnotes/sync';\nimport type { CanvasElement } from '@fieldnotes/core';\nimport type { 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 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","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';\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 { canonicalizeFogTile, 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 parseApplyResult(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 parsePatchApplyResult(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\nfunction tileIntersectsDefinition(\n x: number,\n y: number,\n definition: NonNullable<FogMetaRecord['definition']>,\n): boolean {\n const tileWorldSize = 128 * definition.cellSize;\n const tileWorldX = x * tileWorldSize;\n const tileWorldY = y * tileWorldSize;\n return !(\n tileWorldX + tileWorldSize <= definition.bounds.x ||\n tileWorldY + tileWorldSize <= definition.bounds.y ||\n tileWorldX >= definition.bounds.x + definition.bounds.w ||\n tileWorldY >= definition.bounds.y + definition.bounds.h\n );\n}\n\nfunction parseApplyResult<T>(\n raw: unknown,\n guard: (value: unknown) => value is T,\n): FogApplyResult<T> {\n if (!Array.isArray(raw) || (raw[0] !== 0 && raw[0] !== 1)) {\n throw new Error('Redis returned an invalid fog apply result');\n }\n if (raw[0] === 1) return { accepted: true };\n if (typeof raw[1] !== 'string' || raw[1].length === 0) return { accepted: false };\n let correction: unknown;\n try {\n correction = JSON.parse(raw[1]);\n } catch {\n throw new Error('Redis returned an invalid fog correction');\n }\n if (!guard(correction)) throw new Error('Redis returned an invalid fog correction');\n return { accepted: false, correction };\n}\n\nfunction parsePatchApplyResult(raw: unknown): FogPatchApplyResult {\n if (!Array.isArray(raw) || typeof raw[0] !== 'number') {\n throw new Error('Redis returned an invalid fog patch result');\n }\n const acceptedCount = raw[0];\n if (!Number.isSafeInteger(acceptedCount) || acceptedCount < 0) {\n throw new Error('Redis returned an invalid fog patch result');\n }\n const accepted: FogTileRecord[] = [];\n let cursor = 1;\n for (let i = 0; i < acceptedCount; i++, cursor++) {\n const parsed = parseFogTileResultRecord(raw[cursor]);\n accepted.push(parsed);\n }\n const correctionCount = raw[cursor++];\n if (!Number.isSafeInteger(correctionCount) || (correctionCount as number) < 0) {\n throw new Error('Redis returned an invalid fog patch result');\n }\n const corrections: FogTileRecord[] = [];\n for (let i = 0; i < (correctionCount as number); i++, cursor++) {\n corrections.push(parseFogTileResultRecord(raw[cursor]));\n }\n if (cursor !== raw.length) throw new Error('Redis returned an invalid fog patch result');\n return { accepted, corrections };\n}\n\nfunction parseFogTileResultRecord(raw: unknown): FogTileRecord {\n if (typeof raw !== 'string') throw new Error('Redis returned an invalid fog patch record');\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n throw new Error('Redis returned an invalid fog patch record');\n }\n if (!isValidFogTileRecord(parsed)) throw new Error('Redis returned an invalid fog patch record');\n return parsed;\n}\n\nconst FOG_META_LWW_SCRIPT = `\nlocal incomingRaw = ARGV[1]\nlocal incoming = cjson.decode(incomingRaw)\nlocal currentRaw = redis.call('HGET', KEYS[1], 'current')\nlocal expectedMetaRaw = ARGV[2]\nif (currentRaw or '') ~= expectedMetaRaw then return {2} end\nlocal expectedTiles = cjson.decode(ARGV[3])\nlocal expectedTileCount = 0\nfor field, raw in pairs(expectedTiles) do\n expectedTileCount = expectedTileCount + 1\n if redis.call('HGET', KEYS[2], field) ~= raw then return {2} end\nend\nif redis.call('HLEN', KEYS[2]) ~= expectedTileCount then return {2} end\nlocal replacementTiles = cjson.decode(ARGV[4])\nlocal function ascii(value)\n if type(value) ~= 'string' or #value < 1 or #value > 128 then return false end\n for i = 1, #value do\n local b = string.byte(value, i)\n if b < 32 or b > 126 then return false end\n end\n return true\nend\nlocal function integer(value)\n return type(value) == 'number' and value >= 1 and value <= 9007199254740991\n and value == math.floor(value)\nend\nlocal function validDef(def)\n return type(def) == 'table' and def.version == 1 and ascii(def.generation)\n and type(def.bounds) == 'table' and type(def.bounds.x) == 'number'\n and type(def.bounds.y) == 'number' and type(def.bounds.w) == 'number' and def.bounds.w > 0\n and type(def.bounds.h) == 'number' and def.bounds.h > 0 and type(def.cellSize) == 'number'\n and def.cellSize > 0 and def.tileCells == 128 and (def.base == 'covered' or def.base == 'revealed')\nend\nlocal function validMeta(value)\n return type(value) == 'table' and integer(value.version) and ascii(value.editor)\n and (value.definition == nil or validDef(value.definition))\nend\nlocal current = nil\nif currentRaw then\n local ok, decoded = pcall(cjson.decode, currentRaw)\n if ok and validMeta(decoded) then current = decoded else redis.call('HDEL', KEYS[1], 'current') end\nend\nlocal function newer(a, b)\n return a.version > b.version or (a.version == b.version and a.editor > b.editor)\nend\nif current and not newer(incoming, current) then return {0, currentRaw} end\nlocal oldDef = current and current.definition or nil\nlocal newDef = incoming.definition\nif oldDef and newDef and oldDef.generation == newDef.generation\n and (oldDef.cellSize ~= newDef.cellSize or oldDef.tileCells ~= newDef.tileCells\n or oldDef.base ~= newDef.base or newDef.bounds.x > oldDef.bounds.x\n or newDef.bounds.y > oldDef.bounds.y\n or newDef.bounds.x + newDef.bounds.w < oldDef.bounds.x + oldDef.bounds.w\n or newDef.bounds.y + newDef.bounds.h < oldDef.bounds.y + oldDef.bounds.h) then\n return {0, currentRaw}\nend\n\nredis.call('HSET', KEYS[1], 'current', incomingRaw)\nredis.call('DEL', KEYS[2])\nif newDef and oldDef and oldDef.generation == newDef.generation then\n for i = 1, #replacementTiles do\n local tile = replacementTiles[i]\n redis.call('HSET', KEYS[2], tostring(tile.x) .. ',' .. tostring(tile.y), cjson.encode(tile))\n end\nend\nreturn {1}\n`;\n\nconst FOG_PATCH_LWW_SCRIPT = `\nlocal incomingRaws = cjson.decode(ARGV[1])\nlocal metaRaw = redis.call('HGET', KEYS[1], 'current')\nif (metaRaw or '') ~= ARGV[2] then return {2} end\nlocal invalidStored = cjson.decode(ARGV[3])\nfor field, raw in pairs(invalidStored) do\n if redis.call('HGET', KEYS[2], field) == raw then redis.call('HDEL', KEYS[2], field) end\nend\nif not metaRaw then return {0, 0} end\nlocal metaOk, meta = pcall(cjson.decode, metaRaw)\nif not metaOk or type(meta) ~= 'table' or type(meta.definition) ~= 'table'\n or type(meta.definition.generation) ~= 'string' then return {0, 0} end\nlocal def = meta.definition\nlocal tileSize = 128 * def.cellSize\nlocal function intersects(tile)\n local tx = tile.x * tileSize\n local ty = tile.y * tileSize\n return tx + tileSize > def.bounds.x and ty + tileSize > def.bounds.y\n and tx < def.bounds.x + def.bounds.w and ty < def.bounds.y + def.bounds.h\nend\nlocal function newer(a, b)\n return a.version > b.version or (a.version == b.version and a.editor > b.editor)\nend\nlocal function validTile(tile)\n if type(tile) ~= 'table' then return false end\n local function ascii(value)\n if type(value) ~= 'string' or #value < 1 or #value > 128 then return false end\n for i = 1, #value do\n local b = string.byte(value, i)\n if b < 32 or b > 126 then return false end\n end\n return true\n end\n local dataOk = tile.data == nil or (type(tile.data) == 'string' and #tile.data == 2732\n and string.match(tile.data, '^[A-Za-z0-9+/]+[AEIMQUYcgkosw048]=$') ~= nil)\n return ascii(tile.generation)\n and type(tile.x) == 'number' and math.abs(tile.x) <= 9007199254740991\n and tile.x == math.floor(tile.x)\n and type(tile.y) == 'number' and math.abs(tile.y) <= 9007199254740991\n and tile.y == math.floor(tile.y)\n and type(tile.version) == 'number' and tile.version >= 1\n and tile.version <= 9007199254740991 and tile.version == math.floor(tile.version)\n and ascii(tile.editor) and dataOk\nend\nlocal stored = redis.call('HGETALL', KEYS[2])\nlocal validCount = 0\nfor i = 1, #stored, 2 do\n local ok, tile = pcall(cjson.decode, stored[i + 1])\n if not ok or not validTile(tile) or stored[i] ~= tostring(tile.x) .. ',' .. tostring(tile.y)\n or tile.generation ~= def.generation or not intersects(tile) then\n redis.call('HDEL', KEYS[2], stored[i])\n else\n validCount = validCount + 1\n end\nend\nlocal accepted = {}\nlocal corrections = {}\nlocal newCount = 0\nlocal currents = {}\nfor i = 1, #incomingRaws do\n local incomingRaw = cjson.encode(incomingRaws[i])\n local incoming = incomingRaws[i]\n local field = tostring(incoming.x) .. ',' .. tostring(incoming.y)\n local currentRaw = redis.call('HGET', KEYS[2], field)\n local current = nil\n if currentRaw then\n local ok, decoded = pcall(cjson.decode, currentRaw)\n if ok and validTile(decoded) then current = decoded else currentRaw = nil end\n end\n currents[i] = currentRaw or false\n if incoming.generation ~= def.generation or not intersects(incoming)\n or (current and not newer(incoming, current)) then\n corrections[#corrections + 1] = currentRaw or cjson.encode({generation=def.generation,\n x=incoming.x, y=incoming.y, version=1, editor='hub'})\n else\n accepted[#accepted + 1] = incomingRaw\n if not current then newCount = newCount + 1 end\n end\nend\nif validCount + newCount > 256 then\n accepted = {}\n corrections = {}\n for i = 1, #incomingRaws do\n local incoming = incomingRaws[i]\n corrections[#corrections + 1] = currents[i] or cjson.encode({generation=def.generation,\n x=incoming.x, y=incoming.y, version=1, editor='hub'})\n end\nelse\n for i = 1, #accepted do\n local record = cjson.decode(accepted[i])\n redis.call('HSET', KEYS[2], tostring(record.x) .. ',' .. tostring(record.y), accepted[i])\n end\nend\nlocal result = {#accepted}\nfor i = 1, #accepted do result[#result + 1] = accepted[i] end\nresult[#result + 1] = #corrections\nfor i = 1, #corrections do result[#result + 1] = corrections[i] end\nreturn result\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 { SyncOp, LayerRecord } from '@fieldnotes/sync';
1
+ import { SyncOp, LayerRecord, FogSnapshot, FogMetaRecord, FogTileRecord } from '@fieldnotes/sync';
2
2
  import { CanvasElement } from '@fieldnotes/core';
3
- import { HubBackend, HubFanout } from '@fieldnotes/sync-server';
3
+ import { HubBackend, FogApplyResult, FogPatchApplyResult, HubFanout } from '@fieldnotes/sync-server';
4
4
 
5
5
  interface RedisHashClient {
6
6
  hGetAll(key: string): Promise<Record<string, string>>;
@@ -8,12 +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
+ /** Required for atomic fog LWW updates; node-redis conforms directly. */
12
+ eval(script: string, options: {
13
+ keys: string[];
14
+ arguments: string[];
15
+ }): Promise<unknown>;
11
16
  }
12
17
 
13
18
  interface RedisHubBackendOptions {
14
19
  keyPrefix?: string;
15
20
  }
16
21
  declare class RedisHubBackend implements HubBackend {
22
+ readonly sharedAcrossInstances = true;
17
23
  private readonly client;
18
24
  private readonly keyPrefix;
19
25
  constructor(client: RedisHashClient, options?: RedisHubBackendOptions);
@@ -25,6 +31,13 @@ declare class RedisHubBackend implements HubBackend {
25
31
  layerRecords(room: string): Promise<LayerRecord[]>;
26
32
  getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
27
33
  applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
34
+ private fogMetaKey;
35
+ private fogTilesKey;
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;
28
41
  }
29
42
 
30
43
  interface RedisPublisher {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { SyncOp, LayerRecord } from '@fieldnotes/sync';
1
+ import { SyncOp, LayerRecord, FogSnapshot, FogMetaRecord, FogTileRecord } from '@fieldnotes/sync';
2
2
  import { CanvasElement } from '@fieldnotes/core';
3
- import { HubBackend, HubFanout } from '@fieldnotes/sync-server';
3
+ import { HubBackend, FogApplyResult, FogPatchApplyResult, HubFanout } from '@fieldnotes/sync-server';
4
4
 
5
5
  interface RedisHashClient {
6
6
  hGetAll(key: string): Promise<Record<string, string>>;
@@ -8,12 +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
+ /** Required for atomic fog LWW updates; node-redis conforms directly. */
12
+ eval(script: string, options: {
13
+ keys: string[];
14
+ arguments: string[];
15
+ }): Promise<unknown>;
11
16
  }
12
17
 
13
18
  interface RedisHubBackendOptions {
14
19
  keyPrefix?: string;
15
20
  }
16
21
  declare class RedisHubBackend implements HubBackend {
22
+ readonly sharedAcrossInstances = true;
17
23
  private readonly client;
18
24
  private readonly keyPrefix;
19
25
  constructor(client: RedisHashClient, options?: RedisHubBackendOptions);
@@ -25,6 +31,13 @@ declare class RedisHubBackend implements HubBackend {
25
31
  layerRecords(room: string): Promise<LayerRecord[]>;
26
32
  getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
27
33
  applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
34
+ private fogMetaKey;
35
+ private fogTilesKey;
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;
28
41
  }
29
42
 
30
43
  interface RedisPublisher {