@sylphx/lens-server 2.2.0 → 2.3.1

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.
@@ -29,6 +29,7 @@ import {
29
29
  type ResolverDef,
30
30
  type RouterDef,
31
31
  toResolverMap,
32
+ valuesEqual,
32
33
  } from "@sylphx/lens-core";
33
34
  import { createContext, runWithContext } from "../context/index.js";
34
35
  import {
@@ -41,7 +42,7 @@ import {
41
42
  type UpdateFieldsContext,
42
43
  } from "../plugin/types.js";
43
44
  import { DataLoader } from "./dataloader.js";
44
- import { applySelection } from "./selection.js";
45
+ import { applySelection, extractNestedInputs } from "./selection.js";
45
46
  import type {
46
47
  ClientSendFn,
47
48
  EntitiesMap,
@@ -234,8 +235,17 @@ class LensServerImpl<
234
235
  subscribe: (observer) => {
235
236
  let cancelled = false;
236
237
  let currentState: unknown;
238
+ let lastEmittedResult: unknown;
237
239
  const cleanups: (() => void)[] = [];
238
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
+
239
249
  // Run the operation
240
250
  (async () => {
241
251
  try {
@@ -277,14 +287,66 @@ class LensServerImpl<
277
287
  return;
278
288
  }
279
289
 
280
- // Create emit handler that pushes to observer
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
+
281
336
  const emitHandler = (command: EmitCommand) => {
282
337
  if (cancelled) return;
283
- currentState = this.applyEmitCommand(command, currentState);
284
- observer.next?.({ data: currentState });
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
+ });
285
345
  };
286
346
 
287
- const emit = createEmit(emitHandler);
347
+ // Detect array output type: [EntityDef] is stored as single-element array
348
+ const isArrayOutput = Array.isArray(def._output);
349
+ const emit = createEmit(emitHandler, isArrayOutput);
288
350
  const onCleanup = (fn: () => void) => {
289
351
  cleanups.push(fn);
290
352
  return () => {
@@ -293,6 +355,20 @@ class LensServerImpl<
293
355
  };
294
356
  };
295
357
 
358
+ // Create field emit factory for field-level live queries
359
+ const createFieldEmit = isQuery
360
+ ? this.createFieldEmitFactory(
361
+ () => currentState,
362
+ (state) => {
363
+ currentState = state;
364
+ },
365
+ emitIfChanged,
366
+ select,
367
+ context,
368
+ onCleanup,
369
+ )
370
+ : undefined;
371
+
296
372
  const lensContext = { ...context, emit, onCleanup };
297
373
  const result = resolver({ input: cleanInput, ctx: lensContext });
298
374
 
@@ -301,8 +377,15 @@ class LensServerImpl<
301
377
  for await (const value of result) {
302
378
  if (cancelled) break;
303
379
  currentState = value;
304
- const processed = await this.processQueryResult(path, value, select);
305
- observer.next?.({ data: processed });
380
+ const processed = await this.processQueryResult(
381
+ path,
382
+ value,
383
+ select,
384
+ context,
385
+ onCleanup,
386
+ createFieldEmit,
387
+ );
388
+ emitIfChanged(processed);
306
389
  }
307
390
  if (!cancelled) {
308
391
  observer.complete?.();
@@ -312,13 +395,18 @@ class LensServerImpl<
312
395
  const value = await result;
313
396
  currentState = value;
314
397
  const processed = isQuery
315
- ? await this.processQueryResult(path, value, select)
398
+ ? await this.processQueryResult(
399
+ path,
400
+ value,
401
+ select,
402
+ context,
403
+ onCleanup,
404
+ createFieldEmit,
405
+ )
316
406
  : value;
317
- if (!cancelled) {
318
- observer.next?.({ data: processed });
319
- // Don't complete immediately - stay open for potential emit calls
320
- // For true one-shot, client can unsubscribe after first value
321
- }
407
+ emitIfChanged(processed);
408
+ // Don't complete immediately - stay open for potential emit calls
409
+ // For true one-shot, client can unsubscribe after first value
322
410
  }
323
411
  });
324
412
  } catch (error) {
@@ -409,25 +497,149 @@ class LensServerImpl<
409
497
  // Result Processing
410
498
  // =========================================================================
411
499
 
500
+ /**
501
+ * Factory type for creating field-level emit handlers.
502
+ * Each field gets its own emit that updates just that field path.
503
+ */
504
+ private createFieldEmitFactory(
505
+ getCurrentState: () => unknown,
506
+ setCurrentState: (state: unknown) => void,
507
+ notifyObserver: (data: unknown) => void,
508
+ select: SelectionObject | undefined,
509
+ context: TContext | undefined,
510
+ onCleanup: ((fn: () => void) => void) | undefined,
511
+ ): (fieldPath: string) => ((value: unknown) => void) | undefined {
512
+ return (fieldPath: string) => {
513
+ if (!fieldPath) return undefined;
514
+
515
+ return (newValue: unknown) => {
516
+ // Get current state and update the field at the given path
517
+ const state = getCurrentState();
518
+ if (!state || typeof state !== "object") return;
519
+
520
+ const updatedState = this.setFieldByPath(
521
+ state as Record<string, unknown>,
522
+ fieldPath,
523
+ newValue,
524
+ );
525
+ setCurrentState(updatedState);
526
+
527
+ // Resolve nested fields on the new value and notify observer
528
+ (async () => {
529
+ try {
530
+ const nestedInputs = select ? extractNestedInputs(select) : undefined;
531
+ const processed = await this.resolveEntityFields(
532
+ updatedState,
533
+ nestedInputs,
534
+ context,
535
+ "",
536
+ onCleanup,
537
+ this.createFieldEmitFactory(
538
+ getCurrentState,
539
+ setCurrentState,
540
+ notifyObserver,
541
+ select,
542
+ context,
543
+ onCleanup,
544
+ ),
545
+ );
546
+ const result = select ? applySelection(processed, select) : processed;
547
+ notifyObserver(result);
548
+ } catch (err) {
549
+ // Field emit errors are logged but don't break the stream
550
+ console.error(`Field emit error at path "${fieldPath}":`, err);
551
+ }
552
+ })();
553
+ };
554
+ };
555
+ }
556
+
557
+ /**
558
+ * Set a value at a nested path in an object.
559
+ * Creates a shallow copy at each level.
560
+ */
561
+ private setFieldByPath(
562
+ obj: Record<string, unknown>,
563
+ path: string,
564
+ value: unknown,
565
+ ): Record<string, unknown> {
566
+ const parts = path.split(".");
567
+ if (parts.length === 1) {
568
+ return { ...obj, [path]: value };
569
+ }
570
+
571
+ const [first, ...rest] = parts;
572
+ const nested = obj[first];
573
+ if (nested && typeof nested === "object") {
574
+ return {
575
+ ...obj,
576
+ [first]: this.setFieldByPath(nested as Record<string, unknown>, rest.join("."), value),
577
+ };
578
+ }
579
+ return obj;
580
+ }
581
+
412
582
  private async processQueryResult<T>(
413
583
  _operationName: string,
414
584
  data: T,
415
585
  select?: SelectionObject,
586
+ context?: TContext,
587
+ onCleanup?: (fn: () => void) => void,
588
+ createFieldEmit?: (fieldPath: string) => ((value: unknown) => void) | undefined,
416
589
  ): Promise<T> {
417
590
  if (!data) return data;
418
591
 
419
- const processed = await this.resolveEntityFields(data);
592
+ // Extract nested inputs from selection for field resolver args
593
+ const nestedInputs = select ? extractNestedInputs(select) : undefined;
594
+
595
+ const processed = await this.resolveEntityFields(
596
+ data,
597
+ nestedInputs,
598
+ context,
599
+ "",
600
+ onCleanup,
601
+ createFieldEmit,
602
+ );
420
603
  if (select) {
421
604
  return applySelection(processed, select) as T;
422
605
  }
423
606
  return processed as T;
424
607
  }
425
608
 
426
- private async resolveEntityFields<T>(data: T): Promise<T> {
609
+ /**
610
+ * Resolve entity fields using field resolvers.
611
+ * Supports nested inputs for field-level arguments (like GraphQL).
612
+ *
613
+ * @param data - The data to resolve
614
+ * @param nestedInputs - Map of field paths to their input args (from extractNestedInputs)
615
+ * @param context - Request context to pass to field resolvers
616
+ * @param fieldPath - Current path for nested field resolution
617
+ * @param onCleanup - Cleanup registration for live query subscriptions
618
+ * @param createFieldEmit - Factory for creating field-specific emit handlers
619
+ */
620
+ private async resolveEntityFields<T>(
621
+ data: T,
622
+ nestedInputs?: Map<string, Record<string, unknown>>,
623
+ context?: TContext,
624
+ fieldPath = "",
625
+ onCleanup?: (fn: () => void) => void,
626
+ createFieldEmit?: (fieldPath: string) => ((value: unknown) => void) | undefined,
627
+ ): Promise<T> {
427
628
  if (!data || !this.resolverMap) return data;
428
629
 
429
630
  if (Array.isArray(data)) {
430
- return Promise.all(data.map((item) => this.resolveEntityFields(item))) as Promise<T>;
631
+ return Promise.all(
632
+ data.map((item) =>
633
+ this.resolveEntityFields(
634
+ item,
635
+ nestedInputs,
636
+ context,
637
+ fieldPath,
638
+ onCleanup,
639
+ createFieldEmit,
640
+ ),
641
+ ),
642
+ ) as Promise<T>;
431
643
  }
432
644
 
433
645
  if (typeof data !== "object") return data;
@@ -447,37 +659,107 @@ class LensServerImpl<
447
659
  // Skip exposed fields
448
660
  if (resolverDef.isExposed(field)) continue;
449
661
 
662
+ // Calculate the path for this field (for nested input lookup)
663
+ const currentPath = fieldPath ? `${fieldPath}.${field}` : field;
664
+
665
+ // Get args for this field from nested inputs
666
+ const args = nestedInputs?.get(currentPath) ?? {};
667
+ const hasArgs = Object.keys(args).length > 0;
668
+
450
669
  // Skip if value already exists
451
670
  const existingValue = result[field];
452
671
  if (existingValue !== undefined) {
453
- result[field] = await this.resolveEntityFields(existingValue);
672
+ result[field] = await this.resolveEntityFields(
673
+ existingValue,
674
+ nestedInputs,
675
+ context,
676
+ currentPath,
677
+ onCleanup,
678
+ createFieldEmit,
679
+ );
454
680
  continue;
455
681
  }
456
682
 
457
683
  // Resolve the field
458
- const loaderKey = `${typeName}.${field}`;
459
- const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
460
- result[field] = await loader.load(obj);
461
- result[field] = await this.resolveEntityFields(result[field]);
684
+ if (hasArgs || context) {
685
+ // Direct resolution when we have args or context (skip DataLoader)
686
+ try {
687
+ // Build extended context with emit and onCleanup
688
+ // Lens is a live query library - these are always available
689
+ const extendedCtx = {
690
+ ...(context ?? {}),
691
+ emit: createFieldEmit!(currentPath),
692
+ onCleanup: onCleanup!,
693
+ };
694
+ result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
695
+ } catch {
696
+ result[field] = null;
697
+ }
698
+ } else {
699
+ // Use DataLoader for batching when no args (default case)
700
+ const loaderKey = `${typeName}.${field}`;
701
+ const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
702
+ result[field] = await loader.load(obj);
703
+ }
704
+
705
+ // Recursively resolve nested fields
706
+ result[field] = await this.resolveEntityFields(
707
+ result[field],
708
+ nestedInputs,
709
+ context,
710
+ currentPath,
711
+ onCleanup,
712
+ createFieldEmit,
713
+ );
462
714
  }
463
715
 
464
716
  return result as T;
465
717
  }
466
718
 
719
+ /**
720
+ * Get the type name for an object by matching against entity definitions.
721
+ *
722
+ * Matching priority:
723
+ * 1. Explicit __typename or _type property
724
+ * 2. Best matching entity (highest field overlap score)
725
+ *
726
+ * Requires at least 50% field match to avoid false positives.
727
+ */
467
728
  private getTypeName(obj: Record<string, unknown>): string | undefined {
729
+ // Priority 1: Explicit type marker
468
730
  if ("__typename" in obj) return obj.__typename as string;
469
731
  if ("_type" in obj) return obj._type as string;
470
732
 
733
+ // Priority 2: Find best matching entity by field overlap
734
+ let bestMatch: { name: string; score: number } | undefined;
735
+
471
736
  for (const [name, def] of Object.entries(this.entities)) {
472
- if (isEntityDef(def) && this.matchesEntity(obj, def)) {
473
- return name;
737
+ if (!isEntityDef(def)) continue;
738
+
739
+ const score = this.getEntityMatchScore(obj, def);
740
+ // Require at least 50% field match to avoid false positives
741
+ if (score >= 0.5 && (!bestMatch || score > bestMatch.score)) {
742
+ bestMatch = { name, score };
474
743
  }
475
744
  }
476
- return undefined;
745
+
746
+ return bestMatch?.name;
477
747
  }
478
748
 
479
- private matchesEntity(obj: Record<string, unknown>, entityDef: EntityDef<string, any>): boolean {
480
- return "id" in obj || entityDef._name! in obj;
749
+ /**
750
+ * Calculate how well an object matches an entity definition.
751
+ *
752
+ * @returns Score between 0 and 1 (1 = perfect match, all entity fields present)
753
+ */
754
+ private getEntityMatchScore(
755
+ obj: Record<string, unknown>,
756
+ entityDef: EntityDef<string, any>,
757
+ ): number {
758
+ const fieldNames = Object.keys(entityDef.fields);
759
+ if (fieldNames.length === 0) return 0;
760
+
761
+ const matchingFields = fieldNames.filter((field) => field in obj);
762
+ return matchingFields.length / fieldNames.length;
481
763
  }
482
764
 
483
765
  private getOrCreateLoaderForField(
@@ -0,0 +1,253 @@
1
+ /**
2
+ * @sylphx/lens-server - Selection Tests
3
+ *
4
+ * Tests for field selection and nested input extraction.
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import { applySelection, extractNestedInputs } from "./selection.js";
9
+
10
+ // =============================================================================
11
+ // applySelection Tests
12
+ // =============================================================================
13
+
14
+ describe("applySelection", () => {
15
+ it("selects simple fields", () => {
16
+ const data = { id: "1", name: "Alice", email: "alice@example.com", age: 30 };
17
+ const select = { name: true, email: true };
18
+
19
+ const result = applySelection(data, select);
20
+
21
+ expect(result).toEqual({ id: "1", name: "Alice", email: "alice@example.com" });
22
+ });
23
+
24
+ it("always includes id field", () => {
25
+ const data = { id: "1", name: "Alice" };
26
+ const select = { name: true };
27
+
28
+ const result = applySelection(data, select);
29
+
30
+ expect(result).toEqual({ id: "1", name: "Alice" });
31
+ });
32
+
33
+ it("handles nested selection with { select: ... }", () => {
34
+ const data = {
35
+ id: "1",
36
+ name: "Alice",
37
+ profile: { avatar: "url", bio: "Hello" },
38
+ };
39
+ const select = {
40
+ name: true,
41
+ profile: { select: { avatar: true } },
42
+ };
43
+
44
+ const result = applySelection(data, select);
45
+
46
+ expect(result).toEqual({
47
+ id: "1",
48
+ name: "Alice",
49
+ profile: { avatar: "url" },
50
+ });
51
+ });
52
+
53
+ it("handles nested selection with { input: ..., select: ... }", () => {
54
+ const data = {
55
+ id: "1",
56
+ name: "Alice",
57
+ posts: [
58
+ { id: "p1", title: "Post 1" },
59
+ { id: "p2", title: "Post 2" },
60
+ ],
61
+ };
62
+ const select = {
63
+ name: true,
64
+ posts: {
65
+ input: { limit: 5 }, // input is ignored for selection, used for resolver args
66
+ select: { title: true },
67
+ },
68
+ };
69
+
70
+ const result = applySelection(data, select);
71
+
72
+ expect(result).toEqual({
73
+ id: "1",
74
+ name: "Alice",
75
+ posts: [
76
+ { id: "p1", title: "Post 1" },
77
+ { id: "p2", title: "Post 2" },
78
+ ],
79
+ });
80
+ });
81
+
82
+ it("handles arrays", () => {
83
+ const data = [
84
+ { id: "1", name: "Alice", email: "alice@example.com" },
85
+ { id: "2", name: "Bob", email: "bob@example.com" },
86
+ ];
87
+ const select = { name: true };
88
+
89
+ const result = applySelection(data, select);
90
+
91
+ expect(result).toEqual([
92
+ { id: "1", name: "Alice" },
93
+ { id: "2", name: "Bob" },
94
+ ]);
95
+ });
96
+
97
+ it("returns null/undefined as-is", () => {
98
+ expect(applySelection(null, { name: true })).toBeNull();
99
+ expect(applySelection(undefined, { name: true })).toBeUndefined();
100
+ });
101
+
102
+ it("includes whole field when no nested select", () => {
103
+ const data = {
104
+ id: "1",
105
+ profile: { avatar: "url", bio: "Hello" },
106
+ };
107
+ const select = {
108
+ profile: { input: { size: "large" } }, // input only, no select
109
+ };
110
+
111
+ const result = applySelection(data, select);
112
+
113
+ expect(result).toEqual({
114
+ id: "1",
115
+ profile: { avatar: "url", bio: "Hello" },
116
+ });
117
+ });
118
+ });
119
+
120
+ // =============================================================================
121
+ // extractNestedInputs Tests
122
+ // =============================================================================
123
+
124
+ describe("extractNestedInputs", () => {
125
+ it("extracts input from nested selection", () => {
126
+ const select = {
127
+ name: true,
128
+ posts: {
129
+ input: { limit: 5, published: true },
130
+ select: { title: true },
131
+ },
132
+ };
133
+
134
+ const inputs = extractNestedInputs(select);
135
+
136
+ expect(inputs.size).toBe(1);
137
+ expect(inputs.get("posts")).toEqual({ limit: 5, published: true });
138
+ });
139
+
140
+ it("extracts inputs at multiple levels", () => {
141
+ const select = {
142
+ name: true,
143
+ posts: {
144
+ input: { limit: 5 },
145
+ select: {
146
+ title: true,
147
+ comments: {
148
+ input: { limit: 3 },
149
+ select: { body: true },
150
+ },
151
+ },
152
+ },
153
+ };
154
+
155
+ const inputs = extractNestedInputs(select);
156
+
157
+ expect(inputs.size).toBe(2);
158
+ expect(inputs.get("posts")).toEqual({ limit: 5 });
159
+ expect(inputs.get("posts.comments")).toEqual({ limit: 3 });
160
+ });
161
+
162
+ it("returns empty map when no nested inputs", () => {
163
+ const select = {
164
+ name: true,
165
+ posts: { select: { title: true } },
166
+ };
167
+
168
+ const inputs = extractNestedInputs(select);
169
+
170
+ expect(inputs.size).toBe(0);
171
+ });
172
+
173
+ it("handles deeply nested inputs", () => {
174
+ const select = {
175
+ author: {
176
+ input: { includeDeleted: false },
177
+ select: {
178
+ posts: {
179
+ input: { status: "published" },
180
+ select: {
181
+ comments: {
182
+ input: { limit: 10 },
183
+ select: {
184
+ replies: {
185
+ input: { depth: 2 },
186
+ select: { body: true },
187
+ },
188
+ },
189
+ },
190
+ },
191
+ },
192
+ },
193
+ },
194
+ };
195
+
196
+ const inputs = extractNestedInputs(select);
197
+
198
+ expect(inputs.size).toBe(4);
199
+ expect(inputs.get("author")).toEqual({ includeDeleted: false });
200
+ expect(inputs.get("author.posts")).toEqual({ status: "published" });
201
+ expect(inputs.get("author.posts.comments")).toEqual({ limit: 10 });
202
+ expect(inputs.get("author.posts.comments.replies")).toEqual({ depth: 2 });
203
+ });
204
+
205
+ it("handles mixed selection (some with input, some without)", () => {
206
+ const select = {
207
+ name: true,
208
+ posts: {
209
+ input: { limit: 5 },
210
+ select: { title: true },
211
+ },
212
+ followers: {
213
+ select: { name: true }, // no input
214
+ },
215
+ settings: true, // simple selection
216
+ };
217
+
218
+ const inputs = extractNestedInputs(select);
219
+
220
+ expect(inputs.size).toBe(1);
221
+ expect(inputs.get("posts")).toEqual({ limit: 5 });
222
+ expect(inputs.has("followers")).toBe(false);
223
+ expect(inputs.has("settings")).toBe(false);
224
+ });
225
+
226
+ it("handles input with empty object", () => {
227
+ const select = {
228
+ posts: {
229
+ input: {},
230
+ select: { title: true },
231
+ },
232
+ };
233
+
234
+ const inputs = extractNestedInputs(select);
235
+
236
+ // Empty input object is still recorded
237
+ expect(inputs.size).toBe(1);
238
+ expect(inputs.get("posts")).toEqual({});
239
+ });
240
+
241
+ it("handles selection with input but no select", () => {
242
+ const select = {
243
+ posts: {
244
+ input: { limit: 5 },
245
+ },
246
+ };
247
+
248
+ const inputs = extractNestedInputs(select);
249
+
250
+ expect(inputs.size).toBe(1);
251
+ expect(inputs.get("posts")).toEqual({ limit: 5 });
252
+ });
253
+ });