@sylphx/lens-server 1.11.3 → 2.1.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.ts +1262 -262
- package/dist/index.js +1714 -1154
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +123 -0
- package/src/server/types.ts +306 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
|
@@ -1,890 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @sylphx/lens-server - Graph State Manager
|
|
3
|
-
*
|
|
4
|
-
* Core orchestration layer that:
|
|
5
|
-
* - Maintains canonical state per entity (server truth)
|
|
6
|
-
* - Tracks per-client last known state
|
|
7
|
-
* - Computes minimal diffs when state changes
|
|
8
|
-
* - Auto-selects transfer strategy (value/delta/patch)
|
|
9
|
-
* - Pushes updates to subscribed clients
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
type ArrayOperation,
|
|
14
|
-
applyUpdate,
|
|
15
|
-
computeArrayDiff,
|
|
16
|
-
createUpdate,
|
|
17
|
-
type EmitCommand,
|
|
18
|
-
type EntityKey,
|
|
19
|
-
type InternalFieldUpdate,
|
|
20
|
-
makeEntityKey,
|
|
21
|
-
type Update,
|
|
22
|
-
} from "@sylphx/lens-core";
|
|
23
|
-
|
|
24
|
-
// Re-export for convenience
|
|
25
|
-
export type { EntityKey };
|
|
26
|
-
|
|
27
|
-
/** Client connection interface */
|
|
28
|
-
export interface StateClient {
|
|
29
|
-
id: string;
|
|
30
|
-
send: (message: StateUpdateMessage) => void;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Update message sent to clients */
|
|
34
|
-
export interface StateUpdateMessage {
|
|
35
|
-
type: "update";
|
|
36
|
-
entity: string;
|
|
37
|
-
id: string;
|
|
38
|
-
/** Field-level updates with strategy */
|
|
39
|
-
updates: Record<string, Update>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Full entity update message */
|
|
43
|
-
export interface StateFullMessage {
|
|
44
|
-
type: "data";
|
|
45
|
-
entity: string;
|
|
46
|
-
id: string;
|
|
47
|
-
data: Record<string, unknown>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Subscription info */
|
|
51
|
-
export interface Subscription {
|
|
52
|
-
clientId: string;
|
|
53
|
-
fields: Set<string> | "*";
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Per-client state for an entity */
|
|
57
|
-
interface ClientEntityState {
|
|
58
|
-
/** Last state sent to this client */
|
|
59
|
-
lastState: Record<string, unknown>;
|
|
60
|
-
/** Fields this client is subscribed to */
|
|
61
|
-
fields: Set<string> | "*";
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Per-client state for an array */
|
|
65
|
-
interface ClientArrayState {
|
|
66
|
-
/** Last array state sent to this client */
|
|
67
|
-
lastState: unknown[];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Configuration */
|
|
71
|
-
export interface GraphStateManagerConfig {
|
|
72
|
-
/** Called when an entity has no more subscribers */
|
|
73
|
-
onEntityUnsubscribed?: (entity: string, id: string) => void;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// =============================================================================
|
|
77
|
-
// GraphStateManager
|
|
78
|
-
// =============================================================================
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Manages server-side canonical state and syncs to clients.
|
|
82
|
-
*
|
|
83
|
-
* @example
|
|
84
|
-
* ```typescript
|
|
85
|
-
* const manager = new GraphStateManager();
|
|
86
|
-
*
|
|
87
|
-
* // Add client
|
|
88
|
-
* manager.addClient({
|
|
89
|
-
* id: "client-1",
|
|
90
|
-
* send: (msg) => ws.send(JSON.stringify(msg)),
|
|
91
|
-
* });
|
|
92
|
-
*
|
|
93
|
-
* // Subscribe client to entity
|
|
94
|
-
* manager.subscribe("client-1", "Post", "123", ["title", "content"]);
|
|
95
|
-
*
|
|
96
|
-
* // Emit updates (from resolvers)
|
|
97
|
-
* manager.emit("Post", "123", { content: "Updated content" });
|
|
98
|
-
* // → Automatically computes diff and sends to subscribed clients
|
|
99
|
-
* ```
|
|
100
|
-
*/
|
|
101
|
-
export class GraphStateManager {
|
|
102
|
-
/** Connected clients */
|
|
103
|
-
private clients = new Map<string, StateClient>();
|
|
104
|
-
|
|
105
|
-
/** Canonical state per entity (server truth) */
|
|
106
|
-
private canonical = new Map<EntityKey, Record<string, unknown>>();
|
|
107
|
-
|
|
108
|
-
/** Canonical array state per entity (server truth for array outputs) */
|
|
109
|
-
private canonicalArrays = new Map<EntityKey, unknown[]>();
|
|
110
|
-
|
|
111
|
-
/** Per-client state tracking */
|
|
112
|
-
private clientStates = new Map<string, Map<EntityKey, ClientEntityState>>();
|
|
113
|
-
|
|
114
|
-
/** Per-client array state tracking */
|
|
115
|
-
private clientArrayStates = new Map<string, Map<EntityKey, ClientArrayState>>();
|
|
116
|
-
|
|
117
|
-
/** Entity → subscribed client IDs */
|
|
118
|
-
private entitySubscribers = new Map<EntityKey, Set<string>>();
|
|
119
|
-
|
|
120
|
-
/** Configuration */
|
|
121
|
-
private config: GraphStateManagerConfig;
|
|
122
|
-
|
|
123
|
-
constructor(config: GraphStateManagerConfig = {}) {
|
|
124
|
-
this.config = config;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ===========================================================================
|
|
128
|
-
// Client Management
|
|
129
|
-
// ===========================================================================
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Add a client connection
|
|
133
|
-
*/
|
|
134
|
-
addClient(client: StateClient): void {
|
|
135
|
-
this.clients.set(client.id, client);
|
|
136
|
-
this.clientStates.set(client.id, new Map());
|
|
137
|
-
this.clientArrayStates.set(client.id, new Map());
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Remove a client and cleanup all subscriptions
|
|
142
|
-
*/
|
|
143
|
-
removeClient(clientId: string): void {
|
|
144
|
-
// Remove from all entity subscribers
|
|
145
|
-
for (const [key, subscribers] of this.entitySubscribers) {
|
|
146
|
-
subscribers.delete(clientId);
|
|
147
|
-
if (subscribers.size === 0) {
|
|
148
|
-
this.cleanupEntity(key);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
this.clients.delete(clientId);
|
|
153
|
-
this.clientStates.delete(clientId);
|
|
154
|
-
this.clientArrayStates.delete(clientId);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ===========================================================================
|
|
158
|
-
// Subscription Management
|
|
159
|
-
// ===========================================================================
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Subscribe a client to an entity
|
|
163
|
-
*/
|
|
164
|
-
subscribe(clientId: string, entity: string, id: string, fields: string[] | "*" = "*"): void {
|
|
165
|
-
const key = this.makeKey(entity, id);
|
|
166
|
-
|
|
167
|
-
// Add to entity subscribers
|
|
168
|
-
let subscribers = this.entitySubscribers.get(key);
|
|
169
|
-
if (!subscribers) {
|
|
170
|
-
subscribers = new Set();
|
|
171
|
-
this.entitySubscribers.set(key, subscribers);
|
|
172
|
-
}
|
|
173
|
-
subscribers.add(clientId);
|
|
174
|
-
|
|
175
|
-
// Initialize client state for this entity
|
|
176
|
-
const clientStateMap = this.clientStates.get(clientId);
|
|
177
|
-
if (clientStateMap) {
|
|
178
|
-
const fieldSet = fields === "*" ? "*" : new Set(fields);
|
|
179
|
-
clientStateMap.set(key, {
|
|
180
|
-
lastState: {},
|
|
181
|
-
fields: fieldSet,
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// If we have canonical state, send initial data
|
|
186
|
-
const canonicalState = this.canonical.get(key);
|
|
187
|
-
if (canonicalState) {
|
|
188
|
-
this.sendInitialData(clientId, entity, id, canonicalState, fields);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Unsubscribe a client from an entity
|
|
194
|
-
*/
|
|
195
|
-
unsubscribe(clientId: string, entity: string, id: string): void {
|
|
196
|
-
const key = this.makeKey(entity, id);
|
|
197
|
-
|
|
198
|
-
// Remove from entity subscribers
|
|
199
|
-
const subscribers = this.entitySubscribers.get(key);
|
|
200
|
-
if (subscribers) {
|
|
201
|
-
subscribers.delete(clientId);
|
|
202
|
-
if (subscribers.size === 0) {
|
|
203
|
-
this.cleanupEntity(key);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Remove client state
|
|
208
|
-
const clientStateMap = this.clientStates.get(clientId);
|
|
209
|
-
if (clientStateMap) {
|
|
210
|
-
clientStateMap.delete(key);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Update subscription fields for a client
|
|
216
|
-
*/
|
|
217
|
-
updateSubscription(clientId: string, entity: string, id: string, fields: string[] | "*"): void {
|
|
218
|
-
const key = this.makeKey(entity, id);
|
|
219
|
-
const clientStateMap = this.clientStates.get(clientId);
|
|
220
|
-
|
|
221
|
-
if (clientStateMap) {
|
|
222
|
-
const state = clientStateMap.get(key);
|
|
223
|
-
if (state) {
|
|
224
|
-
state.fields = fields === "*" ? "*" : new Set(fields);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ===========================================================================
|
|
230
|
-
// State Emission (Core)
|
|
231
|
-
// ===========================================================================
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Emit data for an entity.
|
|
235
|
-
* This is the core method called by resolvers.
|
|
236
|
-
*
|
|
237
|
-
* @param entity - Entity name
|
|
238
|
-
* @param id - Entity ID
|
|
239
|
-
* @param data - Full or partial entity data
|
|
240
|
-
* @param options - Emit options
|
|
241
|
-
*/
|
|
242
|
-
emit(
|
|
243
|
-
entity: string,
|
|
244
|
-
id: string,
|
|
245
|
-
data: Record<string, unknown>,
|
|
246
|
-
options: { replace?: boolean } = {},
|
|
247
|
-
): void {
|
|
248
|
-
const key = this.makeKey(entity, id);
|
|
249
|
-
|
|
250
|
-
// Get or create canonical state
|
|
251
|
-
let currentCanonical = this.canonical.get(key);
|
|
252
|
-
|
|
253
|
-
if (options.replace || !currentCanonical) {
|
|
254
|
-
// Replace mode or first emit
|
|
255
|
-
currentCanonical = { ...data };
|
|
256
|
-
} else {
|
|
257
|
-
// Merge mode (default)
|
|
258
|
-
currentCanonical = { ...currentCanonical, ...data };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
this.canonical.set(key, currentCanonical);
|
|
262
|
-
|
|
263
|
-
// Push updates to all subscribed clients
|
|
264
|
-
const subscribers = this.entitySubscribers.get(key);
|
|
265
|
-
if (!subscribers) return;
|
|
266
|
-
|
|
267
|
-
for (const clientId of subscribers) {
|
|
268
|
-
this.pushToClient(clientId, entity, id, key, currentCanonical);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Emit a field-level update with a specific strategy.
|
|
274
|
-
* Applies the update to canonical state and pushes to clients.
|
|
275
|
-
*
|
|
276
|
-
* @param entity - Entity name
|
|
277
|
-
* @param id - Entity ID
|
|
278
|
-
* @param field - Field name to update
|
|
279
|
-
* @param update - Update with strategy (value/delta/patch)
|
|
280
|
-
*/
|
|
281
|
-
emitField(entity: string, id: string, field: string, update: Update): void {
|
|
282
|
-
const key = this.makeKey(entity, id);
|
|
283
|
-
|
|
284
|
-
// Get or create canonical state
|
|
285
|
-
let currentCanonical = this.canonical.get(key);
|
|
286
|
-
if (!currentCanonical) {
|
|
287
|
-
currentCanonical = {};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Apply update to canonical state based on strategy
|
|
291
|
-
const oldValue = currentCanonical[field];
|
|
292
|
-
const newValue = applyUpdate(oldValue, update);
|
|
293
|
-
currentCanonical = { ...currentCanonical, [field]: newValue };
|
|
294
|
-
|
|
295
|
-
this.canonical.set(key, currentCanonical);
|
|
296
|
-
|
|
297
|
-
// Push updates to all subscribed clients
|
|
298
|
-
const subscribers = this.entitySubscribers.get(key);
|
|
299
|
-
if (!subscribers) return;
|
|
300
|
-
|
|
301
|
-
for (const clientId of subscribers) {
|
|
302
|
-
this.pushFieldToClient(clientId, entity, id, key, field, newValue);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Emit multiple field updates in a batch.
|
|
308
|
-
* More efficient than multiple emitField calls.
|
|
309
|
-
*
|
|
310
|
-
* @param entity - Entity name
|
|
311
|
-
* @param id - Entity ID
|
|
312
|
-
* @param updates - Array of field updates
|
|
313
|
-
*/
|
|
314
|
-
emitBatch(entity: string, id: string, updates: InternalFieldUpdate[]): void {
|
|
315
|
-
const key = this.makeKey(entity, id);
|
|
316
|
-
|
|
317
|
-
// Get or create canonical state
|
|
318
|
-
let currentCanonical = this.canonical.get(key);
|
|
319
|
-
if (!currentCanonical) {
|
|
320
|
-
currentCanonical = {};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Apply all updates to canonical state
|
|
324
|
-
const changedFields: string[] = [];
|
|
325
|
-
for (const { field, update } of updates) {
|
|
326
|
-
const oldValue = currentCanonical[field];
|
|
327
|
-
const newValue = applyUpdate(oldValue, update);
|
|
328
|
-
currentCanonical[field] = newValue;
|
|
329
|
-
changedFields.push(field);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
this.canonical.set(key, currentCanonical);
|
|
333
|
-
|
|
334
|
-
// Push updates to all subscribed clients
|
|
335
|
-
const subscribers = this.entitySubscribers.get(key);
|
|
336
|
-
if (!subscribers) return;
|
|
337
|
-
|
|
338
|
-
for (const clientId of subscribers) {
|
|
339
|
-
this.pushFieldsToClient(clientId, entity, id, key, changedFields, currentCanonical);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Process an EmitCommand from the Emit API.
|
|
345
|
-
* Routes to appropriate emit method.
|
|
346
|
-
*
|
|
347
|
-
* @param entity - Entity name
|
|
348
|
-
* @param id - Entity ID
|
|
349
|
-
* @param command - Emit command from resolver
|
|
350
|
-
*/
|
|
351
|
-
processCommand(entity: string, id: string, command: EmitCommand): void {
|
|
352
|
-
switch (command.type) {
|
|
353
|
-
case "full":
|
|
354
|
-
this.emit(entity, id, command.data as Record<string, unknown>, {
|
|
355
|
-
replace: command.replace,
|
|
356
|
-
});
|
|
357
|
-
break;
|
|
358
|
-
case "field":
|
|
359
|
-
this.emitField(entity, id, command.field, command.update);
|
|
360
|
-
break;
|
|
361
|
-
case "batch":
|
|
362
|
-
this.emitBatch(entity, id, command.updates);
|
|
363
|
-
break;
|
|
364
|
-
case "array":
|
|
365
|
-
this.emitArrayOperation(entity, id, command.operation);
|
|
366
|
-
break;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// ===========================================================================
|
|
371
|
-
// Array State Emission
|
|
372
|
-
// ===========================================================================
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Emit array data (replace entire array).
|
|
376
|
-
*
|
|
377
|
-
* @param entity - Entity name
|
|
378
|
-
* @param id - Entity ID
|
|
379
|
-
* @param items - Array items
|
|
380
|
-
*/
|
|
381
|
-
emitArray(entity: string, id: string, items: unknown[]): void {
|
|
382
|
-
const key = this.makeKey(entity, id);
|
|
383
|
-
this.canonicalArrays.set(key, [...items]);
|
|
384
|
-
|
|
385
|
-
// Push updates to all subscribed clients
|
|
386
|
-
const subscribers = this.entitySubscribers.get(key);
|
|
387
|
-
if (!subscribers) return;
|
|
388
|
-
|
|
389
|
-
for (const clientId of subscribers) {
|
|
390
|
-
this.pushArrayToClient(clientId, entity, id, key, items);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Apply an array operation to the canonical state.
|
|
396
|
-
*
|
|
397
|
-
* @param entity - Entity name
|
|
398
|
-
* @param id - Entity ID
|
|
399
|
-
* @param operation - Array operation to apply
|
|
400
|
-
*/
|
|
401
|
-
emitArrayOperation(entity: string, id: string, operation: ArrayOperation): void {
|
|
402
|
-
const key = this.makeKey(entity, id);
|
|
403
|
-
|
|
404
|
-
// Get or create canonical array state
|
|
405
|
-
let currentArray = this.canonicalArrays.get(key);
|
|
406
|
-
if (!currentArray) {
|
|
407
|
-
currentArray = [];
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Apply operation
|
|
411
|
-
const newArray = this.applyArrayOperation([...currentArray], operation);
|
|
412
|
-
this.canonicalArrays.set(key, newArray);
|
|
413
|
-
|
|
414
|
-
// Push updates to all subscribed clients
|
|
415
|
-
const subscribers = this.entitySubscribers.get(key);
|
|
416
|
-
if (!subscribers) return;
|
|
417
|
-
|
|
418
|
-
for (const clientId of subscribers) {
|
|
419
|
-
this.pushArrayToClient(clientId, entity, id, key, newArray);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Apply an array operation and return new array.
|
|
425
|
-
*/
|
|
426
|
-
private applyArrayOperation(array: unknown[], operation: ArrayOperation): unknown[] {
|
|
427
|
-
switch (operation.op) {
|
|
428
|
-
case "push":
|
|
429
|
-
return [...array, operation.item];
|
|
430
|
-
|
|
431
|
-
case "unshift":
|
|
432
|
-
return [operation.item, ...array];
|
|
433
|
-
|
|
434
|
-
case "insert":
|
|
435
|
-
return [
|
|
436
|
-
...array.slice(0, operation.index),
|
|
437
|
-
operation.item,
|
|
438
|
-
...array.slice(operation.index),
|
|
439
|
-
];
|
|
440
|
-
|
|
441
|
-
case "remove":
|
|
442
|
-
return [...array.slice(0, operation.index), ...array.slice(operation.index + 1)];
|
|
443
|
-
|
|
444
|
-
case "removeById": {
|
|
445
|
-
const idx = array.findIndex(
|
|
446
|
-
(item) =>
|
|
447
|
-
typeof item === "object" &&
|
|
448
|
-
item !== null &&
|
|
449
|
-
"id" in item &&
|
|
450
|
-
(item as { id: string }).id === operation.id,
|
|
451
|
-
);
|
|
452
|
-
if (idx === -1) return array;
|
|
453
|
-
return [...array.slice(0, idx), ...array.slice(idx + 1)];
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
case "update":
|
|
457
|
-
return array.map((item, i) => (i === operation.index ? operation.item : item));
|
|
458
|
-
|
|
459
|
-
case "updateById":
|
|
460
|
-
return array.map((item) =>
|
|
461
|
-
typeof item === "object" &&
|
|
462
|
-
item !== null &&
|
|
463
|
-
"id" in item &&
|
|
464
|
-
(item as { id: string }).id === operation.id
|
|
465
|
-
? operation.item
|
|
466
|
-
: item,
|
|
467
|
-
);
|
|
468
|
-
|
|
469
|
-
case "merge":
|
|
470
|
-
return array.map((item, i) =>
|
|
471
|
-
i === operation.index && typeof item === "object" && item !== null
|
|
472
|
-
? { ...item, ...(operation.partial as object) }
|
|
473
|
-
: item,
|
|
474
|
-
);
|
|
475
|
-
|
|
476
|
-
case "mergeById":
|
|
477
|
-
return array.map((item) =>
|
|
478
|
-
typeof item === "object" &&
|
|
479
|
-
item !== null &&
|
|
480
|
-
"id" in item &&
|
|
481
|
-
(item as { id: string }).id === operation.id
|
|
482
|
-
? { ...item, ...(operation.partial as object) }
|
|
483
|
-
: item,
|
|
484
|
-
);
|
|
485
|
-
|
|
486
|
-
default:
|
|
487
|
-
return array;
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Push array update to a specific client.
|
|
493
|
-
* Computes optimal diff strategy.
|
|
494
|
-
*/
|
|
495
|
-
private pushArrayToClient(
|
|
496
|
-
clientId: string,
|
|
497
|
-
entity: string,
|
|
498
|
-
id: string,
|
|
499
|
-
key: EntityKey,
|
|
500
|
-
newArray: unknown[],
|
|
501
|
-
): void {
|
|
502
|
-
const client = this.clients.get(clientId);
|
|
503
|
-
if (!client) return;
|
|
504
|
-
|
|
505
|
-
const clientArrayStateMap = this.clientArrayStates.get(clientId);
|
|
506
|
-
if (!clientArrayStateMap) return;
|
|
507
|
-
|
|
508
|
-
let clientArrayState = clientArrayStateMap.get(key);
|
|
509
|
-
if (!clientArrayState) {
|
|
510
|
-
// Initialize client array state
|
|
511
|
-
clientArrayState = { lastState: [] };
|
|
512
|
-
clientArrayStateMap.set(key, clientArrayState);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
const { lastState } = clientArrayState;
|
|
516
|
-
|
|
517
|
-
// Skip if unchanged
|
|
518
|
-
if (JSON.stringify(lastState) === JSON.stringify(newArray)) {
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Compute optimal array diff
|
|
523
|
-
const diff = computeArrayDiff(lastState, newArray);
|
|
524
|
-
|
|
525
|
-
if (diff === null || diff.length === 0) {
|
|
526
|
-
// Full replace is more efficient
|
|
527
|
-
client.send({
|
|
528
|
-
type: "update",
|
|
529
|
-
entity,
|
|
530
|
-
id,
|
|
531
|
-
updates: {
|
|
532
|
-
_items: { strategy: "value", data: newArray },
|
|
533
|
-
},
|
|
534
|
-
});
|
|
535
|
-
} else if (diff.length === 1 && diff[0].op === "replace") {
|
|
536
|
-
// Single replace op - send as value
|
|
537
|
-
client.send({
|
|
538
|
-
type: "update",
|
|
539
|
-
entity,
|
|
540
|
-
id,
|
|
541
|
-
updates: {
|
|
542
|
-
_items: { strategy: "value", data: newArray },
|
|
543
|
-
},
|
|
544
|
-
});
|
|
545
|
-
} else {
|
|
546
|
-
// Send incremental diff operations
|
|
547
|
-
client.send({
|
|
548
|
-
type: "update",
|
|
549
|
-
entity,
|
|
550
|
-
id,
|
|
551
|
-
updates: {
|
|
552
|
-
_items: { strategy: "array", data: diff },
|
|
553
|
-
},
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// Update client's last known state
|
|
558
|
-
clientArrayState.lastState = [...newArray];
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Get current canonical array state
|
|
563
|
-
*/
|
|
564
|
-
getArrayState(entity: string, id: string): unknown[] | undefined {
|
|
565
|
-
return this.canonicalArrays.get(this.makeKey(entity, id));
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* Get current canonical state for an entity
|
|
570
|
-
*/
|
|
571
|
-
getState(entity: string, id: string): Record<string, unknown> | undefined {
|
|
572
|
-
return this.canonical.get(this.makeKey(entity, id));
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* Check if entity has any subscribers
|
|
577
|
-
*/
|
|
578
|
-
hasSubscribers(entity: string, id: string): boolean {
|
|
579
|
-
const subscribers = this.entitySubscribers.get(this.makeKey(entity, id));
|
|
580
|
-
return subscribers !== undefined && subscribers.size > 0;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// ===========================================================================
|
|
584
|
-
// Internal Methods
|
|
585
|
-
// ===========================================================================
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Push update to a specific client
|
|
589
|
-
*/
|
|
590
|
-
private pushToClient(
|
|
591
|
-
clientId: string,
|
|
592
|
-
entity: string,
|
|
593
|
-
id: string,
|
|
594
|
-
key: EntityKey,
|
|
595
|
-
newState: Record<string, unknown>,
|
|
596
|
-
): void {
|
|
597
|
-
const client = this.clients.get(clientId);
|
|
598
|
-
if (!client) return;
|
|
599
|
-
|
|
600
|
-
const clientStateMap = this.clientStates.get(clientId);
|
|
601
|
-
if (!clientStateMap) return;
|
|
602
|
-
|
|
603
|
-
const clientEntityState = clientStateMap.get(key);
|
|
604
|
-
if (!clientEntityState) return;
|
|
605
|
-
|
|
606
|
-
const { lastState, fields } = clientEntityState;
|
|
607
|
-
|
|
608
|
-
// Determine which fields to send
|
|
609
|
-
const fieldsToCheck = fields === "*" ? Object.keys(newState) : Array.from(fields);
|
|
610
|
-
|
|
611
|
-
// Compute updates for changed fields
|
|
612
|
-
const updates: Record<string, Update> = {};
|
|
613
|
-
let hasChanges = false;
|
|
614
|
-
|
|
615
|
-
for (const field of fieldsToCheck) {
|
|
616
|
-
const oldValue = lastState[field];
|
|
617
|
-
const newValue = newState[field];
|
|
618
|
-
|
|
619
|
-
// Skip if unchanged
|
|
620
|
-
if (oldValue === newValue) continue;
|
|
621
|
-
if (
|
|
622
|
-
typeof oldValue === "object" &&
|
|
623
|
-
typeof newValue === "object" &&
|
|
624
|
-
JSON.stringify(oldValue) === JSON.stringify(newValue)
|
|
625
|
-
) {
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// Compute optimal update
|
|
630
|
-
const update = createUpdate(oldValue, newValue);
|
|
631
|
-
updates[field] = update;
|
|
632
|
-
hasChanges = true;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
if (!hasChanges) return;
|
|
636
|
-
|
|
637
|
-
// Send update
|
|
638
|
-
client.send({
|
|
639
|
-
type: "update",
|
|
640
|
-
entity,
|
|
641
|
-
id,
|
|
642
|
-
updates,
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
// Update client's last known state
|
|
646
|
-
for (const field of fieldsToCheck) {
|
|
647
|
-
if (newState[field] !== undefined) {
|
|
648
|
-
clientEntityState.lastState[field] = newState[field];
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Push a single field update to a client.
|
|
655
|
-
* Computes optimal transfer strategy.
|
|
656
|
-
*/
|
|
657
|
-
private pushFieldToClient(
|
|
658
|
-
clientId: string,
|
|
659
|
-
entity: string,
|
|
660
|
-
id: string,
|
|
661
|
-
key: EntityKey,
|
|
662
|
-
field: string,
|
|
663
|
-
newValue: unknown,
|
|
664
|
-
): void {
|
|
665
|
-
const client = this.clients.get(clientId);
|
|
666
|
-
if (!client) return;
|
|
667
|
-
|
|
668
|
-
const clientStateMap = this.clientStates.get(clientId);
|
|
669
|
-
if (!clientStateMap) return;
|
|
670
|
-
|
|
671
|
-
const clientEntityState = clientStateMap.get(key);
|
|
672
|
-
if (!clientEntityState) return;
|
|
673
|
-
|
|
674
|
-
const { lastState, fields } = clientEntityState;
|
|
675
|
-
|
|
676
|
-
// Check if client is subscribed to this field
|
|
677
|
-
if (fields !== "*" && !fields.has(field)) {
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
const oldValue = lastState[field];
|
|
682
|
-
|
|
683
|
-
// Skip if unchanged
|
|
684
|
-
if (oldValue === newValue) return;
|
|
685
|
-
if (
|
|
686
|
-
typeof oldValue === "object" &&
|
|
687
|
-
typeof newValue === "object" &&
|
|
688
|
-
JSON.stringify(oldValue) === JSON.stringify(newValue)
|
|
689
|
-
) {
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Compute optimal update for transfer
|
|
694
|
-
const update = createUpdate(oldValue, newValue);
|
|
695
|
-
|
|
696
|
-
// Send update
|
|
697
|
-
client.send({
|
|
698
|
-
type: "update",
|
|
699
|
-
entity,
|
|
700
|
-
id,
|
|
701
|
-
updates: { [field]: update },
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
// Update client's last known state
|
|
705
|
-
clientEntityState.lastState[field] = newValue;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* Push multiple field updates to a client.
|
|
710
|
-
* Computes optimal transfer strategy for each field.
|
|
711
|
-
*/
|
|
712
|
-
private pushFieldsToClient(
|
|
713
|
-
clientId: string,
|
|
714
|
-
entity: string,
|
|
715
|
-
id: string,
|
|
716
|
-
key: EntityKey,
|
|
717
|
-
changedFields: string[],
|
|
718
|
-
newState: Record<string, unknown>,
|
|
719
|
-
): void {
|
|
720
|
-
const client = this.clients.get(clientId);
|
|
721
|
-
if (!client) return;
|
|
722
|
-
|
|
723
|
-
const clientStateMap = this.clientStates.get(clientId);
|
|
724
|
-
if (!clientStateMap) return;
|
|
725
|
-
|
|
726
|
-
const clientEntityState = clientStateMap.get(key);
|
|
727
|
-
if (!clientEntityState) return;
|
|
728
|
-
|
|
729
|
-
const { lastState, fields } = clientEntityState;
|
|
730
|
-
|
|
731
|
-
// Compute updates for changed fields
|
|
732
|
-
const updates: Record<string, Update> = {};
|
|
733
|
-
let hasChanges = false;
|
|
734
|
-
|
|
735
|
-
for (const field of changedFields) {
|
|
736
|
-
// Check if client is subscribed to this field
|
|
737
|
-
if (fields !== "*" && !fields.has(field)) {
|
|
738
|
-
continue;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const oldValue = lastState[field];
|
|
742
|
-
const newValue = newState[field];
|
|
743
|
-
|
|
744
|
-
// Skip if unchanged
|
|
745
|
-
if (oldValue === newValue) continue;
|
|
746
|
-
if (
|
|
747
|
-
typeof oldValue === "object" &&
|
|
748
|
-
typeof newValue === "object" &&
|
|
749
|
-
JSON.stringify(oldValue) === JSON.stringify(newValue)
|
|
750
|
-
) {
|
|
751
|
-
continue;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// Compute optimal update for transfer
|
|
755
|
-
const update = createUpdate(oldValue, newValue);
|
|
756
|
-
updates[field] = update;
|
|
757
|
-
hasChanges = true;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
if (!hasChanges) return;
|
|
761
|
-
|
|
762
|
-
// Send update
|
|
763
|
-
client.send({
|
|
764
|
-
type: "update",
|
|
765
|
-
entity,
|
|
766
|
-
id,
|
|
767
|
-
updates,
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
// Update client's last known state
|
|
771
|
-
for (const field of changedFields) {
|
|
772
|
-
if (newState[field] !== undefined) {
|
|
773
|
-
clientEntityState.lastState[field] = newState[field];
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Send initial data to a newly subscribed client
|
|
780
|
-
*/
|
|
781
|
-
private sendInitialData(
|
|
782
|
-
clientId: string,
|
|
783
|
-
entity: string,
|
|
784
|
-
id: string,
|
|
785
|
-
state: Record<string, unknown>,
|
|
786
|
-
fields: string[] | "*",
|
|
787
|
-
): void {
|
|
788
|
-
const client = this.clients.get(clientId);
|
|
789
|
-
if (!client) return;
|
|
790
|
-
|
|
791
|
-
const key = this.makeKey(entity, id);
|
|
792
|
-
const clientStateMap = this.clientStates.get(clientId);
|
|
793
|
-
if (!clientStateMap) return;
|
|
794
|
-
|
|
795
|
-
// Filter to requested fields
|
|
796
|
-
const fieldsToSend = fields === "*" ? Object.keys(state) : fields;
|
|
797
|
-
const dataToSend: Record<string, unknown> = {};
|
|
798
|
-
const updates: Record<string, Update> = {};
|
|
799
|
-
|
|
800
|
-
for (const field of fieldsToSend) {
|
|
801
|
-
if (state[field] !== undefined) {
|
|
802
|
-
dataToSend[field] = state[field];
|
|
803
|
-
updates[field] = { strategy: "value", data: state[field] };
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Send as update message with value strategy
|
|
808
|
-
client.send({
|
|
809
|
-
type: "update",
|
|
810
|
-
entity,
|
|
811
|
-
id,
|
|
812
|
-
updates,
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
// Update client's last known state
|
|
816
|
-
const clientEntityState = clientStateMap.get(key);
|
|
817
|
-
if (clientEntityState) {
|
|
818
|
-
clientEntityState.lastState = { ...dataToSend };
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/**
|
|
823
|
-
* Cleanup entity when no subscribers remain
|
|
824
|
-
*/
|
|
825
|
-
private cleanupEntity(key: EntityKey): void {
|
|
826
|
-
const [entity, id] = key.split(":") as [string, string];
|
|
827
|
-
|
|
828
|
-
// Optionally notify
|
|
829
|
-
if (this.config.onEntityUnsubscribed) {
|
|
830
|
-
this.config.onEntityUnsubscribed(entity, id);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// Remove canonical state (optional - could keep for cache)
|
|
834
|
-
// this.canonical.delete(key);
|
|
835
|
-
|
|
836
|
-
// Remove from subscribers map
|
|
837
|
-
this.entitySubscribers.delete(key);
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
private makeKey(entity: string, id: string): EntityKey {
|
|
841
|
-
return makeEntityKey(entity, id);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// ===========================================================================
|
|
845
|
-
// Stats & Debug
|
|
846
|
-
// ===========================================================================
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* Get statistics
|
|
850
|
-
*/
|
|
851
|
-
getStats(): {
|
|
852
|
-
clients: number;
|
|
853
|
-
entities: number;
|
|
854
|
-
totalSubscriptions: number;
|
|
855
|
-
} {
|
|
856
|
-
let totalSubscriptions = 0;
|
|
857
|
-
for (const subscribers of this.entitySubscribers.values()) {
|
|
858
|
-
totalSubscriptions += subscribers.size;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
return {
|
|
862
|
-
clients: this.clients.size,
|
|
863
|
-
entities: this.canonical.size,
|
|
864
|
-
totalSubscriptions,
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
/**
|
|
869
|
-
* Clear all state (for testing)
|
|
870
|
-
*/
|
|
871
|
-
clear(): void {
|
|
872
|
-
this.clients.clear();
|
|
873
|
-
this.canonical.clear();
|
|
874
|
-
this.canonicalArrays.clear();
|
|
875
|
-
this.clientStates.clear();
|
|
876
|
-
this.clientArrayStates.clear();
|
|
877
|
-
this.entitySubscribers.clear();
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// =============================================================================
|
|
882
|
-
// Factory
|
|
883
|
-
// =============================================================================
|
|
884
|
-
|
|
885
|
-
/**
|
|
886
|
-
* Create a GraphStateManager instance
|
|
887
|
-
*/
|
|
888
|
-
export function createGraphStateManager(config?: GraphStateManagerConfig): GraphStateManager {
|
|
889
|
-
return new GraphStateManager(config);
|
|
890
|
-
}
|