@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.
- package/dist/index.d.ts +5 -5
- package/dist/index.js +141 -230
- package/package.json +2 -2
- package/src/e2e/server.test.ts +61 -43
- package/src/handlers/framework.ts +25 -10
- package/src/handlers/http.ts +14 -4
- package/src/handlers/ws.ts +37 -29
- package/src/server/create.test.ts +311 -173
- package/src/server/create.ts +106 -309
- package/src/server/dataloader.test.ts +279 -0
- package/src/server/types.ts +5 -5
package/src/server/create.ts
CHANGED
|
@@ -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?.({
|
|
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
|
-
|
|
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?.({
|
|
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
|
-
|
|
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?.({
|
|
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
|
-
//
|
|
571
|
-
//
|
|
572
|
-
|
|
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
|
-
|
|
625
|
-
|
|
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
|
-
()
|
|
650
|
-
(
|
|
651
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
829
|
-
* Each field gets its own emit
|
|
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
|
-
|
|
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(
|
|
677
|
+
if (Array.isArray(resolvedValue)) {
|
|
854
678
|
outputType = "array";
|
|
855
679
|
} else if (
|
|
856
|
-
|
|
857
|
-
typeof
|
|
858
|
-
typeof
|
|
859
|
-
typeof
|
|
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
|
-
//
|
|
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
|
-
|
|
870
|
-
|
|
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
|
-
*
|
|
700
|
+
* Prefix a command's field path for nested field emits.
|
|
922
701
|
*/
|
|
923
|
-
private
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
|