@sylphx/lens-server 2.13.2 → 2.14.1

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.
@@ -16,7 +16,6 @@
16
16
  */
17
17
 
18
18
  import {
19
- applyUpdate,
20
19
  type ContextValue,
21
20
  collectModelsFromOperations,
22
21
  collectModelsFromRouter,
@@ -35,10 +34,12 @@ import {
35
34
  isMutationDef,
36
35
  isQueryDef,
37
36
  type LiveQueryDef,
37
+ type Message,
38
38
  mergeModelCollections,
39
39
  type Observable,
40
40
  type ResolverDef,
41
41
  type RouterDef,
42
+ toOps,
42
43
  valuesEqual,
43
44
  } from "@sylphx/lens-core";
44
45
  import { createContext, runWithContext } from "../context/index.js";
@@ -106,54 +107,6 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
106
107
  return value != null && typeof value === "object" && Symbol.asyncIterator in value;
107
108
  }
108
109
 
109
- /**
110
- * Ring buffer with O(1) enqueue/dequeue operations.
111
- * Used for emit queue to avoid O(n) Array.shift() performance issues.
112
- */
113
- class RingBuffer<T> {
114
- private buffer: (T | null)[];
115
- private head = 0; // Points to first element to dequeue
116
- private tail = 0; // Points to where to enqueue next
117
- private count = 0;
118
-
119
- constructor(private capacity: number) {
120
- this.buffer = new Array(capacity).fill(null);
121
- }
122
-
123
- get size(): number {
124
- return this.count;
125
- }
126
-
127
- get isEmpty(): boolean {
128
- return this.count === 0;
129
- }
130
-
131
- /** Enqueue item. If at capacity, drops oldest item (returns true if dropped). */
132
- enqueue(item: T): boolean {
133
- let dropped = false;
134
- if (this.count >= this.capacity) {
135
- // Drop oldest (backpressure)
136
- this.head = (this.head + 1) % this.capacity;
137
- this.count--;
138
- dropped = true;
139
- }
140
- this.buffer[this.tail] = item;
141
- this.tail = (this.tail + 1) % this.capacity;
142
- this.count++;
143
- return dropped;
144
- }
145
-
146
- /** Dequeue and return item, or null if empty. */
147
- dequeue(): T | null {
148
- if (this.count === 0) return null;
149
- const item = this.buffer[this.head];
150
- this.buffer[this.head] = null; // Allow GC
151
- this.head = (this.head + 1) % this.capacity;
152
- this.count--;
153
- return item;
154
- }
155
- }
156
-
157
110
  // =============================================================================
158
111
  // Server Implementation
159
112
  // =============================================================================
