@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.
package/dist/index.js CHANGED
@@ -40,7 +40,8 @@ import {
40
40
  isEntityDef,
41
41
  isMutationDef,
42
42
  isQueryDef,
43
- toResolverMap
43
+ toResolverMap,
44
+ valuesEqual
44
45
  } from "@sylphx/lens-core";
45
46
 
46
47
  // src/plugin/types.ts
@@ -215,6 +216,9 @@ class DataLoader {
215
216
  }
216
217
 
217
218
  // src/server/selection.ts
219
+ function isNestedSelection(value) {
220
+ return typeof value === "object" && value !== null && (("input" in value) || ("select" in value)) && !Array.isArray(value);
221
+ }
218
222
  function extractSelect(value) {
219
223
  if (value === true)
220
224
  return null;
@@ -257,6 +261,25 @@ function applySelection(data, select) {
257
261
  }
258
262
  return result;
259
263
  }
264
+ function extractNestedInputs(select, prefix = "") {
265
+ const inputs = new Map;
266
+ for (const [key, value] of Object.entries(select)) {
267
+ const path = prefix ? `${prefix}.${key}` : key;
268
+ if (isNestedSelection(value) && value.input) {
269
+ inputs.set(path, value.input);
270
+ }
271
+ if (typeof value === "object" && value !== null) {
272
+ const nestedSelect = extractSelect(value);
273
+ if (nestedSelect) {
274
+ const nestedInputs = extractNestedInputs(nestedSelect, path);
275
+ for (const [nestedPath, nestedInput] of nestedInputs) {
276
+ inputs.set(nestedPath, nestedInput);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ return inputs;
282
+ }
260
283
 
261
284
  // src/server/create.ts
262
285
  function isAsyncIterable(value) {
@@ -357,7 +380,16 @@ class LensServerImpl {
357
380
  subscribe: (observer) => {
358
381
  let cancelled = false;
359
382
  let currentState;
383
+ let lastEmittedResult;
360
384
  const cleanups = [];
385
+ const emitIfChanged = (data) => {
386
+ if (cancelled)
387
+ return;
388
+ if (valuesEqual(data, lastEmittedResult))
389
+ return;
390
+ lastEmittedResult = data;
391
+ observer.next?.({ data });
392
+ };
361
393
  (async () => {
362
394
  try {
363
395
  const def = isQuery ? this.queries[path] : this.mutations[path];
@@ -391,13 +423,35 @@ class LensServerImpl {
391
423
  observer.complete?.();
392
424
  return;
393
425
  }
426
+ let emitProcessing = false;
427
+ const emitQueue = [];
428
+ const processEmitQueue = async () => {
429
+ if (emitProcessing || cancelled)
430
+ return;
431
+ emitProcessing = true;
432
+ while (emitQueue.length > 0 && !cancelled) {
433
+ const command = emitQueue.shift();
434
+ currentState = this.applyEmitCommand(command, currentState);
435
+ const fieldEmitFactory = isQuery ? this.createFieldEmitFactory(() => currentState, (state) => {
436
+ currentState = state;
437
+ }, emitIfChanged, select, context, onCleanup) : undefined;
438
+ const processed = isQuery ? await this.processQueryResult(path, currentState, select, context, onCleanup, fieldEmitFactory) : currentState;
439
+ emitIfChanged(processed);
440
+ }
441
+ emitProcessing = false;
442
+ };
394
443
  const emitHandler = (command) => {
395
444
  if (cancelled)
396
445
  return;
397
- currentState = this.applyEmitCommand(command, currentState);
398
- observer.next?.({ data: currentState });
446
+ emitQueue.push(command);
447
+ processEmitQueue().catch((err) => {
448
+ if (!cancelled) {
449
+ observer.next?.({ error: err instanceof Error ? err : new Error(String(err)) });
450
+ }
451
+ });
399
452
  };
400
- const emit = createEmit(emitHandler);
453
+ const isArrayOutput = Array.isArray(def._output);
454
+ const emit = createEmit(emitHandler, isArrayOutput);
401
455
  const onCleanup = (fn) => {
402
456
  cleanups.push(fn);
403
457
  return () => {
@@ -406,6 +460,9 @@ class LensServerImpl {
406
460
  cleanups.splice(idx, 1);
407
461
  };
408
462
  };
463
+ const createFieldEmit = isQuery ? this.createFieldEmitFactory(() => currentState, (state) => {
464
+ currentState = state;
465
+ }, emitIfChanged, select, context, onCleanup) : undefined;
409
466
  const lensContext = { ...context, emit, onCleanup };
410
467
  const result = resolver({ input: cleanInput, ctx: lensContext });
411
468
  if (isAsyncIterable(result)) {
@@ -413,8 +470,8 @@ class LensServerImpl {
413
470
  if (cancelled)
414
471
  break;
415
472
  currentState = value;
416
- const processed = await this.processQueryResult(path, value, select);
417
- observer.next?.({ data: processed });
473
+ const processed = await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit);
474
+ emitIfChanged(processed);
418
475
  }
419
476
  if (!cancelled) {
420
477
  observer.complete?.();
@@ -422,10 +479,8 @@ class LensServerImpl {
422
479
  } else {
423
480
  const value = await result;
424
481
  currentState = value;
425
- const processed = isQuery ? await this.processQueryResult(path, value, select) : value;
426
- if (!cancelled) {
427
- observer.next?.({ data: processed });
428
- }
482
+ const processed = isQuery ? await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit) : value;
483
+ emitIfChanged(processed);
429
484
  }
430
485
  });
431
486
  } catch (error) {
@@ -500,20 +555,59 @@ class LensServerImpl {
500
555
  return state;
501
556
  }
502
557
  }
503
- async processQueryResult(_operationName, data, select) {
558
+ createFieldEmitFactory(getCurrentState, setCurrentState, notifyObserver, select, context, onCleanup) {
559
+ return (fieldPath) => {
560
+ if (!fieldPath)
561
+ return;
562
+ return (newValue) => {
563
+ const state = getCurrentState();
564
+ if (!state || typeof state !== "object")
565
+ return;
566
+ const updatedState = this.setFieldByPath(state, fieldPath, newValue);
567
+ setCurrentState(updatedState);
568
+ (async () => {
569
+ try {
570
+ const nestedInputs = select ? extractNestedInputs(select) : undefined;
571
+ const processed = await this.resolveEntityFields(updatedState, nestedInputs, context, "", onCleanup, this.createFieldEmitFactory(getCurrentState, setCurrentState, notifyObserver, select, context, onCleanup));
572
+ const result = select ? applySelection(processed, select) : processed;
573
+ notifyObserver(result);
574
+ } catch (err) {
575
+ console.error(`Field emit error at path "${fieldPath}":`, err);
576
+ }
577
+ })();
578
+ };
579
+ };
580
+ }
581
+ setFieldByPath(obj, path, value) {
582
+ const parts = path.split(".");
583
+ if (parts.length === 1) {
584
+ return { ...obj, [path]: value };
585
+ }
586
+ const [first, ...rest] = parts;
587
+ const nested = obj[first];
588
+ if (nested && typeof nested === "object") {
589
+ return {
590
+ ...obj,
591
+ [first]: this.setFieldByPath(nested, rest.join("."), value)
592
+ };
593
+ }
594
+ return obj;
595
+ }
596
+ async processQueryResult(_operationName, data, select, context, onCleanup, createFieldEmit) {
504
597
  if (!data)
505
598
  return data;
506
- const processed = await this.resolveEntityFields(data);
599
+ const nestedInputs = select ? extractNestedInputs(select) : undefined;
600
+ const processed = await this.resolveEntityFields(data, nestedInputs, context, "", onCleanup, createFieldEmit);
507
601
  if (select) {
508
602
  return applySelection(processed, select);
509
603
  }
510
604
  return processed;
511
605
  }
512
- async resolveEntityFields(data) {
606
+ async resolveEntityFields(data, nestedInputs, context, fieldPath = "", onCleanup, createFieldEmit) {
513
607
  if (!data || !this.resolverMap)
514
608
  return data;
515
609
  if (Array.isArray(data)) {
516
- return Promise.all(data.map((item) => this.resolveEntityFields(item)));
610
+ return Promise.all(data.map((item) => this.resolveEntityFields(item, nestedInputs, context, fieldPath, onCleanup, createFieldEmit)));
517
611
  }
518
612
  if (typeof data !== "object")
519
613
  return data;
@@ -529,15 +623,31 @@ class LensServerImpl {
529
623
  const field = String(fieldName);
530
624
  if (resolverDef.isExposed(field))
531
625
  continue;
626
+ const currentPath = fieldPath ? `${fieldPath}.${field}` : field;
627
+ const args = nestedInputs?.get(currentPath) ?? {};
628
+ const hasArgs = Object.keys(args).length > 0;
532
629
  const existingValue = result[field];
533
630
  if (existingValue !== undefined) {
534
- result[field] = await this.resolveEntityFields(existingValue);
631
+ result[field] = await this.resolveEntityFields(existingValue, nestedInputs, context, currentPath, onCleanup, createFieldEmit);
535
632
  continue;
536
633
  }
537
- const loaderKey = `${typeName}.${field}`;
538
- const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
539
- result[field] = await loader.load(obj);
540
- result[field] = await this.resolveEntityFields(result[field]);
634
+ if (hasArgs || context) {
635
+ try {
636
+ const extendedCtx = {
637
+ ...context ?? {},
638
+ emit: createFieldEmit(currentPath),
639
+ onCleanup
640
+ };
641
+ result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
642
+ } catch {
643
+ result[field] = null;
644
+ }
645
+ } else {
646
+ const loaderKey = `${typeName}.${field}`;
647
+ const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
648
+ result[field] = await loader.load(obj);
649
+ }
650
+ result[field] = await this.resolveEntityFields(result[field], nestedInputs, context, currentPath, onCleanup, createFieldEmit);
541
651
  }
542
652
  return result;
543
653
  }
@@ -546,15 +656,23 @@ class LensServerImpl {
546
656
  return obj.__typename;
547
657
  if ("_type" in obj)
548
658
  return obj._type;
659
+ let bestMatch;
549
660
  for (const [name, def] of Object.entries(this.entities)) {
550
- if (isEntityDef(def) && this.matchesEntity(obj, def)) {
551
- return name;
661
+ if (!isEntityDef(def))
662
+ continue;
663
+ const score = this.getEntityMatchScore(obj, def);
664
+ if (score >= 0.5 && (!bestMatch || score > bestMatch.score)) {
665
+ bestMatch = { name, score };
552
666
  }
553
667
  }
554
- return;
668
+ return bestMatch?.name;
555
669
  }
556
- matchesEntity(obj, entityDef) {
557
- return "id" in obj || entityDef._name in obj;
670
+ getEntityMatchScore(obj, entityDef) {
671
+ const fieldNames = Object.keys(entityDef.fields);
672
+ if (fieldNames.length === 0)
673
+ return 0;
674
+ const matchingFields = fieldNames.filter((field) => (field in obj));
675
+ return matchingFields.length / fieldNames.length;
558
676
  }
559
677
  getOrCreateLoaderForField(loaderKey, resolverDef, fieldName) {
560
678
  let loader = this.loaders.get(loaderKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Server runtime for Lens API framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,7 +30,7 @@
30
30
  "author": "SylphxAI",
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
- "@sylphx/lens-core": "^2.1.0"
33
+ "@sylphx/lens-core": "^2.2.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.9.3",