@sylphx/lens-server 3.0.1 → 4.0.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.
@@ -13,17 +13,21 @@
13
13
  import { describe, expect, it } from "bun:test";
14
14
  import {
15
15
  applyOps,
16
- entity,
16
+ boolean,
17
17
  firstValueFrom,
18
+ id,
18
19
  isError,
19
20
  isOps,
20
21
  isSnapshot,
22
+ list,
21
23
  type Message,
24
+ model,
22
25
  mutation,
26
+ nullable,
23
27
  query,
24
28
  resolver,
25
29
  router,
26
- t,
30
+ string,
27
31
  } from "@sylphx/lens-core";
28
32
  import { z } from "zod";
29
33
  import { optimisticPlugin } from "../plugin/optimistic.js";
@@ -76,10 +80,10 @@ function createResultsCollector() {
76
80
  // Test Entities
77
81
  // =============================================================================
78
82
 
79
- const User = entity("User", {
80
- id: z.string(),
81
- name: z.string(),
82
- email: z.string().optional(),
83
+ const User = model("User", {
84
+ id: id(),
85
+ name: string(),
86
+ email: nullable(string()),
83
87
  });
84
88
 
85
89
  // =============================================================================
@@ -89,8 +93,8 @@ const User = entity("User", {
89
93
  const getUser = query()
90
94
  .input(z.object({ id: z.string() }))
91
95
  .returns(User)
92
- .resolve(({ input }) => ({
93
- id: input.id,
96
+ .resolve(({ args }) => ({
97
+ id: args.id,
94
98
  name: "Test User",
95
99
  email: "test@example.com",
96
100
  }));
@@ -103,18 +107,18 @@ const getUsers = query().resolve(() => [
103
107
  const createUser = mutation()
104
108
  .input(z.object({ name: z.string(), email: z.string().optional() }))
105
109
  .returns(User)
106
- .resolve(({ input }) => ({
110
+ .resolve(({ args }) => ({
107
111
  id: "new-id",
108
- name: input.name,
109
- email: input.email,
112
+ name: args.name,
113
+ email: args.email,
110
114
  }));
111
115
 
112
116
  const updateUser = mutation()
113
117
  .input(z.object({ id: z.string(), name: z.string().optional() }))
114
118
  .returns(User)
115
- .resolve(({ input }) => ({
116
- id: input.id,
117
- name: input.name ?? "Updated",
119
+ .resolve(({ args }) => ({
120
+ id: args.id,
121
+ name: args.name ?? "Updated",
118
122
  }));
119
123
 
120
124
  const deleteUser = mutation()
@@ -531,17 +535,17 @@ describe("type inference", () => {
531
535
 
532
536
  describe("field resolvers", () => {
533
537
  // Test entities for field resolver tests
534
- const Author = entity("Author", {
535
- id: t.id(),
536
- name: t.string(),
538
+ const Author = model("Author", {
539
+ id: id(),
540
+ name: string(),
537
541
  });
538
542
 
539
- const Post = entity("Post", {
540
- id: t.id(),
541
- title: t.string(),
542
- content: t.string(),
543
- published: t.boolean(),
544
- authorId: t.string(),
543
+ const Post = model("Post", {
544
+ id: id(),
545
+ title: string(),
546
+ content: string(),
547
+ published: boolean(),
548
+ authorId: string(),
545
549
  });
546
550
 
547
551
  // Mock data
@@ -562,19 +566,19 @@ describe("field resolvers", () => {
562
566
 
563
567
  it("resolves field with nested input args (like GraphQL)", async () => {
564
568
  // Define field resolver with args (like GraphQL)
565
- const authorResolver = resolver<TestContext>()(Author, (f) => ({
566
- id: f.expose("id"),
567
- name: f.expose("name"),
568
- posts: f
569
- .many(Post)
569
+ const authorResolver = resolver<TestContext>()(Author, (t) => ({
570
+ id: t.expose("id"),
571
+ name: t.expose("name"),
572
+ // New API: t.args().resolve() without type method
573
+ posts: t
570
574
  .args(
571
575
  z.object({
572
576
  limit: z.number().optional(),
573
577
  published: z.boolean().optional(),
574
578
  }),
575
579
  )
576
- .resolve(({ parent, args, ctx }) => {
577
- let posts = ctx.db.posts.filter((p) => p.authorId === parent.id);
580
+ .resolve(({ source, args, ctx }) => {
581
+ let posts = ctx.db.posts.filter((p) => p.authorId === source.id);
578
582
  if (args.published !== undefined) {
579
583
  posts = posts.filter((p) => p.published === args.published);
580
584
  }
@@ -588,8 +592,8 @@ describe("field resolvers", () => {
588
592
  const getAuthor = query<TestContext>()
589
593
  .input(z.object({ id: z.string() }))
590
594
  .returns(Author)
591
- .resolve(({ input, ctx }) => {
592
- const author = ctx.db.authors.find((a) => a.id === input.id);
595
+ .resolve(({ args, ctx }) => {
596
+ const author = ctx.db.authors.find((a) => a.id === args.id);
593
597
  if (!author) throw new Error("Author not found");
594
598
  return author;
595
599
  });
@@ -634,20 +638,21 @@ describe("field resolvers", () => {
634
638
  it("passes context to field resolvers", async () => {
635
639
  let capturedContext: TestContext | null = null;
636
640
 
637
- const authorResolver = resolver<TestContext>()(Author, (f) => ({
638
- id: f.expose("id"),
639
- name: f.expose("name"),
640
- posts: f.many(Post).resolve(({ parent, ctx }) => {
641
+ const authorResolver = resolver<TestContext>()(Author, (t) => ({
642
+ id: t.expose("id"),
643
+ name: t.expose("name"),
644
+ // Plain function for relations (new API)
645
+ posts: ({ source, ctx }) => {
641
646
  capturedContext = ctx;
642
- return ctx.db.posts.filter((p) => p.authorId === parent.id);
643
- }),
647
+ return ctx.db.posts.filter((p) => p.authorId === source.id);
648
+ },
644
649
  }));
645
650
 
646
651
  const getAuthor = query<TestContext>()
647
652
  .input(z.object({ id: z.string() }))
648
653
  .returns(Author)
649
- .resolve(({ input, ctx }) => {
650
- const author = ctx.db.authors.find((a) => a.id === input.id);
654
+ .resolve(({ args, ctx }) => {
655
+ const author = ctx.db.authors.find((a) => a.id === args.id);
651
656
  if (!author) throw new Error("Author not found");
652
657
  return author;
653
658
  });
@@ -677,15 +682,33 @@ describe("field resolvers", () => {
677
682
  });
678
683
 
679
684
  it("supports nested input at multiple levels", async () => {
680
- // Comment entity for deeper nesting
681
- const Comment = entity("Comment", {
682
- id: t.id(),
683
- body: t.string(),
684
- postId: t.string(),
685
+ // Local models with relation fields for this test
686
+ const Comment = model("Comment", {
687
+ id: id(),
688
+ body: string(),
689
+ postId: string(),
690
+ });
691
+
692
+ // Post with comments relation
693
+ const LocalPost = model("LocalPost", {
694
+ id: id(),
695
+ title: string(),
696
+ comments: list(() => Comment),
697
+ });
698
+
699
+ // Author with posts relation
700
+ const LocalAuthor = model("LocalAuthor", {
701
+ id: id(),
702
+ name: string(),
703
+ posts: list(() => LocalPost),
685
704
  });
686
705
 
687
706
  const mockDbWithComments = {
688
- ...mockDb,
707
+ authors: [{ id: "a1", name: "Author 1" }],
708
+ posts: [
709
+ { id: "p1", title: "Post 1", authorId: "a1" },
710
+ { id: "p2", title: "Post 2", authorId: "a1" },
711
+ ],
689
712
  comments: [
690
713
  { id: "c1", body: "Comment 1", postId: "p1" },
691
714
  { id: "c2", body: "Comment 2", postId: "p1" },
@@ -695,47 +718,41 @@ describe("field resolvers", () => {
695
718
 
696
719
  type CtxWithComments = { db: typeof mockDbWithComments };
697
720
 
698
- const postResolver = resolver<CtxWithComments>()(Post, (f) => ({
699
- id: f.expose("id"),
700
- title: f.expose("title"),
701
- comments: f
702
- .many(Comment)
703
- .args(z.object({ limit: z.number().optional() }))
704
- .resolve(({ parent, args, ctx }) => {
705
- let comments = ctx.db.comments.filter((c) => c.postId === parent.id);
706
- if (args.limit !== undefined) {
707
- comments = comments.slice(0, args.limit);
708
- }
709
- return comments;
710
- }),
721
+ const postResolver = resolver<CtxWithComments>()(LocalPost, (t) => ({
722
+ id: t.expose("id"),
723
+ title: t.expose("title"),
724
+ comments: t.args(z.object({ limit: z.number().optional() })).resolve(({ source, args, ctx }) => {
725
+ let comments = ctx.db.comments.filter((c) => c.postId === source.id);
726
+ if (args.limit !== undefined) {
727
+ comments = comments.slice(0, args.limit);
728
+ }
729
+ return comments;
730
+ }),
711
731
  }));
712
732
 
713
- const authorResolver = resolver<CtxWithComments>()(Author, (f) => ({
714
- id: f.expose("id"),
715
- name: f.expose("name"),
716
- posts: f
717
- .many(Post)
718
- .args(z.object({ limit: z.number().optional() }))
719
- .resolve(({ parent, args, ctx }) => {
720
- let posts = ctx.db.posts.filter((p) => p.authorId === parent.id);
721
- if (args.limit !== undefined) {
722
- posts = posts.slice(0, args.limit);
723
- }
724
- return posts;
725
- }),
733
+ const authorResolver = resolver<CtxWithComments>()(LocalAuthor, (t) => ({
734
+ id: t.expose("id"),
735
+ name: t.expose("name"),
736
+ posts: t.args(z.object({ limit: z.number().optional() })).resolve(({ source, args, ctx }) => {
737
+ let posts = ctx.db.posts.filter((p) => p.authorId === source.id);
738
+ if (args.limit !== undefined) {
739
+ posts = posts.slice(0, args.limit);
740
+ }
741
+ return posts;
742
+ }),
726
743
  }));
727
744
 
728
745
  const getAuthor = query<CtxWithComments>()
729
746
  .input(z.object({ id: z.string() }))
730
- .returns(Author)
731
- .resolve(({ input, ctx }) => {
732
- const author = ctx.db.authors.find((a) => a.id === input.id);
747
+ .returns(LocalAuthor)
748
+ .resolve(({ args, ctx }) => {
749
+ const author = ctx.db.authors.find((a) => a.id === args.id);
733
750
  if (!author) throw new Error("Author not found");
734
751
  return author;
735
752
  });
736
753
 
737
754
  const server = createApp({
738
- entities: { Author, Post, Comment },
755
+ entities: { LocalAuthor, LocalPost, Comment },
739
756
  queries: { getAuthor },
740
757
  resolvers: [authorResolver, postResolver],
741
758
  context: () => ({ db: mockDbWithComments }),
@@ -773,22 +790,19 @@ describe("field resolvers", () => {
773
790
  });
774
791
 
775
792
  it("works without nested input (default args)", async () => {
776
- const authorResolver = resolver<TestContext>()(Author, (f) => ({
777
- id: f.expose("id"),
778
- name: f.expose("name"),
779
- posts: f
780
- .many(Post)
781
- .args(z.object({ limit: z.number().default(10) }))
782
- .resolve(({ parent, args, ctx }) => {
783
- return ctx.db.posts.filter((p) => p.authorId === parent.id).slice(0, args.limit);
784
- }),
793
+ const authorResolver = resolver<TestContext>()(Author, (t) => ({
794
+ id: t.expose("id"),
795
+ name: t.expose("name"),
796
+ posts: t.args(z.object({ limit: z.number().default(10) })).resolve(({ source, args, ctx }) => {
797
+ return ctx.db.posts.filter((p) => p.authorId === source.id).slice(0, args.limit);
798
+ }),
785
799
  }));
786
800
 
787
801
  const getAuthor = query<TestContext>()
788
802
  .input(z.object({ id: z.string() }))
789
803
  .returns(Author)
790
- .resolve(({ input, ctx }) => {
791
- const author = ctx.db.authors.find((a) => a.id === input.id);
804
+ .resolve(({ args, ctx }) => {
805
+ const author = ctx.db.authors.find((a) => a.id === args.id);
792
806
  if (!author) throw new Error("Author not found");
793
807
  return author;
794
808
  });
@@ -824,15 +838,15 @@ describe("field resolvers", () => {
824
838
  // Emits forward commands to client - no re-resolution on server.
825
839
  let postsResolverCallCount = 0;
826
840
 
827
- const Author = entity("Author", {
828
- id: t.id(),
829
- name: t.string(),
841
+ const Author = model("Author", {
842
+ id: id(),
843
+ name: string(),
830
844
  });
831
845
 
832
- const Post = entity("Post", {
833
- id: t.id(),
834
- title: t.string(),
835
- authorId: t.string(),
846
+ const Post = model("Post", {
847
+ id: id(),
848
+ title: string(),
849
+ authorId: string(),
836
850
  });
837
851
 
838
852
  const mockDb = {
@@ -843,26 +857,30 @@ describe("field resolvers", () => {
843
857
  ],
844
858
  };
845
859
 
846
- const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
847
- id: f.expose("id"),
848
- name: f.expose("name"),
849
- posts: f.many(Post).resolve(({ parent, ctx }) => {
860
+ const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (t) => ({
861
+ id: t.expose("id"),
862
+ name: t.expose("name"),
863
+ // Plain function for relations (new API)
864
+ posts: ({ source, ctx }) => {
850
865
  postsResolverCallCount++;
851
- return ctx.db.posts.filter((p) => p.authorId === parent.id);
852
- }),
866
+ return ctx.db.posts.filter((p) => p.authorId === source.id);
867
+ },
853
868
  }));
854
869
 
855
870
  type EmitFn = ((data: unknown) => void) & { merge: (partial: unknown) => void };
856
871
  let capturedEmit: EmitFn | null = null;
857
872
 
858
- const getAuthor = query<{ db: typeof mockDb; emit: EmitFn }>()
873
+ // Use .resolve().subscribe() pattern - emit comes through Publisher callback, NOT ctx
874
+ const getAuthor = query<{ db: typeof mockDb }>()
859
875
  .input(z.object({ id: z.string() }))
860
876
  .returns(Author)
861
- .resolve(({ input, ctx }) => {
862
- capturedEmit = ctx.emit as EmitFn;
863
- const author = ctx.db.authors.find((a) => a.id === input.id);
877
+ .resolve(({ args, ctx }) => {
878
+ const author = ctx.db.authors.find((a) => a.id === args.id);
864
879
  if (!author) throw new Error("Author not found");
865
880
  return author;
881
+ })
882
+ .subscribe(() => ({ emit }) => {
883
+ capturedEmit = emit as EmitFn;
866
884
  });
867
885
 
868
886
  const server = createApp({
@@ -924,15 +942,15 @@ describe("field resolvers", () => {
924
942
  let cleanupCalled = false;
925
943
  let resolverReceivedOnCleanup = false;
926
944
 
927
- const Author = entity("Author", {
928
- id: t.id(),
929
- name: t.string(),
945
+ const Author = model("Author", {
946
+ id: id(),
947
+ name: string(),
930
948
  });
931
949
 
932
- const Post = entity("Post", {
933
- id: t.id(),
934
- title: t.string(),
935
- authorId: t.string(),
950
+ const Post = model("Post", {
951
+ id: id(),
952
+ title: string(),
953
+ authorId: string(),
936
954
  });
937
955
 
938
956
  const mockDb = {
@@ -943,14 +961,13 @@ describe("field resolvers", () => {
943
961
  // Use .resolve().subscribe() to get onCleanup access
944
962
  // Per ADR-002: .resolve() alone is pure/batchable, no emit/onCleanup
945
963
  // .resolve().subscribe() gives initial value + subscription capabilities
946
- const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
947
- id: f.expose("id"),
948
- name: f.expose("name"),
949
- posts: f
950
- .many(Post)
951
- .resolve(({ parent, ctx }) => {
964
+ const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (t) => ({
965
+ id: t.expose("id"),
966
+ name: t.expose("name"),
967
+ posts: t
968
+ .resolve(({ source, ctx }) => {
952
969
  // Initial resolution (batchable, no emit/onCleanup)
953
- return ctx.db.posts.filter((p) => p.authorId === parent.id);
970
+ return ctx.db.posts.filter((p) => p.authorId === source.id);
954
971
  })
955
972
  .subscribe(() => ({ onCleanup }) => {
956
973
  // Publisher pattern: emit/onCleanup come from callback, not ctx
@@ -966,8 +983,8 @@ describe("field resolvers", () => {
966
983
  const getAuthor = query<{ db: typeof mockDb }>()
967
984
  .input(z.object({ id: z.string() }))
968
985
  .returns(Author)
969
- .resolve(({ input, ctx }) => {
970
- const author = ctx.db.authors.find((a) => a.id === input.id);
986
+ .resolve(({ args, ctx }) => {
987
+ const author = ctx.db.authors.find((a) => a.id === args.id);
971
988
  if (!author) throw new Error("Author not found");
972
989
  return author;
973
990
  });
@@ -1008,15 +1025,15 @@ describe("field resolvers", () => {
1008
1025
 
1009
1026
  it("field-level emit sends update command (stateless)", async () => {
1010
1027
  // STATELESS: Field emit sends update command with field path prefix
1011
- const Author = entity("Author", {
1012
- id: t.id(),
1013
- name: t.string(),
1028
+ const Author = model("Author", {
1029
+ id: id(),
1030
+ name: string(),
1014
1031
  });
1015
1032
 
1016
- const Post = entity("Post", {
1017
- id: t.id(),
1018
- title: t.string(),
1019
- authorId: t.string(),
1033
+ const Post = model("Post", {
1034
+ id: id(),
1035
+ title: string(),
1036
+ authorId: string(),
1020
1037
  });
1021
1038
 
1022
1039
  const mockDb = {
@@ -1033,14 +1050,13 @@ describe("field resolvers", () => {
1033
1050
  // Use .resolve().subscribe() to get emit access
1034
1051
  // Per ADR-002: .resolve() alone is pure/batchable, no emit/onCleanup
1035
1052
  // .resolve().subscribe() gives initial value + subscription capabilities
1036
- const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
1037
- id: f.expose("id"),
1038
- name: f.expose("name"),
1039
- posts: f
1040
- .many(Post)
1041
- .resolve(({ parent, ctx }) => {
1053
+ const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (t) => ({
1054
+ id: t.expose("id"),
1055
+ name: t.expose("name"),
1056
+ posts: t
1057
+ .resolve(({ source, ctx }) => {
1042
1058
  // Initial resolution (batchable, no emit)
1043
- return ctx.db.posts.filter((p) => p.authorId === parent.id);
1059
+ return ctx.db.posts.filter((p) => p.authorId === source.id);
1044
1060
  })
1045
1061
  .subscribe(() => ({ emit, onCleanup }) => {
1046
1062
  // Publisher pattern: emit/onCleanup come from callback
@@ -1056,8 +1072,8 @@ describe("field resolvers", () => {
1056
1072
  const getAuthor = query<{ db: typeof mockDb }>()
1057
1073
  .input(z.object({ id: z.string() }))
1058
1074
  .returns(Author)
1059
- .resolve(({ input, ctx }) => {
1060
- const author = ctx.db.authors.find((a) => a.id === input.id);
1075
+ .resolve(({ args, ctx }) => {
1076
+ const author = ctx.db.authors.find((a) => a.id === args.id);
1061
1077
  if (!author) throw new Error("Author not found");
1062
1078
  return author;
1063
1079
  });
@@ -1128,13 +1144,15 @@ describe("field resolvers", () => {
1128
1144
  type EmitFn = (data: { id: string; name: string }) => void;
1129
1145
  let capturedEmit: EmitFn | undefined;
1130
1146
 
1131
- // Query that captures emit for external use
1147
+ // Use .resolve().subscribe() pattern - emit comes through Publisher callback
1132
1148
  const liveQuery = query()
1133
1149
  .input(z.object({ id: z.string() }))
1134
1150
  .returns(User)
1135
- .resolve(({ input, ctx }) => {
1136
- capturedEmit = ctx.emit as EmitFn;
1137
- return { id: input.id, name: "Initial" };
1151
+ .resolve(({ args }) => {
1152
+ return { id: args.id, name: "Initial" };
1153
+ })
1154
+ .subscribe(() => ({ emit }) => {
1155
+ capturedEmit = emit as EmitFn;
1138
1156
  });
1139
1157
 
1140
1158
  const testRouter = router({ liveQuery });
@@ -1180,15 +1198,15 @@ describe("field resolvers", () => {
1180
1198
  // .resolve() handles initial data (batchable)
1181
1199
  // .subscribe() handles live updates (fire-and-forget)
1182
1200
 
1183
- const Author = entity("Author", {
1184
- id: t.id(),
1185
- name: t.string(),
1201
+ const Author = model("Author", {
1202
+ id: id(),
1203
+ name: string(),
1186
1204
  });
1187
1205
 
1188
- const Post = entity("Post", {
1189
- id: t.id(),
1190
- title: t.string(),
1191
- authorId: t.string(),
1206
+ const Post = model("Post", {
1207
+ id: id(),
1208
+ title: string(),
1209
+ authorId: string(),
1192
1210
  });
1193
1211
 
1194
1212
  const mockDb = {
@@ -1204,16 +1222,15 @@ describe("field resolvers", () => {
1204
1222
  let subscribeCallCount = 0;
1205
1223
  let capturedFieldEmit: ((value: unknown) => void) | undefined;
1206
1224
 
1207
- // Use .resolve().subscribe() pattern
1208
- const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (f) => ({
1209
- id: f.expose("id"),
1210
- name: f.expose("name"),
1211
- posts: f
1212
- .many(Post)
1213
- .resolve(({ parent, ctx }) => {
1225
+ // Use .resolve().subscribe() pattern (new API - no type method)
1226
+ const authorResolver = resolver<{ db: typeof mockDb }>()(Author, (t) => ({
1227
+ id: t.expose("id"),
1228
+ name: t.expose("name"),
1229
+ posts: t
1230
+ .resolve(({ source, ctx }) => {
1214
1231
  // Phase 1: Initial resolution (batchable, no emit/onCleanup)
1215
1232
  resolveCallCount++;
1216
- return ctx.db.posts.filter((p) => p.authorId === parent.id);
1233
+ return ctx.db.posts.filter((p) => p.authorId === source.id);
1217
1234
  })
1218
1235
  .subscribe(() => ({ emit, onCleanup }) => {
1219
1236
  // Phase 2: Publisher pattern - emit/onCleanup from callback
@@ -1229,8 +1246,8 @@ describe("field resolvers", () => {
1229
1246
  const getAuthor = query<{ db: typeof mockDb }>()
1230
1247
  .input(z.object({ id: z.string() }))
1231
1248
  .returns(Author)
1232
- .resolve(({ input, ctx }) => {
1233
- const author = ctx.db.authors.find((a) => a.id === input.id);
1249
+ .resolve(({ args, ctx }) => {
1250
+ const author = ctx.db.authors.find((a) => a.id === args.id);
1234
1251
  if (!author) throw new Error("Author not found");
1235
1252
  return author;
1236
1253
  });
@@ -1308,7 +1325,7 @@ describe("observable behavior", () => {
1308
1325
  it("delivers initial result immediately for queries", async () => {
1309
1326
  const simpleQuery = query()
1310
1327
  .input(z.object({ id: z.string() }))
1311
- .resolve(({ input }) => ({ id: input.id, name: "Test" }));
1328
+ .resolve(({ args }) => ({ id: args.id, name: "Test" }));
1312
1329
 
1313
1330
  const server = createApp({ queries: { simpleQuery } });
1314
1331
 
@@ -1329,11 +1346,14 @@ describe("observable behavior", () => {
1329
1346
  type EmitFn = (data: unknown) => void;
1330
1347
  let capturedEmit: EmitFn | undefined;
1331
1348
 
1349
+ // Use .resolve().subscribe() pattern - emit comes through Publisher callback
1332
1350
  const liveQuery = query()
1333
1351
  .input(z.object({ id: z.string() }))
1334
- .resolve(({ input, ctx }) => {
1335
- capturedEmit = ctx.emit as EmitFn;
1336
- return { id: input.id, name: "Initial" };
1352
+ .resolve(({ args }) => {
1353
+ return { id: args.id, name: "Initial" };
1354
+ })
1355
+ .subscribe(() => ({ emit }) => {
1356
+ capturedEmit = emit as EmitFn;
1337
1357
  });
1338
1358
 
1339
1359
  const server = createApp({ queries: { liveQuery } });
@@ -1362,7 +1382,7 @@ describe("observable behavior", () => {
1362
1382
  it("delivers mutation result via observable", async () => {
1363
1383
  const testMutation = mutation()
1364
1384
  .input(z.object({ name: z.string() }))
1365
- .resolve(({ input }) => ({ id: "new", name: input.name }));
1385
+ .resolve(({ args }) => ({ id: "new", name: args.name }));
1366
1386
 
1367
1387
  const server = createApp({ mutations: { testMutation } });
1368
1388
 
@@ -1384,9 +1404,9 @@ describe("observable behavior", () => {
1384
1404
 
1385
1405
  const simpleQuery = query()
1386
1406
  .input(z.object({ id: z.string() }))
1387
- .resolve(({ input }) => {
1407
+ .resolve(({ args }) => {
1388
1408
  resolverCalls++;
1389
- return { id: input.id };
1409
+ return { id: args.id };
1390
1410
  });
1391
1411
 
1392
1412
  const server = createApp({ queries: { simpleQuery } });
@@ -1416,11 +1436,14 @@ describe("emit backpressure", () => {
1416
1436
  type EmitFn = (data: unknown) => void;
1417
1437
  let capturedEmit: EmitFn | undefined;
1418
1438
 
1439
+ // Use .resolve().subscribe() pattern - emit comes through Publisher callback
1419
1440
  const liveQuery = query()
1420
1441
  .input(z.object({ id: z.string() }))
1421
- .resolve(({ input, ctx }) => {
1422
- capturedEmit = ctx.emit as EmitFn;
1423
- return { id: input.id, count: 0 };
1442
+ .resolve(({ args }) => {
1443
+ return { id: args.id, count: 0 };
1444
+ })
1445
+ .subscribe(() => ({ emit }) => {
1446
+ capturedEmit = emit as EmitFn;
1424
1447
  });
1425
1448
 
1426
1449
  const server = createApp({ queries: { liveQuery } });
@@ -1507,125 +1530,11 @@ describe("observable error handling", () => {
1507
1530
  });
1508
1531
 
1509
1532
  // =============================================================================
1510
- // Unified Entity Definition (ADR-001) - Auto Resolver Conversion
1533
+ // NOTE: "Unified Entity Definition" tests for model().resolve() chain removed
1534
+ // as the chain API was deprecated in favor of standalone resolver() function.
1535
+ // See ADR-003 for the new design.
1511
1536
  // =============================================================================
1512
1537
 
1513
- describe("Unified Entity Definition", () => {
1514
- describe("auto-converts entities with inline resolvers", () => {
1515
- it("creates resolver from entity with inline .resolve()", async () => {
1516
- // Entity with inline resolver - no separate resolver() needed
1517
- const Product = entity("Product", (t) => ({
1518
- id: t.id(),
1519
- name: t.string(),
1520
- price: t.float(),
1521
- // Computed field with inline resolver
1522
- displayPrice: t.string().resolve(({ parent }) => `$${(parent as { price: number }).price.toFixed(2)}`),
1523
- }));
1524
-
1525
- const getProduct = query()
1526
- .input(z.object({ id: z.string() }))
1527
- .returns(Product)
1528
- .resolve(({ input }) => ({
1529
- id: input.id,
1530
- name: "Test Product",
1531
- price: 19.99,
1532
- }));
1533
-
1534
- // No resolvers array needed! Server auto-detects inline resolvers
1535
- const server = createApp({
1536
- entities: { Product },
1537
- queries: { getProduct },
1538
- });
1539
-
1540
- const result = await firstValueFrom(
1541
- server.execute({
1542
- path: "getProduct",
1543
- input: { id: "prod-1", $select: { id: true, name: true, displayPrice: true } },
1544
- }),
1545
- );
1546
-
1547
- expect(result.data).toEqual({
1548
- id: "prod-1",
1549
- name: "Test Product",
1550
- displayPrice: "$19.99",
1551
- });
1552
- });
1553
-
1554
- it("explicit resolver takes priority over inline resolver", async () => {
1555
- // Entity with inline resolver
1556
- const Item = entity("Item", (t) => ({
1557
- id: t.id(),
1558
- label: t.string().resolve(() => "inline-label"),
1559
- }));
1560
-
1561
- // Explicit resolver overrides inline
1562
- const itemResolver = resolver(Item, (f) => ({
1563
- id: f.expose("id"),
1564
- label: f.string().resolve(() => "explicit-label"),
1565
- }));
1566
-
1567
- const getItem = query()
1568
- .input(z.object({ id: z.string() }))
1569
- .returns(Item)
1570
- .resolve(({ input }) => ({ id: input.id }));
1571
-
1572
- const server = createApp({
1573
- entities: { Item },
1574
- queries: { getItem },
1575
- resolvers: [itemResolver], // Explicit resolver takes priority
1576
- });
1577
-
1578
- const result = await firstValueFrom(
1579
- server.execute({
1580
- path: "getItem",
1581
- input: { id: "item-1", $select: { id: true, label: true } },
1582
- }),
1583
- );
1584
-
1585
- expect(result.data).toEqual({
1586
- id: "item-1",
1587
- label: "explicit-label", // From explicit resolver, not inline
1588
- });
1589
- });
1590
-
1591
- it("includes inline resolver fields in metadata", () => {
1592
- const Task = entity("Task", (t) => ({
1593
- id: t.id(),
1594
- title: t.string(),
1595
- status: t.string().resolve(({ parent }) => parent.status),
1596
- }));
1597
-
1598
- const server = createApp({
1599
- entities: { Task },
1600
- queries: {},
1601
- });
1602
-
1603
- const metadata = server.getMetadata();
1604
- expect(metadata.entities.Task).toBeDefined();
1605
- expect(metadata.entities.Task.id).toBe("exposed");
1606
- expect(metadata.entities.Task.title).toBe("exposed");
1607
- expect(metadata.entities.Task.status).toBe("resolve");
1608
- });
1609
-
1610
- it("skips entities without inline resolvers", () => {
1611
- // Plain entity without inline resolvers
1612
- const SimpleUser = entity("SimpleUser", {
1613
- id: t.id(),
1614
- name: t.string(),
1615
- });
1616
-
1617
- const server = createApp({
1618
- entities: { SimpleUser },
1619
- queries: {},
1620
- });
1621
-
1622
- const metadata = server.getMetadata();
1623
- // No resolver created for entities without inline resolvers
1624
- expect(metadata.entities.SimpleUser).toBeUndefined();
1625
- });
1626
- });
1627
- });
1628
-
1629
1538
  // =============================================================================
1630
1539
  // Operation-Level .resolve().subscribe() Tests (LiveQueryDef)
1631
1540
  // =============================================================================
@@ -1638,8 +1547,8 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1638
1547
 
1639
1548
  const liveUser = query()
1640
1549
  .input(z.object({ id: z.string() }))
1641
- .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1642
- .subscribe(({ input: _input }) => ({ emit, onCleanup }) => {
1550
+ .resolve(({ args }) => ({ id: args.id, name: "Initial" }))
1551
+ .subscribe(({ args: _args }) => ({ emit, onCleanup }) => {
1643
1552
  subscriberCalled = true;
1644
1553
  capturedEmit = emit;
1645
1554
  capturedOnCleanup = onCleanup;
@@ -1678,7 +1587,7 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1678
1587
 
1679
1588
  const liveUser = query()
1680
1589
  .input(z.object({ id: z.string() }))
1681
- .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1590
+ .resolve(({ args }) => ({ id: args.id, name: "Initial" }))
1682
1591
  .subscribe(() => ({ emit }) => {
1683
1592
  capturedEmit = emit;
1684
1593
  });
@@ -1727,7 +1636,7 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1727
1636
 
1728
1637
  const liveUser = query()
1729
1638
  .input(z.object({ id: z.string() }))
1730
- .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1639
+ .resolve(({ args }) => ({ id: args.id, name: "Initial" }))
1731
1640
  .subscribe(() => ({ onCleanup }) => {
1732
1641
  onCleanup(() => {
1733
1642
  cleanupCalled = true;
@@ -1764,9 +1673,9 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1764
1673
 
1765
1674
  const liveUser = query<TestContext>()
1766
1675
  .input(z.object({ id: z.string() }))
1767
- .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1768
- .subscribe(({ input, ctx }) => ({ emit: _emit }) => {
1769
- receivedInput = input;
1676
+ .resolve(({ args }) => ({ id: args.id, name: "Initial" }))
1677
+ .subscribe(({ args, ctx }) => ({ emit: _emit }) => {
1678
+ receivedInput = args;
1770
1679
  receivedCtx = ctx;
1771
1680
  });
1772
1681
 
@@ -1794,7 +1703,7 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1794
1703
  it("handles subscriber errors gracefully", async () => {
1795
1704
  const liveUser = query()
1796
1705
  .input(z.object({ id: z.string() }))
1797
- .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1706
+ .resolve(({ args }) => ({ id: args.id, name: "Initial" }))
1798
1707
  .subscribe(() => () => {
1799
1708
  throw new Error("Subscriber error");
1800
1709
  });
@@ -1837,7 +1746,7 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1837
1746
 
1838
1747
  const liveCounter = query()
1839
1748
  .input(z.object({ id: z.string() }))
1840
- .resolve(({ input }) => ({ id: input.id, count: 0 }))
1749
+ .resolve(({ args }) => ({ id: args.id, count: 0 }))
1841
1750
  .subscribe(() => ({ emit }) => {
1842
1751
  capturedEmit = emit;
1843
1752
  });
@@ -1889,7 +1798,7 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1889
1798
 
1890
1799
  const liveUser = query()
1891
1800
  .input(z.object({ id: z.string() }))
1892
- .resolve(({ input }) => ({ id: input.id, name: "Initial", status: "offline" }))
1801
+ .resolve(({ args }) => ({ id: args.id, name: "Initial", status: "offline" }))
1893
1802
  .subscribe(() => ({ emit }) => {
1894
1803
  capturedEmit = emit as EmitFn;
1895
1804
  });
@@ -1936,7 +1845,7 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1936
1845
 
1937
1846
  const liveUser = query()
1938
1847
  .input(z.object({ id: z.string() }))
1939
- .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1848
+ .resolve(({ args }) => ({ id: args.id, name: "Initial" }))
1940
1849
  .subscribe(() => ({ emit }) => {
1941
1850
  capturedEmit = emit;
1942
1851
  });
@@ -1984,7 +1893,7 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
1984
1893
 
1985
1894
  const liveUser = query()
1986
1895
  .input(z.object({ id: z.string() }))
1987
- .resolve(({ input }) => ({ id: input.id, name: "Initial" }))
1896
+ .resolve(({ args }) => ({ id: args.id, name: "Initial" }))
1988
1897
  .subscribe(() => ({ emit }) => {
1989
1898
  subscriberCallCount++;
1990
1899
  emits.push(emit);
@@ -2032,9 +1941,9 @@ describe("operation-level .resolve().subscribe() (LiveQueryDef)", () => {
2032
1941
  describe("scalar field subscription with emit.delta()", () => {
2033
1942
  it("provides emit.delta() for string field subscriptions", async () => {
2034
1943
  // UserWithBio - bio field is not provided by query, resolved by field resolver
2035
- const UserWithBio = entity("UserWithBio", {
2036
- id: t.id(),
2037
- name: t.string(),
1944
+ const UserWithBio = model("UserWithBio", {
1945
+ id: id(),
1946
+ name: string(),
2038
1947
  });
2039
1948
 
2040
1949
  let capturedEmit: {
@@ -2042,14 +1951,13 @@ describe("scalar field subscription with emit.delta()", () => {
2042
1951
  delta?: (operations: { position: number; insert: string }[]) => void;
2043
1952
  } | null = null;
2044
1953
 
2045
- const userResolver = resolver(UserWithBio, (f) => ({
2046
- id: f.expose("id"),
2047
- name: f.expose("name"),
1954
+ const userResolver = resolver(UserWithBio, (t) => ({
1955
+ id: t.expose("id"),
1956
+ name: t.expose("name"),
2048
1957
  // bio is a computed field with resolve + subscribe
2049
- bio: f
2050
- .string()
1958
+ bio: t
2051
1959
  .resolve(() => "Initial bio")
2052
- .subscribe((_params) => ({ emit, onCleanup }) => {
1960
+ .subscribe(() => ({ emit, onCleanup }) => {
2053
1961
  capturedEmit = emit as typeof capturedEmit;
2054
1962
  onCleanup(() => {});
2055
1963
  }),
@@ -2061,7 +1969,7 @@ describe("scalar field subscription with emit.delta()", () => {
2061
1969
  getUserWithBio: query()
2062
1970
  .input(z.object({ id: z.string() }))
2063
1971
  .returns(UserWithBio)
2064
- .resolve(({ input }) => ({ id: input.id, name: "Alice" })),
1972
+ .resolve(({ args }) => ({ id: args.id, name: "Alice" })),
2065
1973
  },
2066
1974
  resolvers: [userResolver],
2067
1975
  });
@@ -2096,9 +2004,9 @@ describe("scalar field subscription with emit.delta()", () => {
2096
2004
 
2097
2005
  it("emit.delta() sends delta command (stateless)", async () => {
2098
2006
  // UserWithContent - content field resolved by field resolver
2099
- const UserWithContent = entity("UserWithContent", {
2100
- id: t.id(),
2101
- name: t.string(),
2007
+ const UserWithContent = model("UserWithContent", {
2008
+ id: id(),
2009
+ name: string(),
2102
2010
  });
2103
2011
 
2104
2012
  let capturedEmit: {
@@ -2106,14 +2014,13 @@ describe("scalar field subscription with emit.delta()", () => {
2106
2014
  delta?: (operations: { position: number; insert: string }[]) => void;
2107
2015
  } | null = null;
2108
2016
 
2109
- const userResolver = resolver(UserWithContent, (f) => ({
2110
- id: f.expose("id"),
2111
- name: f.expose("name"),
2017
+ const userResolver = resolver(UserWithContent, (t) => ({
2018
+ id: t.expose("id"),
2019
+ name: t.expose("name"),
2112
2020
  // content is a computed field with resolve + subscribe
2113
- content: f
2114
- .string()
2021
+ content: t
2115
2022
  .resolve(() => "Hello")
2116
- .subscribe((_params) => ({ emit, onCleanup }) => {
2023
+ .subscribe(() => ({ emit, onCleanup }) => {
2117
2024
  capturedEmit = emit as typeof capturedEmit;
2118
2025
  onCleanup(() => {});
2119
2026
  }),
@@ -2125,7 +2032,7 @@ describe("scalar field subscription with emit.delta()", () => {
2125
2032
  getUserWithContent: query()
2126
2033
  .input(z.object({ id: z.string() }))
2127
2034
  .returns(UserWithContent)
2128
- .resolve(({ input }) => ({ id: input.id, name: "Alice" })),
2035
+ .resolve(({ args }) => ({ id: args.id, name: "Alice" })),
2129
2036
  },
2130
2037
  resolvers: [userResolver],
2131
2038
  });