@@ -482,7 +435,11 @@ class LensServerImpl<
482
435
  if (!isQuery && !isMutation) {
483
436
  return {
484
437
  subscribe: (observer) => {
485
- observer.next?.({ error: new Error(`Operation not found: ${path}`) });
438
+ observer.next?.({
439
+ $: "error",
440
+ error: `Operation not found: ${path}`,
441
+ code: "NOT_FOUND",
442
+ } as Message);
486
443
  observer.complete?.();
487
444
  return { unsubscribe: () => {} };
488
445
  },
@@ -504,7 +461,6 @@ class LensServerImpl<
504
461
  return {
505
462
  subscribe: (observer) => {
506
463
  let cancelled = false;
507
- let currentState: unknown;
508
464
  let lastEmittedResult: unknown;
509
465
  let lastEmittedHash: string | undefined;
510
466
  const cleanups: (() => void)[] = [];
@@ -522,7 +478,8 @@ class LensServerImpl<
522
478
  }
523
479
  lastEmittedResult = data;
524
480
  lastEmittedHash = dataHash;
525
- observer.next?.({ data });
481
+ // Emit snapshot message
482
+ observer.next?.({ $: "snapshot", data } as Message);
526
483
  };
527
484
 
528
485
  // Run the operation
@@ -530,7 +487,11 @@ class LensServerImpl<
530
487
  try {
531
488
  const def = isQuery ? this.queries[path] : this.mutations[path];
532
489
  if (!def) {
533
- observer.next?.({ error: new Error(`Operation not found: ${path}`) });
490
+ observer.next?.({
491
+ $: "error",
492
+ error: `Operation not found: ${path}`,
493
+ code: "NOT_FOUND",
494
+ } as Message);
534
495
  observer.complete?.();
535
496
  return;
536
497
  }
@@ -549,8 +510,10 @@ class LensServerImpl<
549
510
  const result = def._input.safeParse(cleanInput);
550
511
  if (!result.success) {
551
512
  observer.next?.({
552
- error: new Error(`Invalid input: ${JSON.stringify(result.error)}`),
553
- });
513
+ $: "error",
514
+ error: `Invalid input: ${JSON.stringify(result.error)}`,
515
+ code: "VALIDATION_ERROR",
516
+ } as Message);
554
517
  observer.complete?.();
555
518
  return;
556
519
  }
@@ -561,75 +524,24 @@ class LensServerImpl<
561
524
  await runWithContext(this.ctx, context, async () => {
562
525
  const resolver = def._resolve;
563
526
  if (!resolver) {
564
- observer.next?.({ error: new Error(`Operation ${path} has no resolver`) });
527
+ observer.next?.({
528
+ $: "error",
529
+ error: `Operation ${path} has no resolver`,
530
+ code: "NO_RESOLVER",
531
+ } as Message);
565
532
  observer.complete?.();
566
533
  return;
567
534
  }
568
535
 
569
536
  // Create emit handler with async queue processing
570
- // Emit commands are queued and processed through processQueryResult
571
- // to ensure field resolvers run on every emit
572
- let emitProcessing = false;
573
- const MAX_EMIT_QUEUE_SIZE = 100; // Backpressure: prevent memory bloat
574
- const emitQueue = new RingBuffer<EmitCommand>(MAX_EMIT_QUEUE_SIZE);
575
-
576
- const processEmitQueue = async () => {
577
- if (emitProcessing || cancelled) return;
578
- emitProcessing = true;
579
-
580
- let command = emitQueue.dequeue();
581
- while (command !== null && !cancelled) {
582
- // Clear DataLoader cache before re-processing
583
- // This ensures field resolvers re-run with fresh data
584
- this.clearLoaders();
585
-
586
- currentState = this.applyEmitCommand(command, currentState);
587
-
588
- // Process through field resolvers (unlike before where we bypassed this)
589
- // Note: createFieldEmit is created after this function but used lazily
590
- const fieldEmitFactory = isQuery
591
- ? this.createFieldEmitFactory(
592
- () => currentState,
593
- (state) => {
594
- currentState = state;
595
- },
596
- emitIfChanged,
597
- select,
598
- context,
599
- onCleanup,
600
- )
601
- : undefined;
602
-
603
- const processed = isQuery
604
- ? await this.processQueryResult(
605
- path,
606
- currentState,
607
- select,
608
- context,
609
- onCleanup,
610
- fieldEmitFactory,
611
- )
612
- : currentState;
613
-
614
- emitIfChanged(processed);
615
- command = emitQueue.dequeue();
616
- }
617
-
618
- emitProcessing = false;
619
- };
620
-
537
+ // STATELESS ARCHITECTURE: Server forwards emit commands as ops directly to client.
538
+ // Client is responsible for applying updates to local state.
539
+ // This enables serverless deployments and minimal wire transfer.
621
540
  const emitHandler = (command: EmitCommand) => {
622
541
  if (cancelled) return;
623
-
624
- // Enqueue command - RingBuffer handles backpressure automatically
625
- // (drops oldest if at capacity, which is correct for live queries)
626
- emitQueue.enqueue(command);
627
- // Fire async processing (don't await - emit should be sync from caller's perspective)
628
- processEmitQueue().catch((err) => {
629
- if (!cancelled) {
630
- observer.next?.({ error: err instanceof Error ? err : new Error(String(err)) });
631
- }
632
- });
542
+ // Convert command to ops and send - no state maintained on server
543
+ const ops = toOps(command);
544
+ observer.next?.({ $: "ops", ops } as Message);
633
545
  };
634
546
 
635
547
  // Detect array output type: [EntityDef] is stored as single-element array
@@ -643,18 +555,12 @@ class LensServerImpl<
643
555
  };
644
556
  };
645
557
 
646
- // Create field emit factory for field-level live queries
558
+ // Create field emit factory for field-level live queries (STATELESS)
647
559
  const createFieldEmit = isQuery
648
- ? this.createFieldEmitFactory(
649
- () => currentState,
650
- (state) => {
651
- currentState = state;
652
- },
653
- emitIfChanged,
654
- select,
655
- context,
656
- onCleanup,
657
- )
560
+ ? this.createFieldEmitFactory((command) => {
561
+ const ops = toOps(command);
562
+ observer.next?.({ $: "ops", ops } as Message);
563
+ })
658
564
  : undefined;
659
565
 
660
566
  const lensContext = { ...context, emit, onCleanup };
@@ -664,7 +570,6 @@ class LensServerImpl<
664
570
  // Streaming: emit each yielded value
665
571
  for await (const value of result) {
666
572
  if (cancelled) break;
667
- currentState = value;
668
573
  const processed = await this.processQueryResult(
669
574
  path,
670
575
  value,
@@ -681,7 +586,6 @@ class LensServerImpl<
681
586
  } else {
682
587
  // One-shot: emit single value
683
588
  const value = await result;
684
- currentState = value;
685
589
  const processed = isQuery
686
590
  ? await this.processQueryResult(
687
591
  path,
@@ -712,9 +616,12 @@ class LensServerImpl<
712
616
  }
713
617
  } catch (err) {
714
618
  if (!cancelled) {
619
+ const errMsg = err instanceof Error ? err.message : String(err);
715
620
  observer.next?.({
716
- error: err instanceof Error ? err : new Error(String(err)),
717
- });
621
+ $: "error",
622
+ error: errMsg,
623
+ code: "SUBSCRIBE_ERROR",
624
+ } as Message);
718
625
  }
719
626
  }
