@sylphx/lens-server 3.0.1 → 4.0.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.
@@ -23,22 +23,22 @@ import {
23
23
  createResolverFromEntity,
24
24
  type Emit,
25
25
  type EmitCommand,
26
- type EntityDef,
27
26
  flattenRouter,
28
27
  hashValue,
29
- hasInlineResolvers,
30
28
  type InferRouterContext,
31
- isEntityDef,
32
29
  isLiveQueryDef,
33
30
  isModelDef,
34
31
  isMutationDef,
35
32
  isQueryDef,
33
+ isSubscriptionDef,
36
34
  type LiveQueryDef,
37
35
  type Message,
36
+ type ModelDef,
38
37
  mergeModelCollections,
39
38
  type Observable,
40
39
  type ResolverDef,
41
40
  type RouterDef,
41
+ type SubscriptionDef,
42
42
  toOps,
43
43
  valuesEqual,
44
44
  } from "@sylphx/lens-core";
@@ -63,6 +63,7 @@ import type {
63
63
  ServerConfigLegacy,
64
64
  ServerConfigWithInferredContext,
65
65
  ServerMetadata,
66
+ SubscriptionsMap,
66
67
  } from "./types.js";
67
68
 
68
69
  // Re-export types
@@ -87,6 +88,7 @@ export type {
87
88
  ServerConfigLegacy,
88
89
  ServerConfigWithInferredContext,
89
90
  ServerMetadata,
91
+ SubscriptionsMap,
90
92
  WebSocketLike,
91
93
  } from "./types.js";
92
94
 
@@ -110,25 +112,31 @@ type ResolverMap = Map<string, ResolverDef<any, any, any>>;
110
112
  class LensServerImpl<
111
113
  Q extends QueriesMap = QueriesMap,
112
114
  M extends MutationsMap = MutationsMap,
115
+ S extends SubscriptionsMap = SubscriptionsMap,
113
116
  TContext extends ContextValue = ContextValue,
114
117
  > implements LensServer
115
118
  {
116
119
  private queries: Q;
117
120
  private mutations: M;
121
+ private subscriptions: S;
118
122
  private entities: EntitiesMap;
119
123
  private resolverMap?: ResolverMap | undefined;
120
124
  private contextFactory: (req?: unknown) => TContext | Promise<TContext>;
121
125
  private version: string;
122
126
  private logger: LensLogger;
123
127
  private ctx = createContext<TContext>();
124
- private loaders = new Map<string, DataLoader<unknown, unknown>>();
125
128
  private pluginManager: PluginManager;
129
+ /** Cache for computed type names - O(n×m) entity matching is expensive */
130
+ private typeNameCache = new WeakMap<object, string | undefined>();
126
131
 
127
- constructor(config: LensServerConfig<TContext> & { queries?: Q; mutations?: M }) {
132
+ constructor(
133
+ config: LensServerConfig<TContext> & { queries?: Q; mutations?: M; subscriptions?: S },
134
+ ) {
128
135
  const queries: QueriesMap = { ...(config.queries ?? {}) };
129
136
  const mutations: MutationsMap = { ...(config.mutations ?? {}) };
137
+ const subscriptions: SubscriptionsMap = { ...(config.subscriptions ?? {}) };
130
138
 
131
- // Flatten router into queries/mutations
139
+ // Flatten router into queries/mutations/subscriptions
132
140
  if (config.router) {
133
141
  const flattened = flattenRouter(config.router);
134
142
  for (const [path, procedure] of flattened) {
@@ -136,12 +144,15 @@ class LensServerImpl<
136
144
  queries[path] = procedure;
137
145
  } else if (isMutationDef(procedure)) {
138
146
  mutations[path] = procedure;
147
+ } else if (isSubscriptionDef(procedure)) {
148
+ subscriptions[path] = procedure;
139
149
  }
140
150
  }
141
151
  }
142
152
 
143
153
  this.queries = queries as Q;
144
154
  this.mutations = mutations as M;
155
+ this.subscriptions = subscriptions as S;
145
156
 
146
157
  // Build entities map (priority: explicit config > router > resolvers)
147
158
  // Auto-track models from router return types (new behavior)
@@ -163,19 +174,17 @@ class LensServerImpl<
163
174
  }
164
175
  }
