@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 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
- if (hasArgs || context) {
783
+ const fieldMode = resolverDef.getFieldMode(field);
784
+ if (fieldMode === "live") {
783
785
  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(() => {});
786
+ if (hasArgs) {
787
+ result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
792
788
  } else {
793
- result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
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
- const loaderKey = `${typeName}.${field}`;
800
- const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
801
- result[field] = await loader.load(obj);
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.7.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.5.0"
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.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
  // =============================================================================
@@ -33,7 +33,7 @@ import {
33
33
  type RouterDef,
34
34
  valuesEqual,
35
35
  } from "@sylphx/lens-core";
36
- import { createContext, runWithContext, tryUseContext } from "../context/index.js";
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
- if (hasArgs || context) {
946
- // Direct resolution when we have args or context (skip DataLoader)
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
- // 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
- };
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
- // 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(() => {
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
- } else {
967
- // Regular resolved field - await the result
968
- result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
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
- // 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);
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 {
@@ -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 {