@sylphx/lens-server 2.2.0 → 2.3.1
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.js +142 -24
- package/package.json +2 -2
- package/src/server/create.test.ts +633 -1
- package/src/server/create.ts +308 -26
- package/src/server/selection.test.ts +253 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, it } from "bun:test";
|
|
9
|
-
import { entity, firstValueFrom, 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";
|
|
@@ -444,3 +444,635 @@ describe("type inference", () => {
|
|
|
444
444
|
expect((server as { _types?: unknown })._types).toBeUndefined(); // Runtime undefined, compile-time exists
|
|
445
445
|
});
|
|
446
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
|
+
});
|