@sylphx/lens-server 2.1.0 → 2.3.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 +14 -3
- package/dist/index.js +299 -100
- package/package.json +2 -2
- package/src/e2e/server.test.ts +81 -61
- package/src/handlers/framework.ts +4 -3
- package/src/handlers/http.ts +7 -4
- package/src/handlers/ws.ts +18 -10
- package/src/server/create.test.ts +701 -47
- package/src/server/create.ts +492 -105
- package/src/server/selection.test.ts +253 -0
- package/src/server/types.ts +15 -2
|
@@ -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, resolver, router, t } 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
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
313
|
-
|
|
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
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
@@ -422,3 +444,635 @@ describe("type inference", () => {
|
|
|
422
444
|
expect((server as { _types?: unknown })._types).toBeUndefined(); // Runtime undefined, compile-time exists
|
|
423
445
|
});
|
|
424
446
|
});
|
|
447
|
+
|
|
448
|
+
// =============================================================================
|
|
449
|
+
// Field Resolver Tests (GraphQL-style per-field resolution)
|
|
450
|
+
// =============================================================================
|
|
451
|
+
|
|
452
|
+
describe("field resolvers", () => {
|
|
453
|
+
// Test entities for field resolver tests
|
|
454
|
+
const Author = entity("Author", {
|
|
455
|
+
id: t.id(),
|
|
456
|
+
name: t.string(),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const Post = entity("Post", {
|
|
460
|
+
id: t.id(),
|
|
461
|
+
title: t.string(),
|
|
462
|
+
content: t.string(),
|
|
463
|
+
published: t.boolean(),
|
|
464
|
+
authorId: t.string(),
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Mock data
|
|
468
|
+
const mockDb = {
|
|
469
|
+
authors: [
|
|
470
|
+
{ id: "a1", name: "Alice" },
|
|
471
|
+
{ id: "a2", name: "Bob" },
|
|
472
|
+
],
|
|
473
|
+
posts: [
|
|
474
|
+
{ id: "p1", title: "Post 1", content: "Content 1", published: true, authorId: "a1" },
|
|
475
|
+
{ id: "p2", title: "Post 2", content: "Content 2", published: false, authorId: "a1" },
|
|
476
|
+
{ id: "p3", title: "Post 3", content: "Content 3", published: true, authorId: "a1" },
|
|
477
|
+
{ id: "p4", title: "Post 4", content: "Content 4", published: true, authorId: "a2" },
|
|
478
|
+
],
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
type TestContext = { db: typeof mockDb };
|
|
482
|
+
|
|
483
|
+
it("resolves field with nested input args (like GraphQL)", async () => {
|
|
484
|
+
// Define field resolver with args (like GraphQL)
|
|
485
|
+
const authorResolver = resolver<TestContext>()(Author, (f) => ({
|
|
486
|
+
id: f.expose("id"),
|
|
487
|
+
name: f.expose("name"),
|
|
488
|
+
posts: f
|
|
489
|
+
.many(Post)
|
|
490
|
+
.args(
|
|
491
|
+
z.object({
|
|
492
|
+
limit: z.number().optional(),
|
|
493
|
+
published: z.boolean().optional(),
|
|
494
|
+
}),
|
|
495
|
+
)
|
|
496
|
+
.resolve(({ parent, args, ctx }) => {
|
|
497
|
+
let posts = ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
498
|
+
if (args.published !== undefined) {
|
|
499
|
+
posts = posts.filter((p) => p.published === args.published);
|
|
500
|
+
}
|
|
501
|
+
if (args.limit !== undefined) {
|
|
502
|
+
posts = posts.slice(0, args.limit);
|
|
503
|
+
}
|
|
504
|
+
return posts;
|
|
505
|
+
}),
|
|
506
|
+
}));
|
|
507
|
+
|
|
508
|
+
const getAuthor = query<TestContext>()
|
|
509
|
+
.input(z.object({ id: z.string() }))
|
|
510
|
+
.returns(Author)
|
|
511
|
+
.resolve(({ input, ctx }) => {
|
|
512
|
+
const author = ctx.db.authors.find((a) => a.id === input.id);
|
|
513
|
+
if (!author) throw new Error("Author not found");
|
|
514
|
+
return author;
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const server = createApp({
|
|
518
|
+
entities: { Author, Post },
|
|
519
|
+
queries: { getAuthor },
|
|
520
|
+
resolvers: [authorResolver],
|
|
521
|
+
context: () => ({ db: mockDb }),
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Test with nested input: get author with only published posts, limit 2
|
|
525
|
+
const result = await firstValueFrom(
|
|
526
|
+
server.execute({
|
|
527
|
+
path: "getAuthor",
|
|
528
|
+
input: {
|
|
529
|
+
id: "a1",
|
|
530
|
+
$select: {
|
|
531
|
+
id: true,
|
|
532
|
+
name: true,
|
|
533
|
+
posts: {
|
|
534
|
+
input: { published: true, limit: 2 },
|
|
535
|
+
select: { id: true, title: true },
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
expect(result.error).toBeUndefined();
|
|
543
|
+
expect(result.data).toBeDefined();
|
|
544
|
+
|
|
545
|
+
const data = result.data as { id: string; name: string; posts: { id: string; title: string }[] };
|
|
546
|
+
expect(data.id).toBe("a1");
|
|
547
|
+
expect(data.name).toBe("Alice");
|
|
548
|
+
expect(data.posts).toHaveLength(2); // limit: 2
|
|
549
|
+
expect(data.posts.every((p) => p.title)).toBe(true); // only selected fields
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("passes context to field resolvers", async () => {
|
|
553
|
+
let capturedContext: TestContext | null = null;
|
|
554
|
+
|
|
555
|
+
const authorResolver = resolver<TestContext>()(Author, (f) => ({
|
|
556
|
+
id: f.expose("id"),
|
|
557
|
+
name: f.expose("name"),
|
|
558
|
+
posts: f.many(Post).resolve(({ parent, ctx }) => {
|
|
559
|
+
capturedContext = ctx;
|
|
560
|
+
return ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
561
|
+
}),
|
|
562
|
+
}));
|
|
563
|
+
|
|
564
|
+
const getAuthor = query<TestContext>()
|
|
565
|
+
.input(z.object({ id: z.string() }))
|
|
566
|
+
.returns(Author)
|
|
567
|
+
.resolve(({ input, ctx }) => {
|
|
568
|
+
const author = ctx.db.authors.find((a) => a.id === input.id);
|
|
569
|
+
if (!author) throw new Error("Author not found");
|
|
570
|
+
return author;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const server = createApp({
|
|
574
|
+
entities: { Author, Post },
|
|
575
|
+
queries: { getAuthor },
|
|
576
|
+
resolvers: [authorResolver],
|
|
577
|
+
context: () => ({ db: mockDb }),
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
await firstValueFrom(
|
|
581
|
+
server.execute({
|
|
582
|
+
path: "getAuthor",
|
|
583
|
+
input: {
|
|
584
|
+
id: "a1",
|
|
585
|
+
$select: {
|
|
586
|
+
id: true,
|
|
587
|
+
posts: true,
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
}),
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
expect(capturedContext).toBeDefined();
|
|
594
|
+
expect(capturedContext?.db).toBe(mockDb);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("supports nested input at multiple levels", async () => {
|
|
598
|
+
// Comment entity for deeper nesting
|
|
599
|
+
const Comment = entity("Comment", {
|
|
600
|
+
id: t.id(),
|
|
601
|
+
body: t.string(),
|
|
602
|
+
postId: t.string(),
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const mockDbWithComments = {
|
|
606
|
+
...mockDb,
|
|
607
|
+
comments: [
|
|
608
|
+
{ id: "c1", body: "Comment 1", postId: "p1" },
|
|
609
|
+
{ id: "c2", body: "Comment 2", postId: "p1" },
|
|
610
|
+
{ id: "c3", body: "Comment 3", postId: "p2" },
|
|
611
|
+
],
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
type CtxWithComments = { db: typeof mockDbWithComments };
|
|
615
|
+
|
|
616
|
+
const postResolver = resolver<CtxWithComments>()(Post, (f) => ({
|
|
617
|
+
id: f.expose("id"),
|
|
618
|
+
title: f.expose("title"),
|
|
619
|
+
comments: f
|
|
620
|
+
.many(Comment)
|
|
621
|
+
.args(z.object({ limit: z.number().optional() }))
|
|
622
|
+
.resolve(({ parent, args, ctx }) => {
|
|
623
|
+
let comments = ctx.db.comments.filter((c) => c.postId === parent.id);
|
|
624
|
+
if (args.limit !== undefined) {
|
|
625
|
+
comments = comments.slice(0, args.limit);
|
|
626
|
+
}
|
|
627
|
+
return comments;
|
|
628
|
+
}),
|
|
629
|
+
}));
|
|
630
|
+
|
|
631
|
+
const authorResolver = resolver<CtxWithComments>()(Author, (f) => ({
|
|
632
|
+
id: f.expose("id"),
|
|
633
|
+
name: f.expose("name"),
|
|
634
|
+
posts: f
|
|
635
|
+
.many(Post)
|
|
636
|
+
.args(z.object({ limit: z.number().optional() }))
|
|
637
|
+
.resolve(({ parent, args, ctx }) => {
|
|
638
|
+
let posts = ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
639
|
+
if (args.limit !== undefined) {
|
|
640
|
+
posts = posts.slice(0, args.limit);
|
|
641
|
+
}
|
|
642
|
+
return posts;
|
|
643
|
+
}),
|
|
644
|
+
}));
|
|
645
|
+
|
|
646
|
+
const getAuthor = query<CtxWithComments>()
|
|
647
|
+
.input(z.object({ id: z.string() }))
|
|
648
|
+
.returns(Author)
|
|
649
|
+
.resolve(({ input, ctx }) => {
|
|
650
|
+
const author = ctx.db.authors.find((a) => a.id === input.id);
|
|
651
|
+
if (!author) throw new Error("Author not found");
|
|
652
|
+
return author;
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const server = createApp({
|
|
656
|
+
entities: { Author, Post, Comment },
|
|
657
|
+
queries: { getAuthor },
|
|
658
|
+
resolvers: [authorResolver, postResolver],
|
|
659
|
+
context: () => ({ db: mockDbWithComments }),
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// Nested input at multiple levels:
|
|
663
|
+
// Author.posts(limit: 1) -> Post.comments(limit: 1)
|
|
664
|
+
const result = await firstValueFrom(
|
|
665
|
+
server.execute({
|
|
666
|
+
path: "getAuthor",
|
|
667
|
+
input: {
|
|
668
|
+
id: "a1",
|
|
669
|
+
$select: {
|
|
670
|
+
id: true,
|
|
671
|
+
posts: {
|
|
672
|
+
input: { limit: 1 },
|
|
673
|
+
select: {
|
|
674
|
+
id: true,
|
|
675
|
+
title: true,
|
|
676
|
+
comments: {
|
|
677
|
+
input: { limit: 1 },
|
|
678
|
+
select: { id: true, body: true },
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
}),
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
expect(result.error).toBeUndefined();
|
|
688
|
+
const data = result.data as any;
|
|
689
|
+
expect(data.posts).toHaveLength(1);
|
|
690
|
+
expect(data.posts[0].comments).toHaveLength(1);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("works without nested input (default args)", async () => {
|
|
694
|
+
const authorResolver = resolver<TestContext>()(Author, (f) => ({
|
|
695
|
+
id: f.expose("id"),
|
|
696
|
+
name: f.expose("name"),
|
|
697
|
+
posts: f
|
|
698
|
+
.many(Post)
|
|
699
|
+
.args(z.object({ limit: z.number().default(10) }))
|
|
700
|
+
.resolve(({ parent, args, ctx }) => {
|
|
701
|
+
return ctx.db.posts.filter((p) => p.authorId === parent.id).slice(0, args.limit);
|
|
702
|
+
}),
|
|
703
|
+
}));
|
|
704
|
+
|
|
705
|
+
const getAuthor = query<TestContext>()
|
|
706
|
+
.input(z.object({ id: z.string() }))
|
|
707
|
+
.returns(Author)
|
|
708
|
+
.resolve(({ input, ctx }) => {
|
|
709
|
+
const author = ctx.db.authors.find((a) => a.id === input.id);
|
|
710
|
+
if (!author) throw new Error("Author not found");
|
|
711
|
+
return author;
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
const server = createApp({
|
|
715
|
+
entities: { Author, Post },
|
|
716
|
+
queries: { getAuthor },
|
|
717
|
+
resolvers: [authorResolver],
|
|
718
|
+
context: () => ({ db: mockDb }),
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// Without nested input - should use default args
|
|
722
|
+
const result = await firstValueFrom(
|
|
723
|
+
server.execute({
|
|
724
|
+
path: "getAuthor",
|
|
725
|
+
input: {
|
|
726
|
+
id: "a1",
|
|
727
|
+
$select: {
|
|
728
|
+
id: true,
|
|
729
|
+
posts: { select: { id: true } },
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
}),
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
expect(result.error).toBeUndefined();
|
|
736
|
+
const data = result.data as any;
|
|
737
|
+
expect(data.posts).toHaveLength(3); // All of Alice's posts (default limit 10)
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("emit() goes through field resolvers", async () => {
|
|
741
|
+
// Track how many times posts resolver is called
|
|
742
|
+
let postsResolverCallCount = 0;
|
|
743
|
+
|
|
744
|
+
const Author = entity("Author", {
|
|
745
|
+
id: t.id(),
|
|
746
|
+
name: t.string(),
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const Post = entity("Post", {
|
|
750
|
+
id: t.id(),
|
|
751
|
+
title: t.string(),
|
|
752
|
+
authorId: t.string(),
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const mockDb = {
|
|
756
|
+
authors: [{ id: "a1", name: "Alice" }],
|
|
757
|
+
posts: [
|
|
758
|
+
{ id: "p1", title: "Post 1", authorId: "a1" },
|
|
759
|
+
{ id: "p2", title: "Post 2", authorId: "a1" },
|
|
760
|
+
],
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
|
|
764
|
+
id: f.expose("id"),
|
|
765
|
+
name: f.expose("name"),
|
|
766
|
+
posts: f.many(Post).resolve(({ parent, ctx }) => {
|
|
767
|
+
postsResolverCallCount++;
|
|
768
|
+
return ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
769
|
+
}),
|
|
770
|
+
}));
|
|
771
|
+
|
|
772
|
+
type EmitFn = ((data: unknown) => void) & { merge: (partial: unknown) => void };
|
|
773
|
+
let capturedEmit: EmitFn | null = null;
|
|
774
|
+
|
|
775
|
+
const getAuthor = query<{ db: typeof mockDb; emit: EmitFn }>()
|
|
776
|
+
.input(z.object({ id: z.string() }))
|
|
777
|
+
.returns(Author)
|
|
778
|
+
.resolve(({ input, ctx }) => {
|
|
779
|
+
capturedEmit = ctx.emit as EmitFn;
|
|
780
|
+
const author = ctx.db.authors.find((a) => a.id === input.id);
|
|
781
|
+
if (!author) throw new Error("Author not found");
|
|
782
|
+
return author;
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
const server = createApp({
|
|
786
|
+
entities: { Author, Post },
|
|
787
|
+
queries: { getAuthor },
|
|
788
|
+
resolvers: [authorResolver],
|
|
789
|
+
context: () => ({ db: mockDb }),
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Subscribe to query with nested posts
|
|
793
|
+
const results: unknown[] = [];
|
|
794
|
+
const subscription = server
|
|
795
|
+
.execute({
|
|
796
|
+
path: "getAuthor",
|
|
797
|
+
input: {
|
|
798
|
+
id: "a1",
|
|
799
|
+
$select: {
|
|
800
|
+
id: true,
|
|
801
|
+
name: true,
|
|
802
|
+
posts: { select: { id: true, title: true } },
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
})
|
|
806
|
+
.subscribe({
|
|
807
|
+
next: (result) => {
|
|
808
|
+
results.push(result);
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// Wait for initial result
|
|
813
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
814
|
+
|
|
815
|
+
expect(results.length).toBe(1);
|
|
816
|
+
expect(postsResolverCallCount).toBe(1); // Called once for initial query
|
|
817
|
+
|
|
818
|
+
// Add a new post to the mock DB
|
|
819
|
+
mockDb.posts.push({ id: "p3", title: "Post 3", authorId: "a1" });
|
|
820
|
+
|
|
821
|
+
// Emit updated author (this should trigger field resolvers)
|
|
822
|
+
capturedEmit!({ id: "a1", name: "Alice Updated" });
|
|
823
|
+
|
|
824
|
+
// Wait for emit to process
|
|
825
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
826
|
+
|
|
827
|
+
expect(results.length).toBe(2);
|
|
828
|
+
expect(postsResolverCallCount).toBe(2); // Called again after emit!
|
|
829
|
+
|
|
830
|
+
// Verify the emitted result includes the new post (from re-running posts resolver)
|
|
831
|
+
const latestResult = results[1] as { data: { posts: { id: string }[] } };
|
|
832
|
+
expect(latestResult.data.posts).toHaveLength(3); // Should have 3 posts now
|
|
833
|
+
|
|
834
|
+
subscription.unsubscribe();
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it("field resolvers receive onCleanup for cleanup registration", async () => {
|
|
838
|
+
let cleanupCalled = false;
|
|
839
|
+
let resolverReceivedOnCleanup = false;
|
|
840
|
+
|
|
841
|
+
const Author = entity("Author", {
|
|
842
|
+
id: t.id(),
|
|
843
|
+
name: t.string(),
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
const Post = entity("Post", {
|
|
847
|
+
id: t.id(),
|
|
848
|
+
title: t.string(),
|
|
849
|
+
authorId: t.string(),
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
const mockDb = {
|
|
853
|
+
authors: [{ id: "a1", name: "Alice" }],
|
|
854
|
+
posts: [{ id: "p1", title: "Post 1", authorId: "a1" }],
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
|
|
858
|
+
id: f.expose("id"),
|
|
859
|
+
name: f.expose("name"),
|
|
860
|
+
posts: f.many(Post).resolve(({ parent, ctx }) => {
|
|
861
|
+
// Track if onCleanup was received (via ctx)
|
|
862
|
+
resolverReceivedOnCleanup = ctx.onCleanup !== undefined;
|
|
863
|
+
|
|
864
|
+
// Register a cleanup if available
|
|
865
|
+
if (ctx.onCleanup) {
|
|
866
|
+
ctx.onCleanup(() => {
|
|
867
|
+
cleanupCalled = true;
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
872
|
+
}),
|
|
873
|
+
}));
|
|
874
|
+
|
|
875
|
+
const getAuthor = query<{ db: typeof mockDb }>()
|
|
876
|
+
.input(z.object({ id: z.string() }))
|
|
877
|
+
.returns(Author)
|
|
878
|
+
.resolve(({ input, ctx }) => {
|
|
879
|
+
const author = ctx.db.authors.find((a) => a.id === input.id);
|
|
880
|
+
if (!author) throw new Error("Author not found");
|
|
881
|
+
return author;
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
const server = createApp({
|
|
885
|
+
entities: { Author, Post },
|
|
886
|
+
queries: { getAuthor },
|
|
887
|
+
resolvers: [authorResolver],
|
|
888
|
+
context: () => ({ db: mockDb }),
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
const subscription = server
|
|
892
|
+
.execute({
|
|
893
|
+
path: "getAuthor",
|
|
894
|
+
input: {
|
|
895
|
+
id: "a1",
|
|
896
|
+
$select: {
|
|
897
|
+
id: true,
|
|
898
|
+
posts: { select: { id: true } },
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
})
|
|
902
|
+
.subscribe({});
|
|
903
|
+
|
|
904
|
+
// Wait for query to execute
|
|
905
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
906
|
+
|
|
907
|
+
// Verify field resolver received onCleanup
|
|
908
|
+
expect(resolverReceivedOnCleanup).toBe(true);
|
|
909
|
+
expect(cleanupCalled).toBe(false); // Not called yet
|
|
910
|
+
|
|
911
|
+
// Unsubscribe should trigger cleanup
|
|
912
|
+
subscription.unsubscribe();
|
|
913
|
+
|
|
914
|
+
// Cleanup should be called
|
|
915
|
+
expect(cleanupCalled).toBe(true);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("field-level emit updates specific field and notifies observer", async () => {
|
|
919
|
+
const Author = entity("Author", {
|
|
920
|
+
id: t.id(),
|
|
921
|
+
name: t.string(),
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const Post = entity("Post", {
|
|
925
|
+
id: t.id(),
|
|
926
|
+
title: t.string(),
|
|
927
|
+
authorId: t.string(),
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
const mockDb = {
|
|
931
|
+
authors: [{ id: "a1", name: "Alice" }],
|
|
932
|
+
posts: [
|
|
933
|
+
{ id: "p1", title: "Post 1", authorId: "a1" },
|
|
934
|
+
{ id: "p2", title: "Post 2", authorId: "a1" },
|
|
935
|
+
],
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// Track field emit
|
|
939
|
+
let capturedFieldEmit: ((value: unknown) => void) | undefined;
|
|
940
|
+
|
|
941
|
+
const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
|
|
942
|
+
id: f.expose("id"),
|
|
943
|
+
name: f.expose("name"),
|
|
944
|
+
posts: f.many(Post).resolve(({ parent, ctx }) => {
|
|
945
|
+
// Capture the field emit for later use
|
|
946
|
+
capturedFieldEmit = ctx.emit;
|
|
947
|
+
|
|
948
|
+
// Set up a mock subscription that will use field emit
|
|
949
|
+
if (ctx.emit && ctx.onCleanup) {
|
|
950
|
+
// Simulate subscription setup
|
|
951
|
+
ctx.onCleanup(() => {
|
|
952
|
+
capturedFieldEmit = undefined;
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return ctx.db.posts.filter((p) => p.authorId === parent.id);
|
|
957
|
+
}),
|
|
958
|
+
}));
|
|
959
|
+
|
|
960
|
+
const getAuthor = query<{ db: typeof mockDb }>()
|
|
961
|
+
.input(z.object({ id: z.string() }))
|
|
962
|
+
.returns(Author)
|
|
963
|
+
.resolve(({ input, ctx }) => {
|
|
964
|
+
const author = ctx.db.authors.find((a) => a.id === input.id);
|
|
965
|
+
if (!author) throw new Error("Author not found");
|
|
966
|
+
return author;
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
const server = createApp({
|
|
970
|
+
entities: { Author, Post },
|
|
971
|
+
queries: { getAuthor },
|
|
972
|
+
resolvers: [authorResolver],
|
|
973
|
+
context: () => ({ db: mockDb }),
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
const results: unknown[] = [];
|
|
977
|
+
const subscription = server
|
|
978
|
+
.execute({
|
|
979
|
+
path: "getAuthor",
|
|
980
|
+
input: {
|
|
981
|
+
id: "a1",
|
|
982
|
+
$select: {
|
|
983
|
+
id: true,
|
|
984
|
+
name: true,
|
|
985
|
+
posts: { select: { id: true, title: true } },
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
})
|
|
989
|
+
.subscribe({
|
|
990
|
+
next: (result) => {
|
|
991
|
+
results.push(result);
|
|
992
|
+
},
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// Wait for initial result
|
|
996
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
997
|
+
|
|
998
|
+
expect(results.length).toBe(1);
|
|
999
|
+
expect(capturedFieldEmit).toBeDefined();
|
|
1000
|
+
|
|
1001
|
+
const initialResult = results[0] as { data: { posts: { id: string }[] } };
|
|
1002
|
+
expect(initialResult.data.posts).toHaveLength(2);
|
|
1003
|
+
|
|
1004
|
+
// Use field-level emit to update just the posts field
|
|
1005
|
+
const newPosts = [
|
|
1006
|
+
{ id: "p1", title: "Updated Post 1", authorId: "a1" },
|
|
1007
|
+
{ id: "p2", title: "Updated Post 2", authorId: "a1" },
|
|
1008
|
+
{ id: "p3", title: "New Post 3", authorId: "a1" },
|
|
1009
|
+
];
|
|
1010
|
+
capturedFieldEmit!(newPosts);
|
|
1011
|
+
|
|
1012
|
+
// Wait for field emit to process
|
|
1013
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1014
|
+
|
|
1015
|
+
expect(results.length).toBe(2);
|
|
1016
|
+
const updatedResult = results[1] as { data: { posts: { id: string; title: string }[] } };
|
|
1017
|
+
expect(updatedResult.data.posts).toHaveLength(3);
|
|
1018
|
+
expect(updatedResult.data.posts[2].title).toBe("New Post 3");
|
|
1019
|
+
|
|
1020
|
+
subscription.unsubscribe();
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it("emit skips observer notification when value unchanged", async () => {
|
|
1024
|
+
type EmitFn = (data: { id: string; name: string }) => void;
|
|
1025
|
+
let capturedEmit: EmitFn | undefined;
|
|
1026
|
+
|
|
1027
|
+
// Query that captures emit for external use
|
|
1028
|
+
const liveQuery = query()
|
|
1029
|
+
.input(z.object({ id: z.string() }))
|
|
1030
|
+
.returns(User)
|
|
1031
|
+
.resolve(({ input, ctx }) => {
|
|
1032
|
+
capturedEmit = ctx.emit as EmitFn;
|
|
1033
|
+
return { id: input.id, name: "Initial" };
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const testRouter = router({ liveQuery });
|
|
1037
|
+
const app = createApp({ router: testRouter });
|
|
1038
|
+
|
|
1039
|
+
const results: unknown[] = [];
|
|
1040
|
+
const observable = app.execute({
|
|
1041
|
+
path: "liveQuery",
|
|
1042
|
+
input: { id: "1" },
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
const subscription = observable.subscribe({
|
|
1046
|
+
next: (value) => results.push(value),
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// Wait for initial result
|
|
1050
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1051
|
+
expect(results.length).toBe(1);
|
|
1052
|
+
const firstResult = results[0] as { data: { id: string; name: string } };
|
|
1053
|
+
expect(firstResult.data.name).toBe("Initial");
|
|
1054
|
+
|
|
1055
|
+
// Emit same value - should be skipped (equality check)
|
|
1056
|
+
capturedEmit!({ id: "1", name: "Initial" });
|
|
1057
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1058
|
+
expect(results.length).toBe(1); // Still 1, not 2
|
|
1059
|
+
|
|
1060
|
+
// Emit same value again - should be skipped
|
|
1061
|
+
capturedEmit!({ id: "1", name: "Initial" });
|
|
1062
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1063
|
+
expect(results.length).toBe(1); // Still 1
|
|
1064
|
+
|
|
1065
|
+
// Emit different value - should emit
|
|
1066
|
+
capturedEmit!({ id: "1", name: "Changed" });
|
|
1067
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1068
|
+
expect(results.length).toBe(2); // Now 2
|
|
1069
|
+
expect((results[1] as { data: { name: string } }).data.name).toBe("Changed");
|
|
1070
|
+
|
|
1071
|
+
// Emit same "Changed" value again - should be skipped
|
|
1072
|
+
capturedEmit!({ id: "1", name: "Changed" });
|
|
1073
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1074
|
+
expect(results.length).toBe(2); // Still 2
|
|
1075
|
+
|
|
1076
|
+
subscription.unsubscribe();
|
|
1077
|
+
});
|
|
1078
|
+
});
|