@sylphx/lens-server 2.7.2 → 2.8.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 +1 -1
- package/dist/index.js +79 -19
- package/package.json +2 -2
- package/src/server/create.test.ts +149 -23
- package/src/server/create.ts +132 -39
- package/src/server/types.ts +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -512,7 +512,7 @@ interface LensServerConfig<
|
|
|
512
512
|
plugins?: ServerPlugin[] | undefined;
|
|
513
513
|
}
|
|
514
514
|
/** Field mode for entity fields */
|
|
515
|
-
type FieldMode = "exposed" | "resolve" | "subscribe";
|
|
515
|
+
type FieldMode = "exposed" | "resolve" | "subscribe" | "live";
|
|
516
516
|
/** Entity field metadata for client-side routing decisions */
|
|
517
517
|
interface EntityFieldMetadata {
|
|
518
518
|
[fieldName: string]: FieldMode;
|
package/dist/index.js
CHANGED
|
@@ -575,6 +575,7 @@ class LensServerImpl {
|
|
|
575
575
|
emitProcessing = true;
|
|
576
576
|
let command = emitQueue.dequeue();
|
|
577
577
|
while (command !== null && !cancelled) {
|
|
578
|
+
this.clearLoaders();
|
|
578
579
|
currentState = this.applyEmitCommand(command, currentState);
|
|
579
580
|
const fieldEmitFactory = isQuery ? this.createFieldEmitFactory(() => currentState, (state) => {
|
|
580
581
|
currentState = state;
|
|
@@ -707,11 +708,16 @@ class LensServerImpl {
|
|
|
707
708
|
return (fieldPath) => {
|
|
708
709
|
if (!fieldPath)
|
|
709
710
|
return;
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
711
|
+
const state = getCurrentState();
|
|
712
|
+
const currentFieldValue = state ? this.getFieldByPath(state, fieldPath) : undefined;
|
|
713
|
+
const isArray = Array.isArray(currentFieldValue);
|
|
714
|
+
const emitHandler = (command) => {
|
|
715
|
+
const fullState = getCurrentState();
|
|
716
|
+
if (!fullState || typeof fullState !== "object")
|
|
713
717
|
return;
|
|
714
|
-
const
|
|
718
|
+
const fieldValue = this.getFieldByPath(fullState, fieldPath);
|
|
719
|
+
const newFieldValue = this.applyEmitCommand(command, fieldValue);
|
|
720
|
+
const updatedState = this.setFieldByPath(fullState, fieldPath, newFieldValue);
|
|
715
721
|
setCurrentState(updatedState);
|
|
716
722
|
(async () => {
|
|
717
723
|
try {
|
|
@@ -724,8 +730,21 @@ class LensServerImpl {
|
|
|
724
730
|
}
|
|
725
731
|
})();
|
|
726
732
|
};
|
|
733
|
+
return createEmit(emitHandler, isArray);
|
|
727
734
|
};
|
|
728
735
|
}
|
|
736
|
+
getFieldByPath(obj, path) {
|
|
737
|
+
if (!obj || typeof obj !== "object")
|
|
738
|
+
return;
|
|
739
|
+
const parts = path.split(".");
|
|
740
|
+
let current = obj;
|
|
741
|
+
for (const part of parts) {
|
|
742
|
+
if (!current || typeof current !== "object")
|
|
743
|
+
return;
|
|
744
|
+
current = current[part];
|
|
745
|
+
}
|
|
746
|
+
return current;
|
|
747
|
+
}
|
|
729
748
|
setFieldByPath(obj, path, value) {
|
|
730
749
|
const parts = path.split(".");
|
|
731
750
|
if (parts.length === 1) {
|
|
@@ -779,26 +798,68 @@ class LensServerImpl {
|
|
|
779
798
|
result[field] = await this.resolveEntityFields(existingValue, nestedInputs, context, currentPath, onCleanup, createFieldEmit);
|
|
780
799
|
continue;
|
|
781
800
|
}
|
|
782
|
-
|
|
801
|
+
const fieldMode = resolverDef.getFieldMode(field);
|
|
802
|
+
if (fieldMode === "live") {
|
|
783
803
|
try {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
emit: createFieldEmit(currentPath),
|
|
787
|
-
onCleanup
|
|
788
|
-
};
|
|
789
|
-
if (resolverDef.isSubscription(field)) {
|
|
790
|
-
result[field] = null;
|
|
791
|
-
resolverDef.resolveField(field, obj, args, extendedCtx).catch(() => {});
|
|
804
|
+
if (hasArgs) {
|
|
805
|
+
result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
|
|
792
806
|
} else {
|
|
793
|
-
|
|
807
|
+
const loaderKey = `${typeName}.${field}`;
|
|
808
|
+
const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field, context ?? {});
|
|
809
|
+
result[field] = await loader.load(obj);
|
|
810
|
+
}
|
|
811
|
+
const publisher = resolverDef.subscribeField(field, obj, args, context ?? {});
|
|
812
|
+
if (publisher && createFieldEmit && onCleanup) {
|
|
813
|
+
try {
|
|
814
|
+
const fieldEmit = createFieldEmit(currentPath);
|
|
815
|
+
if (fieldEmit) {
|
|
816
|
+
publisher({
|
|
817
|
+
emit: fieldEmit,
|
|
818
|
+
onCleanup: (fn) => {
|
|
819
|
+
onCleanup(fn);
|
|
820
|
+
return fn;
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
} catch {}
|
|
825
|
+
}
|
|
826
|
+
} catch {
|
|
827
|
+
result[field] = null;
|
|
828
|
+
}
|
|
829
|
+
} else if (fieldMode === "subscribe") {
|
|
830
|
+
try {
|
|
831
|
+
result[field] = null;
|
|
832
|
+
if (createFieldEmit && onCleanup) {
|
|
833
|
+
try {
|
|
834
|
+
const fieldEmit = createFieldEmit(currentPath);
|
|
835
|
+
if (fieldEmit) {
|
|
836
|
+
const legacyCtx = {
|
|
837
|
+
...context ?? {},
|
|
838
|
+
emit: fieldEmit,
|
|
839
|
+
onCleanup: (fn) => {
|
|
840
|
+
onCleanup(fn);
|
|
841
|
+
return fn;
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
resolverDef.subscribeFieldLegacy(field, obj, args, legacyCtx);
|
|
845
|
+
}
|
|
846
|
+
} catch {}
|
|
794
847
|
}
|
|
795
848
|
} catch {
|
|
796
849
|
result[field] = null;
|
|
797
850
|
}
|
|
798
851
|
} else {
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
852
|
+
try {
|
|
853
|
+
if (hasArgs) {
|
|
854
|
+
result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
|
|
855
|
+
} else {
|
|
856
|
+
const loaderKey = `${typeName}.${field}`;
|
|
857
|
+
const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field, context ?? {});
|
|
858
|
+
result[field] = await loader.load(obj);
|
|
859
|
+
}
|
|
860
|
+
} catch {
|
|
861
|
+
result[field] = null;
|
|
862
|
+
}
|
|
802
863
|
}
|
|
803
864
|
result[field] = await this.resolveEntityFields(result[field], nestedInputs, context, currentPath, onCleanup, createFieldEmit);
|
|
804
865
|
}
|
|
@@ -827,11 +888,10 @@ class LensServerImpl {
|
|
|
827
888
|
const matchingFields = fieldNames.filter((field) => (field in obj));
|
|
828
889
|
return matchingFields.length / fieldNames.length;
|
|
829
890
|
}
|
|
830
|
-
getOrCreateLoaderForField(loaderKey, resolverDef, fieldName) {
|
|
891
|
+
getOrCreateLoaderForField(loaderKey, resolverDef, fieldName, context) {
|
|
831
892
|
let loader = this.loaders.get(loaderKey);
|
|
832
893
|
if (!loader) {
|
|
833
894
|
loader = new DataLoader(async (parents) => {
|
|
834
|
-
const context = tryUseContext() ?? {};
|
|
835
895
|
const results = [];
|
|
836
896
|
for (const parent of parents) {
|
|
837
897
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "Server runtime for Lens API framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"author": "SylphxAI",
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@sylphx/lens-core": "^2.
|
|
33
|
+
"@sylphx/lens-core": "^2.6.1"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.9.3",
|
|
@@ -856,22 +856,27 @@ describe("field resolvers", () => {
|
|
|
856
856
|
posts: [{ id: "p1", title: "Post 1", authorId: "a1" }],
|
|
857
857
|
};
|
|
858
858
|
|
|
859
|
+
// Use .resolve().subscribe() to get onCleanup access
|
|
860
|
+
// Per ADR-002: .resolve() alone is pure/batchable, no emit/onCleanup
|
|
861
|
+
// .resolve().subscribe() gives initial value + subscription capabilities
|
|
859
862
|
const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
|
|
860
863
|
id: f.expose("id"),
|
|
861
864
|
name: f.expose("name"),
|
|
862
|
-
posts: f
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
865
|
+
posts: f
|
|
866
|
+
.many(Post)
|
|
867
|
+
.resolve(({ parent, ctx }) => {
|
|
868
|
+
// Initial resolution (batchable, no emit/onCleanup)
|
|
869
|
+
return ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
870
|
+
})
|
|
871
|
+
.subscribe(() => ({ onCleanup }) => {
|
|
872
|
+
// Publisher pattern: emit/onCleanup come from callback, not ctx
|
|
873
|
+
resolverReceivedOnCleanup = true;
|
|
874
|
+
|
|
875
|
+
// Register cleanup
|
|
876
|
+
onCleanup(() => {
|
|
869
877
|
cleanupCalled = true;
|
|
870
878
|
});
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
return ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
874
|
-
}),
|
|
879
|
+
}),
|
|
875
880
|
}));
|
|
876
881
|
|
|
877
882
|
const getAuthor = query<{ db: typeof mockDb }>()
|
|
@@ -940,23 +945,27 @@ describe("field resolvers", () => {
|
|
|
940
945
|
// Track field emit
|
|
941
946
|
let capturedFieldEmit: ((value: unknown) => void) | undefined;
|
|
942
947
|
|
|
948
|
+
// Use .resolve().subscribe() to get emit access
|
|
949
|
+
// Per ADR-002: .resolve() alone is pure/batchable, no emit/onCleanup
|
|
950
|
+
// .resolve().subscribe() gives initial value + subscription capabilities
|
|
943
951
|
const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
|
|
944
952
|
id: f.expose("id"),
|
|
945
953
|
name: f.expose("name"),
|
|
946
|
-
posts: f
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
+
posts: f
|
|
955
|
+
.many(Post)
|
|
956
|
+
.resolve(({ parent, ctx }) => {
|
|
957
|
+
// Initial resolution (batchable, no emit)
|
|
958
|
+
return ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
959
|
+
})
|
|
960
|
+
.subscribe(() => ({ emit, onCleanup }) => {
|
|
961
|
+
// Publisher pattern: emit/onCleanup come from callback
|
|
962
|
+
capturedFieldEmit = emit;
|
|
963
|
+
|
|
964
|
+
// Register cleanup
|
|
965
|
+
onCleanup(() => {
|
|
954
966
|
capturedFieldEmit = undefined;
|
|
955
967
|
});
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
return ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
959
|
-
}),
|
|
968
|
+
}),
|
|
960
969
|
}));
|
|
961
970
|
|
|
962
971
|
const getAuthor = query<{ db: typeof mockDb }>()
|
|
@@ -1077,6 +1086,123 @@ describe("field resolvers", () => {
|
|
|
1077
1086
|
|
|
1078
1087
|
subscription.unsubscribe();
|
|
1079
1088
|
});
|
|
1089
|
+
|
|
1090
|
+
it(".resolve().subscribe() pattern enables batching with live updates", async () => {
|
|
1091
|
+
// ADR-002: Two-Phase Field Resolution
|
|
1092
|
+
// .resolve() handles initial data (batchable)
|
|
1093
|
+
// .subscribe() handles live updates (fire-and-forget)
|
|
1094
|
+
|
|
1095
|
+
const Author = entity("Author", {
|
|
1096
|
+
id: t.id(),
|
|
1097
|
+
name: t.string(),
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
const Post = entity("Post", {
|
|
1101
|
+
id: t.id(),
|
|
1102
|
+
title: t.string(),
|
|
1103
|
+
authorId: t.string(),
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
const mockDb = {
|
|
1107
|
+
authors: [{ id: "a1", name: "Alice" }],
|
|
1108
|
+
posts: [
|
|
1109
|
+
{ id: "p1", title: "Post 1", authorId: "a1" },
|
|
1110
|
+
{ id: "p2", title: "Post 2", authorId: "a1" },
|
|
1111
|
+
],
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
// Track calls and captured emit
|
|
1115
|
+
let resolveCallCount = 0;
|
|
1116
|
+
let subscribeCallCount = 0;
|
|
1117
|
+
let capturedFieldEmit: ((value: unknown) => void) | undefined;
|
|
1118
|
+
|
|
1119
|
+
// Use .resolve().subscribe() pattern
|
|
1120
|
+
const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
|
|
1121
|
+
id: f.expose("id"),
|
|
1122
|
+
name: f.expose("name"),
|
|
1123
|
+
posts: f
|
|
1124
|
+
.many(Post)
|
|
1125
|
+
.resolve(({ parent, ctx }) => {
|
|
1126
|
+
// Phase 1: Initial resolution (batchable, no emit/onCleanup)
|
|
1127
|
+
resolveCallCount++;
|
|
1128
|
+
return ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
1129
|
+
})
|
|
1130
|
+
.subscribe(() => ({ emit, onCleanup }) => {
|
|
1131
|
+
// Phase 2: Publisher pattern - emit/onCleanup from callback
|
|
1132
|
+
subscribeCallCount++;
|
|
1133
|
+
capturedFieldEmit = emit;
|
|
1134
|
+
|
|
1135
|
+
onCleanup(() => {
|
|
1136
|
+
capturedFieldEmit = undefined;
|
|
1137
|
+
});
|
|
1138
|
+
}),
|
|
1139
|
+
}));
|
|
1140
|
+
|
|
1141
|
+
const getAuthor = query<{ db: typeof mockDb }>()
|
|
1142
|
+
.input(z.object({ id: z.string() }))
|
|
1143
|
+
.returns(Author)
|
|
1144
|
+
.resolve(({ input, ctx }) => {
|
|
1145
|
+
const author = ctx.db.authors.find((a) => a.id === input.id);
|
|
1146
|
+
if (!author) throw new Error("Author not found");
|
|
1147
|
+
return author;
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
const server = createApp({
|
|
1151
|
+
entities: { Author, Post },
|
|
1152
|
+
queries: { getAuthor },
|
|
1153
|
+
resolvers: [authorResolver],
|
|
1154
|
+
context: () => ({ db: mockDb }),
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
const results: unknown[] = [];
|
|
1158
|
+
const subscription = server
|
|
1159
|
+
.execute({
|
|
1160
|
+
path: "getAuthor",
|
|
1161
|
+
input: {
|
|
1162
|
+
id: "a1",
|
|
1163
|
+
$select: {
|
|
1164
|
+
id: true,
|
|
1165
|
+
name: true,
|
|
1166
|
+
posts: { select: { id: true, title: true } },
|
|
1167
|
+
},
|
|
1168
|
+
},
|
|
1169
|
+
})
|
|
1170
|
+
.subscribe({
|
|
1171
|
+
next: (result) => {
|
|
1172
|
+
results.push(result);
|
|
1173
|
+
},
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
// Wait for initial result
|
|
1177
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1178
|
+
|
|
1179
|
+
// Verify initial resolution
|
|
1180
|
+
expect(results.length).toBe(1);
|
|
1181
|
+
expect(resolveCallCount).toBe(1); // Resolver called once
|
|
1182
|
+
expect(subscribeCallCount).toBe(1); // Subscriber called once
|
|
1183
|
+
expect(capturedFieldEmit).toBeDefined(); // Emit captured from subscribe phase
|
|
1184
|
+
|
|
1185
|
+
const initialResult = results[0] as { data: { posts: { id: string }[] } };
|
|
1186
|
+
expect(initialResult.data.posts).toHaveLength(2);
|
|
1187
|
+
|
|
1188
|
+
// Use field emit to push update (from subscribe phase)
|
|
1189
|
+
const updatedPosts = [
|
|
1190
|
+
{ id: "p1", title: "Updated 1", authorId: "a1" },
|
|
1191
|
+
{ id: "p2", title: "Updated 2", authorId: "a1" },
|
|
1192
|
+
{ id: "p3", title: "New Post", authorId: "a1" },
|
|
1193
|
+
];
|
|
1194
|
+
capturedFieldEmit!(updatedPosts);
|
|
1195
|
+
|
|
1196
|
+
// Wait for update
|
|
1197
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1198
|
+
|
|
1199
|
+
expect(results.length).toBe(2);
|
|
1200
|
+
const updatedResult = results[1] as { data: { posts: { id: string; title: string }[] } };
|
|
1201
|
+
expect(updatedResult.data.posts).toHaveLength(3);
|
|
1202
|
+
expect(updatedResult.data.posts[2].title).toBe("New Post");
|
|
1203
|
+
|
|
1204
|
+
subscription.unsubscribe();
|
|
1205
|
+
});
|
|
1080
1206
|
});
|
|
1081
1207
|
|
|
1082
1208
|
// =============================================================================
|
package/src/server/create.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ContextValue,
|
|
20
20
|
createEmit,
|
|
21
21
|
createResolverFromEntity,
|
|
22
|
+
type Emit,
|
|
22
23
|
type EmitCommand,
|
|
23
24
|
type EntityDef,
|
|
24
25
|
flattenRouter,
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
type RouterDef,
|
|
34
35
|
valuesEqual,
|
|
35
36
|
} from "@sylphx/lens-core";
|
|
36
|
-
import { createContext, runWithContext
|
|
37
|
+
import { createContext, runWithContext } from "../context/index.js";
|
|
37
38
|
import {
|
|
38
39
|
createPluginManager,
|
|
39
40
|
type PluginManager,
|
|
@@ -552,6 +553,10 @@ class LensServerImpl<
|
|
|
552
553
|
|
|
553
554
|
let command = emitQueue.dequeue();
|
|
554
555
|
while (command !== null && !cancelled) {
|
|
556
|
+
// Clear DataLoader cache before re-processing
|
|
557
|
+
// This ensures field resolvers re-run with fresh data
|
|
558
|
+
this.clearLoaders();
|
|
559
|
+
|
|
555
560
|
currentState = this.applyEmitCommand(command, currentState);
|
|
556
561
|
|
|
557
562
|
// Process through field resolvers (unlike before where we bypassed this)
|
|
@@ -760,7 +765,7 @@ class LensServerImpl<
|
|
|
760
765
|
|
|
761
766
|
/**
|
|
762
767
|
* Factory type for creating field-level emit handlers.
|
|
763
|
-
* Each field gets its own emit
|
|
768
|
+
* Each field gets its own emit with full Emit<T> API (.delta, .patch, .push, etc).
|
|
764
769
|
*/
|
|
765
770
|
private createFieldEmitFactory(
|
|
766
771
|
getCurrentState: () => unknown,
|
|
@@ -769,19 +774,29 @@ class LensServerImpl<
|
|
|
769
774
|
select: SelectionObject | undefined,
|
|
770
775
|
context: TContext | undefined,
|
|
771
776
|
onCleanup: ((fn: () => void) => void) | undefined,
|
|
772
|
-
): (fieldPath: string) =>
|
|
777
|
+
): (fieldPath: string) => Emit<unknown> | undefined {
|
|
773
778
|
return (fieldPath: string) => {
|
|
774
779
|
if (!fieldPath) return undefined;
|
|
775
780
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
781
|
+
// Determine if field value is an array (check current state)
|
|
782
|
+
const state = getCurrentState();
|
|
783
|
+
const currentFieldValue = state ? this.getFieldByPath(state, fieldPath) : undefined;
|
|
784
|
+
const isArray = Array.isArray(currentFieldValue);
|
|
785
|
+
|
|
786
|
+
// Create emit handler that applies commands to the field's value
|
|
787
|
+
const emitHandler = (command: EmitCommand) => {
|
|
788
|
+
const fullState = getCurrentState();
|
|
789
|
+
if (!fullState || typeof fullState !== "object") return;
|
|
790
|
+
|
|
791
|
+
// Get current field value and apply command to it
|
|
792
|
+
const fieldValue = this.getFieldByPath(fullState, fieldPath);
|
|
793
|
+
const newFieldValue = this.applyEmitCommand(command, fieldValue);
|
|
780
794
|
|
|
795
|
+
// Update state with new field value
|
|
781
796
|
const updatedState = this.setFieldByPath(
|
|
782
|
-
|
|
797
|
+
fullState as Record<string, unknown>,
|
|
783
798
|
fieldPath,
|
|
784
|
-
|
|
799
|
+
newFieldValue,
|
|
785
800
|
);
|
|
786
801
|
setCurrentState(updatedState);
|
|
787
802
|
|
|
@@ -812,9 +827,25 @@ class LensServerImpl<
|
|
|
812
827
|
}
|
|
813
828
|
})();
|
|
814
829
|
};
|
|
830
|
+
|
|
831
|
+
return createEmit<unknown>(emitHandler, isArray);
|
|
815
832
|
};
|
|
816
833
|
}
|
|
817
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Get a value at a nested path in an object.
|
|
837
|
+
*/
|
|
838
|
+
private getFieldByPath(obj: unknown, path: string): unknown {
|
|
839
|
+
if (!obj || typeof obj !== "object") return undefined;
|
|
840
|
+
const parts = path.split(".");
|
|
841
|
+
let current: unknown = obj;
|
|
842
|
+
for (const part of parts) {
|
|
843
|
+
if (!current || typeof current !== "object") return undefined;
|
|
844
|
+
current = (current as Record<string, unknown>)[part];
|
|
845
|
+
}
|
|
846
|
+
return current;
|
|
847
|
+
}
|
|
848
|
+
|
|
818
849
|
/**
|
|
819
850
|
* Set a value at a nested path in an object.
|
|
820
851
|
* Creates a shallow copy at each level.
|
|
@@ -846,7 +877,7 @@ class LensServerImpl<
|
|
|
846
877
|
select?: SelectionObject,
|
|
847
878
|
context?: TContext,
|
|
848
879
|
onCleanup?: (fn: () => void) => void,
|
|
849
|
-
createFieldEmit?: (fieldPath: string) =>
|
|
880
|
+
createFieldEmit?: (fieldPath: string) => Emit<unknown> | undefined,
|
|
850
881
|
): Promise<T> {
|
|
851
882
|
if (!data) return data;
|
|
852
883
|
|
|
@@ -884,7 +915,7 @@ class LensServerImpl<
|
|
|
884
915
|
context?: TContext,
|
|
885
916
|
fieldPath = "",
|
|
886
917
|
onCleanup?: (fn: () => void) => void,
|
|
887
|
-
createFieldEmit?: (fieldPath: string) =>
|
|
918
|
+
createFieldEmit?: (fieldPath: string) => Emit<unknown> | undefined,
|
|
888
919
|
): Promise<T> {
|
|
889
920
|
if (!data || !this.resolverMap) return data;
|
|
890
921
|
|
|
@@ -941,40 +972,101 @@ class LensServerImpl<
|
|
|
941
972
|
continue;
|
|
942
973
|
}
|
|
943
974
|
|
|
944
|
-
// Resolve the field
|
|
945
|
-
|
|
946
|
-
|
|
975
|
+
// Resolve the field based on mode
|
|
976
|
+
// ADR-002: Two-Phase Field Resolution
|
|
977
|
+
const fieldMode = resolverDef.getFieldMode(field);
|
|
978
|
+
|
|
979
|
+
if (fieldMode === "live") {
|
|
980
|
+
// LIVE MODE: Two-phase resolution
|
|
981
|
+
// Phase 1: Run resolver for initial value (batchable)
|
|
982
|
+
// Phase 2: Run subscriber for live updates (fire-and-forget)
|
|
947
983
|
try {
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
984
|
+
// Phase 1: Get initial value (no emit/onCleanup needed)
|
|
985
|
+
if (hasArgs) {
|
|
986
|
+
// Direct resolution with args
|
|
987
|
+
result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
|
|
988
|
+
} else {
|
|
989
|
+
// Use DataLoader for batching
|
|
990
|
+
const loaderKey = `${typeName}.${field}`;
|
|
991
|
+
const loader = this.getOrCreateLoaderForField(
|
|
992
|
+
loaderKey,
|
|
993
|
+
resolverDef,
|
|
994
|
+
field,
|
|
995
|
+
context ?? ({} as TContext),
|
|
996
|
+
);
|
|
997
|
+
result[field] = await loader.load(obj);
|
|
998
|
+
}
|
|
955
999
|
|
|
956
|
-
//
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1000
|
+
// Phase 2: Set up subscription (fire-and-forget)
|
|
1001
|
+
// Publisher pattern: get publisher function and call with callbacks
|
|
1002
|
+
const publisher = resolverDef.subscribeField(field, obj, args, context ?? {});
|
|
1003
|
+
if (publisher && createFieldEmit && onCleanup) {
|
|
1004
|
+
try {
|
|
1005
|
+
const fieldEmit = createFieldEmit(currentPath);
|
|
1006
|
+
if (fieldEmit) {
|
|
1007
|
+
publisher({
|
|
1008
|
+
emit: fieldEmit,
|
|
1009
|
+
onCleanup: (fn) => {
|
|
1010
|
+
onCleanup(fn);
|
|
1011
|
+
return fn;
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
} catch {
|
|
964
1016
|
// Subscription errors are handled via emit, ignore here
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
} catch {
|
|
1020
|
+
result[field] = null;
|
|
1021
|
+
}
|
|
1022
|
+
} else if (fieldMode === "subscribe") {
|
|
1023
|
+
// SUBSCRIBE MODE (legacy): Call resolver with ctx.emit/ctx.onCleanup
|
|
1024
|
+
// Legacy mode - resolver handles both initial value and updates via ctx.emit
|
|
1025
|
+
try {
|
|
1026
|
+
result[field] = null;
|
|
1027
|
+
if (createFieldEmit && onCleanup) {
|
|
1028
|
+
try {
|
|
1029
|
+
const fieldEmit = createFieldEmit(currentPath);
|
|
1030
|
+
if (fieldEmit) {
|
|
1031
|
+
// Build legacy ctx with emit/onCleanup
|
|
1032
|
+
const legacyCtx = {
|
|
1033
|
+
...(context ?? {}),
|
|
1034
|
+
emit: fieldEmit,
|
|
1035
|
+
onCleanup: (fn: () => void) => {
|
|
1036
|
+
onCleanup(fn);
|
|
1037
|
+
return fn;
|
|
1038
|
+
},
|
|
1039
|
+
};
|
|
1040
|
+
// Call legacy subscription method
|
|
1041
|
+
resolverDef.subscribeFieldLegacy(field, obj, args, legacyCtx);
|
|
1042
|
+
}
|
|
1043
|
+
} catch {
|
|
1044
|
+
// Subscription errors are handled via emit, ignore here
|
|
1045
|
+
}
|
|
969
1046
|
}
|
|
970
1047
|
} catch {
|
|
971
1048
|
result[field] = null;
|
|
972
1049
|
}
|
|
973
1050
|
} else {
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1051
|
+
// RESOLVE MODE: One-shot resolution (batchable)
|
|
1052
|
+
try {
|
|
1053
|
+
if (hasArgs) {
|
|
1054
|
+
// Direct resolution with args (no batching)
|
|
1055
|
+
result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
|
|
1056
|
+
} else {
|
|
1057
|
+
// Use DataLoader for batching
|
|
1058
|
+
const loaderKey = `${typeName}.${field}`;
|
|
1059
|
+
const loader = this.getOrCreateLoaderForField(
|
|
1060
|
+
loaderKey,
|
|
1061
|
+
resolverDef,
|
|
1062
|
+
field,
|
|
1063
|
+
context ?? ({} as TContext),
|
|
1064
|
+
);
|
|
1065
|
+
result[field] = await loader.load(obj);
|
|
1066
|
+
}
|
|
1067
|
+
} catch {
|
|
1068
|
+
result[field] = null;
|
|
1069
|
+
}
|
|
978
1070
|
}
|
|
979
1071
|
|
|
980
1072
|
// Recursively resolve nested fields
|
|
@@ -1041,12 +1133,13 @@ class LensServerImpl<
|
|
|
1041
1133
|
loaderKey: string,
|
|
1042
1134
|
resolverDef: ResolverDef<any, any, any>,
|
|
1043
1135
|
fieldName: string,
|
|
1136
|
+
context: TContext,
|
|
1044
1137
|
): DataLoader<unknown, unknown> {
|
|
1045
1138
|
let loader = this.loaders.get(loaderKey);
|
|
1046
1139
|
if (!loader) {
|
|
1140
|
+
// Capture context at loader creation time
|
|
1141
|
+
// This ensures the batch function has access to request context
|
|
1047
1142
|
loader = new DataLoader(async (parents: unknown[]) => {
|
|
1048
|
-
// Get context from AsyncLocalStorage - maintains request context in batched calls
|
|
1049
|
-
const context = tryUseContext<TContext>() ?? ({} as TContext);
|
|
1050
1143
|
const results: unknown[] = [];
|
|
1051
1144
|
for (const parent of parents) {
|
|
1052
1145
|
try {
|
package/src/server/types.ts
CHANGED
|
@@ -124,7 +124,7 @@ export interface LensServerConfig<
|
|
|
124
124
|
// =============================================================================
|
|
125
125
|
|
|
126
126
|
/** Field mode for entity fields */
|
|
127
|
-
export type FieldMode = "exposed" | "resolve" | "subscribe";
|
|
127
|
+
export type FieldMode = "exposed" | "resolve" | "subscribe" | "live";
|
|
128
128
|
|
|
129
129
|
/** Entity field metadata for client-side routing decisions */
|
|
130
130
|
export interface EntityFieldMetadata {
|