@fieldnotes/sync-redis 0.3.2 → 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/README.md CHANGED
@@ -14,7 +14,8 @@ pnpm add ioredis
14
14
  ```
15
15
 
16
16
  `@fieldnotes/sync-redis` has **no Redis dependency of its own** — you inject a client that satisfies a
17
- minimal `RedisHashClient` interface (`hGetAll` / `hGet` / `hSet` / `hDel` / `del`).
17
+ minimal `RedisHashClient` interface (`hGetAll` / `hGet` / `hSet` / `hDel` / `del` / `eval`).
18
+ `eval` is required for atomic fog-of-war updates.
18
19
 
19
20
  ## node-redis v4 (direct)
20
21
 
@@ -45,6 +46,8 @@ const client = {
45
46
  hSet: (k: string, f: string, v: string) => io.hset(k, f, v),
46
47
  hDel: (k: string, f: string) => io.hdel(k, f),
47
48
  del: (k: string) => io.del(k),
49
+ eval: (script: string, options: { keys: string[]; arguments: string[] }) =>
50
+ io.eval(script, options.keys.length, ...options.keys, ...options.arguments),
48
51
  };
49
52
  const backend = new RedisHubBackend(client);
50
53
  ```
@@ -56,6 +59,10 @@ Each room is stored as a Redis **HASH** at `{keyPrefix}{room}` (default prefix `
56
59
  - **field** = element id
57
60
  - **value** = `JSON.stringify(element)`
58
61
 
62
+ Fog uses two additional hashes, `${key}:fog:meta` and `${key}:fog:tiles`, and atomic Lua scripts via
63
+ `EVAL`; custom Redis adapters must expose the node-redis-compatible `eval(script, { keys, arguments })`
64
+ shape shown above.
65
+
59
66
  node-redis v4 conforms to `RedisHashClient` directly (it has `hGet`); `RedisHubBackend.get(room, id)`
60
67
  (via `HGET`) powers the relay's ownership lookups for write authorization (D2).
61
68
 
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 = {}) {
@@ -37,6 +39,9 @@ var RedisHubBackend = class {
37
39
  key(room) {
38
40
  return `${this.keyPrefix}${room}`;
39
41
  }
42
+ layersKey(room) {
43
+ return `${this.keyPrefix}${room}:layers`;
44
+ }
40
45
  async snapshot(room) {
41
46
  const map = await this.client.hGetAll(this.key(room));
42
47
  const out = [];
@@ -68,7 +73,417 @@ var RedisHubBackend = class {
68
73
  else if (op.kind === "remove") await this.client.hDel(key, op.id);
69
74
  else if (op.kind === "clear") await this.client.del(key);
70
75
  }
76
+ async layerRecords(room) {
77
+ const map = await this.client.hGetAll(this.layersKey(room));
78
+ const out = [];
79
+ for (const value of Object.values(map)) {
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(value);
83
+ } catch {
84
+ continue;
85
+ }
86
+ if ((0, import_sync.isValidLayerRecord)(parsed)) out.push(parsed);
87
+ }
88
+ return out;
89
+ }
90
+ async getLayerRecord(room, id) {
91
+ const value = await this.client.hGet(this.layersKey(room), id);
92
+ if (value == null) return void 0;
93
+ try {
94
+ const parsed = JSON.parse(value);
95
+ return (0, import_sync.isValidLayerRecord)(parsed) ? parsed : void 0;
96
+ } catch {
97
+ return void 0;
98
+ }
99
+ }
100
+ async applyLayerRecord(room, record) {
101
+ await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));
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
+ }
71
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
+ `;
72
487
 
73
488
  // src/redis-hub-fanout.ts
74
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';\r\nexport type { RedisHubBackendOptions } from './redis-hub-backend';\r\nexport type { RedisHashClient } from './redis-hash-client';\r\nexport { RedisHubFanout } from './redis-hub-fanout';\r\nexport type { RedisHubFanoutOptions } from './redis-hub-fanout';\r\nexport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\r\n","import { isValidElement, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from '@fieldnotes/sync-server';\r\nimport type { RedisHashClient } from './redis-hash-client';\r\n\r\nexport interface RedisHubBackendOptions {\r\n keyPrefix?: string; // default 'fieldnotes:room:'\r\n}\r\n\r\nexport class RedisHubBackend implements HubBackend {\r\n private readonly client: RedisHashClient;\r\n private readonly keyPrefix: string;\r\n\r\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\r\n this.client = client;\r\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\r\n }\r\n\r\n private key(room: string): string {\r\n return `${this.keyPrefix}${room}`;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n const map = await this.client.hGetAll(this.key(room));\r\n const out: CanvasElement[] = [];\r\n for (const value of Object.values(map)) {\r\n let parsed: unknown;\r\n try {\r\n parsed = JSON.parse(value);\r\n } catch {\r\n continue; // skip a corrupt stored value rather than throwing the whole snapshot\r\n }\r\n if (isValidElement(parsed)) out.push(parsed);\r\n }\r\n return out;\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n const value = await this.client.hGet(this.key(room), id);\r\n if (value == null) return undefined;\r\n try {\r\n const parsed: unknown = JSON.parse(value);\r\n return isValidElement(parsed) ? parsed : undefined;\r\n } catch {\r\n return undefined;\r\n }\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n const key = this.key(room);\r\n if (op.kind === 'upsert')\r\n await this.client.hSet(key, op.element.id, JSON.stringify(op.element));\r\n else if (op.kind === 'remove') await this.client.hDel(key, op.id);\r\n else if (op.kind === 'clear') await this.client.del(key);\r\n // request-snapshot/snapshot never reach apply (the hub only applies data ops)\r\n }\r\n}\r\n","import type { HubFanout } from '@fieldnotes/sync-server';\nimport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\n\nexport interface RedisHubFanoutOptions {\n channel?: string;\n onError?: (err: unknown) => void;\n}\n\nexport class RedisHubFanout implements HubFanout {\n private readonly publisher: RedisPublisher;\n private readonly subscriber: RedisSubscriber;\n private readonly channel: string;\n private readonly onError: (err: unknown) => void;\n private readonly handlers = new Set<(payload: string) => void>();\n private subscribed = false;\n\n constructor(\n publisher: RedisPublisher,\n subscriber: RedisSubscriber,\n options: RedisHubFanoutOptions = {},\n ) {\n this.publisher = publisher;\n this.subscriber = subscriber;\n this.channel = options.channel ?? 'fieldnotes:fanout';\n this.onError = options.onError ?? (() => undefined);\n }\n\n async publish(payload: string): Promise<void> {\n try {\n await this.publisher.publish(this.channel, payload);\n } catch (error) {\n try {\n this.onError(error);\n } catch {\n /* preserve the publication failure even when the observer throws */\n }\n throw error;\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n if (!this.subscribed) {\n this.subscribed = true;\n Promise.resolve(\n this.subscriber.subscribe(this.channel, (message) => {\n for (const h of this.handlers) {\n try {\n h(message);\n } catch {\n /* isolate handlers */\n }\n }\n }),\n ).catch(this.onError);\n }\n return () => this.handlers.delete(handler);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,kBAA4C;AASrC,IAAM,kBAAN,MAA4C;AAAA,EAChC;AAAA,EACA;AAAA,EAEjB,YAAY,QAAyB,UAAkC,CAAC,GAAG;AACzE,SAAK,SAAS;AACd,SAAK,YAAY,QAAQ,aAAa;AAAA,EACxC;AAAA,EAEQ,IAAI,MAAsB;AAChC,WAAO,GAAG,KAAK,SAAS,GAAG,IAAI;AAAA,EACjC;AAAA,EAEA,MAAM,SAAS,MAAwC;AACrD,UAAM,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,IAAI,IAAI,CAAC;AACpD,UAAM,MAAuB,CAAC;AAC9B,eAAW,SAAS,OAAO,OAAO,GAAG,GAAG;AACtC,UAAI;AACJ,UAAI;AACF,iBAAS,KAAK,MAAM,KAAK;AAAA,MAC3B,QAAQ;AACN;AAAA,MACF;AACA,cAAI,4BAAe,MAAM,EAAG,KAAI,KAAK,MAAM;AAAA,IAC7C;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,IAAI,MAAc,IAAgD;AACtE,UAAM,QAAQ,MAAM,KAAK,OAAO,KAAK,KAAK,IAAI,IAAI,GAAG,EAAE;AACvD,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI;AACF,YAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,iBAAO,4BAAe,MAAM,IAAI,SAAS;AAAA,IAC3C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,MAAM,MAAc,IAA2B;AACnD,UAAM,MAAM,KAAK,IAAI,IAAI;AACzB,QAAI,GAAG,SAAS;AACd,YAAM,KAAK,OAAO,KAAK,KAAK,GAAG,QAAQ,IAAI,KAAK,UAAU,GAAG,OAAO,CAAC;AAAA,aAC9D,GAAG,SAAS,SAAU,OAAM,KAAK,OAAO,KAAK,KAAK,GAAG,EAAE;AAAA,aACvD,GAAG,SAAS,QAAS,OAAM,KAAK,OAAO,IAAI,GAAG;AAAA,EAEzD;AACF;;;AChDO,IAAM,iBAAN,MAA0C;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW,oBAAI,IAA+B;AAAA,EACvD,aAAa;AAAA,EAErB,YACE,WACA,YACA,UAAiC,CAAC,GAClC;AACA,SAAK,YAAY;AACjB,SAAK,aAAa;AAClB,SAAK,UAAU,QAAQ,WAAW;AAClC,SAAK,UAAU,QAAQ,YAAY,MAAM;AAAA,EAC3C;AAAA,EAEA,MAAM,QAAQ,SAAgC;AAC5C,QAAI;AACF,YAAM,KAAK,UAAU,QAAQ,KAAK,SAAS,OAAO;AAAA,IACpD,SAAS,OAAO;AACd,UAAI;AACF,aAAK,QAAQ,KAAK;AAAA,MACpB,QAAQ;AAAA,MAER;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,UAAU,SAAgD;AACxD,SAAK,SAAS,IAAI,OAAO;AACzB,QAAI,CAAC,KAAK,YAAY;AACpB,WAAK,aAAa;AAClB,cAAQ;AAAA,QACN,KAAK,WAAW,UAAU,KAAK,SAAS,CAAC,YAAY;AACnD,qBAAW,KAAK,KAAK,UAAU;AAC7B,gBAAI;AACF,gBAAE,OAAO;AAAA,YACX,QAAQ;AAAA,YAER;AAAA,UACF;AAAA,QACF,CAAC;AAAA,MACH,EAAE,MAAM,KAAK,OAAO;AAAA,IACtB;AACA,WAAO,MAAM,KAAK,SAAS,OAAO,OAAO;AAAA,EAC3C;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/redis-hub-backend.ts","../src/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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,kBAWO;AACP,kBAAwD;AAQjD,IAAM,kBAAN,MAA4C;AAAA,EACxC,wBAAwB;AAAA,EAChB;AAAA,EACA;AAAA,EAEjB,YAAY,QAAyB,UAAkC,CAAC,GAAG;AACzE,SAAK,SAAS;AACd,SAAK,YAAY,QAAQ,aAAa;AAAA,EACxC;AAAA,EAEQ,IAAI,MAAsB;AAChC,WAAO,GAAG,KAAK,SAAS,GAAG,IAAI;AAAA,EACjC;AAAA,EAEQ,UAAU,MAAsB;AACtC,WAAO,GAAG,KAAK,SAAS,GAAG,IAAI;AAAA,EACjC;AAAA,EAEA,MAAM,SAAS,MAAwC;AACrD,UAAM,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,IAAI,IAAI,CAAC;AACpD,UAAM,MAAuB,CAAC;AAC9B,eAAW,SAAS,OAAO,OAAO,GAAG,GAAG;AACtC,UAAI;AACJ,UAAI;AACF,iBAAS,KAAK,MAAM,KAAK;AAAA,MAC3B,QAAQ;AACN;AAAA,MACF;AACA,cAAI,4BAAe,MAAM,EAAG,KAAI,KAAK,MAAM;AAAA,IAC7C;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,IAAI,MAAc,IAAgD;AACtE,UAAM,QAAQ,MAAM,KAAK,OAAO,KAAK,KAAK,IAAI,IAAI,GAAG,EAAE;AACvD,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI;AACF,YAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,iBAAO,4BAAe,MAAM,IAAI,SAAS;AAAA,IAC3C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,MAAM,MAAc,IAA2B;AACnD,UAAM,MAAM,KAAK,IAAI,IAAI;AACzB,QAAI,GAAG,SAAS;AACd,YAAM,KAAK,OAAO,KAAK,KAAK,GAAG,QAAQ,IAAI,KAAK,UAAU,GAAG,OAAO,CAAC;AAAA,aAC9D,GAAG,SAAS,SAAU,OAAM,KAAK,OAAO,KAAK,KAAK,GAAG,EAAE;AAAA,aAEvD,GAAG,SAAS,QAAS,OAAM,KAAK,OAAO,IAAI,GAAG;AAAA,EAEzD;AAAA,EAEA,MAAM,aAAa,MAAsC;AACvD,UAAM,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,UAAU,IAAI,CAAC;AAC1D,UAAM,MAAqB,CAAC;AAC5B,eAAW,SAAS,OAAO,OAAO,GAAG,GAAG;AACtC,UAAI;AACJ,UAAI;AACF,iBAAS,KAAK,MAAM,KAAK;AAAA,MAC3B,QAAQ;AACN;AAAA,MACF;AACA,cAAI,gCAAmB,MAAM,EAAG,KAAI,KAAK,MAAM;AAAA,IACjD;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,eAAe,MAAc,IAA8C;AAC/E,UAAM,QAAQ,MAAM,KAAK,OAAO,KAAK,KAAK,UAAU,IAAI,GAAG,EAAE;AAC7D,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI;AACF,YAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,iBAAO,gCAAmB,MAAM,IAAI,SAAS;AAAA,IAC/C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,iBAAiB,MAAc,QAAoC;AACvE,UAAM,KAAK,OAAO,KAAK,KAAK,UAAU,IAAI,GAAG,OAAO,IAAI,KAAK,UAAU,MAAM,CAAC;AAAA,EAChF;AAAA,EAEQ,WAAW,MAAsB;AACvC,WAAO,GAAG,KAAK,SAAS,GAAG,IAAI;AAAA,EACjC;AAAA,EAEQ,YAAY,MAAsB;AACxC,WAAO,GAAG,KAAK,SAAS,GAAG,IAAI;AAAA,EACjC;AAAA,EAEA,MAAM,YAAY,MAAgD;AAChE,UAAM,UAAU,MAAM,KAAK,OAAO,KAAK,KAAK,WAAW,IAAI,GAAG,SAAS;AACvE,QAAI,WAAW,KAAM,QAAO;AAC5B,QAAI;AACJ,QAAI;AACF,aAAO,KAAK,MAAM,OAAO;AAAA,IAC3B,QAAQ;AACN,aAAO;AAAA,IACT;AACA,QAAI,KAAC,kCAAqB,IAAI,EAAG,QAAO;AAExC,QAAI,CAAE,KAAuB,WAAY,QAAO,EAAE,MAA6B,OAAO,CAAC,EAAE;AAEzF,UAAM,aAAc,KAAuB;AAC3C,QAAI,CAAC,WAAY,QAAO,EAAE,MAA6B,OAAO,CAAC,EAAE;AACjE,UAAM,UAAU,MAAM,KAAK,OAAO,QAAQ,KAAK,YAAY,IAAI,CAAC;AAChE,UAAM,QAAyB,CAAC;AAChC,UAAM,OAAO,oBAAI,IAAY;AAC7B,eAAW,SAAS,OAAO,OAAO,OAAO,GAAG;AAC1C,UAAI;AACJ,UAAI;AACF,iBAAS,KAAK,MAAM,KAAK;AAAA,MAC3B,QAAQ;AACN;AAAA,MACF;AACA,UAAI,KAAC,kCAAqB,MAAM,EAAG;AACnC,UAAI,OAAO,eAAe,WAAW,WAAY;AACjD,UAAI,CAAC,yBAAyB,OAAO,GAAG,OAAO,GAAG,UAAU,EAAG;AAC/D,UAAI,KAAC,gCAAmB,EAAE,MAA6B,OAAO,CAAC,MAAM,EAAE,CAAC,EAAG;AAC3E,YAAM,MAAM,GAAG,OAAO,CAAC,IAAI,OAAO,CAAC;AACnC,UAAI,KAAK,IAAI,GAAG,EAAG;AACnB,WAAK,IAAI,GAAG;AACZ,YAAM,KAAK,MAAM;AACjB,UAAI,MAAM,WAAW,IAAK;AAAA,IAC5B;AAEA,UAAM,WAAW,EAAE,MAA6B,MAAM;AACtD,eAAO,gCAAmB,QAAQ,IAAI,WAAW;AAAA,EACnD;AAAA,EAEA,MAAM,aAAa,MAAc,QAA+D;AAC9F,aAAS,UAAU,GAAG,UAAU,GAAG,WAAW;AAC5C,YAAM,UAAU,KAAK,WAAW,IAAI;AACpC,YAAM,WAAW,KAAK,YAAY,IAAI;AACtC,YAAM,eAAe,MAAM,KAAK,OAAO,KAAK,SAAS,SAAS;AAC9D,YAAM,gBAAgB,MAAM,KAAK,OAAO,QAAQ,QAAQ;AACxD,YAAM,eAAgC,CAAC;AACvC,UAAI;AACJ,UAAI,iBAAiB,MAAM;AACzB,YAAI;AACF,gBAAM,SAAkB,KAAK,MAAM,YAAY;AAC/C,kBAAI,kCAAqB,MAAM,EAAG,WAAU;AAAA,QAC9C,QAAQ;AAAA,QAER;AAAA,MACF;AACA,UACE,SAAS,cACT,OAAO,cACP,QAAQ,WAAW,eAAe,OAAO,WAAW,YACpD;AACA,mBAAW,OAAO,OAAO,OAAO,aAAa,GAAG;AAC9C,cAAI;AACJ,cAAI;AACF,mBAAO,KAAK,MAAM,GAAG;AAAA,UACvB,QAAQ;AACN;AAAA,UACF;AACA,cAAI,KAAC,gCAAmB,EAAE,MAAM,SAAS,OAAO,CAAC,IAAI,EAAE,CAAC,EAAG;AAC3D,gBAAM,QAAQ;AACd,cAAI,CAAC,yBAAyB,MAAM,GAAG,MAAM,GAAG,OAAO,UAAU,EAAG;AACpE,cAAI,MAAM,SAAS,QAAW;AAC5B,yBAAa,KAAK,KAAK;AACvB;AAAA,UACF;AACA,gBAAM,gBAAY;AAAA,YAChB,EAAE,GAAG,MAAM,GAAG,GAAG,MAAM,GAAG,MAAM,MAAM,KAAK;AAAA,YAC3C,OAAO;AAAA,UACT;AACA,cAAI,UAAW,cAAa,KAAK,EAAE,GAAG,OAAO,MAAM,UAAU,KAAK,CAAC;AAAA,QACrE;AAAA,MACF;AACA,YAAM,SAAS,MAAM,KAAK,QAAQ,qBAAqB,MAAM,QAAQ;AAAA,QACnE,gBAAgB;AAAA,QAChB,KAAK,UAAU,aAAa;AAAA,QAC5B,KAAK,UAAU,YAAY;AAAA,MAC7B,CAAC;AACD,UAAI,MAAM,QAAQ,MAAM,KAAK,OAAO,CAAC,MAAM,EAAG;AAC9C,aAAO,iBAAiB,QAAQ,gCAAoB;AAAA,IACtD;AACA,UAAM,IAAI,MAAM,gEAAgE;AAAA,EAClF;AAAA,EAEA,MAAM,aAAa,MAAc,QAA+D;AAC9F,UAAM,SAAS,MAAM,KAAK,cAAc,MAAM,CAAC,MAAM,CAAC;AACtD,QAAI,OAAO,SAAS,WAAW,EAAG,QAAO,EAAE,UAAU,KAAK;AAC1D,WAAO,EAAE,UAAU,OAAO,YAAY,OAAO,YAAY,CAAC,EAAE;AAAA,EAC9D;AAAA,EAEA,MAAM,cACJ,MACA,SAC8B;AAC9B,aAAS,UAAU,GAAG,UAAU,GAAG,WAAW;AAC5C,YAAM,UAAU,MAAM,KAAK,OAAO,KAAK,KAAK,WAAW,IAAI,GAAG,SAAS;AACvE,UAAI,YAAY,KAAM,QAAO,EAAE,UAAU,CAAC,GAAG,aAAa,CAAC,EAAE;AAC7D,UAAI;AACJ,UAAI;AACF,eAAO,KAAK,MAAM,OAAO;AAAA,MAC3B,QAAQ;AACN,eAAO,EAAE,UAAU,CAAC,GAAG,aAAa,CAAC,EAAE;AAAA,MACzC;AACA,UAAI,KAAC,kCAAqB,IAAI,KAAK,CAAC,KAAK,YAAY;AACnD,eAAO,EAAE,UAAU,CAAC,GAAG,aAAa,CAAC,EAAE;AAAA,MACzC;AACA,YAAM,aAAa,KAAK;AACxB,YAAM,SAAS,MAAM,KAAK,OAAO,QAAQ,KAAK,YAAY,IAAI,CAAC;AAC/D,YAAM,gBAAwC,CAAC;AAC/C,iBAAW,CAAC,OAAO,GAAG,KAAK,OAAO,QAAQ,MAAM,GAAG;AACjD,YAAI;AACJ,YAAI;AACF,mBAAS,KAAK,MAAM,GAAG;AAAA,QACzB,QAAQ;AACN,wBAAc,KAAK,IAAI;AACvB;AAAA,QACF;AACA,YAAI,KAAC,gCAAmB,EAAE,MAAM,OAAO,CAAC,MAAM,EAAE,CAAC,EAAG,eAAc,KAAK,IAAI;AAAA,MAC7E;AACA,UAAI,QAAQ,KAAK,CAAC,SAAS,KAAC,gCAAmB,EAAE,MAAM,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG;AACxE,eAAO;AAAA,UACL,UAAU,CAAC;AAAA,UACX,aAAa,QAAQ,IAAI,CAAC,SAAS;AACjC,kBAAM,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,KAAK,CAAC,EAAE;AACxC,gBAAI,KAAK;AACP,kBAAI;AACF,sBAAM,SAAkB,KAAK,MAAM,GAAG;AACtC,wBAAI,gCAAmB,EAAE,MAAM,OAAO,CAAC,MAAM,EAAE,CAAC,EAAG,QAAO;AAAA,cAC5D,QAAQ;AAAA,cAER;AAAA,YACF;AACA,mBAAO;AAAA,cACL,YAAY,WAAW;AAAA,cACvB,GAAG,KAAK;AAAA,cACR,GAAG,KAAK;AAAA,cACR,SAAS;AAAA,cACT,QAAQ;AAAA,YACV;AAAA,UACF,CAAC;AAAA,QACH;AAAA,MACF;AACA,YAAM,SAAS,MAAM,KAAK,QAAQ,sBAAsB,MAAM,SAAS;AAAA,QACrE;AAAA,QACA,KAAK,UAAU,aAAa;AAAA,MAC9B,CAAC;AACD,UAAI,MAAM,QAAQ,MAAM,KAAK,OAAO,CAAC,MAAM,EAAG;AAC9C,aAAO,sBAAsB,MAAM;AAAA,IACrC;AACA,UAAM,IAAI,MAAM,qEAAqE;AAAA,EACvF;AAAA,EAEA,MAAc,QACZ,QACA,MACA,QACA,iBAA2B,CAAC,GACV;AAClB,QAAI,CAAC,KAAK,OAAO,MAAM;AACrB,YAAM,IAAI,MAAM,iEAAiE;AAAA,IACnF;AACA,WAAO,KAAK,OAAO,KAAK,QAAQ;AAAA,MAC9B,MAAM,CAAC,KAAK,WAAW,IAAI,GAAG,KAAK,YAAY,IAAI,CAAC;AAAA,MACpD,WAAW,CAAC,KAAK,UAAU,MAAM,GAAG,GAAG,cAAc;AAAA,IACvD,CAAC;AAAA,EACH;AACF;AAEA,SAAS,yBACP,GACA,GACA,YACS;AACT,QAAM,gBAAgB,MAAM,WAAW;AACvC,QAAM,aAAa,IAAI;AACvB,QAAM,aAAa,IAAI;AACvB,SAAO,EACL,aAAa,iBAAiB,WAAW,OAAO,KAChD,aAAa,iBAAiB,WAAW,OAAO,KAChD,cAAc,WAAW,OAAO,IAAI,WAAW,OAAO,KACtD,cAAc,WAAW,OAAO,IAAI,WAAW,OAAO;AAE1D;AAEA,SAAS,iBACP,KACA,OACmB;AACnB,MAAI,CAAC,MAAM,QAAQ,GAAG,KAAM,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM,GAAI;AACzD,UAAM,IAAI,MAAM,4CAA4C;AAAA,EAC9D;AACA,MAAI,IAAI,CAAC,MAAM,EAAG,QAAO,EAAE,UAAU,KAAK;AAC1C,MAAI,OAAO,IAAI,CAAC,MAAM,YAAY,IAAI,CAAC,EAAE,WAAW,EAAG,QAAO,EAAE,UAAU,MAAM;AAChF,MAAI;AACJ,MAAI;AACF,iBAAa,KAAK,MAAM,IAAI,CAAC,CAAC;AAAA,EAChC,QAAQ;AACN,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,MAAI,CAAC,MAAM,UAAU,EAAG,OAAM,IAAI,MAAM,0CAA0C;AAClF,SAAO,EAAE,UAAU,OAAO,WAAW;AACvC;AAEA,SAAS,sBAAsB,KAAmC;AAChE,MAAI,CAAC,MAAM,QAAQ,GAAG,KAAK,OAAO,IAAI,CAAC,MAAM,UAAU;AACrD,UAAM,IAAI,MAAM,4CAA4C;AAAA,EAC9D;AACA,QAAM,gBAAgB,IAAI,CAAC;AAC3B,MAAI,CAAC,OAAO,cAAc,aAAa,KAAK,gBAAgB,GAAG;AAC7D,UAAM,IAAI,MAAM,4CAA4C;AAAA,EAC9D;AACA,QAAM,WAA4B,CAAC;AACnC,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,eAAe,KAAK,UAAU;AAChD,UAAM,SAAS,yBAAyB,IAAI,MAAM,CAAC;AACnD,aAAS,KAAK,MAAM;AAAA,EACtB;AACA,QAAM,kBAAkB,IAAI,QAAQ;AACpC,MAAI,CAAC,OAAO,cAAc,eAAe,KAAM,kBAA6B,GAAG;AAC7E,UAAM,IAAI,MAAM,4CAA4C;AAAA,EAC9D;AACA,QAAM,cAA+B,CAAC;AACtC,WAAS,IAAI,GAAG,IAAK,iBAA4B,KAAK,UAAU;AAC9D,gBAAY,KAAK,yBAAyB,IAAI,MAAM,CAAC,CAAC;AAAA,EACxD;AACA,MAAI,WAAW,IAAI,OAAQ,OAAM,IAAI,MAAM,4CAA4C;AACvF,SAAO,EAAE,UAAU,YAAY;AACjC;AAEA,SAAS,yBAAyB,KAA6B;AAC7D,MAAI,OAAO,QAAQ,SAAU,OAAM,IAAI,MAAM,4CAA4C;AACzF,MAAI;AACJ,MAAI;AACF,aAAS,KAAK,MAAM,GAAG;AAAA,EACzB,QAAQ;AACN,UAAM,IAAI,MAAM,4CAA4C;AAAA,EAC9D;AACA,MAAI,KAAC,kCAAqB,MAAM,EAAG,OAAM,IAAI,MAAM,4CAA4C;AAC/F,SAAO;AACT;AAEA,IAAM,sBAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAoE5B,IAAM,uBAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACtatB,IAAM,iBAAN,MAA0C;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW,oBAAI,IAA+B;AAAA,EACvD,aAAa;AAAA,EAErB,YACE,WACA,YACA,UAAiC,CAAC,GAClC;AACA,SAAK,YAAY;AACjB,SAAK,aAAa;AAClB,SAAK,UAAU,QAAQ,WAAW;AAClC,SAAK,UAAU,QAAQ,YAAY,MAAM;AAAA,EAC3C;AAAA,EAEA,MAAM,QAAQ,SAAgC;AAC5C,QAAI;AACF,YAAM,KAAK,UAAU,QAAQ,KAAK,SAAS,OAAO;AAAA,IACpD,SAAS,OAAO;AACd,UAAI;AACF,aAAK,QAAQ,KAAK;AAAA,MACpB,QAAQ;AAAA,MAER;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,UAAU,SAAgD;AACxD,SAAK,SAAS,IAAI,OAAO;AACzB,QAAI,CAAC,KAAK,YAAY;AACpB,WAAK,aAAa;AAClB,cAAQ;AAAA,QACN,KAAK,WAAW,UAAU,KAAK,SAAS,CAAC,YAAY;AACnD,qBAAW,KAAK,KAAK,UAAU;AAC7B,gBAAI;AACF,gBAAE,OAAO;AAAA,YACX,QAAQ;AAAA,YAER;AAAA,UACF;AAAA,QACF,CAAC;AAAA,MACH,EAAE,MAAM,KAAK,OAAO;AAAA,IACtB;AACA,WAAO,MAAM,KAAK,SAAS,OAAO,OAAO;AAAA,EAC3C;AACF;","names":[]}
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
- import { SyncOp } 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,19 +8,36 @@ 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);
20
26
  private key;
