@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.
- package/dist/index.d.ts +16 -7
- package/dist/index.js +318 -114
- package/package.json +2 -2
- package/src/e2e/server.test.ts +70 -56
- package/src/handlers/framework.ts +65 -32
- package/src/handlers/http.test.ts +8 -8
- package/src/handlers/http.ts +3 -5
- package/src/handlers/ws-types.ts +1 -0
- package/src/handlers/ws.test.ts +6 -6
- package/src/handlers/ws.ts +14 -3
- package/src/index.ts +0 -2
- package/src/plugin/optimistic.ts +6 -6
- package/src/reconnect/operation-log.ts +20 -9
- package/src/server/create.test.ts +223 -316
- package/src/server/create.ts +328 -123
- package/src/server/types.ts +23 -6
- package/src/storage/memory.ts +24 -3
package/src/server/create.ts
CHANGED
|
@@ -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(
|
|
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
|
|
177
|
+
// Convert Map to Record for entities
|
|
167
178
|
const entities: EntitiesMap = {};
|
|
168
179
|
for (const [name, model] of mergedModels) {
|
|
169
|
-
|
|
170
|
-
|
|
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-
|
|
177
|
-
//
|
|
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
|
|
246
|
+
* Build resolver map from explicit resolvers.
|
|
228
247
|
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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-
|
|
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-
|
|
269
|
+
// 2. Auto-create exposed-only resolvers for models without explicit resolvers
|
|
251
270
|
for (const [name, entity] of Object.entries(entities)) {
|
|
252
|
-
|
|
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
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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,
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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,
|
|
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
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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.
|
|
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:
|
|
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 (!
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
1089
|
+
let loader = loaders.get(loaderKey);
|
|
906
1090
|
if (!loader) {
|
|
907
|
-
//
|
|
908
|
-
//
|
|
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
|
-
|
|
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
|
|
975
|
-
|
|
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
|
|
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
|
};
|