165
176
 
166
- // Convert Map to Record for entities (supports both EntityDef and ModelDef)
177
+ // Convert Map to Record for entities
167
178
  const entities: EntitiesMap = {};
168
179
  for (const [name, model] of mergedModels) {
169
- // Both ModelDef and EntityDef work as EntitiesMap values
170
- if (isEntityDef(model) || isModelDef(model)) {
171
- entities[name] = model as EntityDef<string, any>;
180
+ if (isModelDef(model)) {
181
+ entities[name] = model as ModelDef<string, any>;
172
182
  }
173
183
  }
174
184
  this.entities = entities;
175
185
 
176
- // Build resolver map: explicit resolvers + auto-converted from entities with inline resolvers
177
- // Unified Entity Definition (ADR-001): entities can have inline .resolve()/.subscribe() methods
178
- // These are automatically converted to resolvers, no need to call createResolverFromEntity() manually
186
+ // Build resolver map: explicit resolvers + auto-generated exposed-only resolvers
187
+ // Models without explicit resolvers get auto-generated exposed-only resolvers
179
188
  this.resolverMap = this.buildResolverMap(config.resolvers, entities);
180
189
  this.contextFactory = config.context ?? (() => ({}) as TContext);
181
190
  this.version = config.version ?? "1.0.0";
@@ -208,6 +217,11 @@ class LensServerImpl<
208
217
  (def as { _name?: string })._name = name;
209
218
  }
210
219
  }
220
+ for (const [name, def] of Object.entries(this.subscriptions)) {
221
+ if (def && typeof def === "object") {
222
+ (def as { _name?: string })._name = name;
223
+ }
224
+ }
211
225
  }
212
226
 
213
227
  private validateDefinitions(): void {
@@ -221,15 +235,20 @@ class LensServerImpl<
221
235
  throw new Error(`Invalid mutation definition: ${name}`);
222
236
  }
223
237
  }
238
+ for (const [name, def] of Object.entries(this.subscriptions)) {
239
+ if (!isSubscriptionDef(def)) {
240
+ throw new Error(`Invalid subscription definition: ${name}`);
241
+ }
242
+ }
224
243
  }
225
244
 
226
245
  /**
227
- * Build resolver map from explicit resolvers and entities with inline resolvers.
246
+ * Build resolver map from explicit resolvers.
228
247
  *
229
- * Unified Entity Definition (ADR-001): Entities can have inline .resolve()/.subscribe() methods.
230
- * These are automatically converted to resolvers - no manual createResolverFromEntity() needed.
248
+ * Models without explicit resolvers get auto-generated exposed-only resolvers.
249
+ * Use standalone resolver(Model, ...) for custom field resolution.
231
250
  *
232
- * Priority: explicit resolvers > auto-converted from entities
251
+ * Priority: explicit resolvers > auto-generated exposed-only resolvers
233
252
  */
234
253
  private buildResolverMap(
235
254
  explicitResolvers: import("@sylphx/lens-core").Resolvers | undefined,
@@ -247,17 +266,14 @@ class LensServerImpl<
247
266
  }
248
267
  }
249
268
 
250
- // 2. Auto-convert entities/models with inline resolvers (if not already in map)
269
+ // 2. Auto-create exposed-only resolvers for models without explicit resolvers
251
270
  for (const [name, entity] of Object.entries(entities)) {
252
- // Support both EntityDef and ModelDef
253
- if (!isEntityDef(entity) && !isModelDef(entity)) continue;
271
+ if (!isModelDef(entity)) continue;
254
272
  if (resolverMap.has(name)) continue; // Explicit resolver takes priority
255
273
 
256
- // Check if entity/model has inline resolvers
257
- if (hasInlineResolvers(entity)) {
258
- const resolver = createResolverFromEntity(entity);
259
- resolverMap.set(name, resolver);
260
- }
274
+ // Create exposed-only resolver for this model
275
+ const resolver = createResolverFromEntity(entity);
276
+ resolverMap.set(name, resolver);
261
277
  }