720
627
  }
@@ -729,7 +636,8 @@ class LensServerImpl<
729
636
  });
730
637
  } catch (error) {
731
638
  if (!cancelled) {
732
- observer.next?.({ error: error instanceof Error ? error : new Error(String(error)) });
639
+ const errMsg = error instanceof Error ? error.message : String(error);
640
+ observer.next?.({ $: "error", error: errMsg, code: "INTERNAL_ERROR" } as Message);
733
641
  observer.complete?.();
734
642
  }
735
643
  } finally {
@@ -749,168 +657,39 @@ class LensServerImpl<
749
657
  };
750
658
  }
751
659
 
752
- /**
753
- * Apply emit command to current state.
754
- */
755
- private applyEmitCommand(command: EmitCommand, state: unknown): unknown {
756
- switch (command.type) {
757
- case "full":
758
- if (command.replace) {
759
- return command.data;
760
- }
761
- // Merge mode
762
- if (state && typeof state === "object" && typeof command.data === "object") {
763
- return { ...state, ...(command.data as Record<string, unknown>) };
764
- }
765
- return command.data;
766
-
767
- case "field": {
768
- // Empty field = scalar root value (e.g., emit.delta on a string field)
769
- if (command.field === "") {
770
- return applyUpdate(state, command.update);
771
- }
772
- // Named field - apply update to that field
773
- if (state && typeof state === "object") {
774
- const currentValue = (state as Record<string, unknown>)[command.field];
775
- const newValue = applyUpdate(currentValue, command.update);
776
- return {
777
- ...(state as Record<string, unknown>),
778
- [command.field]: newValue,
779
- };
780
- }
781
- return { [command.field]: applyUpdate(undefined, command.update) };
782
- }
783
-
784
- case "batch":
785
- if (state && typeof state === "object") {
786
- const result = { ...(state as Record<string, unknown>) };
787
- for (const update of command.updates) {
788
- const currentValue = result[update.field];
789
- result[update.field] = applyUpdate(currentValue, update.update);
790
- }
791
- return result;
792
- }
793
- return state;
794
-
795
- case "array": {
796
- // Array operations - simplified handling
797
- const arr = Array.isArray(state) ? [...state] : [];
798
- const op = command.operation;
799
- switch (op.op) {
800
- case "push":
801
- return [...arr, op.item];
802
- case "unshift":
803
- return [op.item, ...arr];
804
- case "insert":
805
- arr.splice(op.index, 0, op.item);
806
- return arr;
807
- case "remove":
808
- arr.splice(op.index, 1);
809
- return arr;
810
- case "update":
811
- arr[op.index] = op.item;
812
- return arr;
813
- default:
814
- return arr;
815
- }
816
- }
817
-
818
- default:
819
- return state;
820
- }
821
- }
822
-
823
660
  // =========================================================================
824
661
  // Result Processing
825
662
  // =========================================================================
826
663
 
827
664
  /**
828
- * Factory type for creating field-level emit handlers.
829
- * Each field gets its own emit with full Emit<T> API (.delta, .patch, .push, etc).
665
+ * Factory for creating field-level emit handlers (STATELESS).
666
+ * Each field gets its own emit that forwards commands to the observer with the field path.
667
+ * Client is responsible for applying updates to local state.
830
668
  */
