@sylphx/lens-server 2.7.2 → 2.8.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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +57 -15
- package/package.json +2 -2
- package/src/server/create.test.ts +149 -23
- package/src/server/create.ts +95 -29
- 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;
|
|
@@ -779,26 +780,68 @@ class LensServerImpl {
|
|
|
779
780
|
result[field] = await this.resolveEntityFields(existingValue, nestedInputs, context, currentPath, onCleanup, createFieldEmit);
|
|
780
781
|
continue;
|
|
781
782
|
}
|
|
782
|
-
|
|
783
|
+
const fieldMode = resolverDef.getFieldMode(field);
|
|
784
|
+
if (fieldMode === "live") {
|
|
783
785
|
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(() => {});
|
|
786
|
+
if (hasArgs) {
|
|
787
|
+
result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
|
|
792
788
|
} else {
|
|
793
|
-
|
|
789
|
+
const loaderKey = `${typeName}.${field}`;
|
|
790
|
+
const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field, context ?? {});
|
|
791
|
+
result[field] = await loader.load(obj);
|
|
792
|
+
}
|
|
793
|
+
const publisher = resolverDef.subscribeField(field, obj, args, context ?? {});
|
|
794
|
+
if (publisher && createFieldEmit && onCleanup) {
|
|
795
|
+
try {
|
|
796
|
+
const fieldEmit = createFieldEmit(currentPath);
|
|
797
|
+
if (fieldEmit) {
|
|
798
|
+
publisher({
|
|
799
|
+
emit: fieldEmit,
|
|
800
|
+
onCleanup: (fn) => {
|
|
801
|
+
onCleanup(fn);
|
|
802
|
+
return fn;
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
} catch {}
|
|
807
|
+
}
|
|
808
|
+
} catch {
|
|
809
|
+
result[field] = null;
|
|
810
|
+
}
|
|
811
|
+
} else if (fieldMode === "subscribe") {
|
|
812
|
+
try {
|
|
813
|
+
result[field] = null;
|
|
814
|
+
if (createFieldEmit && onCleanup) {
|
|
815
|
+
try {
|
|
816
|
+
const fieldEmit = createFieldEmit(currentPath);
|
|
817
|
+
if (fieldEmit) {
|
|
818
|
+
const legacyCtx = {
|
|
819
|
+
...context ?? {},
|
|
820
|
+
emit: fieldEmit,
|
|
821
|
+
onCleanup: (fn) => {
|
|
822
|
+
onCleanup(fn);
|
|
823
|
+
return fn;
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
resolverDef.subscribeFieldLegacy(field, obj, args, legacyCtx);
|
|
827
|
+
}
|
|
828
|
+
} catch {}
|
|
794
829
|
}
|
|
795
830
|
} catch {
|
|
796
831
|
result[field] = null;
|
|
797
832
|
}
|
|
798
833
|
} else {
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
834
|
+
try {
|
|
835
|
+
if (hasArgs) {
|
|
836
|
+
result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
|
|
837
|
+
} else {
|
|
838
|
+
const loaderKey = `${typeName}.${field}`;
|
|
839
|
+
const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field, context ?? {});
|
|
840
|
+
result[field] = await loader.load(obj);
|
|
841
|
+
}
|
|
842
|
+
} catch {
|
|
843
|
+
result[field] = null;
|
|
844
|
+
}
|
|
802
845
|
}
|
|
803
846
|
result[field] = await this.resolveEntityFields(result[field], nestedInputs, context, currentPath, onCleanup, createFieldEmit);
|
|
804
847
|
}
|
|
@@ -827,11 +870,10 @@ class LensServerImpl {
|
|
|
827
870
|
const matchingFields = fieldNames.filter((field) => (field in obj));
|
|
828
871
|
return matchingFields.length / fieldNames.length;
|
|
829
872
|
}
|
|
830
|
-
getOrCreateLoaderForField(loaderKey, resolverDef, fieldName) {
|
|
873
|
+
getOrCreateLoaderForField(loaderKey, resolverDef, fieldName, context) {
|
|
831
874
|
let loader = this.loaders.get(loaderKey);
|
|
832
875
|
if (!loader) {
|
|
833
876
|
loader = new DataLoader(async (parents) => {
|
|
834
|
-
const context = tryUseContext() ?? {};
|
|
835
877
|
const results = [];
|
|
836
878
|
for (const parent of parents) {
|
|
837
879
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
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.0"
|
|
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
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
type RouterDef,
|
|
34
34
|
valuesEqual,
|
|
35
35
|
} from "@sylphx/lens-core";
|
|
36
|
-
import { createContext, runWithContext
|
|
36
|
+
import { createContext, runWithContext } from "../context/index.js";
|
|
37
37
|
import {
|
|
38
38
|
createPluginManager,
|
|
39
39
|
type PluginManager,
|
|
@@ -552,6 +552,10 @@ class LensServerImpl<
|
|
|
552
552
|
|
|
553
553
|
let command = emitQueue.dequeue();
|
|
554
554
|
while (command !== null && !cancelled) {
|
|
555
|
+
// Clear DataLoader cache before re-processing
|
|
556
|
+
// This ensures field resolvers re-run with fresh data
|
|
557
|
+
this.clearLoaders();
|
|
558
|
+
|
|
555
559
|
currentState = this.applyEmitCommand(command, currentState);
|
|
556
560
|
|
|
557
561
|
// Process through field resolvers (unlike before where we bypassed this)
|
|
@@ -941,40 +945,101 @@ class LensServerImpl<
|
|
|
941
945
|
continue;
|
|
942
946
|
}
|
|
943
947
|
|
|
944
|
-
// Resolve the field
|
|
945
|
-
|
|
946
|
-
|
|
948
|
+
// Resolve the field based on mode
|
|
949
|
+
// ADR-002: Two-Phase Field Resolution
|
|
950
|
+
const fieldMode = resolverDef.getFieldMode(field);
|
|
951
|
+
|
|
952
|
+
if (fieldMode === "live") {
|
|
953
|
+
// LIVE MODE: Two-phase resolution
|
|
954
|
+
// Phase 1: Run resolver for initial value (batchable)
|
|
955
|
+
// Phase 2: Run subscriber for live updates (fire-and-forget)
|
|
947
956
|
try {
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
957
|
+
// Phase 1: Get initial value (no emit/onCleanup needed)
|
|
958
|
+
if (hasArgs) {
|
|
959
|
+
// Direct resolution with args
|
|
960
|
+
result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
|
|
961
|
+
} else {
|
|
962
|
+
// Use DataLoader for batching
|
|
963
|
+
const loaderKey = `${typeName}.${field}`;
|
|
964
|
+
const loader = this.getOrCreateLoaderForField(
|
|
965
|
+
loaderKey,
|
|
966
|
+
resolverDef,
|
|
967
|
+
field,
|
|
968
|
+
context ?? ({} as TContext),
|
|
969
|
+
);
|
|
970
|
+
result[field] = await loader.load(obj);
|
|
971
|
+
}
|
|
955
972
|
|
|
956
|
-
//
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
973
|
+
// Phase 2: Set up subscription (fire-and-forget)
|
|
974
|
+
// Publisher pattern: get publisher function and call with callbacks
|
|
975
|
+
const publisher = resolverDef.subscribeField(field, obj, args, context ?? {});
|
|
976
|
+
if (publisher && createFieldEmit && onCleanup) {
|
|
977
|
+
try {
|
|
978
|
+
const fieldEmit = createFieldEmit(currentPath);
|
|
979
|
+
if (fieldEmit) {
|
|
980
|
+
publisher({
|
|
981
|
+
emit: fieldEmit,
|
|
982
|
+
onCleanup: (fn) => {
|
|
983
|
+
onCleanup(fn);
|
|
984
|
+
return fn;
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
} catch {
|
|
964
989
|
// Subscription errors are handled via emit, ignore here
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
} catch {
|
|
993
|
+
result[field] = null;
|
|
994
|
+
}
|
|
995
|
+
} else if (fieldMode === "subscribe") {
|
|
996
|
+
// SUBSCRIBE MODE (legacy): Call resolver with ctx.emit/ctx.onCleanup
|
|
997
|
+
// Legacy mode - resolver handles both initial value and updates via ctx.emit
|
|
998
|
+
try {
|
|
999
|
+
result[field] = null;
|
|
1000
|
+
if (createFieldEmit && onCleanup) {
|
|
1001
|
+
try {
|
|
1002
|
+
const fieldEmit = createFieldEmit(currentPath);
|
|
1003
|
+
if (fieldEmit) {
|
|
1004
|
+
// Build legacy ctx with emit/onCleanup
|
|
1005
|
+
const legacyCtx = {
|
|
1006
|
+
...(context ?? {}),
|
|
1007
|
+
emit: fieldEmit,
|
|
1008
|
+
onCleanup: (fn: () => void) => {
|
|
1009
|
+
onCleanup(fn);
|
|
1010
|
+
return fn;
|
|
1011
|
+
},
|
|
1012
|
+
};
|
|
1013
|
+
// Call legacy subscription method
|
|
1014
|
+
resolverDef.subscribeFieldLegacy(field, obj, args, legacyCtx);
|
|
1015
|
+
}
|
|
1016
|
+
} catch {
|
|
1017
|
+
// Subscription errors are handled via emit, ignore here
|
|
1018
|
+
}
|
|
969
1019
|
}
|
|
970
1020
|
} catch {
|
|
971
1021
|
result[field] = null;
|
|
972
1022
|
}
|
|
973
1023
|
} else {
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1024
|
+
// RESOLVE MODE: One-shot resolution (batchable)
|
|
1025
|
+
try {
|
|
1026
|
+
if (hasArgs) {
|
|
1027
|
+
// Direct resolution with args (no batching)
|
|
1028
|
+
result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
|
|
1029
|
+
} else {
|
|
1030
|
+
// Use DataLoader for batching
|
|
1031
|
+
const loaderKey = `${typeName}.${field}`;
|
|
1032
|
+
const loader = this.getOrCreateLoaderForField(
|
|
1033
|
+
loaderKey,
|
|
1034
|
+
resolverDef,
|
|
1035
|
+
field,
|
|
1036
|
+
context ?? ({} as TContext),
|
|
1037
|
+
);
|
|
1038
|
+
result[field] = await loader.load(obj);
|
|
1039
|
+
}
|
|
1040
|
+
} catch {
|
|
1041
|
+
result[field] = null;
|
|
1042
|
+
}
|
|
978
1043
|
}
|
|
979
1044
|
|
|
980
1045
|
// Recursively resolve nested fields
|
|
@@ -1041,12 +1106,13 @@ class LensServerImpl<
|
|
|
1041
1106
|
loaderKey: string,
|
|
1042
1107
|
resolverDef: ResolverDef<any, any, any>,
|
|
1043
1108
|
fieldName: string,
|
|
1109
|
+
context: TContext,
|
|
1044
1110
|
): DataLoader<unknown, unknown> {
|
|
1045
1111
|
let loader = this.loaders.get(loaderKey);
|
|
1046
1112
|
if (!loader) {
|
|
1113
|
+
// Capture context at loader creation time
|
|
1114
|
+
// This ensures the batch function has access to request context
|
|
1047
1115
|
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
1116
|
const results: unknown[] = [];
|
|
1051
1117
|
for (const parent of parents) {
|
|
1052
1118
|
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 {
|