@sylphx/lens-server 2.1.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.d.ts CHANGED
@@ -41,7 +41,7 @@ declare function extendContext<
41
41
  E extends ContextValue
42
42
  >(current: T, extension: E): T & E;
43
43
  import { ContextValue as ContextValue3, InferRouterContext as InferRouterContext2, RouterDef as RouterDef2 } from "@sylphx/lens-core";
44
- import { ContextValue as ContextValue2, EntityDef, InferRouterContext, MutationDef, OptimisticDSL, QueryDef, Resolvers, RouterDef } from "@sylphx/lens-core";
44
+ import { ContextValue as ContextValue2, EntityDef, InferRouterContext, MutationDef, Observable, OptimisticDSL, QueryDef, Resolvers, RouterDef } from "@sylphx/lens-core";
45
45
  /**
46
46
  * @sylphx/lens-server - Plugin System Types
47
47
  *
@@ -555,8 +555,19 @@ interface WebSocketLike {
555
555
  interface LensServer {
556
556
  /** Get server metadata for transport handshake */
557
557
  getMetadata(): ServerMetadata;
558
- /** Execute operation - auto-detects query vs mutation */
559
- execute(op: LensOperation): Promise<LensResult>;
558
+ /**
559
+ * Execute operation - auto-detects query vs mutation.
560
+ *
561
+ * Always returns Observable<LensResult>:
562
+ * - One-shot: emits once, then completes
563
+ * - Streaming: emits multiple times (AsyncIterable or emit-based)
564
+ *
565
+ * Usage:
566
+ * - HTTP: `await firstValueFrom(server.execute(op))`
567
+ * - WS/SSE: `server.execute(op).subscribe(...)`
568
+ * - direct: pass through Observable directly
569
+ */
570
+ execute(op: LensOperation): Observable<LensResult>;
560
571
  /**
561
572
  * Register a client connection.
562
573
  * Call when a client connects via WebSocket/SSE.
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) {
@@ -337,106 +360,253 @@ class LensServerImpl {
337
360
  operations: this.buildOperationsMap()
338
361
  };
339
362
  }
340
- async execute(op) {
363
+ execute(op) {
341
364
  const { path, input } = op;
342
- try {
343
- if (this.queries[path]) {
344
- const data = await this.executeQuery(path, input);
345
- return { data };
346
- }
347
- if (this.mutations[path]) {
348
- const data = await this.executeMutation(path, input);
349
- return { data };
350
- }
351
- return { error: new Error(`Operation not found: ${path}`) };
352
- } catch (error) {
353
- return { error: error instanceof Error ? error : new Error(String(error)) };
365
+ const isQuery = !!this.queries[path];
366
+ const isMutation = !!this.mutations[path];
367
+ if (!isQuery && !isMutation) {
368
+ return {
369
+ subscribe: (observer) => {
370
+ observer.next?.({ error: new Error(`Operation not found: ${path}`) });
371
+ observer.complete?.();
372
+ return { unsubscribe: () => {} };
373
+ }
374
+ };
354
375
  }
376
+ return this.executeAsObservable(path, input, isQuery);
355
377
  }
356
- async executeQuery(name, input) {
357
- const queryDef = this.queries[name];
358
- if (!queryDef)
359
- throw new Error(`Query not found: ${name}`);
360
- let select;
361
- let cleanInput = input;
362
- if (input && typeof input === "object" && "$select" in input) {
363
- const { $select, ...rest } = input;
364
- select = $select;
365
- cleanInput = Object.keys(rest).length > 0 ? rest : undefined;
366
- }
367
- if (queryDef._input && cleanInput !== undefined) {
368
- const result = queryDef._input.safeParse(cleanInput);
369
- if (!result.success) {
370
- throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
371
- }
372
- }
373
- const context = await this.contextFactory();
374
- try {
375
- return await runWithContext(this.ctx, context, async () => {
376
- const resolver = queryDef._resolve;
377
- if (!resolver)
378
- throw new Error(`Query ${name} has no resolver`);
379
- const emit = createEmit(() => {});
380
- const onCleanup = () => () => {};
381
- const lensContext = { ...context, emit, onCleanup };
382
- const result = resolver({ input: cleanInput, ctx: lensContext });
383
- let data;
384
- if (isAsyncIterable(result)) {
385
- for await (const value of result) {
386
- data = value;
387
- break;
378
+ executeAsObservable(path, input, isQuery) {
379
+ return {
380
+ subscribe: (observer) => {
381
+ let cancelled = false;
382
+ let currentState;
383
+ let lastEmittedResult;
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
+ };
393
+ (async () => {
394
+ try {
395
+ const def = isQuery ? this.queries[path] : this.mutations[path];
396
+ if (!def) {
397
+ observer.next?.({ error: new Error(`Operation not found: ${path}`) });
398
+ observer.complete?.();
399
+ return;
400
+ }
401
+ let select;
402
+ let cleanInput = input;
403
+ if (isQuery && input && typeof input === "object" && "$select" in input) {
404
+ const { $select, ...rest } = input;
405
+ select = $select;
406
+ cleanInput = Object.keys(rest).length > 0 ? rest : undefined;
407
+ }
408
+ if (def._input && cleanInput !== undefined) {
409
+ const result = def._input.safeParse(cleanInput);
410
+ if (!result.success) {
411
+ observer.next?.({
412
+ error: new Error(`Invalid input: ${JSON.stringify(result.error)}`)
413
+ });
414
+ observer.complete?.();
415
+ return;
416
+ }
417
+ }
418
+ const context = await this.contextFactory();
419
+ await runWithContext(this.ctx, context, async () => {
420
+ const resolver = def._resolve;
421
+ if (!resolver) {
422
+ observer.next?.({ error: new Error(`Operation ${path} has no resolver`) });
423
+ observer.complete?.();
424
+ return;
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
+ };
443
+ const emitHandler = (command) => {
444
+ if (cancelled)
445
+ return;
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
+ });
452
+ };
453
+ const emit = createEmit(emitHandler);
454
+ const onCleanup = (fn) => {
455
+ cleanups.push(fn);
456
+ return () => {
457
+ const idx = cleanups.indexOf(fn);
458
+ if (idx >= 0)
459
+ cleanups.splice(idx, 1);
460
+ };
461
+ };
462
+ const createFieldEmit = isQuery ? this.createFieldEmitFactory(() => currentState, (state) => {
463
+ currentState = state;
464
+ }, emitIfChanged, select, context, onCleanup) : undefined;
465
+ const lensContext = { ...context, emit, onCleanup };
466
+ const result = resolver({ input: cleanInput, ctx: lensContext });
467
+ if (isAsyncIterable(result)) {
468
+ for await (const value of result) {
469
+ if (cancelled)
470
+ break;
471
+ currentState = value;
472
+ const processed = await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit);
473
+ emitIfChanged(processed);
474
+ }
475
+ if (!cancelled) {
476
+ observer.complete?.();
477
+ }
478
+ } else {
479
+ const value = await result;
480
+ currentState = value;
481
+ const processed = isQuery ? await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit) : value;
482
+ emitIfChanged(processed);
483
+ }
484
+ });
485
+ } catch (error) {
486
+ if (!cancelled) {
487
+ observer.next?.({ error: error instanceof Error ? error : new Error(String(error)) });
488
+ observer.complete?.();
489
+ }
490
+ } finally {
491
+ this.clearLoaders();
388
492
  }
389
- if (data === undefined) {
390
- throw new Error(`Query ${name} returned empty stream`);
493
+ })();
494
+ return {
495
+ unsubscribe: () => {
496
+ cancelled = true;
497
+ for (const fn of cleanups) {
498
+ fn();
499
+ }
391
500
  }
392
- } else {
393
- data = await result;
501
+ };
502
+ }
503
+ };
504
+ }
505
+ applyEmitCommand(command, state) {
506
+ switch (command.type) {
507
+ case "full":
508
+ if (command.replace) {
509
+ return command.data;
394
510
  }
395
- return this.processQueryResult(name, data, select);
396
- });
397
- } finally {
398
- this.clearLoaders();
511
+ if (state && typeof state === "object" && typeof command.data === "object") {
512
+ return { ...state, ...command.data };
513
+ }
514
+ return command.data;
515
+ case "field":
516
+ if (state && typeof state === "object") {
517
+ return {
518
+ ...state,
519
+ [command.field]: command.update.data
520
+ };
521
+ }
522
+ return { [command.field]: command.update.data };
523
+ case "batch":
524
+ if (state && typeof state === "object") {
525
+ const result = { ...state };
526
+ for (const update of command.updates) {
527
+ result[update.field] = update.update.data;
528
+ }
529
+ return result;
530
+ }
531
+ return state;
532
+ case "array": {
533
+ const arr = Array.isArray(state) ? [...state] : [];
534
+ const op = command.operation;
535
+ switch (op.op) {
536
+ case "push":
537
+ return [...arr, op.item];
538
+ case "unshift":
539
+ return [op.item, ...arr];
540
+ case "insert":
541
+ arr.splice(op.index, 0, op.item);
542
+ return arr;
543
+ case "remove":
544
+ arr.splice(op.index, 1);
545
+ return arr;
546
+ case "update":
547
+ arr[op.index] = op.item;
548
+ return arr;
549
+ default:
550
+ return arr;
551
+ }
552
+ }
553
+ default:
554
+ return state;
399
555
  }
400
556
  }
401
- async executeMutation(name, input) {
402
- const mutationDef = this.mutations[name];
403
- if (!mutationDef)
404
- throw new Error(`Mutation not found: ${name}`);
405
- if (mutationDef._input) {
406
- const result = mutationDef._input.safeParse(input);
407
- if (!result.success) {
408
- throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
409
- }
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 };
410
584
  }
411
- const context = await this.contextFactory();
412
- try {
413
- return await runWithContext(this.ctx, context, async () => {
414
- const resolver = mutationDef._resolve;
415
- if (!resolver)
416
- throw new Error(`Mutation ${name} has no resolver`);
417
- const emit = createEmit(() => {});
418
- const onCleanup = () => () => {};
419
- const lensContext = { ...context, emit, onCleanup };
420
- return await resolver({ input, ctx: lensContext });
421
- });
422
- } finally {
423
- this.clearLoaders();
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
+ };
424
592
  }
593
+ return obj;
425
594
  }
426
- async processQueryResult(_operationName, data, select) {
595
+ async processQueryResult(_operationName, data, select, context, onCleanup, createFieldEmit) {
427
596
  if (!data)
428
597
  return data;
429
- const processed = await this.resolveEntityFields(data);
598
+ const nestedInputs = select ? extractNestedInputs(select) : undefined;
599
+ const processed = await this.resolveEntityFields(data, nestedInputs, context, "", onCleanup, createFieldEmit);
430
600
  if (select) {
431
601
  return applySelection(processed, select);
432
602
  }
433
603
  return processed;
434
604
  }
435
- async resolveEntityFields(data) {
605
+ async resolveEntityFields(data, nestedInputs, context, fieldPath = "", onCleanup, createFieldEmit) {
436
606
  if (!data || !this.resolverMap)
437
607
  return data;
438
608
  if (Array.isArray(data)) {
439
- return Promise.all(data.map((item) => this.resolveEntityFields(item)));
609
+ return Promise.all(data.map((item) => this.resolveEntityFields(item, nestedInputs, context, fieldPath, onCleanup, createFieldEmit)));
440
610
  }
441
611
  if (typeof data !== "object")
442
612
  return data;
@@ -452,15 +622,31 @@ class LensServerImpl {
452
622
  const field = String(fieldName);
453
623
  if (resolverDef.isExposed(field))
454
624
  continue;
625
+ const currentPath = fieldPath ? `${fieldPath}.${field}` : field;
626
+ const args = nestedInputs?.get(currentPath) ?? {};
627
+ const hasArgs = Object.keys(args).length > 0;
455
628
  const existingValue = result[field];
456
629
  if (existingValue !== undefined) {
457
- result[field] = await this.resolveEntityFields(existingValue);
630
+ result[field] = await this.resolveEntityFields(existingValue, nestedInputs, context, currentPath, onCleanup, createFieldEmit);
458
631
  continue;
459
632
  }
460
- const loaderKey = `${typeName}.${field}`;
461
- const loader = this.getOrCreateLoaderForField(loaderKey, resolverDef, field);
462
- result[field] = await loader.load(obj);
463
- result[field] = await this.resolveEntityFields(result[field]);
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);
464
650
  }
465
651
  return result;
466
652
  }
@@ -469,15 +655,23 @@ class LensServerImpl {
469
655
  return obj.__typename;
470
656
  if ("_type" in obj)
471
657
  return obj._type;
658
+ let bestMatch;
472
659
  for (const [name, def] of Object.entries(this.entities)) {
473
- if (isEntityDef(def) && this.matchesEntity(obj, def)) {
474
- return name;
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 };
475
665
  }
476
666
  }
477
- return;
667
+ return bestMatch?.name;
478
668
  }
479
- matchesEntity(obj, entityDef) {
480
- return "id" in obj || entityDef._name in obj;
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;
481
675
  }
482
676
  getOrCreateLoaderForField(loaderKey, resolverDef, fieldName) {
483
677
  let loader = this.loaders.get(loaderKey);
@@ -716,6 +910,7 @@ function createSSEHandler(config = {}) {
716
910
  }
717
911
 
718
912
  // src/handlers/http.ts
913
+ import { firstValueFrom } from "@sylphx/lens-core";
719
914
  function createHTTPHandler(server, options = {}) {
720
915
  const { pathPrefix = "", cors } = options;
721
916
  const corsHeaders = {
@@ -755,10 +950,10 @@ function createHTTPHandler(server, options = {}) {
755
950
  }
756
951
  });
757
952
  }
758
- const result2 = await server.execute({
953
+ const result2 = await firstValueFrom(server.execute({
759
954
  path: operationPath2,
760
955
  input: body.input
761
- });
956
+ }));
762
957
  if (result2.error) {
763
958
  return new Response(JSON.stringify({ error: result2.error.message }), {
764
959
  status: 500,
@@ -826,6 +1021,7 @@ function createHandler(server, options = {}) {
826
1021
  return result;
827
1022
  }
828
1023
  // src/handlers/framework.ts
1024
+ import { firstValueFrom as firstValueFrom2 } from "@sylphx/lens-core";
829
1025
  function createServerClientProxy(server) {
830
1026
  function createProxy(path) {
831
1027
  return new Proxy(() => {}, {
@@ -839,7 +1035,7 @@ function createServerClientProxy(server) {
839
1035
  },
840
1036
  async apply(_, __, args) {
841
1037
  const input = args[0];
842
- const result = await server.execute({ path, input });
1038
+ const result = await firstValueFrom2(server.execute({ path, input }));
843
1039
  if (result.error) {
844
1040
  throw result.error;
845
1041
  }
@@ -853,7 +1049,7 @@ async function handleWebQuery(server, path, url) {
853
1049
  try {
854
1050
  const inputParam = url.searchParams.get("input");
855
1051
  const input = inputParam ? JSON.parse(inputParam) : undefined;
856
- const result = await server.execute({ path, input });
1052
+ const result = await firstValueFrom2(server.execute({ path, input }));
857
1053
  if (result.error) {
858
1054
  return Response.json({ error: result.error.message }, { status: 400 });
859
1055
  }
@@ -866,7 +1062,7 @@ async function handleWebMutation(server, path, request) {
866
1062
  try {
867
1063
  const body = await request.json();
868
1064
  const input = body.input;
869
- const result = await server.execute({ path, input });
1065
+ const result = await firstValueFrom2(server.execute({ path, input }));
870
1066
  if (result.error) {
871
1067
  return Response.json({ error: result.error.message }, { status: 400 });
872
1068
  }
@@ -938,6 +1134,9 @@ function createFrameworkHandler(server, options = {}) {
938
1134
  };
939
1135
  }
940
1136
  // src/handlers/ws.ts
1137
+ import {
1138
+ firstValueFrom as firstValueFrom3
1139
+ } from "@sylphx/lens-core";
941
1140
  function createWSHandler(server, options = {}) {
942
1141
  const { logger = {} } = options;
943
1142
  const connections = new Map;
@@ -1014,7 +1213,7 @@ function createWSHandler(server, options = {}) {
1014
1213
  const { id, operation, input, fields } = message;
1015
1214
  let result;
1016
1215
  try {
1017
- result = await server.execute({ path: operation, input });
1216
+ result = await firstValueFrom3(server.execute({ path: operation, input }));
1018
1217
  if (result.error) {
1019
1218
  conn.ws.send(JSON.stringify({
1020
1219
  type: "error",
@@ -1140,10 +1339,10 @@ function createWSHandler(server, options = {}) {
1140
1339
  }
1141
1340
  async function handleQuery(conn, message) {
1142
1341
  try {
1143
- const result = await server.execute({
1342
+ const result = await firstValueFrom3(server.execute({
1144
1343
  path: message.operation,
1145
1344
  input: message.input
1146
- });
1345
+ }));
1147
1346
  if (result.error) {
1148
1347
  conn.ws.send(JSON.stringify({
1149
1348
  type: "error",
@@ -1168,10 +1367,10 @@ function createWSHandler(server, options = {}) {
1168
1367
  }
1169
1368
  async function handleMutation(conn, message) {
1170
1369
  try {
1171
- const result = await server.execute({
1370
+ const result = await firstValueFrom3(server.execute({
1172
1371
  path: message.operation,
1173
1372
  input: message.input
1174
- });
1373
+ }));
1175
1374
  if (result.error) {
1176
1375
  conn.ws.send(JSON.stringify({
1177
1376
  type: "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "2.1.0",
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.0.1"
33
+ "@sylphx/lens-core": "^2.2.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.9.3",