@nkzw/fate 0.0.7 → 0.0.8

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/README.md CHANGED
@@ -26,21 +26,42 @@
26
26
 
27
27
  GraphQL and Relay introduced several novel ideas: fragments co‑located with components, [a normalized cache](https://relay.dev/docs/principles-and-architecture/thinking-in-graphql/#caching-a-graph) keyed by global identifiers, and a compiler that hoists fragments into a single network request. These innovations made it possible to build large applications where data requirements are modular and self‑contained.
28
28
 
29
- Nakazawa Tech builds apps primarily with GraphQL and Relay. We advocate for these technologies in [talks](https://www.youtube.com/watch?v=rxPTEko8J7c&t=36s) and provide templates ([server](https://github.com/nkzw-tech/server-template), [client](https://github.com/nkzw-tech/web-app-template/tree/with-relay)) to help developers get started quickly.
29
+ [Nakazawa Tech](https://nakazawa.tech) builds apps and [games](https://athenacrisis.com) primarily with GraphQL and Relay. We advocate for these technologies in [talks](https://www.youtube.com/watch?v=rxPTEko8J7c&t=36s) and provide templates ([server](https://github.com/nkzw-tech/server-template), [client](https://github.com/nkzw-tech/web-app-template/tree/with-relay)) to help developers get started quickly.
30
30
 
31
31
  However, GraphQL comes with its own type system and query language. If you are already using tRPC or another type‑safe RPC framework, it's a significant investment to adopt and implement GraphQL on the backend. This investment often prevents teams from adopting Relay on the frontend.
32
32
 
33
33
  Many React data frameworks lack Relay's ergonomics, especially fragment composition, co-located data requirements, predictable caching, and deep integration with modern React features. Optimistic updates usually require manually managing keys and imperative data updates, which is error-prone and tedious.
34
34
 
35
- fate takes the great ideas from Relay and puts them on top of tRPC. You get the best of both worlds: type safety between the client and server, and GraphQL-like ergonomics for data fetching.
35
+ fate takes the great ideas from Relay and puts them on top of tRPC. You get the best of both worlds: type safety between the client and server, and GraphQL-like ergonomics for data fetching. Using _fate_ usually looks like this:
36
36
 
37
- _[Learn more](/docs/guide/getting-started.md) about fate's core concepts and features._
37
+ ```tsx
38
+ export const PostView = view<Post>()({
39
+ content: true,
40
+ id: true,
41
+ title: true,
42
+ author: UserView,
43
+ });
44
+
45
+ export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
46
+ const post = useView(PostView, postRef);
47
+
48
+ return (
49
+ <Card>
50
+ <h2>{post.title}</h2>
51
+ <p>{post.content}</p>
52
+ <UserCard user={post.author} />
53
+ </Card>
54
+ );
55
+ };
56
+ ```
57
+
58
+ _[Learn more](/docs/guide/getting-started.md) about fate's core concepts or get started with [a ready-made template](https://github.com/nkzw-tech/fate-template#readme)._
38
59
 
39
60
  ## Getting Started
40
61
 
41
62
  ### Template
42
63
 
43
- Get started with [a ready-made template](https://github.com/nkzw-tech/fate-template) quickly:
64
+ Get started with [a ready-made template](https://github.com/nkzw-tech/fate-template#readme) quickly:
44
65
 
45
66
  ::: code-group
46
67
 
@@ -58,7 +79,7 @@ yarn dlx giget@latest gh:nkzw-tech/fate-template
58
79
 
59
80
  :::
60
81
 
61
- The `fate-template` comes with a simple tRPC backend and a React frontend using **_fate_**. It features modern tools to deliver an incredibly fast development experience. Follow its [README.md](https://github.com/nkzw-tech/fate-template#fate-quick-start-template) to get started.
82
+ `fate-template` comes with a simple tRPC backend and a React frontend using **_fate_**. It features modern tools to deliver an incredibly fast development experience. Follow its [README.md](https://github.com/nkzw-tech/fate-template#fate-quick-start-template) to get started.
62
83
 
63
84
  ### Manual Installation
64
85
 
@@ -126,6 +147,9 @@ Traditionally, React apps are built with components and hooks. fate introduces a
126
147
 
127
148
  With fate, you no longer worry about _when_ to fetch data, how to coordinate loading states, or how to handle errors imperatively. You avoid overfetching, stop passing unnecessary data down the tree, and eliminate boilerplate types created solely for passing server data to child components.
128
149
 
150
+ > [!NOTE]
151
+ > Views in _fate_ are what fragments are in GraphQL.
152
+
129
153
  ## Views
130
154
 
131
155
  ### Defining Views
@@ -525,6 +549,9 @@ Mutations in your tRPC backend are made available as actions and mutations by fa
525
549
  Let's assume that our `Post` entity has a tRPC mutation for liking a post called `post.like`. A `LikeButton` component using fate Actions and an async component library could then look like this:
526
550
 
527
551
  ```tsx
552
+ import { useActionState } from 'react';
553
+ import { useFateClient } from 'react-fate';
554
+
528
555
  const LikeButton = ({ post }: { post: { id: string; likes: number } }) => {
529
556
  const fate = useFateClient();
530
557
  const [result, like] = useActionState(fate.actions.post.like, null);
@@ -663,16 +690,17 @@ export const postRouter = router({
663
690
  view: postDataView,
664
691
  });
665
692
 
666
- const updated = await ctx.prisma.post.update({
667
- data: {
668
- likes: {
669
- increment: 1,
693
+ return resolve(
694
+ await ctx.prisma.post.update({
695
+ data: {
696
+ likes: {
697
+ increment: 1,
698
+ },
670
699
  },
671
- },
672
- select,
673
- where: { id: input.id },
674
- });
675
- return resolve(updated as unknown as PostItem);
700
+ select,
701
+ where: { id: input.id },
702
+ } as PostUpdateArgs),
703
+ );
676
704
  }),
677
705
  });
678
706
  ```
@@ -741,6 +769,26 @@ useEffect(() => {
741
769
  }, [like, result]);
742
770
  ```
743
771
 
772
+ ### Controlling List Insertion Behavior
773
+
774
+ When inserting new objects into lists, the default behavior is to append the new object to the list. You can provide an `insert` option with `before`, `after` or `none` values to customize this behavior and specify where the new object should be inserted in the list:
775
+
776
+ ```tsx
777
+ addComment({
778
+ input: { content: 'New Comment text', postId: post.id },
779
+ insert: 'before', // Insert the new comment at the beginning of the list.
780
+ });
781
+ ```
782
+
783
+ Or, use the `none` option if you want to ignore inserting the new object into any lists:
784
+
785
+ ```tsx
786
+ addComment({
787
+ input: { content: 'New Comment text', postId: post.id },
788
+ insert: 'none', // Do not insert the new comment into any lists.
789
+ });
790
+ ```
791
+
744
792
  ## Server Integration
745
793
 
746
794
  Until now, we have focused on the client-side API of fate. You'll need a tRPC backend that follows some conventions so you can generate a typed client using fate's CLI. At the moment _fate_ is designed to work with tRPC and Prisma, but the framework is not coupled to any particular ORM or database, it's just what we are starting with.
@@ -787,35 +835,27 @@ _Note: Currently, fate provides helpers to integrate with Prisma, but the framew
787
835
  We can apply the above data view in our tRPC router and resolve the client's selection against it using `createResolver`. Here is an example implementation of the `byId` query for the `User` type which allows fetching multiple users by `id`:
788
836
 
789
837
  ```tsx
790
- import { connectionArgs, createResolver } from '@nkzw/fate/server';
838
+ import { byIdInput, createResolver } from '@nkzw/fate/server';
791
839
  import { z } from 'zod';
792
840
  import type { UserFindManyArgs } from '../../prisma/prisma-client/models.ts';
793
841
  import { procedure, router } from '../init.ts';
794
842
  import { userDataView } from '../views.ts';
795
843
 
796
844
  export const userRouter = router({
797
- byId: procedure
798
- .input(
799
- z.object({
800
- args: connectionArgs,
801
- ids: z.array(z.string().min(1)).nonempty(),
802
- select: z.array(z.string()),
803
- }),
804
- )
805
- .query(async ({ ctx, input }) => {
806
- const { resolveMany, select } = createResolver({
807
- ...input,
808
- ctx,
809
- view: userDataView,
810
- });
811
-
812
- const users = await ctx.prisma.user.findMany({
813
- select: select,
814
- where: { id: { in: input.ids } },
815
- } as UserFindManyArgs);
816
-
817
- return await resolveMany(users);
818
- }),
845
+ byId: procedure.input(byIdInput).query(async ({ ctx, input }) => {
846
+ const { resolveMany, select } = createResolver({
847
+ ...input,
848
+ ctx,
849
+ view: userDataView,
850
+ });
851
+
852
+ const users = await ctx.prisma.user.findMany({
853
+ select: select,
854
+ where: { id: { in: input.ids } },
855
+ } as UserFindManyArgs);
856
+
857
+ return await resolveMany(users);
858
+ }),
819
859
  });
820
860
  ```
821
861
 
@@ -896,29 +936,19 @@ export const postDataView = dataView<PostItem>('Post')({
896
936
  });
897
937
  ```
898
938
 
899
- We can also define root-level lists by exporting a `Lists` object from our `views.ts` file:
939
+ We can define extra root-level lists and queries by exporting a `Root` object from our `views.ts` file using the same view syntax as everywhere else:
900
940
 
901
941
  ```tsx
902
- export const Lists = {
903
- posts: postDataView,
942
+ export const Root = {
943
+ categories: list(categoryDataView),
944
+ commentSearch: { procedure: 'search', view: list(commentDataView) },
945
+ events: list(eventDataView),
946
+ posts: list(postDataView),
947
+ viewer: userDataView,
904
948
  };
905
949
  ```
906
950
 
907
- This makes it possible to fetch a list of posts from the client using `useRequest`.
908
-
909
- #### Custom Root Lists
910
-
911
- You might want to define custom root lists that don't directly map to a single data view. For example, a search endpoint that returns a list of posts based on a search query:
912
-
913
- ```tsx
914
- export const Lists = {
915
- // …
916
- postSearch: { procedure: 'search', view: postDataView },
917
- // …
918
- };
919
- ```
920
-
921
- This maps the `postSearch` list to a `search` procedure on your post router.
951
+ Entries that wrap their view in `list(...)` are treated as list resolvers and use the `procedure` name when calling the corresponding router procedure, defaulting to `list`. If you omit `list(...)`, fate treats the entry as a standard query and uses the view type name to infer the router name.
922
952
 
923
953
  ### Data View Resolvers
924
954
 
@@ -927,19 +957,33 @@ fate data views support resolvers for computed fields. If we want to add a `comm
927
957
  ```tsx
928
958
  export const postDataView = dataView<PostItem>('Post')({
929
959
  author: userDataView,
930
- commentCount: resolver<PostItem>({
931
- resolve: ({ item }) => item._count?.comments ?? 0,
960
+ commentCount: resolver<PostItem, number>({
961
+ resolve: ({ _count }) => _count?.comments ?? 0,
932
962
  select: () => ({
933
963
  _count: { select: { comments: true } },
934
964
  }),
935
965
  }),
936
966
  comments: list(commentDataView),
937
967
  id: true,
938
- } as const;
968
+ });
939
969
  ```
940
970
 
941
971
  This definition makes the `commentCount` field available to your client-side views.
942
972
 
973
+ ### Authorization in Resolvers
974
+
975
+ You might want to restrict access to certain fields based on the current user or other contextual information. You can do this by adding an `authorize` function to your resolver definition:
976
+
977
+ ```tsx
978
+ export const userDataView = dataView<UserItem>('User')({
979
+ email: resolver<UserItem, string | null, { sessionUser: string }>({
980
+ authorize: ({ id }, context) => context?.sessionUserId === id,
981
+ resolve: ({ email }) => email,
982
+ }),
983
+ id: true,
984
+ });
985
+ ```
986
+
943
987
  ### Generating a typed client
944
988
 
945
989
  Now that we have defined our client views and our tRPC server, we need to connect them with some glue code. We recommend using fate's CLI for convenience.
@@ -1026,7 +1070,9 @@ Probably. One day. _Maybe._
1026
1070
  ### How was fate built?
1027
1071
 
1028
1072
  > [!NOTE]
1029
- > 80% of _fate_'s code was written by OpenAI's Codex – four versions per task, carefully curated by a human. The remaining 20% was written by [@cnakazawa](https://x.com/cnakazawa). You get to decide which parts are the good ones. The docs were 100% written by a human.
1073
+ > 80% of _fate_'s code was written by OpenAI's Codex – four versions per task, carefully curated by a human. The remaining 20% was written by [@cnakazawa](https://x.com/cnakazawa). _You get to decide which parts are the good ones!_ The docs were 100% written by a human.
1074
+ >
1075
+ > If you contribute to _fate_, we [require you to disclose your use of AI tools](https://github.com/nkzw-tech/fate/blob/main/CONTRIBUTING.md#ai-assistance-notice).
1030
1076
 
1031
1077
  ## Future
1032
1078
 
@@ -1045,4 +1091,4 @@ Probably. One day. _Maybe._
1045
1091
  - [Ricky Hanlon](https://x.com/rickyfm) for guidance on Async React
1046
1092
  - [Anthony Powell](https://x.com/Cephalization) for testing fate and providing feedback
1047
1093
 
1048
- **_fate_** was created by [@cnakazawa](https://x.com/cnakazawa) and is maintained by [Nakazawa Tech](https://nakazawa.tech/).
1094
+ **_fate_** was created by [@cnakazawa](https://x.com/cnakazawa) and is maintained by [Nakazawa Tech](https://nakazawa.tech/).
package/lib/cli.mjs CHANGED
@@ -7,11 +7,11 @@ import { styleText } from "node:util";
7
7
  const isDataViewField = (field) => Boolean(field) && typeof field === "object" && "fields" in field;
8
8
  /**
9
9
  * Builds the schema object used by the CLI generator from your data views and
10
- * list resolver configs.
10
+ * root resolver configs.
11
11
  */
12
- const createSchema = (dataViews, lists) => {
12
+ const createSchema = (dataViews, roots) => {
13
13
  const canonicalViews = /* @__PURE__ */ new Map();
14
- const entities = {};
14
+ const rootSchema = {};
15
15
  const fateTypes = /* @__PURE__ */ new Map();
16
16
  const processing = /* @__PURE__ */ new Set();
17
17
  const ensureType = (view) => {
@@ -36,20 +36,25 @@ const createSchema = (dataViews, lists) => {
36
36
  const typeName = view.typeName;
37
37
  if (!typeName) throw new Error("Data view is missing a type name.");
38
38
  if (!canonicalViews.has(typeName)) canonicalViews.set(typeName, view);
39
- entities[typeName.toLowerCase()] = { type: typeName };
40
39
  }
41
- for (const [name, list] of Object.entries(lists)) {
42
- const config = "fields" in list ? { view: list } : list;
43
- const typeName = ensureType(config.view);
44
- entities[typeName.toLowerCase()] = {
45
- list: name,
46
- listProcedure: config.procedure,
47
- type: typeName
40
+ for (const view of dataViews) ensureType(view);
41
+ for (const [name, root$1] of Object.entries(roots)) {
42
+ const config = "fields" in root$1 ? { view: root$1 } : root$1;
43
+ const view = config.view;
44
+ const type = ensureType(view);
45
+ if (!view.typeName) throw new Error(`Root "${name}" is missing a data view.`);
46
+ const router = config.router ?? view.typeName[0]?.toLowerCase() + view.typeName.slice(1);
47
+ if (!router) throw new Error(`Root "${name}" is missing a router name.`);
48
+ rootSchema[name] = {
49
+ kind: view.kind === "list" ? "list" : "query",
50
+ procedure: config.procedure ?? (view.kind === "list" ? "list" : name),
51
+ router,
52
+ type
48
53
  };
49
54
  }
50
55
  for (const view of dataViews) ensureType(view);
51
56
  return {
52
- entities,
57
+ roots: rootSchema,
53
58
  types: Array.from(fateTypes.values())
54
59
  };
55
60
  };
@@ -89,21 +94,29 @@ const formatTypes = (types) => {
89
94
  const indentBlock = (value, spaces) => value.split("\n").map((line) => line.length ? `${" ".repeat(spaces)}${line}` : line).join("\n");
90
95
  const generate = async () => {
91
96
  console.log(styleText("bold", `Generating fate client…\n`));
92
- const { appRouter, Lists, ...dataViews } = await import(moduleName);
93
- const { entities, types } = createSchema(Object.values(dataViews), Lists);
97
+ const { appRouter, Root, ...dataViews } = await import(moduleName);
98
+ const { roots, types } = createSchema(Object.values(dataViews), Root ?? {});
94
99
  const routerRecord = appRouter._def?.record ?? {};
95
100
  const mutationEntries = [];
96
101
  const byIdEntries = [];
97
102
  const listEntries = [];
103
+ const queryEntries = [];
104
+ const rootsByRouter = /* @__PURE__ */ new Map();
105
+ for (const entry of Object.entries(roots)) {
106
+ const list = rootsByRouter.get(entry[1].router) ?? [];
107
+ list.push(entry);
108
+ rootsByRouter.set(entry[1].router, list);
109
+ }
98
110
  for (const [router, procedures] of Object.entries(routerRecord)) {
99
- const entity = entities[router];
100
- if (!entity) continue;
111
+ const routerRoots = rootsByRouter.get(router);
112
+ if (!routerRoots?.length) continue;
113
+ const entityType = routerRoots[0][1].type;
101
114
  for (const [procedureName, procedure] of Object.entries(procedures)) {
102
115
  const type = procedure?._def?.type;
103
116
  if (!type) continue;
104
117
  if (type === "mutation") {
105
118
  mutationEntries.push({
106
- entityType: entity.type,
119
+ entityType,
107
120
  name: `${router}.${procedureName}`,
108
121
  procedure: procedureName,
109
122
  router
@@ -112,21 +125,32 @@ const generate = async () => {
112
125
  }
113
126
  if (procedureName === "byId" && type === "query") {
114
127
  byIdEntries.push({
115
- entityType: entity.type,
128
+ entityType,
116
129
  router
117
130
  });
118
131
  continue;
119
132
  }
120
- const listProcedure = entity.listProcedure ?? "list";
121
- if (procedureName === listProcedure && type === "query" && entity.list) listEntries.push({
122
- list: entity.list,
123
- procedure: listProcedure,
124
- router
125
- });
133
+ for (const [queryName, root$1] of routerRoots) {
134
+ if (root$1.kind !== "query") continue;
135
+ if (procedureName === root$1.procedure && type === "query") queryEntries.push({
136
+ name: queryName,
137
+ procedure: root$1.procedure,
138
+ router
139
+ });
140
+ }
141
+ for (const [listName, root$1] of routerRoots) {
142
+ if (root$1.kind !== "list") continue;
143
+ if (procedureName === root$1.procedure && type === "query") listEntries.push({
144
+ list: listName,
145
+ procedure: root$1.procedure,
146
+ router
147
+ });
148
+ }
126
149
  }
127
150
  }
128
151
  mutationEntries.sort((a, b) => a.name.localeCompare(b.name));
129
152
  byIdEntries.sort((a, b) => a.entityType.localeCompare(b.entityType));
153
+ queryEntries.sort((a, b) => a.name.localeCompare(b.name));
130
154
  listEntries.sort((a, b) => a.list.localeCompare(b.list));
131
155
  const viewTypes = Array.from(["AppRouter", ...new Set(mutationEntries.map((entry) => entry.entityType))]).sort();
132
156
  const mutationResolverLines = mutationEntries.map(({ name, procedure, router }) => `'${name}': (client: TRPCClientType) => client.${router}.${procedure}.mutate,`);
@@ -146,12 +170,15 @@ const generate = async () => {
146
170
  select,
147
171
  }),`);
148
172
  const listLines = listEntries.map(({ list, procedure, router }) => `${list}: (client: TRPCClientType) => client.${router}.${procedure}.query,`);
173
+ const queryLines = queryEntries.map(({ name, procedure, router }) => `${name}: (client: TRPCClientType) => client.${router}.${procedure}.query,`);
149
174
  const typeImports = `import type { ${viewTypes.join(", ")} } from '${moduleName}';`;
150
175
  const typesBlock = indentBlock(formatTypes(types), 6);
151
176
  const mutationResolverBlock = indentBlock(mutationResolverLines.join("\n"), 4);
152
177
  const mutationConfigBlock = indentBlock(mutationConfigLines.join("\n"), 2);
153
178
  const byIdBlock = indentBlock(byIdLines.join("\n"), 8);
154
179
  const listsBlockContent = listLines.join("\n");
180
+ const listsBlock = listLines.length ? ` lists: {\n${indentBlock(listsBlockContent, 8)}\n },\n` : "";
181
+ const queriesBlockContent = queryLines.join("\n");
155
182
  const source = `// @generated by \`pnpm fate generate\`
156
183
  ${typeImports}
157
184
  import { createTRPCProxyClient } from '@trpc/client';
@@ -188,7 +215,7 @@ ${mutationResolverBlock}
188
215
  ${byIdBlock}
189
216
  },
190
217
  client: trpcClient,
191
- ${listLines.length ? ` lists: {\n${indentBlock(listsBlockContent, 8)}\n },\n` : ""} mutations: trpcMutations,
218
+ ${queryLines.length ? ` queries: {\n${indentBlock(queriesBlockContent, 8)}\n },\n` : ""}${listsBlock} mutations: trpcMutations,
192
219
  }),
193
220
  types: ${typesBlock.trimStart()},
194
221
  });