831
669
  private createFieldEmitFactory(
832
- getCurrentState: () => unknown,
833
- setCurrentState: (state: unknown) => void,
834
- notifyObserver: (data: unknown) => void,
835
- select: SelectionObject | undefined,
836
- context: TContext | undefined,
837
- onCleanup: ((fn: () => void) => void) | undefined,
670
+ sendUpdate: (command: EmitCommand) => void,
838
671
  ): (fieldPath: string, resolvedValue?: unknown) => Emit<unknown> | undefined {
839
672
  return (fieldPath: string, resolvedValue?: unknown) => {
840
673
  if (!fieldPath) return undefined;
841
674
 
842
- // Determine output type from resolved value (if provided) or current field value
843
- const state = getCurrentState();
844
- const currentFieldValue =
845
- resolvedValue !== undefined
846
- ? resolvedValue
847
- : state
848
- ? this.getFieldByPath(state, fieldPath)
849
- : undefined;
850
-
851
675
  // Determine emit type: array, scalar, or object
852
676
  let outputType: "array" | "object" | "scalar" = "object";
853
- if (Array.isArray(currentFieldValue)) {
677
+ if (Array.isArray(resolvedValue)) {
854
678
  outputType = "array";
855
679
  } else if (
856
- currentFieldValue === null ||
857
- typeof currentFieldValue === "string" ||
858
- typeof currentFieldValue === "number" ||
859
- typeof currentFieldValue === "boolean"
680
+ resolvedValue === null ||
681
+ typeof resolvedValue === "string" ||
682
+ typeof resolvedValue === "number" ||
683
+ typeof resolvedValue === "boolean"
860
684
  ) {
861
685
  outputType = "scalar";
862
686
  }
863
687
 
864
- // Track field value locally (for fields not yet in fullState)
865
- let localFieldValue = resolvedValue;
866
-
867
- // Create emit handler that applies commands to the field's value
688
+ // STATELESS: Forward command with field path prefix to client
868
689
  const emitHandler = (command: EmitCommand) => {
869
- const fullState = getCurrentState();
870
- if (!fullState || typeof fullState !== "object") return;
871
-
872
- // Get current field value from state, or use local value if not in state yet
873
- const stateFieldValue = this.getFieldByPath(fullState, fieldPath);
874
- const fieldValue = stateFieldValue !== undefined ? stateFieldValue : localFieldValue;
875
- const newFieldValue = this.applyEmitCommand(command, fieldValue);
876
-
877
- // Update local tracking
878
- localFieldValue = newFieldValue;
879
-
880
- // Update state with new field value
881
- const updatedState = this.setFieldByPath(
882
- fullState as Record<string, unknown>,
883
- fieldPath,
884
- newFieldValue,
885
- );
886
- setCurrentState(updatedState);
887
-
888
- // Resolve nested fields on the new value and notify observer
889
- (async () => {
890
- try {
891
- const nestedInputs = select ? extractNestedInputs(select) : undefined;
892
- const processed = await this.resolveEntityFields(
893
- updatedState,
894
- nestedInputs,
895
- context,
896
- "",
897
- onCleanup,
898
- this.createFieldEmitFactory(
899
- getCurrentState,
900
- setCurrentState,
901
- notifyObserver,
902
- select,
903
- context,
904
- onCleanup,
905
- ),
906
- );
907
- const result = select ? applySelection(processed, select) : processed;
908
- notifyObserver(result);
909
- } catch (err) {
910
- // Field emit errors are logged but don't break the stream
911
- console.error(`Field emit error at path "${fieldPath}":`, err);
912
- }
913
- })();
690
+ // Transform command to include field path
691
+ const prefixedCommand = this.prefixCommandPath(command, fieldPath);
692
+ sendUpdate(prefixedCommand);
914
693
  };
915
694
 
916
695
  return createEmit<unknown>(emitHandler, outputType);
@@ -918,42 +697,43 @@ class LensServerImpl<
918
697
  }
919
698
 
920
699
  /**
921
- * Get a value at a nested path in an object.
700
+ * Prefix a command's field path for nested field emits.
922
701
  */
923
- private getFieldByPath(obj: unknown, path: string): unknown {
924
- if (!obj || typeof obj !== "object") return undefined;
925
- const parts = path.split(".");
926
- let current: unknown = obj;
927
- for (const part of parts) {
928
- if (!current || typeof current !== "object") return undefined;
929
- current = (current as Record<string, unknown>)[part];
930
- }
931
- return current;
932
- }
933
-
934
- /**
935
- * Set a value at a nested path in an object.
936
- * Creates a shallow copy at each level.
937
- */
938
- private setFieldByPath(
939
- obj: Record<string, unknown>,
940
- path: string,
941
- value: unknown,
942
- ): Record<string, unknown> {
943
- const parts = path.split(".");
944
- if (parts.length === 1) {
945
- return { ...obj, [path]: value };
946
- }
947
-
948
- const [first, ...rest] = parts;
949
- const nested = obj[first];
950
- if (nested && typeof nested === "object") {
951
- return {
952
- ...obj,
953
- [first]: this.setFieldByPath(nested as Record<string, unknown>, rest.join("."), value),
954
- };
702
+ private prefixCommandPath(command: EmitCommand, prefix: string): EmitCommand {
703
+ switch (command.type) {
704
+ case "full":
705
+ // Full replacement at field path
706
+ return {
707
+ type: "field",
708
+ field: prefix,
709
+ update: { strategy: "value", data: command.data },
710
+ };
711
+ case "field":
712
+ // Nested field path
713
+ return {
714
+ type: "field",
715
+ field: command.field ? `${prefix}.${command.field}` : prefix,
716
+ update: command.update,
717
+ };
718
+ case "batch":
719
+ // Prefix all fields in batch
720
+ return {
721
+ type: "batch",
722
+ updates: command.updates.map((u) => ({
723
+ field: `${prefix}.${u.field}`,
724
+ update: u.update,
725
+ })),
726
+ };
727
+ case "array":
728
+ // Array operations at field path - preserve as array command with field
729
+ return {
730
+ type: "array",
731
+ operation: command.operation,
732
+ field: prefix,
733
+ };
734
+ default:
735
+ return command;
955
736
  }
956
- return obj;
957
737
  }
958
738
 
959
739
  private async processQueryResult<T>(
@@ -976,6 +756,7 @@ class LensServerImpl<
976
756
  "",
977
757
  onCleanup,
978
758
  createFieldEmit,
759
+ new Set(), // Cycle detection for circular entity references (type:id)
979
760
  );
980
761
  if (select) {
981
762
  return applySelection(processed, select) as T;
@@ -993,6 +774,7 @@ class LensServerImpl<
993
774
  * @param fieldPath - Current path for nested field resolution
994
775
  * @param onCleanup - Cleanup registration for live query subscriptions
995
776
  * @param createFieldEmit - Factory for creating field-specific emit handlers
777
+ * @param visited - Set of "type:id" keys to track visited entities and prevent circular reference infinite loops
996
778
  */
997
779
  private async resolveEntityFields<T>(
998
780
  data: T,
@@ -1001,6 +783,7 @@ class LensServerImpl<
1001
783
  fieldPath = "",
1002
784
  onCleanup?: (fn: () => void) => void,
1003
785
  createFieldEmit?: (fieldPath: string, resolvedValue?: unknown) => Emit<unknown> | undefined,
786
+ visited: Set<string> = new Set(),
1004
787
  ): Promise<T> {
1005
788
  if (!data || !this.resolverMap) return data;
1006
789
 
@@ -1014,6 +797,7 @@ class LensServerImpl<
1014
797
  fieldPath,
1015
798
  onCleanup,
1016
799
  createFieldEmit,
800
+ visited,
1017
801
  ),
1018
802
  ),
1019
803
  ) as Promise<T>;
@@ -1025,6 +809,17 @@ class LensServerImpl<
1025
809
  const typeName = this.getTypeName(obj);
1026
810
  if (!typeName) return data;
1027
811
 
812
+ // Cycle detection using entity type + ID to prevent infinite loops
813
+ // This handles circular entity references like User.posts -> Post.author -> User
814
+ const entityId = obj.id ?? obj._id ?? obj.uuid;
815
+ if (entityId !== undefined) {
816
+ const entityKey = `${typeName}:${entityId}`;
817
+ if (visited.has(entityKey)) {
818
+ return data; // Already resolved this entity, return as-is
819
+ }
820
+ visited.add(entityKey);
821
+ }
822
+
1028
823
  const resolverDef = this.resolverMap.get(typeName);
1029
824
  if (!resolverDef) return data;
1030
825
 
@@ -1053,6 +848,7 @@ class LensServerImpl<
1053
848
  currentPath,
1054
849
  onCleanup,
1055
850
  createFieldEmit,
851
+ visited,
1056
852
  );
1057
853
  continue;
1058
854
  }
@@ -1163,6 +959,7 @@ class LensServerImpl<
1163
959
  currentPath,
1164
960
  onCleanup,
1165
961
  createFieldEmit,
962
+ visited,
1166
963
  );
1167
964
  }
1168
965