@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 +8 -1
- package/dist/index.cjs +415 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -2
- package/dist/index.d.ts +19 -2
- package/dist/index.js +422 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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 {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/redis-hub-backend.ts","../src/redis-hub-fanout.ts"],"sourcesContent":["export { RedisHubBackend } from './redis-hub-backend';\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":"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","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 {
|
|
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
|
+
"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.
|
|
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/
|
|
46
|
-
"@fieldnotes/
|
|
45
|
+
"@fieldnotes/core": "0.66.0",
|
|
46
|
+
"@fieldnotes/sync-server": "0.14.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsup",
|