@sylphx/lens-server 1.11.3 → 2.1.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.
@@ -1,19 +1,17 @@
1
1
  /**
2
2
  * @lens - E2E Tests
3
3
  *
4
- * End-to-end tests for server and client working together.
5
- * Tests the complete flow of:
6
- * - Operations protocol (queries, mutations, subscriptions)
7
- * - GraphStateManager integration
8
- * - Field-level subscriptions
9
- * - Minimum transfer (diff computation)
10
- * - Reference counting and canDerive
4
+ * End-to-end tests for the pure executor server.
5
+ * Tests: execute(), getMetadata(), context, selections, entity resolvers
6
+ *
7
+ * For WebSocket protocol tests, see adapters/*.test.ts
11
8
  */
12
9
 
13
10
  import { describe, expect, it } from "bun:test";
14
- import { applyUpdate, entity, lens, mutation, query, t, type Update } from "@sylphx/lens-core";
11
+ import { entity, lens, mutation, query, t } from "@sylphx/lens-core";
15
12
  import { z } from "zod";
16
- import { createServer, type WebSocketLike } from "../server/create";
13
+ import { optimisticPlugin } from "../plugin/optimistic.js";
14
+ import { createApp } from "../server/create.js";
17
15
 
18
16
  // =============================================================================
19
17
  // Test Fixtures
@@ -45,171 +43,6 @@ const _mockPosts = [
45
43
  { id: "post-2", title: "Test", content: "Post", authorId: "user-1" },
46
44
  ];
47
45
 
