@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.
- package/dist/index.js +140 -23
- package/package.json +2 -2
- package/src/server/create.test.ts +633 -1
- package/src/server/create.ts +305 -25
- package/src/server/selection.test.ts +253 -0
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,11 +423,32 @@ 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
|
-
|
|
398
|
-
|
|
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
453
|
const emit = createEmit(emitHandler);
|
|
401
454
|
const onCleanup = (fn) => {
|
|
@@ -406,6 +459,9 @@ class LensServerImpl {
|
|
|
406
459
|
cleanups.splice(idx, 1);
|
|
407
460
|
};
|
|
408
461
|
};
|
|
462
|
+
const createFieldEmit = isQuery ? this.createFieldEmitFactory(() => currentState, (state) => {
|
|
463
|
+
currentState = state;
|
|
464
|
+
}, emitIfChanged, select, context, onCleanup) : undefined;
|
|
409
465
|
const lensContext = { ...context, emit, onCleanup };
|
|
410
466
|
const result = resolver({ input: cleanInput, ctx: lensContext });
|
|
411
467
|
if (isAsyncIterable(result)) {
|
|
@@ -413,8 +469,8 @@ class LensServerImpl {
|
|
|
413
469
|
if (cancelled)
|
|
414
470
|
break;
|
|
415
471
|
currentState = value;
|
|
416
|
-
const processed = await this.processQueryResult(path, value, select);
|
|
417
|
-
|
|
472
|
+
const processed = await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit);
|
|
473
|
+
emitIfChanged(processed);
|
|
418
474
|
}
|
|
419
475
|
if (!cancelled) {
|
|
420
476
|
observer.complete?.();
|
|
@@ -422,10 +478,8 @@ class LensServerImpl {
|
|
|
422
478
|
} else {
|
|
423
479
|
const value = await result;
|
|
424
480
|
currentState = value;
|
|
425
|
-
const processed = isQuery ? await this.processQueryResult(path, value, select) : value;
|
|
426
|
-
|
|
427
|
-
observer.next?.({ data: processed });
|
|
428
|
-
}
|
|
481
|
+
const processed = isQuery ? await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit) : value;
|
|
482
|
+
emitIfChanged(processed);
|
|
429
483
|
}
|
|
430
484
|
});
|
|
431
485
|
} catch (error) {
|
|
@@ -500,20 +554,59 @@ class LensServerImpl {
|
|
|
500
554
|
return state;
|
|
501
555
|
}
|
|
502
556
|
}
|
|
503
|
-
|
|
557
|
+
createFieldEmitFactory(getCurrentState, setCurrentState, notifyObserver, select, context, onCleanup) {
|
|
558
|
+
return (fieldPath) => {
|
|
559
|
+
if (!fieldPath)
|
|
560
|
+
return;
|
|
561
|
+
return (newValue) => {
|
|
562
|
+
const state = getCurrentState();
|
|
563
|
+
if (!state || typeof state !== "object")
|
|
564
|
+
return;
|
|
565
|
+
const updatedState = this.setFieldByPath(state, fieldPath, newValue);
|
|
566
|
+
setCurrentState(updatedState);
|
|
567
|
+
(async () => {
|
|
568
|
+
try {
|
|
569
|
+
const nestedInputs = select ? extractNestedInputs(select) : undefined;
|
|
570
|
+
const processed = await this.resolveEntityFields(updatedState, nestedInputs, context, "", onCleanup, this.createFieldEmitFactory(getCurrentState, setCurrentState, notifyObserver, select, context, onCleanup));
|
|
571
|
+
const result = select ? applySelection(processed, select) : processed;
|
|
572
|
+
notifyObserver(result);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
console.error(`Field emit error at path "${fieldPath}":`, err);
|
|
575
|
+
}
|
|
576
|
+
})();
|
|
577
|
+
};
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
setFieldByPath(obj, path, value) {
|
|
581
|
+
const parts = path.split(".");
|
|
582
|
+
if (parts.length === 1) {
|
|
583
|
+
return { ...obj, [path]: value };
|
|
584
|
+
}
|
|
585
|
+
const [first, ...rest] = parts;
|
|
586
|
+
const nested = obj[first];
|
|
587
|
+
if (nested && typeof nested === "object") {
|
|
588
|
+
return {
|
|
589
|
+
...obj,
|
|
590
|
+
[first]: this.setFieldByPath(nested, rest.join("."), value)
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
return obj;
|
|
594
|
+
}
|
|
595
|
+
async processQueryResult(_operationName, data, select, context, onCleanup, createFieldEmit) {
|
|
504
596
|
if (!data)
|
|
505
597
|
return data;
|
|
506
|
-
const
|
|
598
|
+
const nestedInputs = select ? extractNestedInputs(select) : undefined;
|
|
599
|
+
const processed = await this.resolveEntityFields(data, nestedInputs, context, "", onCleanup, createFieldEmit);
|
|
507
600
|
if (select) {
|
|
508
601
|
return applySelection(processed, select);
|
|
509
602
|
}
|
|
510
603
|
return processed;
|
|
511
604
|
}
|
|
512
|
-
async resolveEntityFields(data) {
|
|
605
|
+
async resolveEntityFields(data, nestedInputs, context, fieldPath = "", onCleanup, createFieldEmit) {
|
|
513
606
|
if (!data || !this.resolverMap)
|
|
514
607
|
return data;
|
|
515
608
|
if (Array.isArray(data)) {
|
|
516
|
-
return Promise.all(data.map((item) => this.resolveEntityFields(item)));
|
|
609
|
+
return Promise.all(data.map((item) => this.resolveEntityFields(item, nestedInputs, context, fieldPath, onCleanup, createFieldEmit)));
|
|
517
610
|
}
|
|
518
611
|
if (typeof data !== "object")
|
|
519
612
|
return data;
|
|
@@ -529,15 +622,31 @@ class LensServerImpl {
|
|
|
529
622
|
const field = String(fieldName);
|
|
530
623
|
if (resolverDef.isExposed(field))
|
|
531
624
|
continue;
|
|
625
|
+
const currentPath = fieldPath ? `${fieldPath}.${field}` : field;
|
|
626
|
+
const args = nestedInputs?.get(currentPath) ?? {};
|
|
627
|
+
const hasArgs = Object.keys(args).length > 0;
|
|
532
628
|
const existingValue = result[field];
|
|
533
629
|
if (existingValue !== undefined) {
|
|
534
|
-
result[field] = await this.resolveEntityFields(existingValue);
|
|
630
|
+
result[field] = await this.resolveEntityFields(existingValue, nestedInputs, context, currentPath, onCleanup, createFieldEmit);
|
|
535
631
|
continue;
|
|
536
632
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
633
|
+
if (hasArgs || context) {
|
|
634
|
+
try {
|
|
635
|
+
const extendedCtx = {
|
|
636
|
+
...context ?? {},
|
|
637
|
+
emit: createFieldEmit(currentPath),
|
|
638
|
+
onCleanup
|
|
639
|
+
};
|
|
640
|
+
result[field] = await resolverDef.resolveField(field, obj, args, extendedCtx);
|
|
641
|
+
} catch {
|
|
642
|
+
result[field] = null;
|
|
643
|
+
}
|
|
644
|
+
} else {
|
|
645
|
+
const loaderKey = `${typeName}.${field}`;
|
|
646
|
+
const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
|
|
647
|
+
result[field] = await loader.load(obj);
|
|
648
|
+
}
|
|
649
|
+
result[field] = await this.resolveEntityFields(result[field], nestedInputs, context, currentPath, onCleanup, createFieldEmit);
|
|
541
650
|
}
|
|
542
651
|
return result;
|
|
543
652
|
}
|
|
@@ -546,15 +655,23 @@ class LensServerImpl {
|
|
|
546
655
|
return obj.__typename;
|
|
547
656
|
if ("_type" in obj)
|
|
548
657
|
return obj._type;
|
|
658
|
+
let bestMatch;
|
|
549
659
|
for (const [name, def] of Object.entries(this.entities)) {
|
|
550
|
-
if (isEntityDef(def)
|
|
551
|
-
|
|
660
|
+
if (!isEntityDef(def))
|
|
661
|
+
continue;
|
|
662
|
+
const score = this.getEntityMatchScore(obj, def);
|
|
663
|
+
if (score >= 0.5 && (!bestMatch || score > bestMatch.score)) {
|
|
664
|
+
bestMatch = { name, score };
|
|
552
665
|
}
|
|
553
666
|
}
|
|
554
|
-
return;
|
|
667
|
+
return bestMatch?.name;
|
|
555
668
|
}
|
|
556
|
-
|
|
557
|
-
|
|
669
|
+
getEntityMatchScore(obj, entityDef) {
|
|
670
|
+
const fieldNames = Object.keys(entityDef.fields);
|
|
671
|
+
if (fieldNames.length === 0)
|
|
672
|
+
return 0;
|
|
673
|
+
const matchingFields = fieldNames.filter((field) => (field in obj));
|
|
674
|
+
return matchingFields.length / fieldNames.length;
|
|
558
675
|
}
|
|
559
676
|
getOrCreateLoaderForField(loaderKey, resolverDef, fieldName) {
|
|
560
677
|
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.
|
|
3
|
+
"version": "2.3.0",
|
|
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.
|
|
33
|
+
"@sylphx/lens-core": "^2.2.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.9.3",
|