@sylphx/lens-server 2.7.1 → 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,21 +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
- result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
786
+ if (hasArgs) {
787
+ result[field] = await resolverDef.resolveField(field, obj, args, context ?? {});
788
+ } else {
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 {}
829
+ }
790
830
  } catch {
791
831
  result[field] = null;
792
832
  }
793
833
  } else {
794
- const loaderKey = `${typeName}.${field}`;
795
- const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
796
- 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
+ }
797
845
  }
798
846
  result[field] = await this.resolveEntityFields(result[field], nestedInputs, context, currentPath, onCleanup, createFieldEmit);
799
847
  }
@@ -822,11 +870,10 @@ class LensServerImpl {
822
870
  const matchingFields = fieldNames.filter((field) => (field in obj));
823
871
  return matchingFields.length / fieldNames.length;
824
872
  }
825
- getOrCreateLoaderForField(loaderKey, resolverDef, fieldName) {
873
+ getOrCreateLoaderForField(loaderKey, resolverDef, fieldName, context) {
826
874
  let loader = this.loaders.get(loaderKey);
827
875
  if (!loader) {
828
876
  loader = new DataLoader(async (parents) => {
829
- const context = tryUseContext() ?? {};
830
877
  const results = [];
831
878
  for (const parent of parents) {
832
879
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "2.7.1",
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,26 +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
- };
955
- result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
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
+ }
972
+
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 {
989
+ // Subscription errors are handled via emit, ignore here
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
+ }
1019
+ }
956
1020
  } catch {
957
1021
  result[field] = null;
958
1022
  }
959
1023
  } else {
960
- // Use DataLoader for batching when no args (default case)
961
- const loaderKey = `${typeName}.${field}`;
962
- const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
963
- 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
+ }
964
1043
  }
965
1044
 
966
1045
  // Recursively resolve nested fields
@@ -1027,12 +1106,13 @@ class LensServerImpl<
1027
1106
  loaderKey: string,
1028
1107
  resolverDef: ResolverDef<any, any, any>,
1029
1108
  fieldName: string,
1109
+ context: TContext,
1030
1110
  ): DataLoader<unknown, unknown> {
1031
1111
  let loader = this.loaders.get(loaderKey);
1032
1112
  if (!loader) {
1113
+ // Capture context at loader creation time
1114
+ // This ensures the batch function has access to request context
1033
1115
  loader = new DataLoader(async (parents: unknown[]) => {
1034
- // Get context from AsyncLocalStorage - maintains request context in batched calls
1035
- const context = tryUseContext<TContext>() ?? ({} as TContext);
1036
1116
  const results: unknown[] = [];
1037
1117
  for (const parent of parents) {
1038
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 {