27
+ private layersKey;
21
28
  snapshot(room: string): Promise<CanvasElement[]>;
22
29
  get(room: string, id: string): Promise<CanvasElement | undefined>;
23
30
  apply(room: string, op: SyncOp): Promise<void>;
31
+ layerRecords(room: string): Promise<LayerRecord[]>;
32
+ getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
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;
24
41
  }
25
42
 
26
43
  interface RedisPublisher {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { SyncOp } 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,19 +8,36 @@ 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);
20
26
  private key;
27
+ private layersKey;
21
28
  snapshot(room: string): Promise<CanvasElement[]>;
22
29
  get(room: string, id: string): Promise<CanvasElement | undefined>;
23
30
  apply(room: string, op: SyncOp): Promise<void>;
31
+ layerRecords(room: string): Promise<LayerRecord[]>;
32
+ getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
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;
24
41
  }
25
42
 
26
43
  interface RedisPublisher {
package/dist/index.js CHANGED
@@ -1,6 +1,14 @@
1
1
  // src/redis-hub-backend.ts
2
- import { isValidElement } from "@fieldnotes/sync";
2
+ import {
3
+ isValidElement,
4
+ isValidLayerRecord,
5
+ isValidFogMetaRecord,
6
+ isValidFogTileRecord,
7
+ isValidFogSnapshot
8
+ } from "@fieldnotes/sync";
9
+ import { canonicalizeFogTile } from "@fieldnotes/core";
3
10
  var RedisHubBackend = class {
11
+ sharedAcrossInstances = true;
4
12
  client;
5
13
  keyPrefix;
6
14
  constructor(client, options = {}) {
@@ -10,6 +18,9 @@ var RedisHubBackend = class {
10
18
  key(room) {
11
19
  return `${this.keyPrefix}${room}`;
12
20
  }
21
+ layersKey(room) {
22
+ return `${this.keyPrefix}${room}:layers`;
23
+ }
13
24
  async snapshot(room) {
14
25
  const map = await this.client.hGetAll(this.key(room));
15
26
  const out = [];
@@ -41,7 +52,417 @@ var RedisHubBackend = class {
41
52
  else if (op.kind === "remove") await this.client.hDel(key, op.id);
42
53
  else if (op.kind === "clear") await this.client.del(key);
43
54
  }
55
+ async layerRecords(room) {
56
+ const map = await this.client.hGetAll(this.layersKey(room));
57
+ const out = [];
58
+ for (const value of Object.values(map)) {
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(value);
62
+ } catch {
63
+ continue;
64
+ }
65
+ if (isValidLayerRecord(parsed)) out.push(parsed);
66
+ }
67
+ return out;
68
+ }
69
+ async getLayerRecord(room, id) {
70
+ const value = await this.client.hGet(this.layersKey(room), id);
71
+ if (value == null) return void 0;
72
+ try {
73
+ const parsed = JSON.parse(value);
74
+ return isValidLayerRecord(parsed) ? parsed : void 0;
75
+ } catch {
76
+ return void 0;
77
+ }
78
+ }
79
+ async applyLayerRecord(room, record) {
80
+ await this.client.hSet(this.layersKey(room), record.id, JSON.stringify(record));
81
+ }
82
+ fogMetaKey(room) {
83
+ return `${this.keyPrefix}${room}:fog:meta`;
84
+ }
85
+ fogTilesKey(room) {
86
+ return `${this.keyPrefix}${room}:fog:tiles`;
87
+ }
88
+ async fogSnapshot(room) {
89
+ const metaStr = await this.client.hGet(this.fogMetaKey(room), "current");
90
+ if (metaStr == null) return void 0;
91
+ let meta;
92
+ try {
93
+ meta = JSON.parse(metaStr);
94
+ } catch {
95
+ return void 0;
96
+ }
97
+ if (!isValidFogMetaRecord(meta)) return void 0;
98
+ if (!meta.definition) return { meta, tiles: [] };
99
+ const definition = meta.definition;
100
+ if (!definition) return { meta, tiles: [] };
101
+ const tileMap = await this.client.hGetAll(this.fogTilesKey(room));
102
+ const tiles = [];
103
+ const seen = /* @__PURE__ */ new Set();
104
+ for (const value of Object.values(tileMap)) {
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(value);
108
+ } catch {
109
+ continue;
110
+ }
111
+ if (!isValidFogTileRecord(parsed)) continue;
112
+ if (parsed.generation !== definition.generation) continue;
113
+ if (!tileIntersectsDefinition(parsed.x, parsed.y, definition)) continue;
114
+ if (!isValidFogSnapshot({ meta, tiles: [parsed] })) continue;
115
+ const key = `${parsed.x},${parsed.y}`;
116
+ if (seen.has(key)) continue;
117
+ seen.add(key);
118
+ tiles.push(parsed);
119
+ if (tiles.length === 256) break;
120
+ }
121
+ const snapshot = { meta, tiles };
122
+ return isValidFogSnapshot(snapshot) ? snapshot : void 0;
123
+ }
124
+ async applyFogMeta(room, record) {
125
+ for (let attempt = 0; attempt < 4; attempt++) {
126
+ const metaKey = this.fogMetaKey(room);
127
+ const tilesKey = this.fogTilesKey(room);
128
+ const expectedMeta = await this.client.hGet(metaKey, "current");
129
+ const expectedTiles = await this.client.hGetAll(tilesKey);
130
+ const replacements = [];
131
+ let current;
132
+ if (expectedMeta !== null) {
133
+ try {
134
+ const parsed = JSON.parse(expectedMeta);
135
+ if (isValidFogMetaRecord(parsed)) current = parsed;
136
+ } catch {
137
+ }
138
+ }
139
+ if (current?.definition && record.definition && current.definition.generation === record.definition.generation) {
140
+ for (const raw of Object.values(expectedTiles)) {
141
+ let tile;
142
+ try {
143
+ tile = JSON.parse(raw);
144
+ } catch {
145
+ continue;
146
+ }
147
+ if (!isValidFogSnapshot({ meta: current, tiles: [tile] })) continue;
148
+ const valid = tile;
149
+ if (!tileIntersectsDefinition(valid.x, valid.y, record.definition)) continue;
150
+ if (valid.data === void 0) {
151
+ replacements.push(valid);
152
+ continue;
153
+ }
154
+ const canonical = canonicalizeFogTile(
155
+ { x: valid.x, y: valid.y, data: valid.data },
156
+ record.definition
157
+ );
158
+ if (canonical) replacements.push({ ...valid, data: canonical.data });
159
+ }
160
+ }
161
+ const result = await this.evalFog(FOG_META_LWW_SCRIPT, room, record, [
162
+ expectedMeta ?? "",
163
+ JSON.stringify(expectedTiles),
164
+ JSON.stringify(replacements)
165
+ ]);
166
+ if (Array.isArray(result) && result[0] === 2) continue;
167
+ return parseApplyResult(result, isValidFogMetaRecord);
168
+ }
169
+ throw new Error("Redis fog meta update did not converge after concurrent writes");
170
+ }
171
+ async applyFogTile(room, record) {
172
+ const result = await this.applyFogPatch(room, [record]);
173
+ if (result.accepted.length === 1) return { accepted: true };
174
+ return { accepted: false, correction: result.corrections[0] };
175
+ }
176
+ async applyFogPatch(room, records) {
177
+ for (let attempt = 0; attempt < 4; attempt++) {
178
+ const metaRaw = await this.client.hGet(this.fogMetaKey(room), "current");
179
+ if (metaRaw === null) return { accepted: [], corrections: [] };
180
+ let meta;
181
+ try {
182
+ meta = JSON.parse(metaRaw);
183
+ } catch {
184
+ return { accepted: [], corrections: [] };
185
+ }
186
+ if (!isValidFogMetaRecord(meta) || !meta.definition) {
187
+ return { accepted: [], corrections: [] };
188
+ }
189
+ const definition = meta.definition;
190
+ const stored = await this.client.hGetAll(this.fogTilesKey(room));
191
+ const invalidStored = {};
192
+ for (const [field, raw] of Object.entries(stored)) {
193
+ let parsed;
194
+ try {
195
+ parsed = JSON.parse(raw);
196
+ } catch {
197
+ invalidStored[field] = raw;
198
+ continue;
199
+ }
200
+ if (!isValidFogSnapshot({ meta, tiles: [parsed] })) invalidStored[field] = raw;
201
+ }
202
+ if (records.some((tile) => !isValidFogSnapshot({ meta, tiles: [tile] }))) {
203
+ return {
204
+ accepted: [],
205
+ corrections: records.map((tile) => {
206
+ const raw = stored[`${tile.x},${tile.y}`];
207
+ if (raw) {
208
+ try {
209
+ const parsed = JSON.parse(raw);
210
+ if (isValidFogSnapshot({ meta, tiles: [parsed] })) return parsed;
211
+ } catch {
212
+ }
213
+ }
214
+ return {
215
+ generation: definition.generation,
216
+ x: tile.x,
217
+ y: tile.y,
218
+ version: 1,
219
+ editor: "hub"
220
+ };
221
+ })
222
+ };
223
+ }
224
+ const result = await this.evalFog(FOG_PATCH_LWW_SCRIPT, room, records, [
225
+ metaRaw,
226
+ JSON.stringify(invalidStored)
227
+ ]);
228
+ if (Array.isArray(result) && result[0] === 2) continue;
229
+ return parsePatchApplyResult(result);
230
+ }
231
+ throw new Error("Redis fog patch did not converge after concurrent definition writes");
232
+ }
233
+ async evalFog(script, room, record, extraArguments = []) {
234
+ if (!this.client.eval) {
235
+ throw new Error("Redis fog persistence requires a Redis client with EVAL support");
236
+ }
237
+ return this.client.eval(script, {
238
+ keys: [this.fogMetaKey(room), this.fogTilesKey(room)],
239
+ arguments: [JSON.stringify(record), ...extraArguments]
240
+ });
241
+ }
44
242
  };
243
+ function tileIntersectsDefinition(x, y, definition) {
244
+ const tileWorldSize = 128 * definition.cellSize;
245
+ const tileWorldX = x * tileWorldSize;
246
+ const tileWorldY = y * tileWorldSize;
247
+ 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);
248
+ }
249
+ function parseApplyResult(raw, guard) {
250
+ if (!Array.isArray(raw) || raw[0] !== 0 && raw[0] !== 1) {
251
+ throw new Error("Redis returned an invalid fog apply result");
252
+ }
253
+ if (raw[0] === 1) return { accepted: true };
254
+ if (typeof raw[1] !== "string" || raw[1].length === 0) return { accepted: false };
255
+ let correction;
256
+ try {
257
+ correction = JSON.parse(raw[1]);
258
+ } catch {
259
+ throw new Error("Redis returned an invalid fog correction");
260
+ }
261
+ if (!guard(correction)) throw new Error("Redis returned an invalid fog correction");
262
+ return { accepted: false, correction };
263
+ }
264
+ function parsePatchApplyResult(raw) {
265
+ if (!Array.isArray(raw) || typeof raw[0] !== "number") {
266
+ throw new Error("Redis returned an invalid fog patch result");
267
+ }
268
+ const acceptedCount = raw[0];
269
+ if (!Number.isSafeInteger(acceptedCount) || acceptedCount < 0) {
270
+ throw new Error("Redis returned an invalid fog patch result");
271
+ }
272
+ const accepted = [];
273
+ let cursor = 1;
274
+ for (let i = 0; i < acceptedCount; i++, cursor++) {
275
+ const parsed = parseFogTileResultRecord(raw[cursor]);
276
+ accepted.push(parsed);
277
+ }
278
+ const correctionCount = raw[cursor++];
279
+ if (!Number.isSafeInteger(correctionCount) || correctionCount < 0) {
280
+ throw new Error("Redis returned an invalid fog patch result");
281
+ }
282
+ const corrections = [];
283
+ for (let i = 0; i < correctionCount; i++, cursor++) {
284
+ corrections.push(parseFogTileResultRecord(raw[cursor]));
285
+ }
286
+ if (cursor !== raw.length) throw new Error("Redis returned an invalid fog patch result");
287
+ return { accepted, corrections };
288
+ }
289
+ function parseFogTileResultRecord(raw) {
290
+ if (typeof raw !== "string") throw new Error("Redis returned an invalid fog patch record");
291
+ let parsed;
292
+ try {
293
+ parsed = JSON.parse(raw);
294
+ } catch {
295
+ throw new Error("Redis returned an invalid fog patch record");
296
+ }
297
+ if (!isValidFogTileRecord(parsed)) throw new Error("Redis returned an invalid fog patch record");
298
+ return parsed;
299
+ }
300
+ var FOG_META_LWW_SCRIPT = `
301
+ local incomingRaw = ARGV[1]
302
+ local incoming = cjson.decode(incomingRaw)
303
+ local currentRaw = redis.call('HGET', KEYS[1], 'current')
304
+ local expectedMetaRaw = ARGV[2]
305
+ if (currentRaw or '') ~= expectedMetaRaw then return {2} end
306
+ local expectedTiles = cjson.decode(ARGV[3])
307
+ local expectedTileCount = 0
308
+ for field, raw in pairs(expectedTiles) do
309
+ expectedTileCount = expectedTileCount + 1
310
+ if redis.call('HGET', KEYS[2], field) ~= raw then return {2} end
311
+ end
312
+ if redis.call('HLEN', KEYS[2]) ~= expectedTileCount then return {2} end
313
+ local replacementTiles = cjson.decode(ARGV[4])
314
+ local function ascii(value)
315
+ if type(value) ~= 'string' or #value < 1 or #value > 128 then return false end
316
+ for i = 1, #value do
317
+ local b = string.byte(value, i)
318
+ if b < 32 or b > 126 then return false end
319
+ end
320
+ return true
321
+ end
322
+ local function integer(value)
323
+ return type(value) == 'number' and value >= 1 and value <= 9007199254740991
324
+ and value == math.floor(value)
325
+ end
326
+ local function validDef(def)
327
+ return type(def) == 'table' and def.version == 1 and ascii(def.generation)
328
+ and type(def.bounds) == 'table' and type(def.bounds.x) == 'number'
329
+ and type(def.bounds.y) == 'number' and type(def.bounds.w) == 'number' and def.bounds.w > 0
330
+ and type(def.bounds.h) == 'number' and def.bounds.h > 0 and type(def.cellSize) == 'number'
331
+ and def.cellSize > 0 and def.tileCells == 128 and (def.base == 'covered' or def.base == 'revealed')
332
+ end
333
+ local function validMeta(value)
334
+ return type(value) == 'table' and integer(value.version) and ascii(value.editor)
335
+ and (value.definition == nil or validDef(value.definition))
336
+ end
337
+ local current = nil
338
+ if currentRaw then
339
+ local ok, decoded = pcall(cjson.decode, currentRaw)
340
+ if ok and validMeta(decoded) then current = decoded else redis.call('HDEL', KEYS[1], 'current') end
341
+ end
342
+ local function newer(a, b)
343
+ return a.version > b.version or (a.version == b.version and a.editor > b.editor)
344
+ end
345
+ if current and not newer(incoming, current) then return {0, currentRaw} end
346
+ local oldDef = current and current.definition or nil
347
+ local newDef = incoming.definition
348
+ if oldDef and newDef and oldDef.generation == newDef.generation
349
+ and (oldDef.cellSize ~= newDef.cellSize or oldDef.tileCells ~= newDef.tileCells
350
+ or oldDef.base ~= newDef.base or newDef.bounds.x > oldDef.bounds.x
351
+ or newDef.bounds.y > oldDef.bounds.y
352
+ or newDef.bounds.x + newDef.bounds.w < oldDef.bounds.x + oldDef.bounds.w
353
+ or newDef.bounds.y + newDef.bounds.h < oldDef.bounds.y + oldDef.bounds.h) then
354
+ return {0, currentRaw}
355
+ end
356
+
357
+ redis.call('HSET', KEYS[1], 'current', incomingRaw)
358
+ redis.call('DEL', KEYS[2])
359
+ if newDef and oldDef and oldDef.generation == newDef.generation then
360
+ for i = 1, #replacementTiles do
361
+ local tile = replacementTiles[i]
362
+ redis.call('HSET', KEYS[2], tostring(tile.x) .. ',' .. tostring(tile.y), cjson.encode(tile))
363
+ end
364
+ end
365
+ return {1}
366
+ `;
367
+ var FOG_PATCH_LWW_SCRIPT = `
368
+ local incomingRaws = cjson.decode(ARGV[1])
369
+ local metaRaw = redis.call('HGET', KEYS[1], 'current')
370
+ if (metaRaw or '') ~= ARGV[2] then return {2} end
371
+ local invalidStored = cjson.decode(ARGV[3])
372
+ for field, raw in pairs(invalidStored) do
373
+ if redis.call('HGET', KEYS[2], field) == raw then redis.call('HDEL', KEYS[2], field) end
374
+ end
375
+ if not metaRaw then return {0, 0} end
376
+ local metaOk, meta = pcall(cjson.decode, metaRaw)
377
+ if not metaOk or type(meta) ~= 'table' or type(meta.definition) ~= 'table'
378
+ or type(meta.definition.generation) ~= 'string' then return {0, 0} end
379
+ local def = meta.definition
380
+ local tileSize = 128 * def.cellSize
381
+ local function intersects(tile)
382
+ local tx = tile.x * tileSize
383
+ local ty = tile.y * tileSize
384
+ return tx + tileSize > def.bounds.x and ty + tileSize > def.bounds.y
385
+ and tx < def.bounds.x + def.bounds.w and ty < def.bounds.y + def.bounds.h
386
+ end
387
+ local function newer(a, b)
388
+ return a.version > b.version or (a.version == b.version and a.editor > b.editor)
389
+ end
390
+ local function validTile(tile)
391
+ if type(tile) ~= 'table' then return false end
392
+ local function ascii(value)
393
+ if type(value) ~= 'string' or #value < 1 or #value > 128 then return false end
394
+ for i = 1, #value do
395
+ local b = string.byte(value, i)
396
+ if b < 32 or b > 126 then return false end
397
+ end
398
+ return true
399
+ end
400
+ local dataOk = tile.data == nil or (type(tile.data) == 'string' and #tile.data == 2732
401
+ and string.match(tile.data, '^[A-Za-z0-9+/]+[AEIMQUYcgkosw048]=$') ~= nil)
402
+ return ascii(tile.generation)
403
+ and type(tile.x) == 'number' and math.abs(tile.x) <= 9007199254740991
404
+ and tile.x == math.floor(tile.x)
405
+ and type(tile.y) == 'number' and math.abs(tile.y) <= 9007199254740991
406
+ and tile.y == math.floor(tile.y)
407
+ and type(tile.version) == 'number' and tile.version >= 1
408
+ and tile.version <= 9007199254740991 and tile.version == math.floor(tile.version)
409
+ and ascii(tile.editor) and dataOk
410
+ end
411
+ local stored = redis.call('HGETALL', KEYS[2])
412
+ local validCount = 0
413
+ for i = 1, #stored, 2 do
414
+ local ok, tile = pcall(cjson.decode, stored[i + 1])
415
+ if not ok or not validTile(tile) or stored[i] ~= tostring(tile.x) .. ',' .. tostring(tile.y)
416
+ or tile.generation ~= def.generation or not intersects(tile) then
417
+ redis.call('HDEL', KEYS[2], stored[i])
418
+ else
419
+ validCount = validCount + 1
420
+ end
421
+ end
422
+ local accepted = {}
423
+ local corrections = {}
424
+ local newCount = 0
425
+ local currents = {}
426
+ for i = 1, #incomingRaws do
427
+ local incomingRaw = cjson.encode(incomingRaws[i])
428
+ local incoming = incomingRaws[i]
429
+ local field = tostring(incoming.x) .. ',' .. tostring(incoming.y)
430
+ local currentRaw = redis.call('HGET', KEYS[2], field)
431
+ local current = nil
432
+ if currentRaw then
433
+ local ok, decoded = pcall(cjson.decode, currentRaw)
434
+ if ok and validTile(decoded) then current = decoded else currentRaw = nil end
435
+ end
436
+ currents[i] = currentRaw or false
437
+ if incoming.generation ~= def.generation or not intersects(incoming)
438
+ or (current and not newer(incoming, current)) then
439
+ corrections[#corrections + 1] = currentRaw or cjson.encode({generation=def.generation,
440
+ x=incoming.x, y=incoming.y, version=1, editor='hub'})
441
+ else
442
+ accepted[#accepted + 1] = incomingRaw
443
+ if not current then newCount = newCount + 1 end
444
+ end
445
+ end
446
+ if validCount + newCount > 256 then
447
+ accepted = {}
448
+ corrections = {}
449
+ for i = 1, #incomingRaws do
450
+ local incoming = incomingRaws[i]
451
+ corrections[#corrections + 1] = currents[i] or cjson.encode({generation=def.generation,
452
+ x=incoming.x, y=incoming.y, version=1, editor='hub'})
453
+ end
454
+ else
455
+ for i = 1, #accepted do
456
+ local record = cjson.decode(accepted[i])
457
+ redis.call('HSET', KEYS[2], tostring(record.x) .. ',' .. tostring(record.y), accepted[i])
458
+ end
459
+ end
460
+ local result = {#accepted}
461
+ for i = 1, #accepted do result[#result + 1] = accepted[i] end
462
+ result[#result + 1] = #corrections
463
+ for i = 1, #corrections do result[#result + 1] = corrections[i] end
464
+ return result
465
+ `;
45
466
 
46
467
  // src/redis-hub-fanout.ts
47
468
  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 { isValidElement, type SyncOp } from '@fieldnotes/sync';\r\nimport type { CanvasElement } from '@fieldnotes/core';\r\nimport type { HubBackend } from '@fieldnotes/sync-server';\r\nimport type { RedisHashClient } from './redis-hash-client';\r\n\r\nexport interface RedisHubBackendOptions {\r\n keyPrefix?: string; // default 'fieldnotes:room:'\r\n}\r\n\r\nexport class RedisHubBackend implements HubBackend {\r\n private readonly client: RedisHashClient;\r\n private readonly keyPrefix: string;\r\n\r\n constructor(client: RedisHashClient, options: RedisHubBackendOptions = {}) {\r\n this.client = client;\r\n this.keyPrefix = options.keyPrefix ?? 'fieldnotes:room:';\r\n }\r\n\r\n private key(room: string): string {\r\n return `${this.keyPrefix}${room}`;\r\n }\r\n\r\n async snapshot(room: string): Promise<CanvasElement[]> {\r\n const map = await this.client.hGetAll(this.key(room));\r\n const out: CanvasElement[] = [];\r\n for (const value of Object.values(map)) {\r\n let parsed: unknown;\r\n try {\r\n parsed = JSON.parse(value);\r\n } catch {\r\n continue; // skip a corrupt stored value rather than throwing the whole snapshot\r\n }\r\n if (isValidElement(parsed)) out.push(parsed);\r\n }\r\n return out;\r\n }\r\n\r\n async get(room: string, id: string): Promise<CanvasElement | undefined> {\r\n const value = await this.client.hGet(this.key(room), id);\r\n if (value == null) return undefined;\r\n try {\r\n const parsed: unknown = JSON.parse(value);\r\n return isValidElement(parsed) ? parsed : undefined;\r\n } catch {\r\n return undefined;\r\n }\r\n }\r\n\r\n async apply(room: string, op: SyncOp): Promise<void> {\r\n const key = this.key(room);\r\n if (op.kind === 'upsert')\r\n await this.client.hSet(key, op.element.id, JSON.stringify(op.element));\r\n else if (op.kind === 'remove') await this.client.hDel(key, op.id);\r\n else if (op.kind === 'clear') await this.client.del(key);\r\n // request-snapshot/snapshot never reach apply (the hub only applies data ops)\r\n }\r\n}\r\n","import type { HubFanout } from '@fieldnotes/sync-server';\nimport type { RedisPublisher, RedisSubscriber } from './redis-fanout-client';\n\nexport interface RedisHubFanoutOptions {\n channel?: string;\n onError?: (err: unknown) => void;\n}\n\nexport class RedisHubFanout implements HubFanout {\n private readonly publisher: RedisPublisher;\n private readonly subscriber: RedisSubscriber;\n private readonly channel: string;\n private readonly onError: (err: unknown) => void;\n private readonly handlers = new Set<(payload: string) => void>();\n private subscribed = false;\n\n constructor(\n publisher: RedisPublisher,\n subscriber: RedisSubscriber,\n options: RedisHubFanoutOptions = {},\n ) {\n this.publisher = publisher;\n this.subscriber = subscriber;\n this.channel = options.channel ?? 'fieldnotes:fanout';\n this.onError = options.onError ?? (() => undefined);\n }\n\n async publish(payload: string): Promise<void> {\n try {\n await this.publisher.publish(this.channel, payload);\n } catch (error) {\n try {\n this.onError(error);\n } catch {\n /* preserve the publication failure even when the observer throws */\n }\n throw error;\n }\n }\n\n subscribe(handler: (payload: string) => void): () => void {\n this.handlers.add(handler);\n if (!this.subscribed) {\n this.subscribed = true;\n Promise.resolve(\n this.subscriber.subscribe(this.channel, (message) => {\n for (const h of this.handlers) {\n try {\n h(message);\n } catch {\n /* isolate handlers */\n }\n }\n }),\n ).catch(this.onError);\n }\n return () => this.handlers.delete(handler);\n }\n}\n"],"mappings":";AAAA,SAAS,sBAAmC;AASrC,IAAM,kBAAN,MAA4C;AAAA,EAChC;AAAA,EACA;AAAA,EAEjB,YAAY,QAAyB,UAAkC,CAAC,GAAG;AACzE,SAAK,SAAS;AACd,SAAK,YAAY,QAAQ,aAAa;AAAA,EACxC;AAAA,EAEQ,IAAI,MAAsB;AAChC,WAAO,GAAG,KAAK,SAAS,GAAG,IAAI;AAAA,EACjC;AAAA,EAEA,MAAM,SAAS,MAAwC;AACrD,UAAM,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,IAAI,IAAI,CAAC;AACpD,UAAM,MAAuB,CAAC;AAC9B,eAAW,SAAS,OAAO,OAAO,GAAG,GAAG;AACtC,UAAI;AACJ,UAAI;AACF,iBAAS,KAAK,MAAM,KAAK;AAAA,MAC3B,QAAQ;AACN;AAAA,MACF;AACA,UAAI,eAAe,MAAM,EAAG,KAAI,KAAK,MAAM;AAAA,IAC7C;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,IAAI,MAAc,IAAgD;AACtE,UAAM,QAAQ,MAAM,KAAK,OAAO,KAAK,KAAK,IAAI,IAAI,GAAG,EAAE;AACvD,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI;AACF,YAAM,SAAkB,KAAK,MAAM,KAAK;AACxC,aAAO,eAAe,MAAM,IAAI,SAAS;AAAA,IAC3C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,MAAM,MAAc,IAA2B;AACnD,UAAM,MAAM,KAAK,IAAI,IAAI;AACzB,QAAI,GAAG,SAAS;AACd,YAAM,KAAK,OAAO,KAAK,KAAK,GAAG,QAAQ,IAAI,KAAK,UAAU,GAAG,OAAO,CAAC;AAAA,aAC9D,GAAG,SAAS,SAAU,OAAM,KAAK,OAAO,KAAK,KAAK,GAAG,EAAE;AAAA,aACvD,GAAG,SAAS,QAAS,OAAM,KAAK,OAAO,IAAI,GAAG;AAAA,EAEzD;AACF;;;AChDO,IAAM,iBAAN,MAA0C;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW,oBAAI,IAA+B;AAAA,EACvD,aAAa;AAAA,EAErB,YACE,WACA,YACA,UAAiC,CAAC,GAClC;AACA,SAAK,YAAY;AACjB,SAAK,aAAa;AAClB,SAAK,UAAU,QAAQ,WAAW;AAClC,SAAK,UAAU,QAAQ,YAAY,MAAM;AAAA,EAC3C;AAAA,EAEA,MAAM,QAAQ,SAAgC;AAC5C,QAAI;AACF,YAAM,KAAK,UAAU,QAAQ,KAAK,SAAS,OAAO;AAAA,IACpD,SAAS,OAAO;AACd,UAAI;AACF,aAAK,QAAQ,KAAK;AAAA,MACpB,QAAQ;AAAA,MAER;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,UAAU,SAAgD;AACxD,SAAK,SAAS,IAAI,OAAO;AACzB,QAAI,CAAC,KAAK,YAAY;AACpB,WAAK,aAAa;AAClB,cAAQ;AAAA,QACN,KAAK,WAAW,UAAU,KAAK,SAAS,CAAC,YAAY;AACnD,qBAAW,KAAK,KAAK,UAAU;AAC7B,gBAAI;AACF,gBAAE,OAAO;AAAA,YACX,QAAQ;AAAA,YAER;AAAA,UACF;AAAA,QACF,CAAC;AAAA,MACH,EAAE,MAAM,KAAK,OAAO;AAAA,IACtB;AACA,WAAO,MAAM,KAAK,SAAS,OAAO,OAAO;AAAA,EAC3C;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/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 { 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fieldnotes/sync-redis",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "Redis-backed HubBackend for Field Notes real-time sync relay",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -36,14 +36,14 @@
36
36
  "fieldnotes"
37
37
  ],
38
38
  "dependencies": {
39
- "@fieldnotes/sync": "0.7.1"
39
+ "@fieldnotes/sync": "0.12.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@vitest/coverage-v8": "^4.1.0",
43
43
  "tsup": "^8.5.1",
44
44
  "vitest": "^4.1.0",
45
- "@fieldnotes/sync-server": "0.10.1",
46
- "@fieldnotes/core": "0.52.2"
45
+ "@fieldnotes/core": "0.66.0",
46
+ "@fieldnotes/sync-server": "0.14.0"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "tsup",