48
- // =============================================================================
49
- // Mock WebSocket Client
50
- // =============================================================================
51
-
52
- /**
53
- * Mock WebSocket client for testing the server.
54
- * Simulates client-side message handling.
55
- */
56
- function createMockClient(server: ReturnType<typeof createServer>) {
57
- const messages: unknown[] = [];
58
- const subscriptions = new Map<
59
- string,
60
- {
61
- onData: (data: unknown) => void;
62
- onUpdate: (updates: Record<string, Update>) => void;
63
- onError: (error: Error) => void;
64
- onComplete: () => void;
65
- lastData: unknown;
66
- }
67
- >();
68
- const pending = new Map<string, { resolve: (data: unknown) => void; reject: (error: Error) => void }>();
69
-
70
- let messageIdCounter = 0;
71
- const nextId = () => `msg_${++messageIdCounter}`;
72
-
73
- // Mock WebSocket interface for server
74
- const ws: WebSocketLike & { messages: unknown[] } = {
75
- messages,
76
- send: (data: string) => {
77
- const msg = JSON.parse(data);
78
- messages.push(msg);
79
-
80
- // Route message to appropriate handler
81
- if (msg.type === "data" || msg.type === "result") {
82
- // Response to pending request or subscription initial data
83
- const pendingReq = pending.get(msg.id);
84
- if (pendingReq) {
85
- pending.delete(msg.id);
86
- pendingReq.resolve(msg.data);
87
- }
88
-
89
- const sub = subscriptions.get(msg.id);
90
- if (sub) {
91
- sub.lastData = msg.data;
92
- sub.onData(msg.data);
93
- }
94
- } else if (msg.type === "update") {
95
- const sub = subscriptions.get(msg.id);
96
- if (sub) {
97
- // Apply updates to last data
98
- if (sub.lastData && typeof sub.lastData === "object" && msg.updates) {
99
- const updated = { ...(sub.lastData as Record<string, unknown>) };
100
- for (const [field, update] of Object.entries(msg.updates as Record<string, Update>)) {
101
- updated[field] = applyUpdate(updated[field], update);
102
- }
103
- sub.lastData = updated;
104
- sub.onData(updated);
105
- }
106
- sub.onUpdate(msg.updates);
107
- }
108
- } else if (msg.type === "error") {
109
- const pendingReq = pending.get(msg.id);
110
- if (pendingReq) {
111
- pending.delete(msg.id);
112
- pendingReq.reject(new Error(msg.error.message));
113
- }
114
-
115
- const sub = subscriptions.get(msg.id);
116
- if (sub) {
117
- sub.onError(new Error(msg.error.message));
118
- }
119
- }
120
- },
121
- close: () => {},
122
- onmessage: null,
123
- onclose: null,
124
- onerror: null,
125
- };
126
-
127
- // Connect server to mock WebSocket
128
- server.handleWebSocket(ws);
129
-
130
- return {
131
- ws,
132
- messages,
133
-
134
- subscribe(
135
- operation: string,
136
- input: unknown,
137
- fields: string[] | "*",
138
- callbacks: {
139
- onData: (data: unknown) => void;
140
- onUpdate: (updates: Record<string, Update>) => void;
141
- onError: (error: Error) => void;
142
- onComplete: () => void;
143
- },
144
- ) {
145
- const id = nextId();
146
- subscriptions.set(id, { ...callbacks, lastData: null });
147
-
148
- // Send subscribe message to server
149
- ws.onmessage?.({
150
- data: JSON.stringify({
151
- type: "subscribe",
152
- id,
153
- operation,
154
- input,
155
- fields,
156
- }),
157
- });
158
-
159
- return {
160
- unsubscribe: () => {
161
- subscriptions.delete(id);
162
- ws.onmessage?.({ data: JSON.stringify({ type: "unsubscribe", id }) });
163
- callbacks.onComplete();
164
- },
165
- updateFields: (add?: string[], remove?: string[]) => {
166
- ws.onmessage?.({
167
- data: JSON.stringify({
168
- type: "updateFields",
169
- id,
170
- addFields: add,
171
- removeFields: remove,
172
- }),
173
- });
174
- },
175
- };
176
- },
177
-
178
- async query(operation: string, input?: unknown, fields?: string[] | "*"): Promise<unknown> {
179
- return new Promise((resolve, reject) => {
180
- const id = nextId();
181
- pending.set(id, { resolve, reject });
182
-
183
- ws.onmessage?.({
184
- data: JSON.stringify({
185
- type: "query",
186
- id,
187
- operation,
188
- input,
189
- fields,
190
- }),
191
- });
192
- });
193
- },
194
-
195
- async mutate(operation: string, input: unknown): Promise<unknown> {
196
- return new Promise((resolve, reject) => {
197
- const id = nextId();
198
- pending.set(id, { resolve, reject });
199
-
200
- ws.onmessage?.({
201
- data: JSON.stringify({
202
- type: "mutation",
203
- id,
204
- operation,
205
- input,
206
- }),
207
- });
208
- });
209
- },
210
- };
211
- }
212
-
213
46
  // =============================================================================
214
47
  // Test: Basic Operations
215
48
  // =============================================================================
