@fieldnotes/sync-server 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CanvasElement } from '@fieldnotes/core';
2
- import { SyncOp, LayerRecord } from '@fieldnotes/sync';
2
+ import { SyncOp, LayerRecord, FogSnapshot, FogMetaRecord, FogTileRecord } from '@fieldnotes/sync';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { IncomingMessage, Server } from 'http';
5
5
 
@@ -14,21 +14,27 @@ declare class InMemoryHubFanout implements HubFanout {
14
14
  subscribe(handler: (payload: string) => void): () => void;
15
15
  }
16
16
 
17
+ interface FogApplyResult<T> {
18
+ readonly accepted: boolean;
19
+ readonly correction?: T;
20
+ }
21
+ interface FogPatchApplyResult {
22
+ readonly accepted: FogTileRecord[];
23
+ readonly corrections: FogTileRecord[];
24
+ }
17
25
  interface HubBackend {
26
+ /** True when every hub instance addresses the same atomic backing state (for example Redis). */
27
+ readonly sharedAcrossInstances?: boolean;
18
28
  snapshot(room: string): Promise<CanvasElement[]>;
19
29
  get(room: string, id: string): Promise<CanvasElement | undefined>;
20
30
  apply(room: string, op: SyncOp): Promise<void>;
21
- /**
22
- * Optional versioned layer-definition persistence. The three methods are
23
- * additive and must be implemented together; when absent, the hub keeps
24
- * per-room layer records in its own memory (they then live and die with the
25
- * hub instance, while elements keep whatever durability the backend has).
26
- * Records include removal tombstones. An element `clear` op never touches
27
- * layer records.
28
- */
29
31
  layerRecords?(room: string): Promise<LayerRecord[]>;
30
32
  getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
31
33
  applyLayerRecord?(room: string, record: LayerRecord): Promise<void>;
34
+ fogSnapshot?(room: string): Promise<FogSnapshot | undefined>;
35
+ applyFogMeta?(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>>;
36
+ applyFogTile?(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>>;
37
+ applyFogPatch?(room: string, records: readonly FogTileRecord[]): Promise<FogPatchApplyResult>;
32
38
  }
33
39
 
34
40
  type OwnedElement = CanvasElement & {
@@ -67,6 +73,16 @@ interface ReadContext {
67
73
  audience: string | undefined;
68
74
  }
69
75
  type CanRead = (ctx: ReadContext) => boolean;
76
+ interface AuthorizeFogContext {
77
+ userId?: string;
78
+ role?: string;
79
+ room: string;
80
+ op: Extract<SyncOp, {
81
+ kind: 'fog-meta' | 'fog-patch';
82
+ }>;
83
+ current: FogSnapshot | undefined;
84
+ }
85
+ type AuthorizeFog = (ctx: AuthorizeFogContext) => boolean | Promise<boolean>;
70
86
 
71
87
  interface Connection {
72
88
  id: string;
@@ -81,6 +97,7 @@ interface SyncHubOptions {
81
97
  instanceId?: string;
82
98
  authorize?: Authorize;
83
99
  authorizeLayer?: AuthorizeLayer;
100
+ authorizeFog?: AuthorizeFog;
84
101
  canRead?: CanRead;
85
102
  maxJsonDepth?: number;
86
103
  presenceThrottleMs?: number;
@@ -97,9 +114,10 @@ declare class SyncHub {
97
114
  private readonly fanoutUnsub;
98
115
  private readonly authorize?;
99
116
  private readonly authorizeLayer?;
117
+ private readonly authorizeFog?;
100
118
  private readonly canRead?;
101
- /** Fallback layer-record store for backends without layer persistence. */
102
119
  private readonly memoryLayers;
120
+ private readonly memoryFog;
103
121
  private readonly maxJsonDepth;
104
122
  private readonly presenceThrottleMs;
105
123
  private readonly maxPresenceLanes;
@@ -137,6 +155,12 @@ declare class SyncHub {
137
155
  private getLayerRecords;
138
156
  private getLayerRecord;
139
157
  private applyLayerRecord;
158
+ private fogBackend;
159
+ private getFogLedger;
160
+ private getFogSnapshot;
161
+ private applyFogMeta;
162
+ private applyFogPatch;
163
+ private processFogOp;
140
164
  private mayRead;
141
165
  private safePublish;
142
166
  private relayToRoom;
@@ -149,12 +173,14 @@ declare class SyncHub {
149
173
  private sendCorrection;
150
174
  private onFanout;
151
175
  private applyFanoutLayerOp;
176
+ private applyFanoutFogOp;
152
177
  close(): void;
153
178
  }
154
179
 
155
180
  declare class MemoryHubBackend implements HubBackend {
156
181
  private rooms;
157
182
  private roomLayers;
183
+ private roomFog;
158
184
  private room;
159
185
  private layers;
160
186
  snapshot(room: string): Promise<CanvasElement[]>;
@@ -163,6 +189,11 @@ declare class MemoryHubBackend implements HubBackend {
163
189
  layerRecords(room: string): Promise<LayerRecord[]>;
164
190
  getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
165
191
  applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
192
+ private fog;
193
+ fogSnapshot(room: string): Promise<FogSnapshot | undefined>;
194
+ applyFogMeta(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>>;
195
+ applyFogTile(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>>;
196
+ applyFogPatch(room: string, records: readonly FogTileRecord[]): Promise<FogPatchApplyResult>;
166
197
  }
167
198
 
168
199
  interface AuthInfo {
@@ -184,6 +215,7 @@ interface CreateSyncServerOptions {
184
215
  authenticate?: Authenticate;
185
216
  authorize?: Authorize;
186
217
  authorizeLayer?: AuthorizeLayer;
218
+ authorizeFog?: AuthorizeFog;
187
219
  canRead?: CanRead;
188
220
  heartbeatIntervalMs?: number;
189
221
  maxMessageBytes?: number;
@@ -216,4 +248,32 @@ interface Heartbeat {
216
248
  }
217
249
  declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
218
250
 
219
- export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
251
+ interface ApplyResult {
252
+ accepted: SyncOp | null;
253
+ corrections: SyncOp[];
254
+ broadcast?: SyncOp[];
255
+ locality?: 'shared' | 'local';
256
+ }
257
+ interface ServerOpContext {
258
+ readonly room: string;
259
+ readonly sender: string;
260
+ readonly backend: HubBackend;
261
+ }
262
+ type ServerNext = () => Promise<ApplyResult>;
263
+ interface ServerSyncPlugin {
264
+ readonly name: string;
265
+ readonly ownedLegacyKinds?: string[];
266
+ process?(op: SyncOp, ctx: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
267
+ snapshot?(room: string, backend: HubBackend): Promise<PluginSnapshot>;
268
+ filterSnapshot?(snapshot: PluginSnapshot, viewer: {
269
+ userId: string;
270
+ role: string;
271
+ }): PluginSnapshot | null;
272
+ }
273
+ interface PluginSnapshot {
274
+ readonly pluginName: string;
275
+ readonly version: number;
276
+ readonly data: unknown;
277
+ }
278
+
279
+ export { type ApplyResult, type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeFog, type AuthorizeFogContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type Connection, type CreateSyncServerOptions, type FogApplyResult, type FogPatchApplyResult, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type PluginSnapshot, type ReadContext, type ServerNext, type ServerOpContext, type ServerSyncPlugin, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CanvasElement } from '@fieldnotes/core';
2
- import { SyncOp, LayerRecord } from '@fieldnotes/sync';
2
+ import { SyncOp, LayerRecord, FogSnapshot, FogMetaRecord, FogTileRecord } from '@fieldnotes/sync';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { IncomingMessage, Server } from 'http';
5
5
 
@@ -14,21 +14,27 @@ declare class InMemoryHubFanout implements HubFanout {
14
14
  subscribe(handler: (payload: string) => void): () => void;
15
15
  }
16
16
 
17
+ interface FogApplyResult<T> {
18
+ readonly accepted: boolean;
19
+ readonly correction?: T;
20
+ }
21
+ interface FogPatchApplyResult {
22
+ readonly accepted: FogTileRecord[];
23
+ readonly corrections: FogTileRecord[];
24
+ }
17
25
  interface HubBackend {
26
+ /** True when every hub instance addresses the same atomic backing state (for example Redis). */
27
+ readonly sharedAcrossInstances?: boolean;
18
28
  snapshot(room: string): Promise<CanvasElement[]>;
19
29
  get(room: string, id: string): Promise<CanvasElement | undefined>;
20
30
  apply(room: string, op: SyncOp): Promise<void>;
21
- /**
22
- * Optional versioned layer-definition persistence. The three methods are
23
- * additive and must be implemented together; when absent, the hub keeps
24
- * per-room layer records in its own memory (they then live and die with the
25
- * hub instance, while elements keep whatever durability the backend has).
26
- * Records include removal tombstones. An element `clear` op never touches
27
- * layer records.
28
- */
29
31
  layerRecords?(room: string): Promise<LayerRecord[]>;
30
32
  getLayerRecord?(room: string, id: string): Promise<LayerRecord | undefined>;
31
33
  applyLayerRecord?(room: string, record: LayerRecord): Promise<void>;
34
+ fogSnapshot?(room: string): Promise<FogSnapshot | undefined>;
35
+ applyFogMeta?(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>>;
36
+ applyFogTile?(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>>;
37
+ applyFogPatch?(room: string, records: readonly FogTileRecord[]): Promise<FogPatchApplyResult>;
32
38
  }
33
39
 
34
40
  type OwnedElement = CanvasElement & {
@@ -67,6 +73,16 @@ interface ReadContext {
67
73
  audience: string | undefined;
68
74
  }
69
75
  type CanRead = (ctx: ReadContext) => boolean;
76
+ interface AuthorizeFogContext {
77
+ userId?: string;
78
+ role?: string;
79
+ room: string;
80
+ op: Extract<SyncOp, {
81
+ kind: 'fog-meta' | 'fog-patch';
82
+ }>;
83
+ current: FogSnapshot | undefined;
84
+ }
85
+ type AuthorizeFog = (ctx: AuthorizeFogContext) => boolean | Promise<boolean>;
70
86
 
71
87
  interface Connection {
72
88
  id: string;
@@ -81,6 +97,7 @@ interface SyncHubOptions {
81
97
  instanceId?: string;
82
98
  authorize?: Authorize;
83
99
  authorizeLayer?: AuthorizeLayer;
100
+ authorizeFog?: AuthorizeFog;
84
101
  canRead?: CanRead;
85
102
  maxJsonDepth?: number;
86
103
  presenceThrottleMs?: number;
@@ -97,9 +114,10 @@ declare class SyncHub {
97
114
  private readonly fanoutUnsub;
98
115
  private readonly authorize?;
99
116
  private readonly authorizeLayer?;
117
+ private readonly authorizeFog?;
100
118
  private readonly canRead?;
101
- /** Fallback layer-record store for backends without layer persistence. */
102
119
  private readonly memoryLayers;
120
+ private readonly memoryFog;
103
121
  private readonly maxJsonDepth;
104
122
  private readonly presenceThrottleMs;
105
123
  private readonly maxPresenceLanes;
@@ -137,6 +155,12 @@ declare class SyncHub {
137
155
  private getLayerRecords;
138
156
  private getLayerRecord;
139
157
  private applyLayerRecord;
158
+ private fogBackend;
159
+ private getFogLedger;
160
+ private getFogSnapshot;
161
+ private applyFogMeta;
162
+ private applyFogPatch;
163
+ private processFogOp;
140
164
  private mayRead;
141
165
  private safePublish;
142
166
  private relayToRoom;
@@ -149,12 +173,14 @@ declare class SyncHub {
149
173
  private sendCorrection;
150
174
  private onFanout;
151
175
  private applyFanoutLayerOp;
176
+ private applyFanoutFogOp;
152
177
  close(): void;
153
178
  }
154
179
 
155
180
  declare class MemoryHubBackend implements HubBackend {
156
181
  private rooms;
157
182
  private roomLayers;
183
+ private roomFog;
158
184
  private room;
159
185
  private layers;
160
186
  snapshot(room: string): Promise<CanvasElement[]>;
@@ -163,6 +189,11 @@ declare class MemoryHubBackend implements HubBackend {
163
189
  layerRecords(room: string): Promise<LayerRecord[]>;
164
190
  getLayerRecord(room: string, id: string): Promise<LayerRecord | undefined>;
165
191
  applyLayerRecord(room: string, record: LayerRecord): Promise<void>;
192
+ private fog;
193
+ fogSnapshot(room: string): Promise<FogSnapshot | undefined>;
194
+ applyFogMeta(room: string, record: FogMetaRecord): Promise<FogApplyResult<FogMetaRecord>>;
195
+ applyFogTile(room: string, record: FogTileRecord): Promise<FogApplyResult<FogTileRecord>>;
196
+ applyFogPatch(room: string, records: readonly FogTileRecord[]): Promise<FogPatchApplyResult>;
166
197
  }
167
198
 
168
199
  interface AuthInfo {
@@ -184,6 +215,7 @@ interface CreateSyncServerOptions {
184
215
  authenticate?: Authenticate;
185
216
  authorize?: Authorize;
186
217
  authorizeLayer?: AuthorizeLayer;
218
+ authorizeFog?: AuthorizeFog;
187
219
  canRead?: CanRead;
188
220
  heartbeatIntervalMs?: number;
189
221
  maxMessageBytes?: number;
@@ -216,4 +248,32 @@ interface Heartbeat {
216
248
  }
217
249
  declare function startHeartbeat(wss: HeartbeatServer, intervalMs: number): Heartbeat;
218
250
 
219
- export { type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type Connection, type CreateSyncServerOptions, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type ReadContext, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
251
+ interface ApplyResult {
252
+ accepted: SyncOp | null;
253
+ corrections: SyncOp[];
254
+ broadcast?: SyncOp[];
255
+ locality?: 'shared' | 'local';
256
+ }
257
+ interface ServerOpContext {
258
+ readonly room: string;
259
+ readonly sender: string;
260
+ readonly backend: HubBackend;
261
+ }
262
+ type ServerNext = () => Promise<ApplyResult>;
263
+ interface ServerSyncPlugin {
264
+ readonly name: string;
265
+ readonly ownedLegacyKinds?: string[];
266
+ process?(op: SyncOp, ctx: ServerOpContext, next: ServerNext): Promise<ApplyResult>;
267
+ snapshot?(room: string, backend: HubBackend): Promise<PluginSnapshot>;
268
+ filterSnapshot?(snapshot: PluginSnapshot, viewer: {
269
+ userId: string;
270
+ role: string;
271
+ }): PluginSnapshot | null;
272
+ }
273
+ interface PluginSnapshot {
274
+ readonly pluginName: string;
275
+ readonly version: number;
276
+ readonly data: unknown;
277
+ }
278
+
279
+ export { type ApplyResult, type AuthInfo, type AuthResult, type Authenticate, type Authorize, type AuthorizeContext, type AuthorizeFog, type AuthorizeFogContext, type AuthorizeLayer, type AuthorizeLayerContext, type CanRead, type Connection, type CreateSyncServerOptions, type FogApplyResult, type FogPatchApplyResult, type Heartbeat, type HeartbeatServer, type HeartbeatSocket, type HubBackend, type HubFanout, InMemoryHubFanout, MemoryHubBackend, type OwnedElement, type PluginSnapshot, type ReadContext, type ServerNext, type ServerOpContext, type ServerSyncPlugin, SyncHub, type SyncHubOptions, createSyncServer, startHeartbeat };
package/dist/index.js CHANGED
@@ -2,14 +2,19 @@
2
2
  import {
3
3
  parseEnvelope,
4
4
  isValidElement,
5
- isNewerLayerRecord
5
+ isNewerLayerRecord,
6
+ FogLedger as FogLedger2
6
7
  } from "@fieldnotes/sync";
7
8
 
8
9
  // src/memory-hub-backend.ts
9
- import { applyOpToMap } from "@fieldnotes/sync";
10
+ import {
11
+ applyOpToMap,
12
+ FogLedger
13
+ } from "@fieldnotes/sync";
10
14
  var MemoryHubBackend = class {
11
15
  rooms = /* @__PURE__ */ new Map();
12
16
  roomLayers = /* @__PURE__ */ new Map();
17
+ roomFog = /* @__PURE__ */ new Map();
13
18
  room(id) {
14
19
  let r = this.rooms.get(id);
15
20
  if (!r) {
@@ -48,6 +53,26 @@ var MemoryHubBackend = class {
48
53
  async applyLayerRecord(room, record) {
49
54
  this.layers(room).set(record.id, record);
50
55
  }
56
+ fog(room) {
57
+ let ledger = this.roomFog.get(room);
58
+ if (!ledger) {
59
+ ledger = new FogLedger();
60
+ this.roomFog.set(room, ledger);
61
+ }
62
+ return ledger;
63
+ }
64
+ async fogSnapshot(room) {
65
+ return this.fog(room).snapshot();
66
+ }
67
+ async applyFogMeta(room, record) {
68
+ return this.fog(room).applyMeta(record);
69
+ }
70
+ async applyFogTile(room, record) {
71
+ return this.fog(room).applyTile(record);
72
+ }
73
+ async applyFogPatch(room, records) {
74
+ return this.fog(room).applyPatch(records);
75
+ }
51
76
  };
52
77
 
53
78
  // src/hub-fanout.ts
@@ -162,6 +187,11 @@ function isPresenceOp(op) {
162
187
  const k = op.kind;
163
188
  return k === "presence" || k === "presence-leave";
164
189
  }
190
+ function isFogOp(op) {
191
+ if (typeof op !== "object" || op === null) return false;
192
+ const k = op.kind;
193
+ return k === "fog-meta" || k === "fog-patch";
194
+ }
165
195
  var SyncHub = class {
166
196
  backend;
167
197
  conns = /* @__PURE__ */ new Map();
@@ -175,9 +205,10 @@ var SyncHub = class {
175
205
  fanoutUnsub;
176
206
  authorize;
177
207
  authorizeLayer;
208
+ authorizeFog;
178
209
  canRead;
179
- /** Fallback layer-record store for backends without layer persistence. */
180
210
  memoryLayers = /* @__PURE__ */ new Map();
211
+ memoryFog = /* @__PURE__ */ new Map();
181
212
  maxJsonDepth;
182
213
  presenceThrottleMs;
183
214
  maxPresenceLanes;
@@ -193,10 +224,21 @@ var SyncHub = class {
193
224
  presenceLanes = /* @__PURE__ */ new Map();
194
225
  constructor(options = {}) {
195
226
  this.backend = options.backend ?? new MemoryHubBackend();
227
+ const fogMethods = [
228
+ this.backend.fogSnapshot,
229
+ this.backend.applyFogMeta,
230
+ this.backend.applyFogPatch
231
+ ];
232
+ if ((fogMethods.some(Boolean) || Boolean(this.backend.applyFogTile)) && !fogMethods.every(Boolean)) {
233
+ throw new Error(
234
+ "HubBackend fog support is an all-or-none capability: fogSnapshot, applyFogMeta, and applyFogPatch are required"
235
+ );
236
+ }
196
237
  this.instanceId = options.instanceId ?? generateInstanceId();
197
238
  this.fanout = options.fanout ?? new InMemoryHubFanout();
198
239
  this.authorize = options.authorize;
199
240
  this.authorizeLayer = options.authorizeLayer;
241
+ this.authorizeFog = options.authorizeFog;
200
242
  this.canRead = options.canRead;
201
243
  this.maxJsonDepth = options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH;
202
244
  this.presenceThrottleMs = options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
@@ -273,16 +315,19 @@ var SyncHub = class {
273
315
  const all = await this.backend.snapshot(conn.room);
274
316
  const elements = this.canRead ? all.filter((el) => this.mayRead(conn, el.audience)) : all;
275
317
  const layers = await this.getLayerRecords(conn.room);
276
- conn.send(
277
- // `to` is a private correlation address for the requesting SyncClient. It is never
278
- // broadcast; every public sender identity below comes from the server-owned connection.
279
- JSON.stringify({
280
- from: HUB_FROM,
281
- op: layers.length > 0 ? { kind: "snapshot", to: env.from, elements, layers } : { kind: "snapshot", to: env.from, elements }
282
- })
283
- );
318
+ const fog = await this.getFogSnapshot(conn.room);
319
+ const snapshotOp = {
320
+ kind: "snapshot",
321
+ to: env.from,
322
+ elements
323
+ };
324
+ if (layers.length > 0) snapshotOp["layers"] = layers;
325
+ if (fog) snapshotOp["fog"] = fog;
326
+ conn.send(JSON.stringify({ from: HUB_FROM, op: snapshotOp }));
284
327
  } else if (op.kind === "layer-upsert" || op.kind === "layer-remove") {
285
328
  await this.processLayerOp(conn, op);
329
+ } else if (op.kind === "fog-meta" || op.kind === "fog-patch") {
330
+ await this.processFogOp(conn, op);
286
331
  } else if (op.kind === "upsert" || op.kind === "remove" || op.kind === "clear") {
287
332
  const id = op.kind === "upsert" ? op.element.id : op.kind === "remove" ? op.id : void 0;
288
333
  const needCurrent = (this.authorize || this.canRead) && id !== void 0;
@@ -388,6 +433,121 @@ var SyncHub = class {
388
433
  }
389
434
  map.set(record.id, record);
390
435
  }
436
+ // ── Fog processing ──
437
+ fogBackend() {
438
+ const { fogSnapshot, applyFogMeta, applyFogPatch } = this.backend;
439
+ if (!fogSnapshot || !applyFogMeta || !applyFogPatch) return null;
440
+ return {
441
+ fogSnapshot: fogSnapshot.bind(this.backend),
442
+ applyFogMeta: applyFogMeta.bind(this.backend),
443
+ applyFogPatch: applyFogPatch.bind(this.backend)
444
+ };
445
+ }
446
+ getFogLedger(room) {
447
+ let ledger = this.memoryFog.get(room);
448
+ if (!ledger) {
449
+ ledger = new FogLedger2();
450
+ this.memoryFog.set(room, ledger);
451
+ }
452
+ return ledger;
453
+ }
454
+ async getFogSnapshot(room) {
455
+ const backend = this.fogBackend();
456
+ if (backend) return backend.fogSnapshot(room);
457
+ return this.getFogLedger(room).snapshot();
458
+ }
459
+ async applyFogMeta(room, record) {
460
+ const backend = this.fogBackend();
461
+ if (backend) return backend.applyFogMeta(room, record);
462
+ return this.getFogLedger(room).applyMeta(record);
463
+ }
464
+ async applyFogPatch(room, records) {
465
+ const backend = this.fogBackend();
466
+ if (backend) return backend.applyFogPatch(room, records);
467
+ return this.getFogLedger(room).applyPatch(records);
468
+ }
469
+ async processFogOp(conn, op) {
470
+ const current = await this.getFogSnapshot(conn.room);
471
+ if (this.authorizeFog) {
472
+ const allowed = await this.authorizeFog({
473
+ userId: conn.userId,
474
+ role: conn.role,
475
+ room: conn.room,
476
+ op,
477
+ current
478
+ });
479
+ if (!allowed) {
480
+ if (op.kind === "fog-meta") {
481
+ const correction = current?.meta ?? { version: 1, editor: HUB_FROM };
482
+ conn.send(
483
+ JSON.stringify({ from: HUB_FROM, op: { kind: "fog-meta", record: correction } })
484
+ );
485
+ } else if (current?.meta.definition) {
486
+ const corrections = op.tiles.map((t) => {
487
+ const existing = current.tiles.find((ct) => ct.x === t.x && ct.y === t.y);
488
+ return existing ?? {
489
+ generation: current.meta.definition?.generation ?? op.generation,
490
+ x: t.x,
491
+ y: t.y,
492
+ version: 1,
493
+ editor: HUB_FROM
494
+ };
495
+ });
496
+ conn.send(
497
+ JSON.stringify({
498
+ from: HUB_FROM,
499
+ op: {
500
+ kind: "fog-patch",
501
+ generation: current.meta.definition.generation,
502
+ tiles: corrections
503
+ }
504
+ })
505
+ );
506
+ } else {
507
+ conn.send(
508
+ JSON.stringify({
509
+ from: HUB_FROM,
510
+ op: {
511
+ kind: "fog-meta",
512
+ record: current?.meta ?? { version: 1, editor: HUB_FROM }
513
+ }
514
+ })
515
+ );
516
+ }
517
+ return;
518
+ }
519
+ }
520
+ let outbound;
521
+ if (op.kind === "fog-meta") {
522
+ const result = await this.applyFogMeta(conn.room, op.record);
523
+ if (!result.accepted) {
524
+ if (result.correction) {
525
+ conn.send(
526
+ JSON.stringify({ from: HUB_FROM, op: { kind: "fog-meta", record: result.correction } })
527
+ );
528
+ }
529
+ return;
530
+ }
531
+ outbound = op;
532
+ } else {
533
+ const { accepted, corrections } = await this.applyFogPatch(conn.room, op.tiles);
534
+ if (corrections.length > 0) {
535
+ const correctionGeneration = corrections[0]?.generation ?? op.generation;
536
+ conn.send(
537
+ JSON.stringify({
538
+ from: HUB_FROM,
539
+ op: { kind: "fog-patch", generation: correctionGeneration, tiles: corrections }
540
+ })
541
+ );
542
+ }
543
+ if (accepted.length === 0) return;
544
+ outbound = { kind: "fog-patch", generation: op.generation, tiles: accepted };
545
+ }
546
+ await this.fanout.publish(
547
+ JSON.stringify({ o: this.instanceId, room: conn.room, from: conn.id, op: outbound })
548
+ );
549
+ this.relayToRoom(conn.room, conn.id, JSON.stringify({ from: conn.id, op: outbound }));
550
+ }
391
551
  mayRead(conn, audience) {
392
552
  if (!this.canRead) return true;
393
553
  return this.canRead({ userId: conn.userId, role: conn.role, room: conn.room, audience });
@@ -568,6 +728,25 @@ var SyncHub = class {
568
728
  this.relayToRoom(env.room, void 0, JSON.stringify({ from: env.from, op }));
569
729
  return;
570
730
  }
731
+ if (isFogOp(op)) {
732
+ const previous = this.roomQueues.get(env.room) ?? Promise.resolve();
733
+ const operation = previous.then(async () => {
734
+ const accepted = await this.applyFanoutFogOp(env.room, op);
735
+ if (accepted) {
736
+ this.relayToRoom(
737
+ env.room,
738
+ void 0,
739
+ JSON.stringify({ from: env.from, op: accepted })
740
+ );
741
+ }
742
+ });
743
+ this.roomQueues.set(
744
+ env.room,
745
+ operation.catch(() => {
746
+ })
747
+ );
748
+ return;
749
+ }
571
750
  if (!isFanoutOp(op)) return;
572
751
  const prevAudience = typeof env.prev === "string" ? env.prev : void 0;
573
752
  const prevExisted = env.existed === true;
@@ -579,6 +758,15 @@ var SyncHub = class {
579
758
  if (current && !isNewerLayerRecord(record, current)) return;
580
759
  await this.applyLayerRecord(room, record);
581
760
  }
761
+ async applyFanoutFogOp(room, op) {
762
+ if (this.backend.sharedAcrossInstances === true && this.fogBackend()) return op;
763
+ if (op.kind === "fog-meta") {
764
+ const result = await this.applyFogMeta(room, op.record);
765
+ return result.accepted ? op : null;
766
+ }
767
+ const { accepted } = await this.applyFogPatch(room, op.tiles);
768
+ return accepted.length > 0 ? { ...op, tiles: accepted } : null;
769
+ }
582
770
  close() {
583
771
  for (const connId of [...this.presenceLanes.keys()]) this.clearPresenceLanes(connId);
584
772
  this.fanoutUnsub();
@@ -669,6 +857,7 @@ function createSyncServer(options = {}) {
669
857
  instanceId: options.instanceId,
670
858
  authorize: options.authorize,
671
859
  authorizeLayer: options.authorizeLayer,
860
+ authorizeFog: options.authorizeFog,
672
861
  canRead: options.canRead,
673
862
  maxJsonDepth: options.maxJsonDepth ?? DEFAULT_MAX_JSON_DEPTH,
674
863
  presenceThrottleMs: options.presenceThrottleMs ?? DEFAULT_PRESENCE_THROTTLE_MS,