@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 +142 -24
- package/package.json +2 -2
- package/src/server/create.test.ts +633 -1
- package/src/server/create.ts +308 -26
- 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,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
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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)
|
|
551
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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.
|
|
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.
|
|
33
|
+
"@sylphx/lens-core": "^2.2.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.9.3",
|