@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.
- package/dist/index.d.ts +1262 -262
- package/dist/index.js +1714 -1154
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +123 -0
- package/src/server/types.ts +306 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
package/src/e2e/server.test.ts
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @lens - E2E Tests
|
|
3
3
|
*
|
|
4
|
-
* End-to-end tests for
|
|
5
|
-
* Tests
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
11
|
+
import { entity, lens, mutation, query, t } from "@sylphx/lens-core";
|
|
15
12
|
import { z } from "zod";
|
|
16
|
-
import {
|
|
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 =
|
|
56
|
+
const server = createApp({
|
|
224
57
|
entities: { User },
|
|
225
58
|
queries: { getUsers },
|
|
226
59
|
});
|
|
227
60
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
expect(result).
|
|
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("
|
|
73
|
+
if (!user) throw new Error("User not found");
|
|
240
74
|
return user;
|
|
241
75
|
});
|
|
242
76
|
|
|
243
|
-
const server =
|
|
77
|
+
const server = createApp({
|
|
244
78
|
entities: { User },
|
|
245
79
|
queries: { getUser },
|
|
246
80
|
});
|
|
247
81
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
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: "
|
|
99
|
+
status: "offline",
|
|
262
100
|
}));
|
|
263
101
|
|
|
264
|
-
const server =
|
|
102
|
+
const server = createApp({
|
|
265
103
|
entities: { User },
|
|
266
104
|
mutations: { createUser },
|
|
267
105
|
});
|
|
268
106
|
|
|
269
|
-
const
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
287
|
-
|
|
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 =
|
|
294
|
-
|
|
295
|
-
queries: { getUser },
|
|
128
|
+
const server = createApp({
|
|
129
|
+
queries: { failingQuery },
|
|
296
130
|
});
|
|
297
131
|
|
|
298
|
-
const
|
|
299
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
expect(
|
|
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("
|
|
315
|
-
|
|
142
|
+
it("handles unknown operation", async () => {
|
|
143
|
+
const server = createApp({});
|
|
316
144
|
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
155
|
+
// =============================================================================
|
|
156
|
+
// Test: Context
|
|
157
|
+
// =============================================================================
|
|
361
158
|
|
|
362
|
-
|
|
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
|
-
.
|
|
365
|
-
|
|
366
|
-
|
|
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 =
|
|
373
|
-
|
|
374
|
-
|
|
170
|
+
const server = createApp({
|
|
171
|
+
queries: { getUser },
|
|
172
|
+
context: () => ({ userId: "ctx-user-1", role: "admin" }),
|
|
375
173
|
});
|
|
376
174
|
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
|
417
|
-
.input(z.object({
|
|
418
|
-
.
|
|
419
|
-
|
|
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 =
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
209
|
+
expect(capturedContext).toMatchObject({
|
|
210
|
+
userId: "async-user",
|
|
211
|
+
});
|
|
453
212
|
});
|
|
454
213
|
});
|
|
455
214
|
|
|
456
215
|
// =============================================================================
|
|
457
|
-
// Test:
|
|
216
|
+
// Test: Selection ($select)
|
|
458
217
|
// =============================================================================
|
|
459
218
|
|
|
460
|
-
describe("E2E -
|
|
461
|
-
it("
|
|
462
|
-
|
|
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
|
|
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("
|
|
226
|
+
if (!user) throw new Error("User not found");
|
|
473
227
|
return user;
|
|
474
228
|
});
|
|
475
229
|
|
|
476
|
-
const server =
|
|
230
|
+
const server = createApp({
|
|
477
231
|
entities: { User },
|
|
478
|
-
queries: {
|
|
232
|
+
queries: { getUser },
|
|
479
233
|
});
|
|
480
234
|
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
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 =
|
|
260
|
+
const server = createApp({
|
|
526
261
|
entities: { User },
|
|
527
262
|
queries: { getUser },
|
|
528
|
-
mutations: { updateUser },
|
|
529
263
|
});
|
|
530
264
|
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
281
|
+
// Test: Entity Resolvers
|
|
562
282
|
// =============================================================================
|
|
563
283
|
|
|
564
284
|
describe("E2E - Entity Resolvers", () => {
|
|
565
|
-
it("executes entity resolvers for nested selection
|
|
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 =
|
|
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.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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).
|
|
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 =
|
|
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.
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
673
|
-
//
|
|
674
|
-
expect(batchCallCount).
|
|
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
|
});
|