@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 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
- return (newValue) => {
711
- const state = getCurrentState();
712
- if (!state || typeof state !== "object")
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 updatedState = this.setFieldByPath(state, fieldPath, newValue);
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
- if (hasArgs || context) {
801
+ const fieldMode = resolverDef.getFieldMode(field);
802
+ if (fieldMode === "live") {
783
803
  try {
784
- const extendedCtx = {
785
- ...context ?? {},
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
- result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
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
- const loaderKey = `${typeName}.${field}`;
800
- const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
801
- result[field] = await loader.load(obj);
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.7.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.5.0"
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.many(Post).resolve(({ parent, ctx }) => {
863
- // Track if onCleanup was received (via ctx)
864
- resolverReceivedOnCleanup = ctx.onCleanup !== undefined;
865
-
866
- // Register a cleanup if available
867
- if (ctx.onCleanup) {
868
- ctx.onCleanup(() => {
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.many(Post).resolve(({ parent, ctx }) => {
947
- // Capture the field emit for later use
948
- capturedFieldEmit = ctx.emit;
949
-
950
- // Set up a mock subscription that will use field emit
951
- if (ctx.emit && ctx.onCleanup) {
952
- // Simulate subscription setup
953
- ctx.onCleanup(() => {
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
  // =============================================================================
@@ -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, tryUseContext } from "../context/index.js";
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 that updates just that field path.
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) => ((value: unknown) => void) | undefined {
777
+ ): (fieldPath: string) => Emit<unknown> | undefined {
773
778
  return (fieldPath: string) => {
774
779
  if (!fieldPath) return undefined;
775
780
 
776
- return (newValue: unknown) => {
777
- // Get current state and update the field at the given path
778
- const state = getCurrentState();
779
- if (!state || typeof state !== "object") return;
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
- state as Record<string, unknown>,
797
+ fullState as Record<string, unknown>,
783
798
  fieldPath,
784
- newValue,
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) => ((value: unknown) => void) | undefined,
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) => ((value: unknown) => void) | undefined,
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
- if (hasArgs || context) {
946
- // Direct resolution when we have args or context (skip DataLoader)
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
- // Build extended context with emit and onCleanup
949
- // Lens is a live query library - these are always available
950
- const extendedCtx = {
951
- ...(context ?? {}),
952
- emit: createFieldEmit!(currentPath),
953
- onCleanup: onCleanup!,
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
- // Check if field is a subscription (uses emit pattern)
957
- if (resolverDef.isSubscription(field)) {
958
- // Subscription fields use emit pattern - don't await completion
959
- // The resolver runs in background, pushing values via emit()
960
- // Set initial value to null, emit() will update it
961
- result[field] = null;
962
- // Start resolver without awaiting (fire and forget)
963
- resolverDef.resolveField(field, obj, args, extendedCtx).catch(() => {
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
- } else {
967
- // Regular resolved field - await the result
968
- result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
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
- // Use DataLoader for batching when no args (default case)
975
- const loaderKey = `${typeName}.${field}`;
976
- const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
977
- result[field] = await loader.load(obj);
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 {
@@ -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 {