262
278
 
263
279
  return resolverMap.size > 0 ? resolverMap : undefined;
@@ -315,8 +331,9 @@ class LensServerImpl<
315
331
  // Check if operation exists
316
332
  const isQuery = !!this.queries[path];
317
333
  const isMutation = !!this.mutations[path];
334
+ const isSubscription = !!this.subscriptions[path];
318
335
 
319
- if (!isQuery && !isMutation) {
336
+ if (!isQuery && !isMutation && !isSubscription) {
320
337
  return {
321
338
  subscribe: (observer) => {
322
339
  observer.next?.({
@@ -330,9 +347,132 @@ class LensServerImpl<
330
347
  };
331
348
  }
332
349
 
350
+ // Subscriptions are handled differently - they only emit events (no initial data)
351
+ if (isSubscription) {
352
+ return this.executeSubscription(path, input);
353
+ }
354
+
333
355
  return this.executeAsObservable(path, input, isQuery);
334
356
  }
335
357
 
358
+ /**
359
+ * Execute subscription and return Observable.
360
+ * Subscriptions only emit events - no initial data fetch.
361
+ */
362
+ private executeSubscription(path: string, input: unknown): Observable<LensResult> {
363
+ return {
364
+ subscribe: (observer) => {
365
+ let cancelled = false;
366
+ const cleanups: (() => void)[] = [];
367
+
368
+ (async () => {
369
+ try {
370
+ const def = this.subscriptions[path] as SubscriptionDef<unknown, unknown, TContext>;
371
+ if (!def) {
372
+ observer.next?.({
373
+ $: "error",
374
+ error: `Subscription not found: ${path}`,
375
+ code: "NOT_FOUND",
376
+ } as Message);
377
+ observer.complete?.();
378
+ return;
379
+ }
380
+
381
+ // Validate input
382
+ if (def._input && input !== undefined) {
383
+ if (def._input.safeParse) {
384
+ const result = def._input.safeParse(input);
385
+ if (!result.success) {
386
+ observer.next?.({
387
+ $: "error",
388
+ error: `Invalid input: ${JSON.stringify(result.error)}`,
389
+ code: "VALIDATION_ERROR",
390
+ } as Message);
391
+ observer.complete?.();
392
+ return;
393
+ }
394
+ } else {
395
+ try {
396
+ def._input.parse(input);
397
+ } catch (e) {
398
+ observer.next?.({
399
+ $: "error",
400
+ error: `Invalid input: ${e instanceof Error ? e.message : String(e)}`,
401
+ code: "VALIDATION_ERROR",
402
+ } as Message);
403
+ observer.complete?.();
404
+ return;
405
+ }
406
+ }
407
+ }
408
+
409
+ const context = await this.contextFactory();
410
+ const subscriber = def._subscriber;
411
+
412
+ if (!subscriber) {
413
+ observer.next?.({
414
+ $: "error",
415
+ error: `Subscription ${path} has no subscriber`,
416
+ code: "NO_SUBSCRIBER",
417
+ } as Message);
418
+ observer.complete?.();
419
+ return;
420
+ }
421
+
422
+ // Get the publisher function
423
+ const publisher = subscriber({ input, ctx: context });
424
+
425
+ // Call publisher with emit/onCleanup callbacks
426
+ if (publisher) {
427
+ // Create a proper Emit instance for subscriptions
428
+ const emit = createEmit((command: EmitCommand) => {
429
+ if (cancelled) return;
430
+ // For subscriptions, emit full value as snapshot
431
+ if (command.type === "full") {
432
+ observer.next?.({ $: "snapshot", data: command.data } as Message);
433
+ }
434
+ }, "scalar");
435
+
436
+ publisher({
437
+ emit,
438
+ onCleanup: (fn) => {
439
+ cleanups.push(fn);
440
+ return fn;
441
+ },
442
+ });
443
+ }
444
+
445
+ // Note: Subscriptions stay open until unsubscribed
446
+ // They do NOT call observer.complete() automatically
447
+ } catch (error) {
448
+ // Call cleanup functions on error path - important for resource cleanup
449
+ for (const fn of cleanups) {
450
+ try {
451
+ fn();
452
+ } catch {
453
+ // Ignore cleanup errors
454
+ }
455
+ }
456
+ if (!cancelled) {
457
+ const errMsg = error instanceof Error ? error.message : String(error);
458
+ observer.next?.({ $: "error", error: errMsg, code: "INTERNAL_ERROR" } as Message);
459
+ observer.complete?.();
460
+ }
461
+ }
462
+ })();
463
+
464
+ return {
465
+ unsubscribe: () => {
466
+ cancelled = true;
467
+ for (const fn of cleanups) {
468
+ fn();
469
+ }
470
+ },
471
+ };
472
+ },
473
+ };
474
+ }
475
+
336
476
  /**
337
477
  * Execute operation and return Observable.
338
478
  * Observable allows streaming for AsyncIterable resolvers and emit-based updates.
@@ -391,15 +531,29 @@ class LensServerImpl<
391
531
 
392
532
  // Validate input
393
533
  if (def._input && cleanInput !== undefined) {
394
- const result = def._input.safeParse(cleanInput);
395
- if (!result.success) {
396
- observer.next?.({
397
- $: "error",
398
- error: `Invalid input: ${JSON.stringify(result.error)}`,
399
- code: "VALIDATION_ERROR",
400
- } as Message);
401
- observer.complete?.();
402
- return;
534
+ if (def._input.safeParse) {
535
+ const result = def._input.safeParse(cleanInput);
536
+ if (!result.success) {
537
+ observer.next?.({
538
+ $: "error",
539
+ error: `Invalid input: ${JSON.stringify(result.error)}`,
540
+ code: "VALIDATION_ERROR",
541
+ } as Message);
542
+ observer.complete?.();
543
+ return;
544
+ }
545
+ } else {
546
+ try {
547
+ def._input.parse(cleanInput);
548
+ } catch (e) {
549
+ observer.next?.({
550
+ $: "error",
551
+ error: `Invalid input: ${e instanceof Error ? e.message : String(e)}`,
552
+ code: "VALIDATION_ERROR",
553
+ } as Message);
554
+ observer.complete?.();
555
+ return;
556
+ }
403
557
  }
404
558
  }
405
559
 
@@ -448,24 +602,47 @@ class LensServerImpl<
448
602
  : undefined;
449
603
 
450
604
  const lensContext = { ...context, emit, onCleanup };
451
- const result = resolver({ args: cleanInput, input: cleanInput, ctx: lensContext });
605
+ const result = resolver({ args: cleanInput, ctx: lensContext } as any);
452
606
 
453
607
  if (isAsyncIterable(result)) {
454
608
  // Streaming: emit each yielded value
455
- for await (const value of result) {
456
- if (cancelled) break;
457
- const processed = await this.processQueryResult(
458
- path,
459
- value,
460
- select,
461
- context,
462
- onCleanup,
463
- createFieldEmit,
464
- );
465
- emitIfChanged(processed);
466
- }
467
- if (!cancelled) {
468
- observer.complete?.();
609
+ try {
610
+ for await (const value of result) {
611
+ if (cancelled) break;
612
+ const processed = await this.processQueryResult(
613
+ path,
614
+ value,
615
+ select,
616
+ context,
617
+ onCleanup,
618
+ createFieldEmit,
619
+ );
620
+ emitIfChanged(processed);
621
+ }
622
+ if (!cancelled) {
623
+ observer.complete?.();
624
+ }
625
+ } catch (iterError) {
626
+ // Handle errors from async iterator
627
+ // Call cleanup functions before reporting error
628
+ for (const fn of cleanups) {
629
+ try {
630
+ fn();
631
+ } catch {
632
+ // Ignore cleanup errors
633
+ }
634
+ }
635
+ if (!cancelled) {
636
+ const errMsg =
637
+ iterError instanceof Error ? iterError.message : String(iterError);
638
+ observer.next?.({
639
+ $: "error",
640
+ error: errMsg,
641
+ code: "STREAM_ERROR",
642
+ } as Message);
643
+ observer.complete?.();
644
+ }
645
+ return; // Don't continue to outer try block
469
646
  }
470
647
  } else {
471
648
  // One-shot: emit single value
@@ -490,8 +667,7 @@ class LensServerImpl<
490
667
  try {
491
668
  // Get publisher function from subscriber
492
669
  const publisher = liveQuery._subscriber({
493
- args: cleanInput as never, // Preferred parameter name
494
- input: cleanInput as never, // Deprecated alias for backwards compatibility
670
+ args: cleanInput as never,
495
671
  ctx: context as TContext,
496
672
  });
497
673
  // Call publisher with emit/onCleanup callbacks
@@ -511,22 +687,38 @@ class LensServerImpl<
511
687
  }
512
688
  }
513
689
 
514
- // Mutations complete immediately - they're truly one-shot
515
- // Queries stay open for potential emit calls from field resolvers
516
- if (!isQuery && !cancelled) {
517
- observer.complete?.();
690
+ // Determine if query should remain open for live updates
691
+ const hasLiveSubscriptions = cleanups.length > 0;
692
+ const isLiveQuery = isQuery && isLiveQueryDef(def) && def._subscriber;
693
+
694
+ // Complete Observable:
695
+ // - Mutations: complete immediately (one-shot)
696
+ // - Queries without live fields/subscriptions: complete after initial data
697
+ // - Queries with live fields or LiveQueryDef with subscriber: stay open
698
+ if (!cancelled) {
699
+ if (!isQuery || (!hasLiveSubscriptions && !isLiveQuery)) {
700
+ observer.complete?.();
701
+ }
518
702
  }
519
703
  }
520
704
  });
521
705
  } catch (error) {
706
+ // Call cleanup functions on error path - important for resource cleanup
707
+ for (const fn of cleanups) {
708
+ try {
709
+ fn();
710
+ } catch {
711
+ // Ignore cleanup errors
712
+ }
713
+ }
522
714
  if (!cancelled) {
523
715
  const errMsg = error instanceof Error ? error.message : String(error);
524
716
  observer.next?.({ $: "error", error: errMsg, code: "INTERNAL_ERROR" } as Message);
525
717
  observer.complete?.();
526
718
  }
527
- } finally {
528
- this.clearLoaders();
529
719
  }
720
+ // Note: No need to clear loaders - they are now request-scoped
721
+ // (created fresh in processQueryResult for each request)
530
722
  })();
531
723
 
532
724
  return {
@@ -633,6 +825,9 @@ class LensServerImpl<
633
825
  // Extract nested inputs from selection for field resolver args
634
826
  const nestedInputs = select ? extractNestedInputs(select) : undefined;
635
827
 
828
+ // Create request-scoped loaders to avoid context leak across concurrent requests
829
+ const requestLoaders = new Map<string, DataLoader<unknown, unknown>>();
830
+
636
831
  const processed = await this.resolveEntityFields(
637
832
  data,
638
833
  nestedInputs,
@@ -641,6 +836,7 @@ class LensServerImpl<
641
836
  onCleanup,
642
837
  createFieldEmit,
643
838
  new Set(), // Cycle detection for circular entity references (type:id)
839
+ requestLoaders, // Request-scoped DataLoaders
644
840
  );
645
841
  if (select) {
646
842
  return applySelection(processed, select) as T;
@@ -659,6 +855,7 @@ class LensServerImpl<
659
855
  * @param onCleanup - Cleanup registration for live query subscriptions
660
856
  * @param createFieldEmit - Factory for creating field-specific emit handlers
661
857
  * @param visited - Set of "type:id" keys to track visited entities and prevent circular reference infinite loops
858
+ * @param loaders - Request-scoped DataLoader map to avoid context leak across concurrent requests
662
859
  */
663
860
  private async resolveEntityFields<T>(
664
861
  data: T,
@@ -668,6 +865,7 @@ class LensServerImpl<
668
865
  onCleanup?: (fn: () => void) => void,
669
866
  createFieldEmit?: (fieldPath: string, resolvedValue?: unknown) => Emit<unknown> | undefined,
670
867
  visited: Set<string> = new Set(),
868
+ loaders: Map<string, DataLoader<unknown, unknown>> = new Map(),
671
869
  ): Promise<T> {
672
870
  if (!data || !this.resolverMap) return data;
673
871
 
@@ -682,6 +880,7 @@ class LensServerImpl<
682
880
  onCleanup,
683
881
  createFieldEmit,
684
882
  visited,
883
+ loaders,
685
884
  ),
686
885
  ),
687
886
  ) as Promise<T>;
@@ -722,21 +921,6 @@ class LensServerImpl<
722
921
  const args = nestedInputs?.get(currentPath) ?? {};
723
922
  const hasArgs = Object.keys(args).length > 0;
724
923
 
725
- // Skip if value already exists
726
- const existingValue = result[field];
727
- if (existingValue !== undefined) {
728
- result[field] = await this.resolveEntityFields(
729
- existingValue,
730
- nestedInputs,
731
- context,
732
- currentPath,
733
- onCleanup,
734
- createFieldEmit,
735
- visited,
736
- );
737
- continue;
738
- }
739
-
740
924
  // Resolve the field based on mode
741
925
  // ADR-002: Two-Phase Field Resolution
742
926
  const fieldMode = resolverDef.getFieldMode(field);
@@ -758,6 +942,7 @@ class LensServerImpl<
758
942
  resolverDef,
759
943
  field,
760
944
  context ?? ({} as TContext),
945
+ loaders,
761
946
  );
