@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 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
@@ -337,90 +337,167 @@ class LensServerImpl {
337
337
  operations: this.buildOperationsMap()
338
338
  };
339
339
  }
340
- async execute(op) {
340
+ execute(op) {
341
341
  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)) };
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
- 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;
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
- if (data === undefined) {
390
- throw new Error(`Query ${name} returned empty stream`);
439
+ })();
440
+ return {
441
+ unsubscribe: () => {
442
+ cancelled = true;
443
+ for (const fn of cleanups) {
444
+ fn();
445
+ }
391
446
  }
392
- } else {
393
- data = await result;
394
- }
395
- return this.processQueryResult(name, data, select);
396
- });
397
- } finally {
398
- this.clearLoaders();
399
- }
447
+ };
448
+ }
449
+ };
400
450
  }
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)}`);
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
- 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();
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.1.0",
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.1"
33
+ "@sylphx/lens-core": "^2.1.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.9.3",
@@ -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 server.execute({
83
- path: "getUser",
84
- input: { id: "user-1" },
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 server.execute({
108
- path: "createUser",
109
- input: { name: "Charlie", email: "charlie@example.com" },
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 server.execute({
133
- path: "failingQuery",
134
- input: { id: "123" },
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 server.execute({
146
- path: "unknownOperation",
147
- input: {},
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 server.execute({
176
- path: "getUser",
177
- input: { id: "user-1" },
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 server.execute({
205
- path: "getUser",
206
- input: { id: "user-1" },
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 server.execute({
236
- path: "getUser",
237
- input: {
238
- id: "user-1",
239
- $select: { name: true },
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 server.execute({
266
- path: "getUser",
267
- input: {
268
- id: "user-1",
269
- $select: { email: true },
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 server.execute({
322
- path: "getUser",
323
- input: {
324
- id: "user-1",
325
- $select: {
326
- name: true,
327
- posts: {
328
- select: {
329
- title: true,
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 server.execute({
384
- path: "getUsers",
385
- input: {
386
- $select: {
387
- name: true,
388
- posts: {
389
- select: {
390
- title: true,
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 });
@@ -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 server.execute({
143
- path: operationPath,
144
- input: body.input,
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 }), {
@@ -22,7 +22,11 @@
22
22
  * ```
23
23
  */
24
24
 
25
- import type { ReconnectMessage, ReconnectSubscription } from "@sylphx/lens-core";
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 server.execute({
353
- path: message.operation,
354
- input: message.input,
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 server.execute({
393
- path: message.operation,
394
- input: message.input,
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 server.execute({
194
- path: "getUser",
195
- input: { id: "123" },
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 server.execute({
212
- path: "createUser",
213
- input: { name: "New User", email: "new@example.com" },
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 server.execute({
230
- path: "unknownOperation",
231
- input: {},
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 server.execute({
245
- path: "getUser",
246
- input: { invalid: true }, // Missing required 'id'
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 server.execute({
264
- path: "user.get",
265
- input: { id: "456" },
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 server.execute({
275
- path: "user.create",
276
- input: { name: "Router User" },
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 server.execute({
298
- path: "errorQuery",
299
- input: { id: "1" },
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 server.execute({
313
- path: "getUsers",
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 server.execute({
341
- path: "contextQuery",
342
- input: { id: "1" },
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 server.execute({
370
- path: "contextQuery",
371
- input: { id: "1" },
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 server.execute({
391
- path: "getUser",
392
- input: {
393
- id: "123",
394
- $select: { name: true },
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
@@ -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
- async execute(op: LensOperation): Promise<LensResult> {
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
- try {
199
- if (this.queries[path]) {
200
- const data = await this.executeQuery(path, input);
201
- return { data };
202
- }
203
- if (this.mutations[path]) {
204
- const data = await this.executeMutation(path, input);
205
- return { data };
206
- }
207
- return { error: new Error(`Operation not found: ${path}`) };
208
- } catch (error) {
209
- return { error: error instanceof Error ? error : new Error(String(error)) };
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
- // Query/Mutation Execution
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
- const result = resolver({ input: cleanInput as TInput, ctx: lensContext });
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
- let data: TOutput;
252
- if (isAsyncIterable(result)) {
253
- for await (const value of result) {
254
- data = value as TOutput;
255
- break;
256
- }
257
- if (data! === undefined) {
258
- throw new Error(`Query ${name} returned empty stream`);
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
- } else {
261
- data = (await result) as TOutput;
262
- }
263
-
264
- return this.processQueryResult(name, data, select);
265
- });
266
- } finally {
267
- this.clearLoaders();
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
- private async executeMutation<TInput, TOutput>(name: string, input: TInput): Promise<TOutput> {
272
- const mutationDef = this.mutations[name];
273
- if (!mutationDef) throw new Error(`Mutation not found: ${name}`);
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
- // Validate input
276
- if (mutationDef._input) {
277
- const result = mutationDef._input.safeParse(input);
278
- if (!result.success) {
279
- throw new Error(`Invalid input: ${JSON.stringify(result.error)}`);
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
- const context = await this.contextFactory();
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
 
@@ -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
- /** Execute operation - auto-detects query vs mutation */
185
- execute(op: LensOperation): Promise<LensResult>;
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)