@sylphx/lens-server 2.0.1 → 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.
@@ -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