762
947
  result[field] = await loader.load(obj);
763
948
  }
@@ -778,39 +963,20 @@ class LensServerImpl<
778
963
  },
779
964
  });
780
965
  }
781
- } catch {
782
- // Subscription errors are handled via emit, ignore here
783
- }
784
- }
785
- } catch {
786
- result[field] = null;
787
- }
788
- } else if (fieldMode === "subscribe") {
789
- // SUBSCRIBE MODE (legacy): Call resolver with ctx.emit/ctx.onCleanup
790
- // Legacy mode - resolver handles both initial value and updates via ctx.emit
791
- try {
792
- result[field] = null;
793
- if (createFieldEmit && onCleanup) {
794
- try {
795
- const fieldEmit = createFieldEmit(currentPath);
796
- if (fieldEmit) {
797
- // Build legacy ctx with emit/onCleanup
798
- const legacyCtx = {
799
- ...(context ?? {}),
800
- emit: fieldEmit,
801
- onCleanup: (fn: () => void) => {
802
- onCleanup(fn);
803
- return fn;
804
- },
805
- };
806
- // Call legacy subscription method
807
- resolverDef.subscribeFieldLegacy(field, obj, args, legacyCtx);
808
- }
809
- } catch {
810
- // Subscription errors are handled via emit, ignore here
966
+ } catch (subscribeError) {
967
+ // Log subscription setup errors for debugging
968
+ this.logger.error?.(
969
+ `Field subscription error at ${currentPath}:`,
970
+ subscribeError instanceof Error ? subscribeError.message : String(subscribeError),
971
+ );
811
972
  }
812
973
  }
813
- } catch {
974
+ } catch (resolveError) {
975
+ // Log live field resolution errors
976
+ this.logger.error?.(
977
+ `Live field resolution error at ${currentPath}:`,
978
+ resolveError instanceof Error ? resolveError.message : String(resolveError),
979
+ );
814
980
  result[field] = null;
815
981
  }
816
982
  } else {
@@ -827,10 +993,16 @@ class LensServerImpl<
827
993
  resolverDef,
828
994
  field,
829
995
  context ?? ({} as TContext),
996
+ loaders,
830
997
  );