@@ -220,14 +53,15 @@ describe("E2E - Basic Operations", () => {
220
53
  .returns([User])
221
54
  .resolve(() => mockUsers);
222
55
 
223
- const server = createServer({
56
+ const server = createApp({
224
57
  entities: { User },
225
58
  queries: { getUsers },
226
59
  });
227
60
 
228
- const client = createMockClient(server);
229
- const result = await client.query("getUsers");
230
- expect(result).toEqual(mockUsers);
61
+ const result = await server.execute({ path: "getUsers" });
62
+
63
+ expect(result.error).toBeUndefined();
64
+ expect(result.data).toEqual(mockUsers);
231
65
  });
232
66
 
233
67
  it("query with input", async () => {
@@ -236,18 +70,22 @@ describe("E2E - Basic Operations", () => {
236
70
  .returns(User)
237
71
  .resolve(({ input }) => {
238
72
  const user = mockUsers.find((u) => u.id === input.id);
239
- if (!user) throw new Error("Not found");
73
+ if (!user) throw new Error("User not found");
240
74
  return user;
241
75
  });
242
76
 
243
- const server = createServer({
77
+ const server = createApp({
244
78
  entities: { User },
245
79
  queries: { getUser },
246
80
  });
247
81
 
248
- const client = createMockClient(server);
249
- const result = await client.query("getUser", { id: "user-1" });
250
- expect(result).toEqual(mockUsers[0]);
82
+ const result = await server.execute({
83
+ path: "getUser",
84
+ input: { id: "user-1" },
85
+ });
86
+
87
+ expect(result.error).toBeUndefined();
88
+ expect(result.data).toEqual(mockUsers[0]);
251
89
  });
252
90
 
253
91
  it("mutation", async () => {
@@ -258,312 +96,193 @@ describe("E2E - Basic Operations", () => {
258
96
  id: "user-new",
259
97
  name: input.name,
260
98
  email: input.email,
261
- status: "online",
99
+ status: "offline",
262
100
  }));
263
101
 
264
- const server = createServer({
102
+ const server = createApp({
265
103
  entities: { User },
266
104
  mutations: { createUser },
267
105
  });
268
106
 
269
- const client = createMockClient(server);
270
- const result = await client.mutate("createUser", {
107
+ const result = await server.execute({
108
+ path: "createUser",
109
+ input: { name: "Charlie", email: "charlie@example.com" },
110
+ });
111
+
112
+ expect(result.error).toBeUndefined();
113
+ expect(result.data).toEqual({
114
+ id: "user-new",
271
115
  name: "Charlie",
272
116
  email: "charlie@example.com",
117
+ status: "offline",
273
118
  });
274
- expect(result).toMatchObject({ name: "Charlie", email: "charlie@example.com" });
275
119
  });
276
- });
277
120
 
278
- // =============================================================================
279
- // Test: Subscriptions
280
- // =============================================================================
281
-
282
- describe("E2E - Subscriptions", () => {
283
- it("subscribe receives initial data", async () => {
284
- const getUser = query()
121
+ it("handles query errors", async () => {
122
+ const failingQuery = query()
285
123
  .input(z.object({ id: z.string() }))
286
- .returns(User)
287
- .resolve(({ input }) => {
288
- const user = mockUsers.find((u) => u.id === input.id);
289
- if (!user) throw new Error("Not found");
290
- return user;
124
+ .resolve(() => {
125
+ throw new Error("Query failed");
291
126
  });
292
127
 
293
- const server = createServer({
294
- entities: { User },
295
- queries: { getUser },
128
+ const server = createApp({
129
+ queries: { failingQuery },
296
130
  });
297
131
 
298
- const client = createMockClient(server);
299
- const received: unknown[] = [];
300
-
301
- client.subscribe("getUser", { id: "user-1" }, "*", {
302
- onData: (data) => received.push(data),
303
- onUpdate: () => {},
304
- onError: () => {},
305
- onComplete: () => {},
132
+ const result = await server.execute({
133
+ path: "failingQuery",
134
+ input: { id: "123" },
306
135
  });
307
136
 
308
- await new Promise((r) => setTimeout(r, 50));
309
-
310
- expect(received.length).toBeGreaterThanOrEqual(1);
311
- expect(received[0]).toMatchObject({ name: "Alice" });
137
+ expect(result.data).toBeUndefined();
138
+ expect(result.error).toBeInstanceOf(Error);
139
+ expect(result.error?.message).toBe("Query failed");
312
140
  });
313
141
 
314
- it("subscribe receives updates via emit", async () => {
315
- let emitFn: ((data: unknown) => void) | null = null;
142
+ it("handles unknown operation", async () => {
143
+ const server = createApp({});
316
144
 
317
- const watchUser = query()
318
- .input(z.object({ id: z.string() }))
319
- .returns(User)
320
- .resolve(({ input, ctx }) => {
321
- emitFn = ctx.emit;
322
- const user = mockUsers.find((u) => u.id === input.id);
323
- if (!user) throw new Error("Not found");
324
- return user;
325
- });
326
-
327
- const server = createServer({
328
- entities: { User },
329
- queries: { watchUser },
330
- });
331
-
332
- const client = createMockClient(server);
333
- const received: unknown[] = [];
334
-
335
- client.subscribe("watchUser", { id: "user-1" }, "*", {
336
- onData: (data) => received.push(data),
337
- onUpdate: () => {},
338
- onError: () => {},
339
- onComplete: () => {},
145
+ const result = await server.execute({
146
+ path: "unknownOperation",
147
+ input: {},
340
148
  });
341
149
 
342
- await new Promise((r) => setTimeout(r, 50));
343
-
344
- // Initial data
345
- expect(received.length).toBeGreaterThanOrEqual(1);
346
- expect(received[0]).toMatchObject({ name: "Alice" });
347
-
348
- const initialCount = received.length;
349
-
350
- // Emit update
351
- emitFn?.({ id: "user-1", name: "Alice Updated", email: "alice@example.com", status: "away" });
352
-
353
- await new Promise((r) => setTimeout(r, 50));
354
-
355
- // Should receive update
356
- expect(received.length).toBeGreaterThan(initialCount);
150
+ expect(result.data).toBeUndefined();
151
+ expect(result.error?.message).toContain("not found");
357
152
  });
153
+ });
358
154
 
359
- it("unsubscribe stops receiving updates", async () => {
360
- let emitFn: ((data: unknown) => void) | null = null;
155
+ // =============================================================================
156
+ // Test: Context
157
+ // =============================================================================
361
158
 
362
- const watchUser = query()
159
+ describe("E2E - Context", () => {
160
+ it("passes context to resolver", async () => {
161
+ let capturedContext: unknown = null;
162
+
163
+ const getUser = query()
363
164
  .input(z.object({ id: z.string() }))
364
- .returns(User)
365
- .resolve(({ input, ctx }) => {
366
- emitFn = ctx.emit;
367
- const user = mockUsers.find((u) => u.id === input.id);
368
- if (!user) throw new Error("Not found");
369
- return user;
165
+ .resolve(({ ctx }) => {
166
+ capturedContext = ctx;
167
+ return mockUsers[0];
370
168
  });
371
169
 
372
- const server = createServer({
373
- entities: { User },
374
- queries: { watchUser },
170
+ const server = createApp({
171
+ queries: { getUser },
172
+ context: () => ({ userId: "ctx-user-1", role: "admin" }),
375
173
  });
376
174
 
377
- const client = createMockClient(server);
378
- const received: unknown[] = [];
379
-
380
- const sub = client.subscribe("watchUser", { id: "user-1" }, "*", {
381
- onData: (data) => received.push(data),
382
- onUpdate: () => {},
383
- onError: () => {},
384
- onComplete: () => {},
175
+ await server.execute({
176
+ path: "getUser",
177
+ input: { id: "user-1" },
385
178
  });
386
179
 
387
- await new Promise((r) => setTimeout(r, 50));
388
-
389
- // Initial data
390
- const initialCount = received.length;
391
- expect(initialCount).toBeGreaterThanOrEqual(1);
392
-
393
- // Unsubscribe
394
- sub.unsubscribe();
395
-
396
- // Emit update after unsubscribe
397
- emitFn?.({ id: "user-1", name: "Alice Updated", email: "alice@example.com", status: "away" });
398
-
399
- await new Promise((r) => setTimeout(r, 50));
400
-
401
- // Should not receive after unsubscribe
402
- expect(received.length).toBe(initialCount);
180
+ expect(capturedContext).toMatchObject({
181
+ userId: "ctx-user-1",
182
+ role: "admin",
183
+ });
403
184
  });
404
- });
405
-
406
- // =============================================================================
407
- // Test: Server API
408
- // =============================================================================
409
185
 
410
- describe("E2E - Server API", () => {
411
- it("executes queries via mock client", async () => {
412
- const whoami = query()
413
- .returns(User)
414
- .resolve(() => mockUsers[0]);
186
+ it("supports async context factory", async () => {
187
+ let capturedContext: unknown = null;
415
188
 
416
- const searchUsers = query()
417
- .input(z.object({ query: z.string() }))
418
- .returns([User])
419
- .resolve(({ input }) => mockUsers.filter((u) => u.name.toLowerCase().includes(input.query.toLowerCase())));
189
+ const getUser = query()
190
+ .input(z.object({ id: z.string() }))
191
+ .resolve(({ ctx }) => {
192
+ capturedContext = ctx;
193
+ return mockUsers[0];
194
+ });
420
195
 
421
- const server = createServer({
422
- entities: { User },
423
- queries: { whoami, searchUsers },
196
+ const server = createApp({
197
+ queries: { getUser },
198
+ context: async () => {
199
+ await new Promise((r) => setTimeout(r, 10));
200
+ return { userId: "async-user" };
201
+ },
424
202
  });
425
203
 
426
- const client = createMockClient(server);
427
-
428
- const me = await client.query("whoami");
429
- expect(me).toMatchObject({ name: "Alice" });
430
-
431
- const users = await client.query("searchUsers", { query: "bob" });
432
- expect(users).toEqual([mockUsers[1]]);
433
- });
434
-
435
- it("executes mutations via mock client", async () => {
436
- const updateStatus = mutation()
437
- .input(z.object({ id: z.string(), status: z.string() }))
438
- .returns(User)
439
- .resolve(({ input }) => {
440
- const user = mockUsers.find((u) => u.id === input.id);
441
- if (!user) throw new Error("User not found");
442
- return { ...user, status: input.status };
443
- });
444
-
445
- const server = createServer({
446
- entities: { User },
447
- mutations: { updateStatus },
204
+ await server.execute({
205
+ path: "getUser",
206
+ input: { id: "user-1" },
448
207
  });
449
208
 
450
- const client = createMockClient(server);
451
- const result = await client.mutate("updateStatus", { id: "user-1", status: "busy" });
452
- expect(result).toMatchObject({ status: "busy" });
209
+ expect(capturedContext).toMatchObject({
210
+ userId: "async-user",
211
+ });
453
212
  });
454
213
  });
455
214
 
456
215
  // =============================================================================
457
- // Test: Cleanup (onCleanup)
216
+ // Test: Selection ($select)
458
217
  // =============================================================================
459
218
 
460
- describe("E2E - Cleanup", () => {
461
- it("calls cleanup on unsubscribe", async () => {
462
- let cleanedUp = false;
463
-
464
- const watchUser = query()
219
+ describe("E2E - Selection", () => {
220
+ it("applies $select to filter fields", async () => {
221
+ const getUser = query()
465
222
  .input(z.object({ id: z.string() }))
466
223
  .returns(User)
467
- .resolve(({ input, ctx }) => {
468
- ctx.onCleanup(() => {
469
- cleanedUp = true;
470
- });
224
+ .resolve(({ input }) => {
471
225
  const user = mockUsers.find((u) => u.id === input.id);
472
- if (!user) throw new Error("Not found");
226
+ if (!user) throw new Error("User not found");
473
227
  return user;
474
228
  });
475
229
 
476
- const server = createServer({
230
+ const server = createApp({
477
231
  entities: { User },
478
- queries: { watchUser },
232
+ queries: { getUser },
479
233
  });
480
234
 
481
- const client = createMockClient(server);
482
-
483
- const sub = client.subscribe("watchUser", { id: "user-1" }, "*", {
484
- onData: () => {},
485
- onUpdate: () => {},
486
- onError: () => {},
487
- onComplete: () => {},
235
+ const result = await server.execute({
236
+ path: "getUser",
237
+ input: {
238
+ id: "user-1",
239
+ $select: { name: true },
240
+ },
488
241
  });
489
242
 
490
- await new Promise((r) => setTimeout(r, 50));
491
-
492
- // Unsubscribe should trigger cleanup
493
- sub.unsubscribe();
494
- expect(cleanedUp).toBe(true);
243
+ expect(result.error).toBeUndefined();
244
+ // Should include id (always) and selected fields
245
+ expect(result.data).toEqual({
246
+ id: "user-1",
247
+ name: "Alice",
248
+ });
249
+ // Should not include unselected fields
250
+ expect((result.data as Record<string, unknown>).email).toBeUndefined();
251
+ expect((result.data as Record<string, unknown>).status).toBeUndefined();
495
252
  });
496
- });
497
-
498
- // =============================================================================
499
- // Test: GraphStateManager Integration
500
- // =============================================================================
501
-
502
- describe("E2E - GraphStateManager", () => {
503
- it("mutation updates are broadcast to subscribers", async () => {
504
- let emitFn: ((data: unknown) => void) | null = null;
505
253
 
254
+ it("includes id by default in selection", async () => {
506
255
  const getUser = query()
507
256
  .input(z.object({ id: z.string() }))
508
257
  .returns(User)
509
- .resolve(({ input, ctx }) => {
510
- emitFn = ctx.emit;
511
- const user = mockUsers.find((u) => u.id === input.id);
512
- if (!user) throw new Error("Not found");
513
- return user;
514
- });
515
-
516
- const updateUser = mutation()
517
- .input(z.object({ id: z.string(), name: z.string() }))
518
- .returns(User)
519
- .resolve(({ input }) => {
520
- const user = mockUsers.find((u) => u.id === input.id);
521
- if (!user) throw new Error("Not found");
522
- return { ...user, name: input.name };
523
- });
258
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id)!);
524
259
 
525
- const server = createServer({
260
+ const server = createApp({
526
261
  entities: { User },
527
262
  queries: { getUser },
528
- mutations: { updateUser },
529
263
  });
530
264
 
531
- const client = createMockClient(server);
532
- const received: unknown[] = [];
533
-
534
- // Subscribe to user
535
- client.subscribe("getUser", { id: "user-1" }, "*", {
536
- onData: (data) => received.push(data),
537
- onUpdate: () => {},
538
- onError: () => {},
539
- onComplete: () => {},
265
+ const result = await server.execute({
266
+ path: "getUser",
267
+ input: {
268
+ id: "user-1",
269
+ $select: { email: true },
270
+ },
540
271
  });
541
272
 
542
- await new Promise((r) => setTimeout(r, 50));
543
-
544
- // Initial data received
545
- expect(received.length).toBeGreaterThanOrEqual(1);
546
-
547
- // Execute mutation
548
- await client.mutate("updateUser", { id: "user-1", name: "Alice Updated" });
549
-
550
- // If using ctx.emit in the subscription, we can manually broadcast
551
- emitFn?.({ id: "user-1", name: "Alice Updated", email: "alice@example.com", status: "online" });
552
-
553
- await new Promise((r) => setTimeout(r, 50));
554
-
555
- // Should have received update
556
- expect(received.length).toBeGreaterThan(1);
273
+ expect(result.data).toEqual({
274
+ id: "user-1",
275
+ email: "alice@example.com",
276
+ });
557
277
  });
558
278
  });
559
279
 
560
280
  // =============================================================================
561
- // Test: Entity Resolvers and Nested Selection
281
+ // Test: Entity Resolvers
562
282
  // =============================================================================
563
283
 
564
284
  describe("E2E - Entity Resolvers", () => {
565
- it("executes entity resolvers for nested selection via $select", async () => {
566
- // Mock data
285
+ it("executes entity resolvers for nested selection", async () => {
567
286
  const users = [
568
287
  { id: "user-1", name: "Alice", email: "alice@example.com" },
569
288
  { id: "user-2", name: "Bob", email: "bob@example.com" },
@@ -592,26 +311,30 @@ describe("E2E - Entity Resolvers", () => {
592
311
  posts: f.many(Post).resolve(({ parent }) => posts.filter((p) => p.authorId === parent.id)),
593
312
  }));
594
313
 
595
- const server = createServer({
314
+ const server = createApp({
596
315
  entities: { User, Post },
597
316
  queries: { getUser },
598
317
  resolvers: [userResolver],
599
318
  });
600
319
 
601
320
  // Test with $select for nested posts
602
- const result = await server.executeQuery("getUser", {
603
- id: "user-1",
604
- $select: {
605
- name: true,
606
- posts: {
607
- select: {
608
- title: true,
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,
330
+ },
609
331
  },
610
332
  },
611
333
  },
612
334
  });
613
335
 
614
- expect(result).toMatchObject({
336
+ expect(result.error).toBeUndefined();
337
+ expect(result.data).toMatchObject({
615
338
  id: "user-1",
616
339
  name: "Alice",
617
340
  posts: [
@@ -645,33 +368,92 @@ describe("E2E - Entity Resolvers", () => {
645
368
  id: f.expose("id"),
646
369
  name: f.expose("name"),
647
370
  posts: f.many(Post).resolve(({ parent }) => {
648
- // Simple resolve - batching is not part of the new pattern
649
371
  batchCallCount++;
650
372
  return posts.filter((p) => p.authorId === parent.id);
651
373
  }),
652
374
  }));
653
375
 
654
- const server = createServer({
376
+ const server = createApp({
655
377
  entities: { User, Post },
656
378
  queries: { getUsers },
657
379
  resolvers: [userResolver],
658
380
  });
659
381
 
660
382
  // Execute query with nested selection for all users
661
- const result = await server.executeQuery("getUsers", {
662
- $select: {
663
- name: true,
664
- posts: {
665
- select: {
666
- title: true,
383
+ const result = await server.execute({
384
+ path: "getUsers",
385
+ input: {
386
+ $select: {
387
+ name: true,
388
+ posts: {
389
+ select: {
390
+ title: true,
391
+ },
667
392
  },
668
393
  },
669
394
  },
670
395
  });
671
396
 
672
- // With the new pattern, each user's posts resolver is called individually
673
- // So batch call count will be equal to number of users
674
- expect(batchCallCount).toBe(2);
675
- expect(result).toHaveLength(2);
397
+ expect(result.error).toBeUndefined();
398
+ // Resolvers are called - exact count depends on DataLoader batching behavior
399
+ expect(batchCallCount).toBeGreaterThanOrEqual(2);
400
+ expect(result.data).toHaveLength(2);
401
+ });
402
+ });
403
+
404
+ // =============================================================================
405
+ // Test: Metadata
406
+ // =============================================================================
407
+
408
+ describe("E2E - Metadata", () => {
409
+ it("returns correct metadata structure", () => {
410
+ const getUser = query()
411
+ .input(z.object({ id: z.string() }))
412
+ .returns(User)
413
+ .resolve(({ input }) => mockUsers.find((u) => u.id === input.id)!);
414
+
415
+ const createUser = mutation()
416
+ .input(z.object({ name: z.string() }))
417
+ .returns(User)
418
+ .resolve(({ input }) => ({ id: "new", name: input.name, email: "", status: "" }));
419
+
420
+ const server = createApp({
421
+ entities: { User },
422
+ queries: { getUser },
423
+ mutations: { createUser },
424
+ plugins: [optimisticPlugin()],
425
+ version: "2.0.0",
426
+ });
427
+
428
+ const metadata = server.getMetadata();
429
+
430
+ expect(metadata.version).toBe("2.0.0");
431
+ expect(metadata.operations.getUser).toEqual({ type: "query" });
432
+ expect(metadata.operations.createUser.type).toBe("mutation");
433
+ // createUser should have auto-derived optimistic hint (with plugin)
434
+ expect(metadata.operations.createUser.optimistic).toBeDefined();
435
+ });
436
+
437
+ it("auto-derives optimistic hints from naming with plugin", () => {
438
+ const updateUser = mutation()
439
+ .input(z.object({ id: z.string(), name: z.string() }))
440
+ .returns(User)
441
+ .resolve(({ input }) => ({ ...mockUsers[0], name: input.name }));
442
+
443
+ const deleteUser = mutation()
444
+ .input(z.object({ id: z.string() }))
445
+ .resolve(() => ({ success: true }));
446
+
447
+ const server = createApp({
448
+ mutations: { updateUser, deleteUser },
449
+ plugins: [optimisticPlugin()],
450
+ });
451
+
452
+ const metadata = server.getMetadata();
453
+
454
+ // updateUser should have 'merge' optimistic (with plugin)
455
+ expect(metadata.operations.updateUser.optimistic).toBeDefined();
456
+ // deleteUser should have 'delete' optimistic (with plugin)
457
+ expect(metadata.operations.deleteUser.optimistic).toBeDefined();
676
458
  });
677
459
  });