@sylphx/lens-server 1.0.4 → 1.3.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.
@@ -9,7 +9,16 @@
9
9
  * - Pushes updates to subscribed clients
10
10
  */
11
11
 
12
- import { type EntityKey, type Update, createUpdate, makeEntityKey } from "@sylphx/lens-core";
12
+ import {
13
+ type EntityKey,
14
+ type Update,
15
+ type EmitCommand,
16
+ type InternalFieldUpdate,
17
+ type ArrayOperation,
18
+ createUpdate,
19
+ applyUpdate,
20
+ makeEntityKey,
21
+ } from "@sylphx/lens-core";
13
22
 
14
23
  // Re-export for convenience
15
24
  export type { EntityKey };
@@ -51,6 +60,12 @@ interface ClientEntityState {
51
60
  fields: Set<string> | "*";
52
61
  }
53
62
 
63
+ /** Per-client state for an array */
64
+ interface ClientArrayState {
65
+ /** Last array state sent to this client */
66
+ lastState: unknown[];
67
+ }
68
+
54
69
  /** Configuration */
55
70
  export interface GraphStateManagerConfig {
56
71
  /** Called when an entity has no more subscribers */
@@ -89,9 +104,15 @@ export class GraphStateManager {
89
104
  /** Canonical state per entity (server truth) */
90
105
  private canonical = new Map<EntityKey, Record<string, unknown>>();
91
106
 
107
+ /** Canonical array state per entity (server truth for array outputs) */
108
+ private canonicalArrays = new Map<EntityKey, unknown[]>();
109
+
92
110
  /** Per-client state tracking */
93
111
  private clientStates = new Map<string, Map<EntityKey, ClientEntityState>>();
94
112
 
113
+ /** Per-client array state tracking */
114
+ private clientArrayStates = new Map<string, Map<EntityKey, ClientArrayState>>();
115
+
95
116
  /** Entity → subscribed client IDs */
96
117
  private entitySubscribers = new Map<EntityKey, Set<string>>();
97
118
 
@@ -112,6 +133,7 @@ export class GraphStateManager {
112
133
  addClient(client: StateClient): void {
113
134
  this.clients.set(client.id, client);
114
135
  this.clientStates.set(client.id, new Map());
136
+ this.clientArrayStates.set(client.id, new Map());
115
137
  }
116
138
 
117
139
  /**
@@ -128,6 +150,7 @@ export class GraphStateManager {
128
150
 
129
151
  this.clients.delete(clientId);
130
152
  this.clientStates.delete(clientId);
153
+ this.clientArrayStates.delete(clientId);
131
154
  }
132
155
 
133
156
  // ===========================================================================
@@ -245,6 +268,278 @@ export class GraphStateManager {
245
268
  }
246
269
  }
247
270
 
271
+ /**
272
+ * Emit a field-level update with a specific strategy.
273
+ * Applies the update to canonical state and pushes to clients.
274
+ *
275
+ * @param entity - Entity name
276
+ * @param id - Entity ID
277
+ * @param field - Field name to update
278
+ * @param update - Update with strategy (value/delta/patch)
279
+ */
280
+ emitField(entity: string, id: string, field: string, update: Update): void {
281
+ const key = this.makeKey(entity, id);
282
+
283
+ // Get or create canonical state
284
+ let currentCanonical = this.canonical.get(key);
285
+ if (!currentCanonical) {
286
+ currentCanonical = {};
287
+ }
288
+
289
+ // Apply update to canonical state based on strategy
290
+ const oldValue = currentCanonical[field];
291
+ const newValue = applyUpdate(oldValue, update);
292
+ currentCanonical = { ...currentCanonical, [field]: newValue };
293
+
294
+ this.canonical.set(key, currentCanonical);
295
+
296
+ // Push updates to all subscribed clients
297
+ const subscribers = this.entitySubscribers.get(key);
298
+ if (!subscribers) return;
299
+
300
+ for (const clientId of subscribers) {
301
+ this.pushFieldToClient(clientId, entity, id, key, field, newValue);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Emit multiple field updates in a batch.
307
+ * More efficient than multiple emitField calls.
308
+ *
309
+ * @param entity - Entity name
310
+ * @param id - Entity ID
311
+ * @param updates - Array of field updates
312
+ */
313
+ emitBatch(entity: string, id: string, updates: InternalFieldUpdate[]): void {
314
+ const key = this.makeKey(entity, id);
315
+
316
+ // Get or create canonical state
317
+ let currentCanonical = this.canonical.get(key);
318
+ if (!currentCanonical) {
319
+ currentCanonical = {};
320
+ }
321
+
322
+ // Apply all updates to canonical state
323
+ const changedFields: string[] = [];
324
+ for (const { field, update } of updates) {
325
+ const oldValue = currentCanonical[field];
326
+ const newValue = applyUpdate(oldValue, update);
327
+ currentCanonical[field] = newValue;
328
+ changedFields.push(field);
329
+ }
330
+
331
+ this.canonical.set(key, currentCanonical);
332
+
333
+ // Push updates to all subscribed clients
334
+ const subscribers = this.entitySubscribers.get(key);
335
+ if (!subscribers) return;
336
+
337
+ for (const clientId of subscribers) {
338
+ this.pushFieldsToClient(clientId, entity, id, key, changedFields, currentCanonical);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Process an EmitCommand from the Emit API.
344
+ * Routes to appropriate emit method.
345
+ *
346
+ * @param entity - Entity name
347
+ * @param id - Entity ID
348
+ * @param command - Emit command from resolver
349
+ */
350
+ processCommand(entity: string, id: string, command: EmitCommand): void {
351
+ switch (command.type) {
352
+ case "full":
353
+ this.emit(entity, id, command.data as Record<string, unknown>, {
354
+ replace: command.replace,
355
+ });
356
+ break;
357
+ case "field":
358
+ this.emitField(entity, id, command.field, command.update);
359
+ break;
360
+ case "batch":
361
+ this.emitBatch(entity, id, command.updates);
362
+ break;
363
+ case "array":
364
+ this.emitArrayOperation(entity, id, command.operation);
365
+ break;
366
+ }
367
+ }
368
+
369
+ // ===========================================================================
370
+ // Array State Emission
371
+ // ===========================================================================
372
+
373
+ /**
374
+ * Emit array data (replace entire array).
375
+ *
376
+ * @param entity - Entity name
377
+ * @param id - Entity ID
378
+ * @param items - Array items
379
+ */
380
+ emitArray(entity: string, id: string, items: unknown[]): void {
381
+ const key = this.makeKey(entity, id);
382
+ this.canonicalArrays.set(key, [...items]);
383
+
384
+ // Push updates to all subscribed clients
385
+ const subscribers = this.entitySubscribers.get(key);
386
+ if (!subscribers) return;
387
+
388
+ for (const clientId of subscribers) {
389
+ this.pushArrayToClient(clientId, entity, id, key, items);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Apply an array operation to the canonical state.
395
+ *
396
+ * @param entity - Entity name
397
+ * @param id - Entity ID
398
+ * @param operation - Array operation to apply
399
+ */
400
+ emitArrayOperation(entity: string, id: string, operation: ArrayOperation): void {
401
+ const key = this.makeKey(entity, id);
402
+
403
+ // Get or create canonical array state
404
+ let currentArray = this.canonicalArrays.get(key);
405
+ if (!currentArray) {
406
+ currentArray = [];
407
+ }
408
+
409
+ // Apply operation
410
+ const newArray = this.applyArrayOperation([...currentArray], operation);
411
+ this.canonicalArrays.set(key, newArray);
412
+
413
+ // Push updates to all subscribed clients
414
+ const subscribers = this.entitySubscribers.get(key);
415
+ if (!subscribers) return;
416
+
417
+ for (const clientId of subscribers) {
418
+ this.pushArrayToClient(clientId, entity, id, key, newArray);
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Apply an array operation and return new array.
424
+ */
425
+ private applyArrayOperation(array: unknown[], operation: ArrayOperation): unknown[] {
426
+ switch (operation.op) {
427
+ case "push":
428
+ return [...array, operation.item];
429
+
430
+ case "unshift":
431
+ return [operation.item, ...array];
432
+
433
+ case "insert":
434
+ return [
435
+ ...array.slice(0, operation.index),
436
+ operation.item,
437
+ ...array.slice(operation.index),
438
+ ];
439
+
440
+ case "remove":
441
+ return [...array.slice(0, operation.index), ...array.slice(operation.index + 1)];
442
+
443
+ case "removeById": {
444
+ const idx = array.findIndex(
445
+ (item) =>
446
+ typeof item === "object" &&
447
+ item !== null &&
448
+ "id" in item &&
449
+ (item as { id: string }).id === operation.id,
450
+ );
451
+ if (idx === -1) return array;
452
+ return [...array.slice(0, idx), ...array.slice(idx + 1)];
453
+ }
454
+
455
+ case "update":
456
+ return array.map((item, i) => (i === operation.index ? operation.item : item));
457
+
458
+ case "updateById":
459
+ return array.map((item) =>
460
+ typeof item === "object" &&
461
+ item !== null &&
462
+ "id" in item &&
463
+ (item as { id: string }).id === operation.id
464
+ ? operation.item
465
+ : item,
466
+ );
467
+
468
+ case "merge":
469
+ return array.map((item, i) =>
470
+ i === operation.index && typeof item === "object" && item !== null
471
+ ? { ...item, ...(operation.partial as object) }
472
+ : item,
473
+ );
474
+
475
+ case "mergeById":
476
+ return array.map((item) =>
477
+ typeof item === "object" &&
478
+ item !== null &&
479
+ "id" in item &&
480
+ (item as { id: string }).id === operation.id
481
+ ? { ...item, ...(operation.partial as object) }
482
+ : item,
483
+ );
484
+
485
+ default:
486
+ return array;
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Push array update to a specific client.
492
+ * Computes optimal diff strategy.
493
+ */
494
+ private pushArrayToClient(
495
+ clientId: string,
496
+ entity: string,
497
+ id: string,
498
+ key: EntityKey,
499
+ newArray: unknown[],
500
+ ): void {
501
+ const client = this.clients.get(clientId);
502
+ if (!client) return;
503
+
504
+ const clientArrayStateMap = this.clientArrayStates.get(clientId);
505
+ if (!clientArrayStateMap) return;
506
+
507
+ let clientArrayState = clientArrayStateMap.get(key);
508
+ if (!clientArrayState) {
509
+ // Initialize client array state
510
+ clientArrayState = { lastState: [] };
511
+ clientArrayStateMap.set(key, clientArrayState);
512
+ }
513
+
514
+ const { lastState } = clientArrayState;
515
+
516
+ // Skip if unchanged
517
+ if (JSON.stringify(lastState) === JSON.stringify(newArray)) {
518
+ return;
519
+ }
520
+
521
+ // For now, send full array replacement
522
+ // TODO: Compute array diff for optimal transfer
523
+ client.send({
524
+ type: "update",
525
+ entity,
526
+ id,
527
+ updates: {
528
+ _items: { strategy: "value", data: newArray },
529
+ },
530
+ });
531
+
532
+ // Update client's last known state
533
+ clientArrayState.lastState = [...newArray];
534
+ }
535
+
536
+ /**
537
+ * Get current canonical array state
538
+ */
539
+ getArrayState(entity: string, id: string): unknown[] | undefined {
540
+ return this.canonicalArrays.get(this.makeKey(entity, id));
541
+ }
542
+
248
543
  /**
249
544
  * Get current canonical state for an entity
250
545
  */
@@ -330,6 +625,131 @@ export class GraphStateManager {
330
625
  }
331
626
  }
332
627
 
628
+ /**
629
+ * Push a single field update to a client.
630
+ * Computes optimal transfer strategy.
631
+ */
632
+ private pushFieldToClient(
633
+ clientId: string,
634
+ entity: string,
635
+ id: string,
636
+ key: EntityKey,
637
+ field: string,
638
+ newValue: unknown,
639
+ ): void {
640
+ const client = this.clients.get(clientId);
641
+ if (!client) return;
642
+
643
+ const clientStateMap = this.clientStates.get(clientId);
644
+ if (!clientStateMap) return;
645
+
646
+ const clientEntityState = clientStateMap.get(key);
647
+ if (!clientEntityState) return;
648
+
649
+ const { lastState, fields } = clientEntityState;
650
+
651
+ // Check if client is subscribed to this field
652
+ if (fields !== "*" && !fields.has(field)) {
653
+ return;
654
+ }
655
+
656
+ const oldValue = lastState[field];
657
+
658
+ // Skip if unchanged
659
+ if (oldValue === newValue) return;
660
+ if (
661
+ typeof oldValue === "object" &&
662
+ typeof newValue === "object" &&
663
+ JSON.stringify(oldValue) === JSON.stringify(newValue)
664
+ ) {
665
+ return;
666
+ }
667
+
668
+ // Compute optimal update for transfer
669
+ const update = createUpdate(oldValue, newValue);
670
+
671
+ // Send update
672
+ client.send({
673
+ type: "update",
674
+ entity,
675
+ id,
676
+ updates: { [field]: update },
677
+ });
678
+
679
+ // Update client's last known state
680
+ clientEntityState.lastState[field] = newValue;
681
+ }
682
+
683
+ /**
684
+ * Push multiple field updates to a client.
685
+ * Computes optimal transfer strategy for each field.
686
+ */
687
+ private pushFieldsToClient(
688
+ clientId: string,
689
+ entity: string,
690
+ id: string,
691
+ key: EntityKey,
692
+ changedFields: string[],
693
+ newState: Record<string, unknown>,
694
+ ): void {
695
+ const client = this.clients.get(clientId);
696
+ if (!client) return;
697
+
698
+ const clientStateMap = this.clientStates.get(clientId);
699
+ if (!clientStateMap) return;
700
+
701
+ const clientEntityState = clientStateMap.get(key);
702
+ if (!clientEntityState) return;
703
+
704
+ const { lastState, fields } = clientEntityState;
705
+
706
+ // Compute updates for changed fields
707
+ const updates: Record<string, Update> = {};
708
+ let hasChanges = false;
709
+
710
+ for (const field of changedFields) {
711
+ // Check if client is subscribed to this field
712
+ if (fields !== "*" && !fields.has(field)) {
713
+ continue;
714
+ }
715
+
716
+ const oldValue = lastState[field];
717
+ const newValue = newState[field];
718
+
719
+ // Skip if unchanged
720
+ if (oldValue === newValue) continue;
721
+ if (
722
+ typeof oldValue === "object" &&
723
+ typeof newValue === "object" &&
724
+ JSON.stringify(oldValue) === JSON.stringify(newValue)
725
+ ) {
726
+ continue;
727
+ }
728
+
729
+ // Compute optimal update for transfer
730
+ const update = createUpdate(oldValue, newValue);
731
+ updates[field] = update;
732
+ hasChanges = true;
733
+ }
734
+
735
+ if (!hasChanges) return;
736
+
737
+ // Send update
738
+ client.send({
739
+ type: "update",
740
+ entity,
741
+ id,
742
+ updates,
743
+ });
744
+
745
+ // Update client's last known state
746
+ for (const field of changedFields) {
747
+ if (newState[field] !== undefined) {
748
+ clientEntityState.lastState[field] = newState[field];
749
+ }
750
+ }
751
+ }
752
+
333
753
  /**
334
754
  * Send initial data to a newly subscribed client
335
755
  */
@@ -426,7 +846,9 @@ export class GraphStateManager {
426
846
  clear(): void {
427
847
  this.clients.clear();
428
848
  this.canonical.clear();
849
+ this.canonicalArrays.clear();
429
850
  this.clientStates.clear();
851
+ this.clientArrayStates.clear();
430
852
  this.entitySubscribers.clear();
431
853
  }
432
854
  }