831
998
  result[field] = await loader.load(obj);
832
999
  }
833
- } catch {
1000
+ } catch (resolveError) {
1001
+ // Log field resolution errors
1002
+ this.logger.error?.(
1003
+ `Field resolution error at ${currentPath}:`,
1004
+ resolveError instanceof Error ? resolveError.message : String(resolveError),
1005
+ );
834
1006
  result[field] = null;
835
1007
  }
836
1008
  }
@@ -844,6 +1016,7 @@ class LensServerImpl<
844
1016
  onCleanup,
845
1017
  createFieldEmit,
846
1018
  visited,
1019
+ loaders,
847
1020
  );
848
1021
  }
849
1022
 
@@ -855,20 +1028,27 @@ class LensServerImpl<
855
1028
  *
856
1029
  * Matching priority:
857
1030
  * 1. Explicit __typename or _type property
858
- * 2. Best matching entity (highest field overlap score)
1031
+ * 2. Cached result from previous lookup
1032
+ * 3. Best matching entity (highest field overlap score)
859
1033
  *
860
1034
  * Requires at least 50% field match to avoid false positives.
1035
+ * Results are cached via WeakMap for O(1) repeated lookups.
861
1036
  */
862
1037
  private getTypeName(obj: Record<string, unknown>): string | undefined {
863
- // Priority 1: Explicit type marker
1038
+ // Priority 1: Explicit type marker (fast path, no caching needed)
864
1039
  if ("__typename" in obj) return obj.__typename as string;
865
1040
  if ("_type" in obj) return obj._type as string;
866
1041
 
867
- // Priority 2: Find best matching entity by field overlap
1042
+ // Priority 2: Check cache for previously computed result
1043
+ if (this.typeNameCache.has(obj)) {
1044
+ return this.typeNameCache.get(obj);
1045
+ }
1046
+
1047
+ // Priority 3: Find best matching entity by field overlap (O(n×m))
868
1048
  let bestMatch: { name: string; score: number } | undefined;
869
1049
 
870
1050
  for (const [name, def] of Object.entries(this.entities)) {
871
- if (!isEntityDef(def)) continue;
1051
+ if (!isModelDef(def)) continue;
872
1052
 
873
1053
  const score = this.getEntityMatchScore(obj, def);
874
1054
  // Require at least 50% field match to avoid false positives
@@ -877,7 +1057,10 @@ class LensServerImpl<
877
1057
  }
878
1058
  }
