@sylphx/lens-server 2.1.0 → 2.3.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 +14 -3
- package/dist/index.js +299 -100
- package/package.json +2 -2
- package/src/e2e/server.test.ts +81 -61
- package/src/handlers/framework.ts +4 -3
- package/src/handlers/http.ts +7 -4
- package/src/handlers/ws.ts +18 -10
- package/src/server/create.test.ts +701 -47
- package/src/server/create.ts +492 -105
- package/src/server/selection.test.ts +253 -0
- package/src/server/types.ts +15 -2
package/src/server/create.ts
CHANGED
|
@@ -18,15 +18,18 @@
|
|
|
18
18
|
import {
|
|
19
19
|
type ContextValue,
|
|
20
20
|
createEmit,
|
|
21
|
+
type EmitCommand,
|
|
21
22
|
type EntityDef,
|
|
22
23
|
flattenRouter,
|
|
23
24
|
type InferRouterContext,
|
|
24
25
|
isEntityDef,
|
|
25
26
|
isMutationDef,
|
|
26
27
|
isQueryDef,
|
|
28
|
+
type Observable,
|
|
27
29
|
type ResolverDef,
|
|
28
30
|
type RouterDef,
|
|
29
31
|
toResolverMap,
|
|
32
|
+
valuesEqual,
|
|
30
33
|
} from "@sylphx/lens-core";
|
|
31
34
|
import { createContext, runWithContext } from "../context/index.js";
|
|
32
35
|
import {
|
|
@@ -39,7 +42,7 @@ import {
|
|
|
39
42
|
type UpdateFieldsContext,
|
|
40
43
|
} from "../plugin/types.js";
|
|
41
44
|
import { DataLoader } from "./dataloader.js";
|
|
42
|
-
import { applySelection } from "./selection.js";
|
|
45
|
+
import { applySelection, extractNestedInputs } from "./selection.js";
|
|
43
46
|
import type {
|
|
44
47
|
ClientSendFn,
|
|
45
48
|
EntitiesMap,
|
|
@@ -192,135 +195,449 @@ class LensServerImpl<
|
|
|
192
195
|
};
|
|
193
196
|
}
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Execute operation and return Observable.
|
|
200
|
+
*
|
|
201
|
+
* Always returns Observable<LensResult>:
|
|
202
|
+
* - One-shot: emits once, then completes
|
|
203
|
+
* - Streaming: emits multiple times (AsyncIterable or emit-based)
|
|
204
|
+
*/
|
|
205
|
+
execute(op: LensOperation): Observable<LensResult> {
|
|
196
206
|
const { path, input } = op;
|
|
197
207
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// =========================================================================
|
|
214
|
-
// Query/Mutation Execution
|
|
215
|
-
// =========================================================================
|
|
216
|
-
|
|
217
|
-
private async executeQuery<TInput, TOutput>(name: string, input?: TInput): Promise<TOutput> {
|
|
218
|
-
const queryDef = this.queries[name];
|
|
219
|
-
if (!queryDef) throw new Error(`Query not found: ${name}`);
|
|
220
|
-
|
|
221
|
-
// Extract $select from input
|
|
222
|
-
let select: SelectionObject | undefined;
|
|
223
|
-
let cleanInput = input;
|
|
224
|
-
if (input && typeof input === "object" && "$select" in input) {
|
|
225
|
-
const { $select, ...rest } = input as Record<string, unknown>;
|
|
226
|
-
select = $select as SelectionObject;
|
|
227
|
-
cleanInput = (Object.keys(rest).length > 0 ? rest : undefined) as TInput;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Validate input
|
|
231
|
-
if (queryDef._input && cleanInput !== undefined) {
|
|
232
|
-
const result = queryDef._input.safeParse(cleanInput);
|
|
233
|
-
if (!result.success) {
|
|
234
|
-
throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
|
|
235
|
-
}
|
|
208
|
+
// Check if operation exists
|
|
209
|
+
const isQuery = !!this.queries[path];
|
|
210
|
+
const isMutation = !!this.mutations[path];
|
|
211
|
+
|
|
212
|
+
if (!isQuery && !isMutation) {
|
|
213
|
+
return {
|
|
214
|
+
subscribe: (observer) => {
|
|
215
|
+
observer.next?.({ error: new Error(`Operation not found: ${path}`) });
|
|
216
|
+
observer.complete?.();
|
|
217
|
+
return { unsubscribe: () => {} };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
236
220
|
}
|
|
237
221
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
return await runWithContext(this.ctx, context, async () => {
|
|
242
|
-
const resolver = queryDef._resolve;
|
|
243
|
-
if (!resolver) throw new Error(`Query ${name} has no resolver`);
|
|
222
|
+
return this.executeAsObservable(path, input, isQuery);
|
|
223
|
+
}
|
|
244
224
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Execute operation and return Observable.
|
|
227
|
+
* Observable allows streaming for AsyncIterable resolvers and emit-based updates.
|
|
228
|
+
*/
|
|
229
|
+
private executeAsObservable(
|
|
230
|
+
path: string,
|
|
231
|
+
input: unknown,
|
|
232
|
+
isQuery: boolean,
|
|
233
|
+
): Observable<LensResult> {
|
|
234
|
+
return {
|
|
235
|
+
subscribe: (observer) => {
|
|
236
|
+
let cancelled = false;
|
|
237
|
+
let currentState: unknown;
|
|
238
|
+
let lastEmittedResult: unknown;
|
|
239
|
+
const cleanups: (() => void)[] = [];
|
|
240
|
+
|
|
241
|
+
// Helper to emit only if value changed
|
|
242
|
+
const emitIfChanged = (data: unknown) => {
|
|
243
|
+
if (cancelled) return;
|
|
244
|
+
if (valuesEqual(data, lastEmittedResult)) return;
|
|
245
|
+
lastEmittedResult = data;
|
|
246
|
+
observer.next?.({ data });
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Run the operation
|
|
250
|
+
(async () => {
|
|
251
|
+
try {
|
|
252
|
+
const def = isQuery ? this.queries[path] : this.mutations[path];
|
|
253
|
+
if (!def) {
|
|
254
|
+
observer.next?.({ error: new Error(`Operation not found: ${path}`) });
|
|
255
|
+
observer.complete?.();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Extract $select from input for queries
|
|
260
|
+
let select: SelectionObject | undefined;
|
|
261
|
+
let cleanInput = input;
|
|
262
|
+
if (isQuery && input && typeof input === "object" && "$select" in input) {
|
|
263
|
+
const { $select, ...rest } = input as Record<string, unknown>;
|
|
264
|
+
select = $select as SelectionObject;
|
|
265
|
+
cleanInput = Object.keys(rest).length > 0 ? rest : undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Validate input
|
|
269
|
+
if (def._input && cleanInput !== undefined) {
|
|
270
|
+
const result = def._input.safeParse(cleanInput);
|
|
271
|
+
if (!result.success) {
|
|
272
|
+
observer.next?.({
|
|
273
|
+
error: new Error(`Invalid input: ${JSON.stringify(result.error)}`),
|
|
274
|
+
});
|
|
275
|
+
observer.complete?.();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const context = await this.contextFactory();
|
|
281
|
+
|
|
282
|
+
await runWithContext(this.ctx, context, async () => {
|
|
283
|
+
const resolver = def._resolve;
|
|
284
|
+
if (!resolver) {
|
|
285
|
+
observer.next?.({ error: new Error(`Operation ${path} has no resolver`) });
|
|
286
|
+
observer.complete?.();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Create emit handler with async queue processing
|
|
291
|
+
// Emit commands are queued and processed through processQueryResult
|
|
292
|
+
// to ensure field resolvers run on every emit
|
|
293
|
+
let emitProcessing = false;
|
|
294
|
+
const emitQueue: EmitCommand[] = [];
|
|
295
|
+
|
|
296
|
+
const processEmitQueue = async () => {
|
|
297
|
+
if (emitProcessing || cancelled) return;
|
|
298
|
+
emitProcessing = true;
|
|
299
|
+
|
|
300
|
+
while (emitQueue.length > 0 && !cancelled) {
|
|
301
|
+
const command = emitQueue.shift()!;
|
|
302
|
+
currentState = this.applyEmitCommand(command, currentState);
|
|
303
|
+
|
|
304
|
+
// Process through field resolvers (unlike before where we bypassed this)
|
|
305
|
+
// Note: createFieldEmit is created after this function but used lazily
|
|
306
|
+
const fieldEmitFactory = isQuery
|
|
307
|
+
? this.createFieldEmitFactory(
|
|
308
|
+
() => currentState,
|
|
309
|
+
(state) => {
|
|
310
|
+
currentState = state;
|
|
311
|
+
},
|
|
312
|
+
emitIfChanged,
|
|
313
|
+
select,
|
|
314
|
+
context,
|
|
315
|
+
onCleanup,
|
|
316
|
+
)
|
|
317
|
+
: undefined;
|
|
318
|
+
|
|
319
|
+
const processed = isQuery
|
|
320
|
+
? await this.processQueryResult(
|
|
321
|
+
path,
|
|
322
|
+
currentState,
|
|
323
|
+
select,
|
|
324
|
+
context,
|
|
325
|
+
onCleanup,
|
|
326
|
+
fieldEmitFactory,
|
|
327
|
+
)
|
|
328
|
+
: currentState;
|
|
329
|
+
|
|
330
|
+
emitIfChanged(processed);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
emitProcessing = false;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const emitHandler = (command: EmitCommand) => {
|
|
337
|
+
if (cancelled) return;
|
|
338
|
+
emitQueue.push(command);
|
|
339
|
+
// Fire async processing (don't await - emit should be sync from caller's perspective)
|
|
340
|
+
processEmitQueue().catch((err) => {
|
|
341
|
+
if (!cancelled) {
|
|
342
|
+
observer.next?.({ error: err instanceof Error ? err : new Error(String(err)) });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const emit = createEmit(emitHandler);
|
|
348
|
+
const onCleanup = (fn: () => void) => {
|
|
349
|
+
cleanups.push(fn);
|
|
350
|
+
return () => {
|
|
351
|
+
const idx = cleanups.indexOf(fn);
|
|
352
|
+
if (idx >= 0) cleanups.splice(idx, 1);
|
|
353
|
+
};
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// Create field emit factory for field-level live queries
|
|
357
|
+
const createFieldEmit = isQuery
|
|
358
|
+
? this.createFieldEmitFactory(
|
|
359
|
+
() => currentState,
|
|
360
|
+
(state) => {
|
|
361
|
+
currentState = state;
|
|
362
|
+
},
|
|
363
|
+
emitIfChanged,
|
|
364
|
+
select,
|
|
365
|
+
context,
|
|
366
|
+
onCleanup,
|
|
367
|
+
)
|
|
368
|
+
: undefined;
|
|
369
|
+
|
|
370
|
+
const lensContext = { ...context, emit, onCleanup };
|
|
371
|
+
const result = resolver({ input: cleanInput, ctx: lensContext });
|
|
372
|
+
|
|
373
|
+
if (isAsyncIterable(result)) {
|
|
374
|
+
// Streaming: emit each yielded value
|
|
375
|
+
for await (const value of result) {
|
|
376
|
+
if (cancelled) break;
|
|
377
|
+
currentState = value;
|
|
378
|
+
const processed = await this.processQueryResult(
|
|
379
|
+
path,
|
|
380
|
+
value,
|
|
381
|
+
select,
|
|
382
|
+
context,
|
|
383
|
+
onCleanup,
|
|
384
|
+
createFieldEmit,
|
|
385
|
+
);
|
|
386
|
+
emitIfChanged(processed);
|
|
387
|
+
}
|
|
388
|
+
if (!cancelled) {
|
|
389
|
+
observer.complete?.();
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
// One-shot: emit single value
|
|
393
|
+
const value = await result;
|
|
394
|
+
currentState = value;
|
|
395
|
+
const processed = isQuery
|
|
396
|
+
? await this.processQueryResult(
|
|
397
|
+
path,
|
|
398
|
+
value,
|
|
399
|
+
select,
|
|
400
|
+
context,
|
|
401
|
+
onCleanup,
|
|
402
|
+
createFieldEmit,
|
|
403
|
+
)
|
|
404
|
+
: value;
|
|
405
|
+
emitIfChanged(processed);
|
|
406
|
+
// Don't complete immediately - stay open for potential emit calls
|
|
407
|
+
// For true one-shot, client can unsubscribe after first value
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (!cancelled) {
|
|
412
|
+
observer.next?.({ error: error instanceof Error ? error : new Error(String(error)) });
|
|
413
|
+
observer.complete?.();
|
|
414
|
+
}
|
|
415
|
+
} finally {
|
|
416
|
+
this.clearLoaders();
|
|
417
|
+
}
|
|
418
|
+
})();
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
unsubscribe: () => {
|
|
422
|
+
cancelled = true;
|
|
423
|
+
for (const fn of cleanups) {
|
|
424
|
+
fn();
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
248
431
|
|
|
249
|
-
|
|
432
|
+
/**
|
|
433
|
+
* Apply emit command to current state.
|
|
434
|
+
*/
|
|
435
|
+
private applyEmitCommand(command: EmitCommand, state: unknown): unknown {
|
|
436
|
+
switch (command.type) {
|
|
437
|
+
case "full":
|
|
438
|
+
if (command.replace) {
|
|
439
|
+
return command.data;
|
|
440
|
+
}
|
|
441
|
+
// Merge mode
|
|
442
|
+
if (state && typeof state === "object" && typeof command.data === "object") {
|
|
443
|
+
return { ...state, ...(command.data as Record<string, unknown>) };
|
|
444
|
+
}
|
|
445
|
+
return command.data;
|
|
446
|
+
|
|
447
|
+
case "field":
|
|
448
|
+
if (state && typeof state === "object") {
|
|
449
|
+
return {
|
|
450
|
+
...(state as Record<string, unknown>),
|
|
451
|
+
[command.field]: command.update.data,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return { [command.field]: command.update.data };
|
|
250
455
|
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
456
|
+
case "batch":
|
|
457
|
+
if (state && typeof state === "object") {
|
|
458
|
+
const result = { ...(state as Record<string, unknown>) };
|
|
459
|
+
for (const update of command.updates) {
|
|
460
|
+
result[update.field] = update.update.data;
|
|
256
461
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
return state;
|
|
465
|
+
|
|
466
|
+
case "array": {
|
|
467
|
+
// Array operations - simplified handling
|
|
468
|
+
const arr = Array.isArray(state) ? [...state] : [];
|
|
469
|
+
const op = command.operation;
|
|
470
|
+
switch (op.op) {
|
|
471
|
+
case "push":
|
|
472
|
+
return [...arr, op.item];
|
|
473
|
+
case "unshift":
|
|
474
|
+
return [op.item, ...arr];
|
|
475
|
+
case "insert":
|
|
476
|
+
arr.splice(op.index, 0, op.item);
|
|
477
|
+
return arr;
|
|
478
|
+
case "remove":
|
|
479
|
+
arr.splice(op.index, 1);
|
|
480
|
+
return arr;
|
|
481
|
+
case "update":
|
|
482
|
+
arr[op.index] = op.item;
|
|
483
|
+
return arr;
|
|
484
|
+
default:
|
|
485
|
+
return arr;
|
|
262
486
|
}
|
|
487
|
+
}
|
|
263
488
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
} finally {
|
|
267
|
-
this.clearLoaders();
|
|
489
|
+
default:
|
|
490
|
+
return state;
|
|
268
491
|
}
|
|
269
492
|
}
|
|
270
493
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// Validate input
|
|
276
|
-
if (mutationDef._input) {
|
|
277
|
-
const result = mutationDef._input.safeParse(input);
|
|
278
|
-
if (!result.success) {
|
|
279
|
-
throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const context = await this.contextFactory();
|
|
494
|
+
// =========================================================================
|
|
495
|
+
// Result Processing
|
|
496
|
+
// =========================================================================
|
|
284
497
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
498
|
+
/**
|
|
499
|
+
* Factory type for creating field-level emit handlers.
|
|
500
|
+
* Each field gets its own emit that updates just that field path.
|
|
501
|
+
*/
|
|
502
|
+
private createFieldEmitFactory(
|
|
503
|
+
getCurrentState: () => unknown,
|
|
504
|
+
setCurrentState: (state: unknown) => void,
|
|
505
|
+
notifyObserver: (data: unknown) => void,
|
|
506
|
+
select: SelectionObject | undefined,
|
|
507
|
+
context: TContext | undefined,
|
|
508
|
+
onCleanup: ((fn: () => void) => void) | undefined,
|
|
509
|
+
): (fieldPath: string) => ((value: unknown) => void) | undefined {
|
|
510
|
+
return (fieldPath: string) => {
|
|
511
|
+
if (!fieldPath) return undefined;
|
|
512
|
+
|
|
513
|
+
return (newValue: unknown) => {
|
|
514
|
+
// Get current state and update the field at the given path
|
|
515
|
+
const state = getCurrentState();
|
|
516
|
+
if (!state || typeof state !== "object") return;
|
|
517
|
+
|
|
518
|
+
const updatedState = this.setFieldByPath(
|
|
519
|
+
state as Record<string, unknown>,
|
|
520
|
+
fieldPath,
|
|
521
|
+
newValue,
|
|
522
|
+
);
|
|
523
|
+
setCurrentState(updatedState);
|
|
524
|
+
|
|
525
|
+
// Resolve nested fields on the new value and notify observer
|
|
526
|
+
(async () => {
|
|
527
|
+
try {
|
|
528
|
+
const nestedInputs = select ? extractNestedInputs(select) : undefined;
|
|
529
|
+
const processed = await this.resolveEntityFields(
|
|
530
|
+
updatedState,
|
|
531
|
+
nestedInputs,
|
|
532
|
+
context,
|
|
533
|
+
"",
|
|
534
|
+
onCleanup,
|
|
535
|
+
this.createFieldEmitFactory(
|
|
536
|
+
getCurrentState,
|
|
537
|
+
setCurrentState,
|
|
538
|
+
notifyObserver,
|
|
539
|
+
select,
|
|
540
|
+
context,
|
|
541
|
+
onCleanup,
|
|
542
|
+
),
|
|
543
|
+
);
|
|
544
|
+
const result = select ? applySelection(processed, select) : processed;
|
|
545
|
+
notifyObserver(result);
|
|
546
|
+
} catch (err) {
|
|
547
|
+
// Field emit errors are logged but don't break the stream
|
|
548
|
+
console.error(`Field emit error at path "${fieldPath}":`, err);
|
|
549
|
+
}
|
|
550
|
+
})();
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
}
|
|
289
554
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
555
|
+
/**
|
|
556
|
+
* Set a value at a nested path in an object.
|
|
557
|
+
* Creates a shallow copy at each level.
|
|
558
|
+
*/
|
|
559
|
+
private setFieldByPath(
|
|
560
|
+
obj: Record<string, unknown>,
|
|
561
|
+
path: string,
|
|
562
|
+
value: unknown,
|
|
563
|
+
): Record<string, unknown> {
|
|
564
|
+
const parts = path.split(".");
|
|
565
|
+
if (parts.length === 1) {
|
|
566
|
+
return { ...obj, [path]: value };
|
|
567
|
+
}
|
|
293
568
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
569
|
+
const [first, ...rest] = parts;
|
|
570
|
+
const nested = obj[first];
|
|
571
|
+
if (nested && typeof nested === "object") {
|
|
572
|
+
return {
|
|
573
|
+
...obj,
|
|
574
|
+
[first]: this.setFieldByPath(nested as Record<string, unknown>, rest.join("."), value),
|
|
575
|
+
};
|
|
298
576
|
}
|
|
577
|
+
return obj;
|
|
299
578
|
}
|
|
300
579
|
|
|
301
|
-
// =========================================================================
|
|
302
|
-
// Result Processing
|
|
303
|
-
// =========================================================================
|
|
304
|
-
|
|
305
580
|
private async processQueryResult<T>(
|
|
306
581
|
_operationName: string,
|
|
307
582
|
data: T,
|
|
308
583
|
select?: SelectionObject,
|
|
584
|
+
context?: TContext,
|
|
585
|
+
onCleanup?: (fn: () => void) => void,
|
|
586
|
+
createFieldEmit?: (fieldPath: string) => ((value: unknown) => void) | undefined,
|
|
309
587
|
): Promise<T> {
|
|
310
588
|
if (!data) return data;
|
|
311
589
|
|
|
312
|
-
|
|
590
|
+
// Extract nested inputs from selection for field resolver args
|
|
591
|
+
const nestedInputs = select ? extractNestedInputs(select) : undefined;
|
|
592
|
+
|
|
593
|
+
const processed = await this.resolveEntityFields(
|
|
594
|
+
data,
|
|
595
|
+
nestedInputs,
|
|
596
|
+
context,
|
|
597
|
+
"",
|
|
598
|
+
onCleanup,
|
|
599
|
+
createFieldEmit,
|
|
600
|
+
);
|
|
313
601
|
if (select) {
|
|
314
602
|
return applySelection(processed, select) as T;
|
|
315
603
|
}
|
|
316
604
|
return processed as T;
|
|
317
605
|
}
|
|
318
606
|
|
|
319
|
-
|
|
607
|
+
/**
|
|
608
|
+
* Resolve entity fields using field resolvers.
|
|
609
|
+
* Supports nested inputs for field-level arguments (like GraphQL).
|
|
610
|
+
*
|
|
611
|
+
* @param data - The data to resolve
|
|
612
|
+
* @param nestedInputs - Map of field paths to their input args (from extractNestedInputs)
|
|
613
|
+
* @param context - Request context to pass to field resolvers
|
|
614
|
+
* @param fieldPath - Current path for nested field resolution
|
|
615
|
+
* @param onCleanup - Cleanup registration for live query subscriptions
|
|
616
|
+
* @param createFieldEmit - Factory for creating field-specific emit handlers
|
|
617
|
+
*/
|
|
618
|
+
private async resolveEntityFields<T>(
|
|
619
|
+
data: T,
|
|
620
|
+
nestedInputs?: Map<string, Record<string, unknown>>,
|
|
621
|
+
context?: TContext,
|
|
622
|
+
fieldPath = "",
|
|
623
|
+
onCleanup?: (fn: () => void) => void,
|
|
624
|
+
createFieldEmit?: (fieldPath: string) => ((value: unknown) => void) | undefined,
|
|
625
|
+
): Promise<T> {
|
|
320
626
|
if (!data || !this.resolverMap) return data;
|
|
321
627
|
|
|
322
628
|
if (Array.isArray(data)) {
|
|
323
|
-
return Promise.all(
|
|
629
|
+
return Promise.all(
|
|
630
|
+
data.map((item) =>
|
|
631
|
+
this.resolveEntityFields(
|
|
632
|
+
item,
|
|
633
|
+
nestedInputs,
|
|
634
|
+
context,
|
|
635
|
+
fieldPath,
|
|
636
|
+
onCleanup,
|
|
637
|
+
createFieldEmit,
|
|
638
|
+
),
|
|
639
|
+
),
|
|
640
|
+
) as Promise<T>;
|
|
324
641
|
}
|
|
325
642
|
|
|
326
643
|
if (typeof data !== "object") return data;
|
|
@@ -340,37 +657,107 @@ class LensServerImpl<
|
|
|
340
657
|
// Skip exposed fields
|
|
341
658
|
if (resolverDef.isExposed(field)) continue;
|
|
342
659
|
|
|
660
|
+
// Calculate the path for this field (for nested input lookup)
|
|
661
|
+
const currentPath = fieldPath ? `${fieldPath}.${field}` : field;
|
|
662
|
+
|
|
663
|
+
// Get args for this field from nested inputs
|
|
664
|
+
const args = nestedInputs?.get(currentPath) ?? {};
|
|
665
|
+
const hasArgs = Object.keys(args).length > 0;
|
|
666
|
+
|
|
343
667
|
// Skip if value already exists
|
|
344
668
|
const existingValue = result[field];
|
|
345
669
|
if (existingValue !== undefined) {
|
|
346
|
-
result[field] = await this.resolveEntityFields(
|
|
670
|
+
result[field] = await this.resolveEntityFields(
|
|
671
|
+
existingValue,
|
|
672
|
+
nestedInputs,
|
|
673
|
+
context,
|
|
674
|
+
currentPath,
|
|
675
|
+
onCleanup,
|
|
676
|
+
createFieldEmit,
|
|
677
|
+
);
|
|
347
678
|
continue;
|
|
348
679
|
}
|
|
349
680
|
|
|
350
681
|
// Resolve the field
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
682
|
+
if (hasArgs || context) {
|
|
683
|
+
// Direct resolution when we have args or context (skip DataLoader)
|
|
684
|
+
try {
|
|
685
|
+
// Build extended context with emit and onCleanup
|
|
686
|
+
// Lens is a live query library - these are always available
|
|
687
|
+
const extendedCtx = {
|
|
688
|
+
...(context ?? {}),
|
|
689
|
+
emit: createFieldEmit!(currentPath),
|
|
690
|
+
onCleanup: onCleanup!,
|
|
691
|
+
};
|
|
692
|
+
result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
|
|
693
|
+
} catch {
|
|
694
|
+
result[field] = null;
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
// Use DataLoader for batching when no args (default case)
|
|
698
|
+
const loaderKey = `${typeName}.${field}`;
|
|
699
|
+
const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
|
|
700
|
+
result[field] = await loader.load(obj);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Recursively resolve nested fields
|
|
704
|
+
result[field] = await this.resolveEntityFields(
|
|
705
|
+
result[field],
|
|
706
|
+
nestedInputs,
|
|
707
|
+
context,
|
|
708
|
+
currentPath,
|
|
709
|
+
onCleanup,
|
|
710
|
+
createFieldEmit,
|
|
711
|
+
);
|
|
355
712
|
}
|
|
356
713
|
|
|
357
714
|
return result as T;
|
|
358
715
|
}
|
|
359
716
|
|
|
717
|
+
/**
|
|
718
|
+
* Get the type name for an object by matching against entity definitions.
|
|
719
|
+
*
|
|
720
|
+
* Matching priority:
|
|
721
|
+
* 1. Explicit __typename or _type property
|
|
722
|
+
* 2. Best matching entity (highest field overlap score)
|
|
723
|
+
*
|
|
724
|
+
* Requires at least 50% field match to avoid false positives.
|
|
725
|
+
*/
|
|
360
726
|
private getTypeName(obj: Record<string, unknown>): string | undefined {
|
|
727
|
+
// Priority 1: Explicit type marker
|
|
361
728
|
if ("__typename" in obj) return obj.__typename as string;
|
|
362
729
|
if ("_type" in obj) return obj._type as string;
|
|
363
730
|
|
|
731
|
+
// Priority 2: Find best matching entity by field overlap
|
|
732
|
+
let bestMatch: { name: string; score: number } | undefined;
|
|
733
|
+
|
|
364
734
|
for (const [name, def] of Object.entries(this.entities)) {
|
|
365
|
-
if (isEntityDef(def)
|
|
366
|
-
|
|
735
|
+
if (!isEntityDef(def)) continue;
|
|
736
|
+
|
|
737
|
+
const score = this.getEntityMatchScore(obj, def);
|
|
738
|
+
// Require at least 50% field match to avoid false positives
|
|
739
|
+
if (score >= 0.5 && (!bestMatch || score > bestMatch.score)) {
|
|
740
|
+
bestMatch = { name, score };
|
|
367
741
|
}
|
|
368
742
|
}
|
|
369
|
-
|
|
743
|
+
|
|
744
|
+
return bestMatch?.name;
|
|
370
745
|
}
|
|
371
746
|
|
|
372
|
-
|
|
373
|
-
|
|
747
|
+
/**
|
|
748
|
+
* Calculate how well an object matches an entity definition.
|
|
749
|
+
*
|
|
750
|
+
* @returns Score between 0 and 1 (1 = perfect match, all entity fields present)
|
|
751
|
+
*/
|
|
752
|
+
private getEntityMatchScore(
|
|
753
|
+
obj: Record<string, unknown>,
|
|
754
|
+
entityDef: EntityDef<string, any>,
|
|
755
|
+
): number {
|
|
756
|
+
const fieldNames = Object.keys(entityDef.fields);
|
|
757
|
+
if (fieldNames.length === 0) return 0;
|
|
758
|
+
|
|
759
|
+
const matchingFields = fieldNames.filter((field) => field in obj);
|
|
760
|
+
return matchingFields.length / fieldNames.length;
|
|
374
761
|
}
|
|
375
762
|
|
|
376
763
|
private getOrCreateLoaderForField(
|