@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.
@@ -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
- }