@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.
@@ -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
+ });