879
1059
 
880
- return bestMatch?.name;
1060
+ const result = bestMatch?.name;
1061
+ // Cache result (even undefined) to avoid repeated expensive lookups
1062
+ this.typeNameCache.set(obj, result);
1063
+ return result;
881
1064
  }
882
1065
 
883
1066
  /**
@@ -887,7 +1070,7 @@ class LensServerImpl<
887
1070
  */
888
1071
  private getEntityMatchScore(
889
1072
  obj: Record<string, unknown>,
890
- entityDef: EntityDef<string, any>,
1073
+ entityDef: ModelDef<string, any>,
891
1074
  ): number {
892
1075
  const fieldNames = Object.keys(entityDef.fields);
893
1076
  if (fieldNames.length === 0) return 0;
@@ -901,11 +1084,12 @@ class LensServerImpl<
901
1084
  resolverDef: ResolverDef<any, any, any>,
902
1085
  fieldName: string,
903
1086
  context: TContext,
1087
+ loaders: Map<string, DataLoader<unknown, unknown>>,
904
1088
  ): DataLoader<unknown, unknown> {
905
- let loader = this.loaders.get(loaderKey);
1089
+ let loader = loaders.get(loaderKey);
906
1090
  if (!loader) {
907
- // Capture context at loader creation time
908
- // This ensures the batch function has access to request context
1091
+ // Create loader with current request's context
1092
+ // Using request-scoped loaders map ensures context isolation between concurrent requests
909
1093
  loader = new DataLoader(async (parents: unknown[]) => {
910
1094
  const results: unknown[] = [];
911
1095
  for (const parent of parents) {
@@ -923,15 +1107,11 @@ class LensServerImpl<
923
1107
  }
924
1108
  return results;
925
1109
  });
926
- this.loaders.set(loaderKey, loader);
1110
+ loaders.set(loaderKey, loader);
927
1111
  }
928
1112
  return loader;
929
1113
  }
