@sylphx/lens-server 2.1.0 → 2.2.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 +14 -3
- package/dist/index.js +169 -87
- package/package.json +2 -2
- package/src/e2e/server.test.ts +81 -61
- package/src/handlers/framework.ts +4 -3
- package/src/handlers/http.ts +7 -4
- package/src/handlers/ws.ts +18 -10
- package/src/server/create.test.ts +69 -47
- package/src/server/create.ts +198 -91
- package/src/server/types.ts +15 -2
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
|
-
/**
|
|
559
|
-
|
|
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
|
@@ -337,90 +337,167 @@ class LensServerImpl {
|
|
|
337
337
|
operations: this.buildOperationsMap()
|
|
338
338
|
};
|
|
339
339
|
}
|
|
340
|
-
|
|
340
|
+
execute(op) {
|
|
341
341
|
const { path, input } = op;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
} catch (error) {
|
|
353
|
-
return { error: error instanceof Error ? error : new Error(String(error)) };
|
|
342
|
+
const isQuery = !!this.queries[path];
|
|
343
|
+
const isMutation = !!this.mutations[path];
|
|
344
|
+
if (!isQuery && !isMutation) {
|
|
345
|
+
return {
|
|
346
|
+
subscribe: (observer) => {
|
|
347
|
+
observer.next?.({ error: new Error(`Operation not found: ${path}`) });
|
|
348
|
+
observer.complete?.();
|
|
349
|
+
return { unsubscribe: () => {} };
|
|
350
|
+
}
|
|
351
|
+
};
|
|
354
352
|
}
|
|
353
|
+
return this.executeAsObservable(path, input, isQuery);
|
|
355
354
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
355
|
+
executeAsObservable(path, input, isQuery) {
|
|
356
|
+
return {
|
|
357
|
+
subscribe: (observer) => {
|
|
358
|
+
let cancelled = false;
|
|
359
|
+
let currentState;
|
|
360
|
+
const cleanups = [];
|
|
361
|
+
(async () => {
|
|
362
|
+
try {
|
|
363
|
+
const def = isQuery ? this.queries[path] : this.mutations[path];
|
|
364
|
+
if (!def) {
|
|
365
|
+
observer.next?.({ error: new Error(`Operation not found: ${path}`) });
|
|
366
|
+
observer.complete?.();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
let select;
|
|
370
|
+
let cleanInput = input;
|
|
371
|
+
if (isQuery && input && typeof input === "object" && "$select" in input) {
|
|
372
|
+
const { $select, ...rest } = input;
|
|
373
|
+
select = $select;
|
|
374
|
+
cleanInput = Object.keys(rest).length > 0 ? rest : undefined;
|
|
375
|
+
}
|
|
376
|
+
if (def._input && cleanInput !== undefined) {
|
|
377
|
+
const result = def._input.safeParse(cleanInput);
|
|
378
|
+
if (!result.success) {
|
|
379
|
+
observer.next?.({
|
|
380
|
+
error: new Error(`Invalid input: ${JSON.stringify(result.error)}`)
|
|
381
|
+
});
|
|
382
|
+
observer.complete?.();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const context = await this.contextFactory();
|
|
387
|
+
await runWithContext(this.ctx, context, async () => {
|
|
388
|
+
const resolver = def._resolve;
|
|
389
|
+
if (!resolver) {
|
|
390
|
+
observer.next?.({ error: new Error(`Operation ${path} has no resolver`) });
|
|
391
|
+
observer.complete?.();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const emitHandler = (command) => {
|
|
395
|
+
if (cancelled)
|
|
396
|
+
return;
|
|
397
|
+
currentState = this.applyEmitCommand(command, currentState);
|
|
398
|
+
observer.next?.({ data: currentState });
|
|
399
|
+
};
|
|
400
|
+
const emit = createEmit(emitHandler);
|
|
401
|
+
const onCleanup = (fn) => {
|
|
402
|
+
cleanups.push(fn);
|
|
403
|
+
return () => {
|
|
404
|
+
const idx = cleanups.indexOf(fn);
|
|
405
|
+
if (idx >= 0)
|
|
406
|
+
cleanups.splice(idx, 1);
|
|
407
|
+
};
|
|
408
|
+
};
|
|
409
|
+
const lensContext = { ...context, emit, onCleanup };
|
|
410
|
+
const result = resolver({ input: cleanInput, ctx: lensContext });
|
|
411
|
+
if (isAsyncIterable(result)) {
|
|
412
|
+
for await (const value of result) {
|
|
413
|
+
if (cancelled)
|
|
414
|
+
break;
|
|
415
|
+
currentState = value;
|
|
416
|
+
const processed = await this.processQueryResult(path, value, select);
|
|
417
|
+
observer.next?.({ data: processed });
|
|
418
|
+
}
|
|
419
|
+
if (!cancelled) {
|
|
420
|
+
observer.complete?.();
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
const value = await result;
|
|
424
|
+
currentState = value;
|
|
425
|
+
const processed = isQuery ? await this.processQueryResult(path, value, select) : value;
|
|
426
|
+
if (!cancelled) {
|
|
427
|
+
observer.next?.({ data: processed });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (!cancelled) {
|
|
433
|
+
observer.next?.({ error: error instanceof Error ? error : new Error(String(error)) });
|
|
434
|
+
observer.complete?.();
|
|
435
|
+
}
|
|
436
|
+
} finally {
|
|
437
|
+
this.clearLoaders();
|
|
388
438
|
}
|
|
389
|
-
|
|
390
|
-
|
|
439
|
+
})();
|
|
440
|
+
return {
|
|
441
|
+
unsubscribe: () => {
|
|
442
|
+
cancelled = true;
|
|
443
|
+
for (const fn of cleanups) {
|
|
444
|
+
fn();
|
|
445
|
+
}
|
|
391
446
|
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return this.processQueryResult(name, data, select);
|
|
396
|
-
});
|
|
397
|
-
} finally {
|
|
398
|
-
this.clearLoaders();
|
|
399
|
-
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
};
|
|
400
450
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
451
|
+
applyEmitCommand(command, state) {
|
|
452
|
+
switch (command.type) {
|
|
453
|
+
case "full":
|
|
454
|
+
if (command.replace) {
|
|
455
|
+
return command.data;
|
|
456
|
+
}
|
|
457
|
+
if (state && typeof state === "object" && typeof command.data === "object") {
|
|
458
|
+
return { ...state, ...command.data };
|
|
459
|
+
}
|
|
460
|
+
return command.data;
|
|
461
|
+
case "field":
|
|
462
|
+
if (state && typeof state === "object") {
|
|
463
|
+
return {
|
|
464
|
+
...state,
|
|
465
|
+
[command.field]: command.update.data
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return { [command.field]: command.update.data };
|
|
469
|
+
case "batch":
|
|
470
|
+
if (state && typeof state === "object") {
|
|
471
|
+
const result = { ...state };
|
|
472
|
+
for (const update of command.updates) {
|
|
473
|
+
result[update.field] = update.update.data;
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
return state;
|
|
478
|
+
case "array": {
|
|
479
|
+
const arr = Array.isArray(state) ? [...state] : [];
|
|
480
|
+
const op = command.operation;
|
|
481
|
+
switch (op.op) {
|
|
482
|
+
case "push":
|
|
483
|
+
return [...arr, op.item];
|
|
484
|
+
case "unshift":
|
|
485
|
+
return [op.item, ...arr];
|
|
486
|
+
case "insert":
|
|
487
|
+
arr.splice(op.index, 0, op.item);
|
|
488
|
+
return arr;
|
|
489
|
+
case "remove":
|
|
490
|
+
arr.splice(op.index, 1);
|
|
491
|
+
return arr;
|
|
492
|
+
case "update":
|
|
493
|
+
arr[op.index] = op.item;
|
|
494
|
+
return arr;
|
|
495
|
+
default:
|
|
496
|
+
return arr;
|
|
497
|
+
}
|
|
409
498
|
}
|
|
410
|
-
|
|
411
|
-
|
|
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();
|
|
499
|
+
default:
|
|
500
|
+
return state;
|
|
424
501
|
}
|
|
425
502
|
}
|
|
426
503
|
async processQueryResult(_operationName, data, select) {
|
|
@@ -716,6 +793,7 @@ function createSSEHandler(config = {}) {
|
|
|
716
793
|
}
|
|
717
794
|
|
|
718
795
|
// src/handlers/http.ts
|
|
796
|
+
import { firstValueFrom } from "@sylphx/lens-core";
|
|
719
797
|
function createHTTPHandler(server, options = {}) {
|
|
720
798
|
const { pathPrefix = "", cors } = options;
|
|
721
799
|
const corsHeaders = {
|
|
@@ -755,10 +833,10 @@ function createHTTPHandler(server, options = {}) {
|
|
|
755
833
|
}
|
|
756
834
|
});
|
|
757
835
|
}
|
|
758
|
-
const result2 = await server.execute({
|
|
836
|
+
const result2 = await firstValueFrom(server.execute({
|
|
759
837
|
path: operationPath2,
|
|
760
838
|
input: body.input
|
|
761
|
-
});
|
|
839
|
+
}));
|
|
762
840
|
if (result2.error) {
|
|
763
841
|
return new Response(JSON.stringify({ error: result2.error.message }), {
|
|
764
842
|
status: 500,
|
|
@@ -826,6 +904,7 @@ function createHandler(server, options = {}) {
|
|
|
826
904
|
return result;
|
|
827
905
|
}
|
|
828
906
|
// src/handlers/framework.ts
|
|
907
|
+
import { firstValueFrom as firstValueFrom2 } from "@sylphx/lens-core";
|
|
829
908
|
function createServerClientProxy(server) {
|
|
830
909
|
function createProxy(path) {
|
|
831
910
|
return new Proxy(() => {}, {
|
|
@@ -839,7 +918,7 @@ function createServerClientProxy(server) {
|
|
|
839
918
|
},
|
|
840
919
|
async apply(_, __, args) {
|
|
841
920
|
const input = args[0];
|
|
842
|
-
const result = await server.execute({ path, input });
|
|
921
|
+
const result = await firstValueFrom2(server.execute({ path, input }));
|
|
843
922
|
if (result.error) {
|
|
844
923
|
throw result.error;
|
|
845
924
|
}
|
|
@@ -853,7 +932,7 @@ async function handleWebQuery(server, path, url) {
|
|
|
853
932
|
try {
|
|
854
933
|
const inputParam = url.searchParams.get("input");
|
|
855
934
|
const input = inputParam ? JSON.parse(inputParam) : undefined;
|
|
856
|
-
const result = await server.execute({ path, input });
|
|
935
|
+
const result = await firstValueFrom2(server.execute({ path, input }));
|
|
857
936
|
if (result.error) {
|
|
858
937
|
return Response.json({ error: result.error.message }, { status: 400 });
|
|
859
938
|
}
|
|
@@ -866,7 +945,7 @@ async function handleWebMutation(server, path, request) {
|
|
|
866
945
|
try {
|
|
867
946
|
const body = await request.json();
|
|
868
947
|
const input = body.input;
|
|
869
|
-
const result = await server.execute({ path, input });
|
|
948
|
+
const result = await firstValueFrom2(server.execute({ path, input }));
|
|
870
949
|
if (result.error) {
|
|
871
950
|
return Response.json({ error: result.error.message }, { status: 400 });
|
|
872
951
|
}
|
|
@@ -938,6 +1017,9 @@ function createFrameworkHandler(server, options = {}) {
|
|
|
938
1017
|
};
|
|
939
1018
|
}
|
|
940
1019
|
// src/handlers/ws.ts
|
|
1020
|
+
import {
|
|
1021
|
+
firstValueFrom as firstValueFrom3
|
|
1022
|
+
} from "@sylphx/lens-core";
|
|
941
1023
|
function createWSHandler(server, options = {}) {
|
|
942
1024
|
const { logger = {} } = options;
|
|
943
1025
|
const connections = new Map;
|
|
@@ -1014,7 +1096,7 @@ function createWSHandler(server, options = {}) {
|
|
|
1014
1096
|
const { id, operation, input, fields } = message;
|
|
1015
1097
|
let result;
|
|
1016
1098
|
try {
|
|
1017
|
-
result = await server.execute({ path: operation, input });
|
|
1099
|
+
result = await firstValueFrom3(server.execute({ path: operation, input }));
|
|
1018
1100
|
if (result.error) {
|
|
1019
1101
|
conn.ws.send(JSON.stringify({
|
|
1020
1102
|
type: "error",
|
|
@@ -1140,10 +1222,10 @@ function createWSHandler(server, options = {}) {
|
|
|
1140
1222
|
}
|
|
1141
1223
|
async function handleQuery(conn, message) {
|
|
1142
1224
|
try {
|
|
1143
|
-
const result = await server.execute({
|
|
1225
|
+
const result = await firstValueFrom3(server.execute({
|
|
1144
1226
|
path: message.operation,
|
|
1145
1227
|
input: message.input
|
|
1146
|
-
});
|
|
1228
|
+
}));
|
|
1147
1229
|
if (result.error) {
|
|
1148
1230
|
conn.ws.send(JSON.stringify({
|
|
1149
1231
|
type: "error",
|
|
@@ -1168,10 +1250,10 @@ function createWSHandler(server, options = {}) {
|
|
|
1168
1250
|
}
|
|
1169
1251
|
async function handleMutation(conn, message) {
|
|
1170
1252
|
try {
|
|
1171
|
-
const result = await server.execute({
|
|
1253
|
+
const result = await firstValueFrom3(server.execute({
|
|
1172
1254
|
path: message.operation,
|
|
1173
1255
|
input: message.input
|
|
1174
|
-
});
|
|
1256
|
+
}));
|
|
1175
1257
|
if (result.error) {
|
|
1176
1258
|
conn.ws.send(JSON.stringify({
|
|
1177
1259
|
type: "error",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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
|
|
33
|
+
"@sylphx/lens-core": "^2.1.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.9.3",
|
package/src/e2e/server.test.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it } from "bun:test";
|
|
11
|
-
import { entity, lens, mutation, query, t } from "@sylphx/lens-core";
|
|
11
|
+
import { entity, firstValueFrom, lens, mutation, query, t } from "@sylphx/lens-core";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { optimisticPlugin } from "../plugin/optimistic.js";
|
|
14
14
|
import { createApp } from "../server/create.js";
|
|
@@ -58,7 +58,7 @@ describe("E2E - Basic Operations", () => {
|
|
|
58
58
|
queries: { getUsers },
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
const result = await server.execute({ path: "getUsers" });
|
|
61
|
+
const result = await firstValueFrom(server.execute({ path: "getUsers" }));
|
|
62
62
|
|
|
63
63
|
expect(result.error).toBeUndefined();
|
|
64
64
|
expect(result.data).toEqual(mockUsers);
|
|
@@ -79,10 +79,12 @@ describe("E2E - Basic Operations", () => {
|
|
|
79
79
|
queries: { getUser },
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
const result = await
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
const result = await firstValueFrom(
|
|
83
|
+
server.execute({
|
|
84
|
+
path: "getUser",
|
|
85
|
+
input: { id: "user-1" },
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
86
88
|
|
|
87
89
|
expect(result.error).toBeUndefined();
|
|
88
90
|
expect(result.data).toEqual(mockUsers[0]);
|
|
@@ -104,10 +106,12 @@ describe("E2E - Basic Operations", () => {
|
|
|
104
106
|
mutations: { createUser },
|
|
105
107
|
});
|
|
106
108
|
|
|
107
|
-
const result = await
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
const result = await firstValueFrom(
|
|
110
|
+
server.execute({
|
|
111
|
+
path: "createUser",
|
|
112
|
+
input: { name: "Charlie", email: "charlie@example.com" },
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
111
115
|
|
|
112
116
|
expect(result.error).toBeUndefined();
|
|
113
117
|
expect(result.data).toEqual({
|
|
@@ -129,10 +133,12 @@ describe("E2E - Basic Operations", () => {
|
|
|
129
133
|
queries: { failingQuery },
|
|
130
134
|
});
|
|
131
135
|
|
|
132
|
-
const result = await
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
const result = await firstValueFrom(
|
|
137
|
+
server.execute({
|
|
138
|
+
path: "failingQuery",
|
|
139
|
+
input: { id: "123" },
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
136
142
|
|
|
137
143
|
expect(result.data).toBeUndefined();
|
|
138
144
|
expect(result.error).toBeInstanceOf(Error);
|
|
@@ -142,10 +148,12 @@ describe("E2E - Basic Operations", () => {
|
|
|
142
148
|
it("handles unknown operation", async () => {
|
|
143
149
|
const server = createApp({});
|
|
144
150
|
|
|
145
|
-
const result = await
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
const result = await firstValueFrom(
|
|
152
|
+
server.execute({
|
|
153
|
+
path: "unknownOperation",
|
|
154
|
+
input: {},
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
149
157
|
|
|
150
158
|
expect(result.data).toBeUndefined();
|
|
151
159
|
expect(result.error?.message).toContain("not found");
|
|
@@ -172,10 +180,12 @@ describe("E2E - Context", () => {
|
|
|
172
180
|
context: () => ({ userId: "ctx-user-1", role: "admin" }),
|
|
173
181
|
});
|
|
174
182
|
|
|
175
|
-
await
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
await firstValueFrom(
|
|
184
|
+
server.execute({
|
|
185
|
+
path: "getUser",
|
|
186
|
+
input: { id: "user-1" },
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
179
189
|
|
|
180
190
|
expect(capturedContext).toMatchObject({
|
|
181
191
|
userId: "ctx-user-1",
|
|
@@ -201,10 +211,12 @@ describe("E2E - Context", () => {
|
|
|
201
211
|
},
|
|
202
212
|
});
|
|
203
213
|
|
|
204
|
-
await
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
214
|
+
await firstValueFrom(
|
|
215
|
+
server.execute({
|
|
216
|
+
path: "getUser",
|
|
217
|
+
input: { id: "user-1" },
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
208
220
|
|
|
209
221
|
expect(capturedContext).toMatchObject({
|
|
210
222
|
userId: "async-user",
|
|
@@ -232,13 +244,15 @@ describe("E2E - Selection", () => {
|
|
|
232
244
|
queries: { getUser },
|
|
233
245
|
});
|
|
234
246
|
|
|
235
|
-
const result = await
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
const result = await firstValueFrom(
|
|
248
|
+
server.execute({
|
|
249
|
+
path: "getUser",
|
|
250
|
+
input: {
|
|
251
|
+
id: "user-1",
|
|
252
|
+
$select: { name: true },
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
242
256
|
|
|
243
257
|
expect(result.error).toBeUndefined();
|
|
244
258
|
// Should include id (always) and selected fields
|
|
@@ -262,13 +276,15 @@ describe("E2E - Selection", () => {
|
|
|
262
276
|
queries: { getUser },
|
|
263
277
|
});
|
|
264
278
|
|
|
265
|
-
const result = await
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
279
|
+
const result = await firstValueFrom(
|
|
280
|
+
server.execute({
|
|
281
|
+
path: "getUser",
|
|
282
|
+
input: {
|
|
283
|
+
id: "user-1",
|
|
284
|
+
$select: { email: true },
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
272
288
|
|
|
273
289
|
expect(result.data).toEqual({
|
|
274
290
|
id: "user-1",
|
|
@@ -318,20 +334,22 @@ describe("E2E - Entity Resolvers", () => {
|
|
|
318
334
|
});
|
|
319
335
|
|
|
320
336
|
// Test with $select for nested posts
|
|
321
|
-
const result = await
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
337
|
+
const result = await firstValueFrom(
|
|
338
|
+
server.execute({
|
|
339
|
+
path: "getUser",
|
|
340
|
+
input: {
|
|
341
|
+
id: "user-1",
|
|
342
|
+
$select: {
|
|
343
|
+
name: true,
|
|
344
|
+
posts: {
|
|
345
|
+
select: {
|
|
346
|
+
title: true,
|
|
347
|
+
},
|
|
330
348
|
},
|
|
331
349
|
},
|
|
332
350
|
},
|
|
333
|
-
},
|
|
334
|
-
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
335
353
|
|
|
336
354
|
expect(result.error).toBeUndefined();
|
|
337
355
|
expect(result.data).toMatchObject({
|
|
@@ -380,19 +398,21 @@ describe("E2E - Entity Resolvers", () => {
|
|
|
380
398
|
});
|
|
381
399
|
|
|
382
400
|
// Execute query with nested selection for all users
|
|
383
|
-
const result = await
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
401
|
+
const result = await firstValueFrom(
|
|
402
|
+
server.execute({
|
|
403
|
+
path: "getUsers",
|
|
404
|
+
input: {
|
|
405
|
+
$select: {
|
|
406
|
+
name: true,
|
|
407
|
+
posts: {
|
|
408
|
+
select: {
|
|
409
|
+
title: true,
|
|
410
|
+
},
|
|
391
411
|
},
|
|
392
412
|
},
|
|
393
413
|
},
|
|
394
|
-
},
|
|
395
|
-
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
396
416
|
|
|
397
417
|
expect(result.error).toBeUndefined();
|
|
398
418
|
// Resolvers are called - exact count depends on DataLoader batching behavior
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
* ```
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
import { firstValueFrom } from "@sylphx/lens-core";
|
|
40
41
|
import type { LensServer } from "../server/create.js";
|
|
41
42
|
|
|
42
43
|
// =============================================================================
|
|
@@ -74,7 +75,7 @@ export function createServerClientProxy(server: LensServer): unknown {
|
|
|
74
75
|
},
|
|
75
76
|
async apply(_, __, args) {
|
|
76
77
|
const input = args[0];
|
|
77
|
-
const result = await server.execute({ path, input });
|
|
78
|
+
const result = await firstValueFrom(server.execute({ path, input }));
|
|
78
79
|
|
|
79
80
|
if (result.error) {
|
|
80
81
|
throw result.error;
|
|
@@ -112,7 +113,7 @@ export async function handleWebQuery(
|
|
|
112
113
|
const inputParam = url.searchParams.get("input");
|
|
113
114
|
const input = inputParam ? JSON.parse(inputParam) : undefined;
|
|
114
115
|
|
|
115
|
-
const result = await server.execute({ path, input });
|
|
116
|
+
const result = await firstValueFrom(server.execute({ path, input }));
|
|
116
117
|
|
|
117
118
|
if (result.error) {
|
|
118
119
|
return Response.json({ error: result.error.message }, { status: 400 });
|
|
@@ -147,7 +148,7 @@ export async function handleWebMutation(
|
|
|
147
148
|
const body = (await request.json()) as { input?: unknown };
|
|
148
149
|
const input = body.input;
|
|
149
150
|
|
|
150
|
-
const result = await server.execute({ path, input });
|
|
151
|
+
const result = await firstValueFrom(server.execute({ path, input }));
|
|
151
152
|
|
|
152
153
|
if (result.error) {
|
|
153
154
|
return Response.json({ error: result.error.message }, { status: 400 });
|
package/src/handlers/http.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Works with Bun, Node (with adapter), Vercel, Cloudflare Workers.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { firstValueFrom } from "@sylphx/lens-core";
|
|
8
9
|
import type { LensServer } from "../server/create.js";
|
|
9
10
|
|
|
10
11
|
// =============================================================================
|
|
@@ -139,10 +140,12 @@ export function createHTTPHandler(
|
|
|
139
140
|
});
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
const result = await
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
const result = await firstValueFrom(
|
|
144
|
+
server.execute({
|
|
145
|
+
path: operationPath,
|
|
146
|
+
input: body.input,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
146
149
|
|
|
147
150
|
if (result.error) {
|
|
148
151
|
return new Response(JSON.stringify({ error: result.error.message }), {
|
package/src/handlers/ws.ts
CHANGED
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import
|
|
25
|
+
import {
|
|
26
|
+
firstValueFrom,
|
|
27
|
+
type ReconnectMessage,
|
|
28
|
+
type ReconnectSubscription,
|
|
29
|
+
} from "@sylphx/lens-core";
|
|
26
30
|
import type { LensServer, WebSocketLike } from "../server/create.js";
|
|
27
31
|
import type {
|
|
28
32
|
ClientConnection,
|
|
@@ -174,7 +178,7 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
|
|
|
174
178
|
// Execute query first to get data
|
|
175
179
|
let result: { data?: unknown; error?: Error };
|
|
176
180
|
try {
|
|
177
|
-
result = await server.execute({ path: operation, input });
|
|
181
|
+
result = await firstValueFrom(server.execute({ path: operation, input }));
|
|
178
182
|
|
|
179
183
|
if (result.error) {
|
|
180
184
|
conn.ws.send(
|
|
@@ -349,10 +353,12 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
|
|
|
349
353
|
// Handle query
|
|
350
354
|
async function handleQuery(conn: ClientConnection, message: QueryMessage): Promise<void> {
|
|
351
355
|
try {
|
|
352
|
-
const result = await
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
+
const result = await firstValueFrom(
|
|
357
|
+
server.execute({
|
|
358
|
+
path: message.operation,
|
|
359
|
+
input: message.input,
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
356
362
|
|
|
357
363
|
if (result.error) {
|
|
358
364
|
conn.ws.send(
|
|
@@ -389,10 +395,12 @@ export function createWSHandler(server: LensServer, options: WSHandlerOptions =
|
|
|
389
395
|
// Handle mutation
|
|
390
396
|
async function handleMutation(conn: ClientConnection, message: MutationMessage): Promise<void> {
|
|
391
397
|
try {
|
|
392
|
-
const result = await
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
398
|
+
const result = await firstValueFrom(
|
|
399
|
+
server.execute({
|
|
400
|
+
path: message.operation,
|
|
401
|
+
input: message.input,
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
396
404
|
|
|
397
405
|
if (result.error) {
|
|
398
406
|
conn.ws.send(
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, it } from "bun:test";
|
|
9
|
-
import { entity, mutation, query, router } from "@sylphx/lens-core";
|
|
9
|
+
import { entity, firstValueFrom, mutation, query, router } from "@sylphx/lens-core";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { optimisticPlugin } from "../plugin/optimistic.js";
|
|
12
12
|
import { createApp } from "./create.js";
|
|
@@ -190,10 +190,12 @@ describe("execute", () => {
|
|
|
190
190
|
queries: { getUser },
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
-
const result = await
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
193
|
+
const result = await firstValueFrom(
|
|
194
|
+
server.execute({
|
|
195
|
+
path: "getUser",
|
|
196
|
+
input: { id: "123" },
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
197
199
|
|
|
198
200
|
expect(result.data).toEqual({
|
|
199
201
|
id: "123",
|
|
@@ -208,10 +210,12 @@ describe("execute", () => {
|
|
|
208
210
|
mutations: { createUser },
|
|
209
211
|
});
|
|
210
212
|
|
|
211
|
-
const result = await
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
const result = await firstValueFrom(
|
|
214
|
+
server.execute({
|
|
215
|
+
path: "createUser",
|
|
216
|
+
input: { name: "New User", email: "new@example.com" },
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
215
219
|
|
|
216
220
|
expect(result.data).toEqual({
|
|
217
221
|
id: "new-id",
|
|
@@ -226,10 +230,12 @@ describe("execute", () => {
|
|
|
226
230
|
queries: { getUser },
|
|
227
231
|
});
|
|
228
232
|
|
|
229
|
-
const result = await
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
+
const result = await firstValueFrom(
|
|
234
|
+
server.execute({
|
|
235
|
+
path: "unknownOperation",
|
|
236
|
+
input: {},
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
233
239
|
|
|
234
240
|
expect(result.data).toBeUndefined();
|
|
235
241
|
expect(result.error).toBeInstanceOf(Error);
|
|
@@ -241,10 +247,12 @@ describe("execute", () => {
|
|
|
241
247
|
queries: { getUser },
|
|
242
248
|
});
|
|
243
249
|
|
|
244
|
-
const result = await
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
250
|
+
const result = await firstValueFrom(
|
|
251
|
+
server.execute({
|
|
252
|
+
path: "getUser",
|
|
253
|
+
input: { invalid: true }, // Missing required 'id'
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
248
256
|
|
|
249
257
|
expect(result.data).toBeUndefined();
|
|
250
258
|
expect(result.error).toBeInstanceOf(Error);
|
|
@@ -260,10 +268,12 @@ describe("execute", () => {
|
|
|
260
268
|
|
|
261
269
|
const server = createApp({ router: appRouter });
|
|
262
270
|
|
|
263
|
-
const queryResult = await
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
271
|
+
const queryResult = await firstValueFrom(
|
|
272
|
+
server.execute({
|
|
273
|
+
path: "user.get",
|
|
274
|
+
input: { id: "456" },
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
267
277
|
|
|
268
278
|
expect(queryResult.data).toEqual({
|
|
269
279
|
id: "456",
|
|
@@ -271,10 +281,12 @@ describe("execute", () => {
|
|
|
271
281
|
email: "test@example.com",
|
|
272
282
|
});
|
|
273
283
|
|
|
274
|
-
const mutationResult = await
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
284
|
+
const mutationResult = await firstValueFrom(
|
|
285
|
+
server.execute({
|
|
286
|
+
path: "user.create",
|
|
287
|
+
input: { name: "Router User" },
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
278
290
|
|
|
279
291
|
expect(mutationResult.data).toEqual({
|
|
280
292
|
id: "new-id",
|
|
@@ -294,10 +306,12 @@ describe("execute", () => {
|
|
|
294
306
|
queries: { errorQuery },
|
|
295
307
|
});
|
|
296
308
|
|
|
297
|
-
const result = await
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
309
|
+
const result = await firstValueFrom(
|
|
310
|
+
server.execute({
|
|
311
|
+
path: "errorQuery",
|
|
312
|
+
input: { id: "1" },
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
301
315
|
|
|
302
316
|
expect(result.data).toBeUndefined();
|
|
303
317
|
expect(result.error).toBeInstanceOf(Error);
|
|
@@ -309,9 +323,11 @@ describe("execute", () => {
|
|
|
309
323
|
queries: { getUsers },
|
|
310
324
|
});
|
|
311
325
|
|
|
312
|
-
const result = await
|
|
313
|
-
|
|
314
|
-
|
|
326
|
+
const result = await firstValueFrom(
|
|
327
|
+
server.execute({
|
|
328
|
+
path: "getUsers",
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
315
331
|
|
|
316
332
|
expect(result.data).toHaveLength(2);
|
|
317
333
|
});
|
|
@@ -337,10 +353,12 @@ describe("context", () => {
|
|
|
337
353
|
context: () => ({ userId: "user-123", role: "admin" }),
|
|
338
354
|
});
|
|
339
355
|
|
|
340
|
-
await
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
356
|
+
await firstValueFrom(
|
|
357
|
+
server.execute({
|
|
358
|
+
path: "contextQuery",
|
|
359
|
+
input: { id: "1" },
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
344
362
|
|
|
345
363
|
expect(capturedContext).toMatchObject({
|
|
346
364
|
userId: "user-123",
|
|
@@ -366,10 +384,12 @@ describe("context", () => {
|
|
|
366
384
|
},
|
|
367
385
|
});
|
|
368
386
|
|
|
369
|
-
await
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
387
|
+
await firstValueFrom(
|
|
388
|
+
server.execute({
|
|
389
|
+
path: "contextQuery",
|
|
390
|
+
input: { id: "1" },
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
373
393
|
|
|
374
394
|
expect(capturedContext).toMatchObject({
|
|
375
395
|
userId: "async-user",
|
|
@@ -387,13 +407,15 @@ describe("selection", () => {
|
|
|
387
407
|
queries: { getUser },
|
|
388
408
|
});
|
|
389
409
|
|
|
390
|
-
const result = await
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
410
|
+
const result = await firstValueFrom(
|
|
411
|
+
server.execute({
|
|
412
|
+
path: "getUser",
|
|
413
|
+
input: {
|
|
414
|
+
id: "123",
|
|
415
|
+
$select: { name: true },
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
397
419
|
|
|
398
420
|
expect(result.data).toEqual({
|
|
399
421
|
id: "123", // id always included
|
package/src/server/create.ts
CHANGED
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
import {
|
|
19
19
|
type ContextValue,
|
|
20
20
|
createEmit,
|
|
21
|
+
type EmitCommand,
|
|
21
22
|
type EntityDef,
|
|
22
23
|
flattenRouter,
|
|
23
24
|
type InferRouterContext,
|
|
24
25
|
isEntityDef,
|
|
25
26
|
isMutationDef,
|
|
26
27
|
isQueryDef,
|
|
28
|
+
type Observable,
|
|
27
29
|
type ResolverDef,
|
|
28
30
|
type RouterDef,
|
|
29
31
|
toResolverMap,
|
|
@@ -192,109 +194,214 @@ class LensServerImpl<
|
|
|
192
194
|
};
|
|
193
195
|
}
|
|
194
196
|
|
|
195
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Execute operation and return Observable.
|
|
199
|
+
*
|
|
200
|
+
* Always returns Observable<LensResult>:
|
|
201
|
+
* - One-shot: emits once, then completes
|
|
202
|
+
* - Streaming: emits multiple times (AsyncIterable or emit-based)
|
|
203
|
+
*/
|
|
204
|
+
execute(op: LensOperation): Observable<LensResult> {
|
|
196
205
|
const { path, input } = op;
|
|
197
206
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
// Check if operation exists
|
|
208
|
+
const isQuery = !!this.queries[path];
|
|
209
|
+
const isMutation = !!this.mutations[path];
|
|
210
|
+
|
|
211
|
+
if (!isQuery && !isMutation) {
|
|
212
|
+
return {
|
|
213
|
+
subscribe: (observer) => {
|
|
214
|
+
observer.next?.({ error: new Error(`Operation not found: ${path}`) });
|
|
215
|
+
observer.complete?.();
|
|
216
|
+
return { unsubscribe: () => {} };
|
|
217
|
+
},
|
|
218
|
+
};
|
|
210
219
|
}
|
|
211
|
-
}
|
|
212
220
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
// =========================================================================
|
|
216
|
-
|
|
217
|
-
private async executeQuery<TInput, TOutput>(name: string, input?: TInput): Promise<TOutput> {
|
|
218
|
-
const queryDef = this.queries[name];
|
|
219
|
-
if (!queryDef) throw new Error(`Query not found: ${name}`);
|
|
220
|
-
|
|
221
|
-
// Extract $select from input
|
|
222
|
-
let select: SelectionObject | undefined;
|
|
223
|
-
let cleanInput = input;
|
|
224
|
-
if (input && typeof input === "object" && "$select" in input) {
|
|
225
|
-
const { $select, ...rest } = input as Record<string, unknown>;
|
|
226
|
-
select = $select as SelectionObject;
|
|
227
|
-
cleanInput = (Object.keys(rest).length > 0 ? rest : undefined) as TInput;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Validate input
|
|
231
|
-
if (queryDef._input && cleanInput !== undefined) {
|
|
232
|
-
const result = queryDef._input.safeParse(cleanInput);
|
|
233
|
-
if (!result.success) {
|
|
234
|
-
throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const context = await this.contextFactory();
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
return await runWithContext(this.ctx, context, async () => {
|
|
242
|
-
const resolver = queryDef._resolve;
|
|
243
|
-
if (!resolver) throw new Error(`Query ${name} has no resolver`);
|
|
244
|
-
|
|
245
|
-
const emit = createEmit(() => {});
|
|
246
|
-
const onCleanup = () => () => {};
|
|
247
|
-
const lensContext = { ...context, emit, onCleanup };
|
|
221
|
+
return this.executeAsObservable(path, input, isQuery);
|
|
222
|
+
}
|
|
248
223
|
|
|
249
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Execute operation and return Observable.
|
|
226
|
+
* Observable allows streaming for AsyncIterable resolvers and emit-based updates.
|
|
227
|
+
*/
|
|
228
|
+
private executeAsObservable(
|
|
229
|
+
path: string,
|
|
230
|
+
input: unknown,
|
|
231
|
+
isQuery: boolean,
|
|
232
|
+
): Observable<LensResult> {
|
|
233
|
+
return {
|
|
234
|
+
subscribe: (observer) => {
|
|
235
|
+
let cancelled = false;
|
|
236
|
+
let currentState: unknown;
|
|
237
|
+
const cleanups: (() => void)[] = [];
|
|
250
238
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
239
|
+
// Run the operation
|
|
240
|
+
(async () => {
|
|
241
|
+
try {
|
|
242
|
+
const def = isQuery ? this.queries[path] : this.mutations[path];
|
|
243
|
+
if (!def) {
|
|
244
|
+
observer.next?.({ error: new Error(`Operation not found: ${path}`) });
|
|
245
|
+
observer.complete?.();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Extract $select from input for queries
|
|
250
|
+
let select: SelectionObject | undefined;
|
|
251
|
+
let cleanInput = input;
|
|
252
|
+
if (isQuery && input && typeof input === "object" && "$select" in input) {
|
|
253
|
+
const { $select, ...rest } = input as Record<string, unknown>;
|
|
254
|
+
select = $select as SelectionObject;
|
|
255
|
+
cleanInput = Object.keys(rest).length > 0 ? rest : undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Validate input
|
|
259
|
+
if (def._input && cleanInput !== undefined) {
|
|
260
|
+
const result = def._input.safeParse(cleanInput);
|
|
261
|
+
if (!result.success) {
|
|
262
|
+
observer.next?.({
|
|
263
|
+
error: new Error(`Invalid input: ${JSON.stringify(result.error)}`),
|
|
264
|
+
});
|
|
265
|
+
observer.complete?.();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const context = await this.contextFactory();
|
|
271
|
+
|
|
272
|
+
await runWithContext(this.ctx, context, async () => {
|
|
273
|
+
const resolver = def._resolve;
|
|
274
|
+
if (!resolver) {
|
|
275
|
+
observer.next?.({ error: new Error(`Operation ${path} has no resolver`) });
|
|
276
|
+
observer.complete?.();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Create emit handler that pushes to observer
|
|
281
|
+
const emitHandler = (command: EmitCommand) => {
|
|
282
|
+
if (cancelled) return;
|
|
283
|
+
currentState = this.applyEmitCommand(command, currentState);
|
|
284
|
+
observer.next?.({ data: currentState });
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const emit = createEmit(emitHandler);
|
|
288
|
+
const onCleanup = (fn: () => void) => {
|
|
289
|
+
cleanups.push(fn);
|
|
290
|
+
return () => {
|
|
291
|
+
const idx = cleanups.indexOf(fn);
|
|
292
|
+
if (idx >= 0) cleanups.splice(idx, 1);
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const lensContext = { ...context, emit, onCleanup };
|
|
297
|
+
const result = resolver({ input: cleanInput, ctx: lensContext });
|
|
298
|
+
|
|
299
|
+
if (isAsyncIterable(result)) {
|
|
300
|
+
// Streaming: emit each yielded value
|
|
301
|
+
for await (const value of result) {
|
|
302
|
+
if (cancelled) break;
|
|
303
|
+
currentState = value;
|
|
304
|
+
const processed = await this.processQueryResult(path, value, select);
|
|
305
|
+
observer.next?.({ data: processed });
|
|
306
|
+
}
|
|
307
|
+
if (!cancelled) {
|
|
308
|
+
observer.complete?.();
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
// One-shot: emit single value
|
|
312
|
+
const value = await result;
|
|
313
|
+
currentState = value;
|
|
314
|
+
const processed = isQuery
|
|
315
|
+
? await this.processQueryResult(path, value, select)
|
|
316
|
+
: 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
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (!cancelled) {
|
|
326
|
+
observer.next?.({ error: error instanceof Error ? error : new Error(String(error)) });
|
|
327
|
+
observer.complete?.();
|
|
328
|
+
}
|
|
329
|
+
} finally {
|
|
330
|
+
this.clearLoaders();
|
|
259
331
|
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
332
|
+
})();
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
unsubscribe: () => {
|
|
336
|
+
cancelled = true;
|
|
337
|
+
for (const fn of cleanups) {
|
|
338
|
+
fn();
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
};
|
|
269
344
|
}
|
|
270
345
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
346
|
+
/**
|
|
347
|
+
* Apply emit command to current state.
|
|
348
|
+
*/
|
|
349
|
+
private applyEmitCommand(command: EmitCommand, state: unknown): unknown {
|
|
350
|
+
switch (command.type) {
|
|
351
|
+
case "full":
|
|
352
|
+
if (command.replace) {
|
|
353
|
+
return command.data;
|
|
354
|
+
}
|
|
355
|
+
// Merge mode
|
|
356
|
+
if (state && typeof state === "object" && typeof command.data === "object") {
|
|
357
|
+
return { ...state, ...(command.data as Record<string, unknown>) };
|
|
358
|
+
}
|
|
359
|
+
return command.data;
|
|
360
|
+
|
|
361
|
+
case "field":
|
|
362
|
+
if (state && typeof state === "object") {
|
|
363
|
+
return {
|
|
364
|
+
...(state as Record<string, unknown>),
|
|
365
|
+
[command.field]: command.update.data,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return { [command.field]: command.update.data };
|
|
274
369
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
370
|
+
case "batch":
|
|
371
|
+
if (state && typeof state === "object") {
|
|
372
|
+
const result = { ...(state as Record<string, unknown>) };
|
|
373
|
+
for (const update of command.updates) {
|
|
374
|
+
result[update.field] = update.update.data;
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
return state;
|
|
379
|
+
|
|
380
|
+
case "array": {
|
|
381
|
+
// Array operations - simplified handling
|
|
382
|
+
const arr = Array.isArray(state) ? [...state] : [];
|
|
383
|
+
const op = command.operation;
|
|
384
|
+
switch (op.op) {
|
|
385
|
+
case "push":
|
|
386
|
+
return [...arr, op.item];
|
|
387
|
+
case "unshift":
|
|
388
|
+
return [op.item, ...arr];
|
|
389
|
+
case "insert":
|
|
390
|
+
arr.splice(op.index, 0, op.item);
|
|
391
|
+
return arr;
|
|
392
|
+
case "remove":
|
|
393
|
+
arr.splice(op.index, 1);
|
|
394
|
+
return arr;
|
|
395
|
+
case "update":
|
|
396
|
+
arr[op.index] = op.item;
|
|
397
|
+
return arr;
|
|
398
|
+
default:
|
|
399
|
+
return arr;
|
|
400
|
+
}
|
|
280
401
|
}
|
|
281
|
-
}
|
|
282
402
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
try {
|
|
286
|
-
return await runWithContext(this.ctx, context, async () => {
|
|
287
|
-
const resolver = mutationDef._resolve;
|
|
288
|
-
if (!resolver) throw new Error(`Mutation ${name} has no resolver`);
|
|
289
|
-
|
|
290
|
-
const emit = createEmit(() => {});
|
|
291
|
-
const onCleanup = () => () => {};
|
|
292
|
-
const lensContext = { ...context, emit, onCleanup };
|
|
293
|
-
|
|
294
|
-
return (await resolver({ input: input as TInput, ctx: lensContext })) as TOutput;
|
|
295
|
-
});
|
|
296
|
-
} finally {
|
|
297
|
-
this.clearLoaders();
|
|
403
|
+
default:
|
|
404
|
+
return state;
|
|
298
405
|
}
|
|
299
406
|
}
|
|
300
407
|
|
package/src/server/types.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
EntityDef,
|
|
10
10
|
InferRouterContext,
|
|
11
11
|
MutationDef,
|
|
12
|
+
Observable,
|
|
12
13
|
OptimisticDSL,
|
|
13
14
|
QueryDef,
|
|
14
15
|
Resolvers,
|
|
@@ -181,8 +182,20 @@ export interface WebSocketLike {
|
|
|
181
182
|
export interface LensServer {
|
|
182
183
|
/** Get server metadata for transport handshake */
|
|
183
184
|
getMetadata(): ServerMetadata;
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Execute operation - auto-detects query vs mutation.
|
|
188
|
+
*
|
|
189
|
+
* Always returns Observable<LensResult>:
|
|
190
|
+
* - One-shot: emits once, then completes
|
|
191
|
+
* - Streaming: emits multiple times (AsyncIterable or emit-based)
|
|
192
|
+
*
|
|
193
|
+
* Usage:
|
|
194
|
+
* - HTTP: `await firstValueFrom(server.execute(op))`
|
|
195
|
+
* - WS/SSE: `server.execute(op).subscribe(...)`
|
|
196
|
+
* - direct: pass through Observable directly
|
|
197
|
+
*/
|
|
198
|
+
execute(op: LensOperation): Observable<LensResult>;
|
|
186
199
|
|
|
187
200
|
// =========================================================================
|
|
188
201
|
// Subscription Support (Optional - used by WS/SSE handlers)
|