@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.
@@ -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
- async execute(op: LensOperation): Promise<LensResult> {
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
- try {
199
- if (this.queries[path]) {
200
- const data = await this.executeQuery(path, input);
201
- return { data };
202
- }
203
- if (this.mutations[path]) {
204
- const data = await this.executeMutation(path, input);
205
- return { data };
206
- }
207
- return { error: new Error(`Operation not found: ${path}`) };
208
- } catch (error) {
209
- return { error: error instanceof Error ? error : new Error(String(error)) };
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
- const context = await this.contextFactory();
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
- const emit = createEmit(() => {});
246
- const onCleanup = () => () => {};
247
- const lensContext = { ...context, emit, onCleanup };
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
- const result = resolver({ input: cleanInput as TInput, ctx: lensContext });
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
- let data: TOutput;
252
- if (isAsyncIterable(result)) {
253
- for await (const value of result) {
254
- data = value as TOutput;
255
- break;
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
- if (data! === undefined) {
258
- throw new Error(`Query ${name} returned empty stream`);
259
- }
260
- } else {
261
- data = (await result) as TOutput;
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
- return this.processQueryResult(name, data, select);
265
- });
266
- } finally {
267
- this.clearLoaders();
489
+ default:
490
+ return state;
268
491
  }
269
492
  }
270
493
 
271
- private async executeMutation<TInput, TOutput>(name: string, input: TInput): Promise<TOutput> {
272
- const mutationDef = this.mutations[name];
273
- if (!mutationDef) throw new Error(`Mutation not found: ${name}`);
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
- try {
286
- return await runWithContext(this.ctx, context, async () => {
287
- const resolver = mutationDef._resolve;
288
- if (!resolver) throw new Error(`Mutation ${name} has no resolver`);
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
- const emit = createEmit(() => {});
291
- const onCleanup = () => () => {};
292
- const lensContext = { ...context, emit, onCleanup };
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
- return (await resolver({ input: input as TInput, ctx: lensContext })) as TOutput;
295
- });
296
- } finally {
297
- this.clearLoaders();
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
- const processed = await this.resolveEntityFields(data);
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
- private async resolveEntityFields<T>(data: T): Promise<T> {
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(data.map((item) => this.resolveEntityFields(item))) as Promise<T>;
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(existingValue);
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
- const loaderKey = `${typeName}.${field}`;
352
- const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
353
- result[field] = await loader.load(obj);
354
- result[field] = await this.resolveEntityFields(result[field]);
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) && this.matchesEntity(obj, def)) {
366
- return name;
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
- return undefined;
743
+
744
+ return bestMatch?.name;
370
745
  }
371
746
 
372
- private matchesEntity(obj: Record<string, unknown>, entityDef: EntityDef<string, any>): boolean {
373
- return "id" in obj || entityDef._name! in obj;
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(