@sylphx/lens-server 2.2.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.
@@ -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,11 +287,61 @@ 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
347
  const emit = createEmit(emitHandler);
@@ -293,6 +353,20 @@ class LensServerImpl<
293
353
  };
294
354
  };
295
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
+
296
370
  const lensContext = { ...context, emit, onCleanup };
297
371
  const result = resolver({ input: cleanInput, ctx: lensContext });
298
372
 
@@ -301,8 +375,15 @@ class LensServerImpl<
301
375
  for await (const value of result) {
302
376
  if (cancelled) break;
303
377
  currentState = value;
304
- const processed = await this.processQueryResult(path, value, select);
305
- observer.next?.({ data: processed });
378
+ const processed = await this.processQueryResult(
379
+ path,
380
+ value,
381
+ select,
382
+ context,
383
+ onCleanup,
384
+ createFieldEmit,
385
+ );
386
+ emitIfChanged(processed);
306
387
  }
307
388
  if (!cancelled) {
308
389
  observer.complete?.();
@@ -312,13 +393,18 @@ class LensServerImpl<
312
393
  const value = await result;
313
394
  currentState = value;
314
395
  const processed = isQuery
315
- ? await this.processQueryResult(path, value, select)
396
+ ? await this.processQueryResult(
397
+ path,
398
+ value,
399
+ select,
400
+ context,
401
+ onCleanup,
402
+ createFieldEmit,
403
+ )
316
404
  : 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
- }
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
322
408
  }
323
409
  });
324
410
  } catch (error) {
@@ -409,25 +495,149 @@ class LensServerImpl<
409
495
  // Result Processing
410
496
  // =========================================================================
411
497
 
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
+ }
554
+
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
+ }
568
+
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
+ };
576
+ }
577
+ return obj;
578
+ }
579
+
412
580
  private async processQueryResult<T>(
413
581
  _operationName: string,
414
582
  data: T,
415
583
  select?: SelectionObject,
584
+ context?: TContext,
585
+ onCleanup?: (fn: () => void) => void,
586
+ createFieldEmit?: (fieldPath: string) => ((value: unknown) => void) | undefined,
416
587
  ): Promise<T> {
417
588
  if (!data) return data;
418
589
 
419
- 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
+ );
420
601
  if (select) {
421
602
  return applySelection(processed, select) as T;
422
603
  }
423
604
  return processed as T;
424
605
  }
425
606
 
426
- 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> {
427
626
  if (!data || !this.resolverMap) return data;
428
627
 
429
628
  if (Array.isArray(data)) {
430
- 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>;
431
641
  }
432
642
 
433
643
  if (typeof data !== "object") return data;
@@ -447,37 +657,107 @@ class LensServerImpl<
447
657
  // Skip exposed fields
448
658
  if (resolverDef.isExposed(field)) continue;
449
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
+
450
667
  // Skip if value already exists
451
668
  const existingValue = result[field];
452
669
  if (existingValue !== undefined) {
453
- 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
+ );
454
678
  continue;
455
679
  }
456
680
 
457
681
  // 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]);
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
+ );
462
712
  }
463
713
 
464
714
  return result as T;
465
715
  }
466
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
+ */
467
726
  private getTypeName(obj: Record<string, unknown>): string | undefined {
727
+ // Priority 1: Explicit type marker
468
728
  if ("__typename" in obj) return obj.__typename as string;
469
729
  if ("_type" in obj) return obj._type as string;
470
730
 
731
+ // Priority 2: Find best matching entity by field overlap
732
+ let bestMatch: { name: string; score: number } | undefined;
733
+
471
734
  for (const [name, def] of Object.entries(this.entities)) {
472
- if (isEntityDef(def) && this.matchesEntity(obj, def)) {
473
- 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 };
474
741
  }
475
742
  }
476
- return undefined;
743
+
744
+ return bestMatch?.name;
477
745
  }
478
746
 
479
- private matchesEntity(obj: Record<string, unknown>, entityDef: EntityDef<string, any>): boolean {
480
- 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;
481
761
  }
482
762
 
483
763
  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
+ });