@sylphx/lens-server 1.0.2
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 +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1437 -0
- package/dist/server/create.d.ts +226 -0
- package/dist/server/create.d.ts.map +1 -0
- package/dist/sse/handler.d.ts +78 -0
- package/dist/sse/handler.d.ts.map +1 -0
- package/dist/state/graph-state-manager.d.ts +146 -0
- package/dist/state/graph-state-manager.d.ts.map +1 -0
- package/dist/state/index.d.ts +7 -0
- package/dist/state/index.d.ts.map +1 -0
- package/package.json +39 -0
- package/src/e2e/server.test.ts +666 -0
- package/src/index.ts +67 -0
- package/src/server/create.test.ts +807 -0
- package/src/server/create.ts +1536 -0
- package/src/sse/handler.ts +185 -0
- package/src/state/graph-state-manager.test.ts +334 -0
- package/src/state/graph-state-manager.ts +443 -0
- package/src/state/index.ts +16 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lens - E2E Tests
|
|
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
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from "bun:test";
|
|
14
|
+
import { type Update, applyUpdate, entity, mutation, query, t } from "@sylphx/lens-core";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { type WebSocketLike, createServer } from "../server/create";
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Test Fixtures
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
// Entities
|
|
23
|
+
const User = entity("User", {
|
|
24
|
+
id: t.id(),
|
|
25
|
+
name: t.string(),
|
|
26
|
+
email: t.string(),
|
|
27
|
+
status: t.string(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const Post = entity("Post", {
|
|
31
|
+
id: t.id(),
|
|
32
|
+
title: t.string(),
|
|
33
|
+
content: t.string(),
|
|
34
|
+
authorId: t.string(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Mock data
|
|
38
|
+
const mockUsers = [
|
|
39
|
+
{ id: "user-1", name: "Alice", email: "alice@example.com", status: "online" },
|
|
40
|
+
{ id: "user-2", name: "Bob", email: "bob@example.com", status: "offline" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const mockPosts = [
|
|
44
|
+
{ id: "post-1", title: "Hello", content: "World", authorId: "user-1" },
|
|
45
|
+
{ id: "post-2", title: "Test", content: "Post", authorId: "user-1" },
|
|
46
|
+
];
|
|
47
|
+
|
|
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<
|
|
69
|
+
string,
|
|
70
|
+
{ resolve: (data: unknown) => void; reject: (error: Error) => void }
|
|
71
|
+
>();
|
|
72
|
+
|
|
73
|
+
let messageIdCounter = 0;
|
|
74
|
+
const nextId = () => `msg_${++messageIdCounter}`;
|
|
75
|
+
|
|
76
|
+
// Mock WebSocket interface for server
|
|
77
|
+
const ws: WebSocketLike & { messages: unknown[] } = {
|
|
78
|
+
messages,
|
|
79
|
+
send: (data: string) => {
|
|
80
|
+
const msg = JSON.parse(data);
|
|
81
|
+
messages.push(msg);
|
|
82
|
+
|
|
83
|
+
// Route message to appropriate handler
|
|
84
|
+
if (msg.type === "data" || msg.type === "result") {
|
|
85
|
+
// Response to pending request or subscription initial data
|
|
86
|
+
const pendingReq = pending.get(msg.id);
|
|
87
|
+
if (pendingReq) {
|
|
88
|
+
pending.delete(msg.id);
|
|
89
|
+
pendingReq.resolve(msg.data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sub = subscriptions.get(msg.id);
|
|
93
|
+
if (sub) {
|
|
94
|
+
sub.lastData = msg.data;
|
|
95
|
+
sub.onData(msg.data);
|
|
96
|
+
}
|
|
97
|
+
} else if (msg.type === "update") {
|
|
98
|
+
const sub = subscriptions.get(msg.id);
|
|
99
|
+
if (sub) {
|
|
100
|
+
// Apply updates to last data
|
|
101
|
+
if (sub.lastData && typeof sub.lastData === "object" && msg.updates) {
|
|
102
|
+
const updated = { ...(sub.lastData as Record<string, unknown>) };
|
|
103
|
+
for (const [field, update] of Object.entries(msg.updates as Record<string, Update>)) {
|
|
104
|
+
updated[field] = applyUpdate(updated[field], update);
|
|
105
|
+
}
|
|
106
|
+
sub.lastData = updated;
|
|
107
|
+
sub.onData(updated);
|
|
108
|
+
}
|
|
109
|
+
sub.onUpdate(msg.updates);
|
|
110
|
+
}
|
|
111
|
+
} else if (msg.type === "error") {
|
|
112
|
+
const pendingReq = pending.get(msg.id);
|
|
113
|
+
if (pendingReq) {
|
|
114
|
+
pending.delete(msg.id);
|
|
115
|
+
pendingReq.reject(new Error(msg.error.message));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sub = subscriptions.get(msg.id);
|
|
119
|
+
if (sub) {
|
|
120
|
+
sub.onError(new Error(msg.error.message));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
close: () => {},
|
|
125
|
+
onmessage: null,
|
|
126
|
+
onclose: null,
|
|
127
|
+
onerror: null,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Connect server to mock WebSocket
|
|
131
|
+
server.handleWebSocket(ws);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
ws,
|
|
135
|
+
messages,
|
|
136
|
+
|
|
137
|
+
subscribe(
|
|
138
|
+
operation: string,
|
|
139
|
+
input: unknown,
|
|
140
|
+
fields: string[] | "*",
|
|
141
|
+
callbacks: {
|
|
142
|
+
onData: (data: unknown) => void;
|
|
143
|
+
onUpdate: (updates: Record<string, Update>) => void;
|
|
144
|
+
onError: (error: Error) => void;
|
|
145
|
+
onComplete: () => void;
|
|
146
|
+
},
|
|
147
|
+
) {
|
|
148
|
+
const id = nextId();
|
|
149
|
+
subscriptions.set(id, { ...callbacks, lastData: null });
|
|
150
|
+
|
|
151
|
+
// Send subscribe message to server
|
|
152
|
+
ws.onmessage?.({
|
|
153
|
+
data: JSON.stringify({
|
|
154
|
+
type: "subscribe",
|
|
155
|
+
id,
|
|
156
|
+
operation,
|
|
157
|
+
input,
|
|
158
|
+
fields,
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
unsubscribe: () => {
|
|
164
|
+
subscriptions.delete(id);
|
|
165
|
+
ws.onmessage?.({ data: JSON.stringify({ type: "unsubscribe", id }) });
|
|
166
|
+
callbacks.onComplete();
|
|
167
|
+
},
|
|
168
|
+
updateFields: (add?: string[], remove?: string[]) => {
|
|
169
|
+
ws.onmessage?.({
|
|
170
|
+
data: JSON.stringify({
|
|
171
|
+
type: "updateFields",
|
|
172
|
+
id,
|
|
173
|
+
addFields: add,
|
|
174
|
+
removeFields: remove,
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async query(operation: string, input?: unknown, fields?: string[] | "*"): Promise<unknown> {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
const id = nextId();
|
|
184
|
+
pending.set(id, { resolve, reject });
|
|
185
|
+
|
|
186
|
+
ws.onmessage?.({
|
|
187
|
+
data: JSON.stringify({
|
|
188
|
+
type: "query",
|
|
189
|
+
id,
|
|
190
|
+
operation,
|
|
191
|
+
input,
|
|
192
|
+
fields,
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async mutate(operation: string, input: unknown): Promise<unknown> {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
const id = nextId();
|
|
201
|
+
pending.set(id, { resolve, reject });
|
|
202
|
+
|
|
203
|
+
ws.onmessage?.({
|
|
204
|
+
data: JSON.stringify({
|
|
205
|
+
type: "mutation",
|
|
206
|
+
id,
|
|
207
|
+
operation,
|
|
208
|
+
input,
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// =============================================================================
|
|
217
|
+
// Test: Basic Operations
|
|
218
|
+
// =============================================================================
|
|
219
|
+
|
|
220
|
+
describe("E2E - Basic Operations", () => {
|
|
221
|
+
it("query without input", async () => {
|
|
222
|
+
const getUsers = query()
|
|
223
|
+
.returns([User])
|
|
224
|
+
.resolve(() => mockUsers);
|
|
225
|
+
|
|
226
|
+
const server = createServer({
|
|
227
|
+
entities: { User },
|
|
228
|
+
queries: { getUsers },
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const client = createMockClient(server);
|
|
232
|
+
const result = await client.query("getUsers");
|
|
233
|
+
expect(result).toEqual(mockUsers);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("query with input", async () => {
|
|
237
|
+
const getUser = query()
|
|
238
|
+
.input(z.object({ id: z.string() }))
|
|
239
|
+
.returns(User)
|
|
240
|
+
.resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
|
|
241
|
+
|
|
242
|
+
const server = createServer({
|
|
243
|
+
entities: { User },
|
|
244
|
+
queries: { getUser },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const client = createMockClient(server);
|
|
248
|
+
const result = await client.query("getUser", { id: "user-1" });
|
|
249
|
+
expect(result).toEqual(mockUsers[0]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("mutation", async () => {
|
|
253
|
+
const createUser = mutation()
|
|
254
|
+
.input(z.object({ name: z.string(), email: z.string() }))
|
|
255
|
+
.returns(User)
|
|
256
|
+
.resolve(({ input }) => ({
|
|
257
|
+
id: "user-new",
|
|
258
|
+
name: input.name,
|
|
259
|
+
email: input.email,
|
|
260
|
+
status: "online",
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
const server = createServer({
|
|
264
|
+
entities: { User },
|
|
265
|
+
mutations: { createUser },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const client = createMockClient(server);
|
|
269
|
+
const result = await client.mutate("createUser", {
|
|
270
|
+
name: "Charlie",
|
|
271
|
+
email: "charlie@example.com",
|
|
272
|
+
});
|
|
273
|
+
expect(result).toMatchObject({ name: "Charlie", email: "charlie@example.com" });
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// =============================================================================
|
|
278
|
+
// Test: Subscriptions
|
|
279
|
+
// =============================================================================
|
|
280
|
+
|
|
281
|
+
describe("E2E - Subscriptions", () => {
|
|
282
|
+
it("subscribe receives initial data", async () => {
|
|
283
|
+
const getUser = query()
|
|
284
|
+
.input(z.object({ id: z.string() }))
|
|
285
|
+
.returns(User)
|
|
286
|
+
.resolve(({ input }) => mockUsers.find((u) => u.id === input.id) ?? null);
|
|
287
|
+
|
|
288
|
+
const server = createServer({
|
|
289
|
+
entities: { User },
|
|
290
|
+
queries: { getUser },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const client = createMockClient(server);
|
|
294
|
+
const received: unknown[] = [];
|
|
295
|
+
|
|
296
|
+
client.subscribe("getUser", { id: "user-1" }, "*", {
|
|
297
|
+
onData: (data) => received.push(data),
|
|
298
|
+
onUpdate: () => {},
|
|
299
|
+
onError: () => {},
|
|
300
|
+
onComplete: () => {},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
304
|
+
|
|
305
|
+
expect(received.length).toBeGreaterThanOrEqual(1);
|
|
306
|
+
expect(received[0]).toMatchObject({ name: "Alice" });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("subscribe receives updates via ctx.emit", async () => {
|
|
310
|
+
let emitFn: ((data: unknown) => void) | null = null;
|
|
311
|
+
|
|
312
|
+
const watchUser = query()
|
|
313
|
+
.input(z.object({ id: z.string() }))
|
|
314
|
+
.returns(User)
|
|
315
|
+
.resolve(({ input, ctx }) => {
|
|
316
|
+
emitFn = ctx.emit;
|
|
317
|
+
return mockUsers.find((u) => u.id === input.id) ?? null;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const server = createServer({
|
|
321
|
+
entities: { User },
|
|
322
|
+
queries: { watchUser },
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const client = createMockClient(server);
|
|
326
|
+
const received: unknown[] = [];
|
|
327
|
+
|
|
328
|
+
client.subscribe("watchUser", { id: "user-1" }, "*", {
|
|
329
|
+
onData: (data) => received.push(data),
|
|
330
|
+
onUpdate: () => {},
|
|
331
|
+
onError: () => {},
|
|
332
|
+
onComplete: () => {},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
336
|
+
|
|
337
|
+
// Initial data
|
|
338
|
+
expect(received.length).toBeGreaterThanOrEqual(1);
|
|
339
|
+
expect(received[0]).toMatchObject({ name: "Alice" });
|
|
340
|
+
|
|
341
|
+
const initialCount = received.length;
|
|
342
|
+
|
|
343
|
+
// Emit update
|
|
344
|
+
emitFn?.({ id: "user-1", name: "Alice Updated", email: "alice@example.com", status: "away" });
|
|
345
|
+
|
|
346
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
347
|
+
|
|
348
|
+
// Should receive update
|
|
349
|
+
expect(received.length).toBeGreaterThan(initialCount);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("unsubscribe stops receiving updates", async () => {
|
|
353
|
+
let emitFn: ((data: unknown) => void) | null = null;
|
|
354
|
+
|
|
355
|
+
const watchUser = query()
|
|
356
|
+
.input(z.object({ id: z.string() }))
|
|
357
|
+
.returns(User)
|
|
358
|
+
.resolve(({ input, ctx }) => {
|
|
359
|
+
emitFn = ctx.emit;
|
|
360
|
+
return mockUsers.find((u) => u.id === input.id) ?? null;
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const server = createServer({
|
|
364
|
+
entities: { User },
|
|
365
|
+
queries: { watchUser },
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const client = createMockClient(server);
|
|
369
|
+
const received: unknown[] = [];
|
|
370
|
+
|
|
371
|
+
const sub = client.subscribe("watchUser", { id: "user-1" }, "*", {
|
|
372
|
+
onData: (data) => received.push(data),
|
|
373
|
+
onUpdate: () => {},
|
|
374
|
+
onError: () => {},
|
|
375
|
+
onComplete: () => {},
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
379
|
+
|
|
380
|
+
// Initial data
|
|
381
|
+
const initialCount = received.length;
|
|
382
|
+
expect(initialCount).toBeGreaterThanOrEqual(1);
|
|
383
|
+
|
|
384
|
+
// Unsubscribe
|
|
385
|
+
sub.unsubscribe();
|
|
386
|
+
|
|
387
|
+
// Emit update after unsubscribe
|
|
388
|
+
emitFn?.({ id: "user-1", name: "Alice Updated", email: "alice@example.com", status: "away" });
|
|
389
|
+
|
|
390
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
391
|
+
|
|
392
|
+
// Should not receive after unsubscribe
|
|
393
|
+
expect(received.length).toBe(initialCount);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// =============================================================================
|
|
398
|
+
// Test: Server API
|
|
399
|
+
// =============================================================================
|
|
400
|
+
|
|
401
|
+
describe("E2E - Server API", () => {
|
|
402
|
+
it("executes queries via mock client", async () => {
|
|
403
|
+
const whoami = query()
|
|
404
|
+
.returns(User)
|
|
405
|
+
.resolve(() => mockUsers[0]);
|
|
406
|
+
|
|
407
|
+
const searchUsers = query()
|
|
408
|
+
.input(z.object({ query: z.string() }))
|
|
409
|
+
.returns([User])
|
|
410
|
+
.resolve(({ input }) =>
|
|
411
|
+
mockUsers.filter((u) => u.name.toLowerCase().includes(input.query.toLowerCase())),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const server = createServer({
|
|
415
|
+
entities: { User },
|
|
416
|
+
queries: { whoami, searchUsers },
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const client = createMockClient(server);
|
|
420
|
+
|
|
421
|
+
const me = await client.query("whoami");
|
|
422
|
+
expect(me).toMatchObject({ name: "Alice" });
|
|
423
|
+
|
|
424
|
+
const users = await client.query("searchUsers", { query: "bob" });
|
|
425
|
+
expect(users).toEqual([mockUsers[1]]);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("executes mutations via mock client", async () => {
|
|
429
|
+
const updateStatus = mutation()
|
|
430
|
+
.input(z.object({ id: z.string(), status: z.string() }))
|
|
431
|
+
.returns(User)
|
|
432
|
+
.resolve(({ input }) => {
|
|
433
|
+
const user = mockUsers.find((u) => u.id === input.id);
|
|
434
|
+
if (!user) throw new Error("User not found");
|
|
435
|
+
return { ...user, status: input.status };
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const server = createServer({
|
|
439
|
+
entities: { User },
|
|
440
|
+
mutations: { updateStatus },
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const client = createMockClient(server);
|
|
444
|
+
const result = await client.mutate("updateStatus", { id: "user-1", status: "busy" });
|
|
445
|
+
expect(result).toMatchObject({ status: "busy" });
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// =============================================================================
|
|
450
|
+
// Test: Cleanup (ctx.onCleanup)
|
|
451
|
+
// =============================================================================
|
|
452
|
+
|
|
453
|
+
describe("E2E - Cleanup", () => {
|
|
454
|
+
it("calls cleanup on unsubscribe", async () => {
|
|
455
|
+
let cleanedUp = false;
|
|
456
|
+
|
|
457
|
+
const watchUser = query()
|
|
458
|
+
.input(z.object({ id: z.string() }))
|
|
459
|
+
.returns(User)
|
|
460
|
+
.resolve(({ input, ctx }) => {
|
|
461
|
+
ctx.onCleanup(() => {
|
|
462
|
+
cleanedUp = true;
|
|
463
|
+
});
|
|
464
|
+
return mockUsers.find((u) => u.id === input.id) ?? null;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const server = createServer({
|
|
468
|
+
entities: { User },
|
|
469
|
+
queries: { watchUser },
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const client = createMockClient(server);
|
|
473
|
+
|
|
474
|
+
const sub = client.subscribe("watchUser", { id: "user-1" }, "*", {
|
|
475
|
+
onData: () => {},
|
|
476
|
+
onUpdate: () => {},
|
|
477
|
+
onError: () => {},
|
|
478
|
+
onComplete: () => {},
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
482
|
+
|
|
483
|
+
// Unsubscribe should trigger cleanup
|
|
484
|
+
sub.unsubscribe();
|
|
485
|
+
expect(cleanedUp).toBe(true);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// =============================================================================
|
|
490
|
+
// Test: GraphStateManager Integration
|
|
491
|
+
// =============================================================================
|
|
492
|
+
|
|
493
|
+
describe("E2E - GraphStateManager", () => {
|
|
494
|
+
it("mutation updates are broadcast to subscribers", async () => {
|
|
495
|
+
let emitFn: ((data: unknown) => void) | null = null;
|
|
496
|
+
|
|
497
|
+
const getUser = query()
|
|
498
|
+
.input(z.object({ id: z.string() }))
|
|
499
|
+
.returns(User)
|
|
500
|
+
.resolve(({ input, ctx }) => {
|
|
501
|
+
emitFn = ctx.emit;
|
|
502
|
+
return mockUsers.find((u) => u.id === input.id) ?? null;
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const updateUser = mutation()
|
|
506
|
+
.input(z.object({ id: z.string(), name: z.string() }))
|
|
507
|
+
.returns(User)
|
|
508
|
+
.resolve(({ input }) => {
|
|
509
|
+
const user = mockUsers.find((u) => u.id === input.id);
|
|
510
|
+
if (!user) throw new Error("Not found");
|
|
511
|
+
return { ...user, name: input.name };
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const server = createServer({
|
|
515
|
+
entities: { User },
|
|
516
|
+
queries: { getUser },
|
|
517
|
+
mutations: { updateUser },
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const client = createMockClient(server);
|
|
521
|
+
const received: unknown[] = [];
|
|
522
|
+
|
|
523
|
+
// Subscribe to user
|
|
524
|
+
client.subscribe("getUser", { id: "user-1" }, "*", {
|
|
525
|
+
onData: (data) => received.push(data),
|
|
526
|
+
onUpdate: () => {},
|
|
527
|
+
onError: () => {},
|
|
528
|
+
onComplete: () => {},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
532
|
+
|
|
533
|
+
// Initial data received
|
|
534
|
+
expect(received.length).toBeGreaterThanOrEqual(1);
|
|
535
|
+
|
|
536
|
+
// Execute mutation
|
|
537
|
+
await client.mutate("updateUser", { id: "user-1", name: "Alice Updated" });
|
|
538
|
+
|
|
539
|
+
// If using ctx.emit in the subscription, we can manually broadcast
|
|
540
|
+
emitFn?.({ id: "user-1", name: "Alice Updated", email: "alice@example.com", status: "online" });
|
|
541
|
+
|
|
542
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
543
|
+
|
|
544
|
+
// Should have received update
|
|
545
|
+
expect(received.length).toBeGreaterThan(1);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// =============================================================================
|
|
550
|
+
// Test: Entity Resolvers and Nested Selection
|
|
551
|
+
// =============================================================================
|
|
552
|
+
|
|
553
|
+
describe("E2E - Entity Resolvers", () => {
|
|
554
|
+
it("executes entity resolvers for nested selection via $select", async () => {
|
|
555
|
+
// Mock data
|
|
556
|
+
const users = [
|
|
557
|
+
{ id: "user-1", name: "Alice", email: "alice@example.com" },
|
|
558
|
+
{ id: "user-2", name: "Bob", email: "bob@example.com" },
|
|
559
|
+
];
|
|
560
|
+
|
|
561
|
+
const posts = [
|
|
562
|
+
{ id: "post-1", title: "Hello World", content: "First post", authorId: "user-1" },
|
|
563
|
+
{ id: "post-2", title: "Second Post", content: "More content", authorId: "user-1" },
|
|
564
|
+
];
|
|
565
|
+
|
|
566
|
+
const getUser = query()
|
|
567
|
+
.input(z.object({ id: z.string() }))
|
|
568
|
+
.returns(User)
|
|
569
|
+
.resolve(({ input }) => users.find((u) => u.id === input.id) ?? null);
|
|
570
|
+
|
|
571
|
+
// Create entity resolvers for User.posts
|
|
572
|
+
const resolvers = {
|
|
573
|
+
getResolver: (entityName: string, fieldName: string) => {
|
|
574
|
+
if (entityName === "User" && fieldName === "posts") {
|
|
575
|
+
return (user: { id: string }) => posts.filter((p) => p.authorId === user.id);
|
|
576
|
+
}
|
|
577
|
+
return undefined;
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const server = createServer({
|
|
582
|
+
entities: { User, Post },
|
|
583
|
+
queries: { getUser },
|
|
584
|
+
resolvers: resolvers as any,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Test with $select for nested posts
|
|
588
|
+
const result = await server.executeQuery("getUser", {
|
|
589
|
+
id: "user-1",
|
|
590
|
+
$select: {
|
|
591
|
+
name: true,
|
|
592
|
+
posts: {
|
|
593
|
+
select: {
|
|
594
|
+
title: true,
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
expect(result).toMatchObject({
|
|
601
|
+
id: "user-1",
|
|
602
|
+
name: "Alice",
|
|
603
|
+
posts: [
|
|
604
|
+
{ id: "post-1", title: "Hello World" },
|
|
605
|
+
{ id: "post-2", title: "Second Post" },
|
|
606
|
+
],
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("handles DataLoader batching for entity resolvers", async () => {
|
|
611
|
+
// Track batch calls
|
|
612
|
+
let batchCallCount = 0;
|
|
613
|
+
|
|
614
|
+
const users = [
|
|
615
|
+
{ id: "user-1", name: "Alice" },
|
|
616
|
+
{ id: "user-2", name: "Bob" },
|
|
617
|
+
];
|
|
618
|
+
|
|
619
|
+
const posts = [
|
|
620
|
+
{ id: "post-1", title: "Post 1", authorId: "user-1" },
|
|
621
|
+
{ id: "post-2", title: "Post 2", authorId: "user-2" },
|
|
622
|
+
];
|
|
623
|
+
|
|
624
|
+
const getUsers = query()
|
|
625
|
+
.returns([User])
|
|
626
|
+
.resolve(() => users);
|
|
627
|
+
|
|
628
|
+
// Create batch resolver for User.posts (object with batch property)
|
|
629
|
+
const resolvers = {
|
|
630
|
+
getResolver: (entityName: string, fieldName: string) => {
|
|
631
|
+
if (entityName === "User" && fieldName === "posts") {
|
|
632
|
+
// Return batch resolver object (not a function with batch attached)
|
|
633
|
+
return {
|
|
634
|
+
batch: async (parents: { id: string }[]) => {
|
|
635
|
+
batchCallCount++;
|
|
636
|
+
return parents.map((parent) => posts.filter((p) => p.authorId === parent.id));
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return undefined;
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const server = createServer({
|
|
645
|
+
entities: { User, Post },
|
|
646
|
+
queries: { getUsers },
|
|
647
|
+
resolvers: resolvers as any,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Execute query with nested selection for all users
|
|
651
|
+
const result = await server.executeQuery("getUsers", {
|
|
652
|
+
$select: {
|
|
653
|
+
name: true,
|
|
654
|
+
posts: {
|
|
655
|
+
select: {
|
|
656
|
+
title: true,
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// Should have batched the posts resolution into a single call
|
|
663
|
+
expect(batchCallCount).toBe(1);
|
|
664
|
+
expect(result).toHaveLength(2);
|
|
665
|
+
});
|
|
666
|
+
});
|