930
1114
 
931
- private clearLoaders(): void {
932
- this.loaders.clear();
933
- }
934
-
935
1115
  // =========================================================================
936
1116
  // Operations Map
937
1117
  // =========================================================================
@@ -971,16 +1151,26 @@ class LensServerImpl<
971
1151
  };
972
1152
 
973
1153
  for (const [name, def] of Object.entries(this.queries)) {
974
- // Auto-detect subscription: if resolver is AsyncGeneratorFunction → subscription
975
- const isSubscription =
1154
+ // Auto-detect live query types:
1155
+ // 1. AsyncGenerator-based: resolver returns async generator
1156
+ // 2. Publisher-based: LiveQueryDef with _subscriber (ADR-002 pattern)
1157
+ const isAsyncGenerator =
976
1158
  def._resolve?.constructor?.name === "AsyncGeneratorFunction" ||
977
1159
  def._resolve?.constructor?.name === "GeneratorFunction";
978
- const opType = isSubscription ? "subscription" : "query";
1160
+ const isLive = isLiveQueryDef(def);
1161
+
1162
+ // Live queries need streaming transport (treated as subscription for transport routing)
1163
+ // But operation type stays "query" for semantic correctness
1164
+ const opType = isAsyncGenerator ? "subscription" : "query";
979
1165
  const returnType = getReturnTypeName(def._output);
980
1166
  const meta: OperationMeta = { type: opType };
981
1167
  if (returnType) {
982
1168
  meta.returnType = returnType;
983
1169
  }
1170
+ // Mark as live for client to use streaming transport
1171
+ if (isLive) {
1172
+ (meta as OperationMeta & { live?: boolean }).live = true;
1173
+ }
984
1174
  this.pluginManager.runEnhanceOperationMeta({
985
1175
  path: name,
986
1176
  type: opType,
@@ -1005,6 +1195,21 @@ class LensServerImpl<
1005
1195
  setNested(name, meta);
1006
1196
  }
1007
1197
 
1198
+ for (const [name, def] of Object.entries(this.subscriptions)) {
1199
+ const returnType = getReturnTypeName(def._output);
1200
+ const meta: OperationMeta = { type: "subscription" };
1201
+ if (returnType) {
1202
+ meta.returnType = returnType;
1203
+ }
1204
+ this.pluginManager.runEnhanceOperationMeta({
1205
+ path: name,
1206
+ type: "subscription",
1207
+ meta: meta as unknown as Record<string, unknown>,
1208
+ definition: def,
1209
+ });
1210
+ setNested(name, meta);
1211
+ }
1212
+
1008
1213
  return result;
1009
1214
  }
1010
1215
 
@@ -1063,7 +1268,7 @@ export function createApp<
1063
1268
  >(
1064
1269
  config: LensServerConfig<TContext> & { queries?: Q; mutations?: M },
1065
1270
  ): LensServer & { _types: { queries: Q; mutations: M; context: TContext } } {
1066
- const server = new LensServerImpl(config) as LensServerImpl<Q, M, TContext>;
1271
+ const server = new LensServerImpl(config) as LensServerImpl<Q, M, SubscriptionsMap, TContext>;
1067
1272
  return server as unknown as LensServer & {
1068
1273
  _types: { queries: Q; mutations: M; context: TContext };
1069
1274
  };