├── .github ├── FUNDING.yml └── workflows │ ├── push.yml │ └── deploy.yml ├── packages ├── react-fate │ ├── src │ │ ├── cli.ts │ │ ├── index.tsx │ │ ├── context.tsx │ │ ├── useRequest.tsx │ │ ├── __tests__ │ │ │ ├── context.test.tsx │ │ │ └── useRequest.test.tsx │ │ ├── useView.tsx │ │ └── useListView.tsx │ └── package.json └── fate │ ├── src │ ├── record.ts │ ├── server │ │ ├── input.ts │ │ ├── __tests__ │ │ │ ├── prismaSelect.test.ts │ │ │ └── connection.test.ts │ │ └── prismaSelect.ts │ ├── root.ts │ ├── server.ts │ ├── node-ref.ts │ ├── index.ts │ ├── __tests__ │ │ ├── args.test.ts │ │ ├── store.test.ts │ │ └── cache.test.ts │ ├── ref.ts │ ├── mask.ts │ ├── cache.ts │ ├── view.ts │ ├── codegen │ │ ├── schema.ts │ │ └── __tests__ │ │ │ └── schema.test.ts │ └── selection.ts │ └── package.json ├── .oxlintrc.json ├── public ├── og-image.png └── icon.svg ├── example ├── server │ ├── src │ │ ├── lib │ │ │ ├── env.ts │ │ │ └── auth.tsx │ │ ├── prisma │ │ │ ├── migrations │ │ │ │ ├── migration_lock.toml │ │ │ │ ├── 20251201035654_remove_resources │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20251201025023_remove_projects │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20251022001236_comments │ │ │ │ │ └── migration.sql │ │ │ │ └── 20251021121731_init │ │ │ │ │ └── migration.sql │ │ │ ├── prisma.tsx │ │ │ └── schema.prisma │ │ ├── trpc │ │ │ ├── connection.ts │ │ │ ├── init.ts │ │ │ ├── router.ts │ │ │ ├── routers │ │ │ │ ├── tag.ts │ │ │ │ ├── __tests__ │ │ │ │ │ └── post.test.ts │ │ │ │ ├── event.ts │ │ │ │ ├── category.ts │ │ │ │ ├── user.ts │ │ │ │ └── comment.ts │ │ │ ├── context.ts │ │ │ └── views.ts │ │ ├── user │ │ │ └── SessionUser.tsx │ │ └── index.tsx │ ├── .env │ ├── prisma.config.ts │ └── package.json └── client │ ├── src │ ├── lib │ │ ├── env.tsx │ │ ├── cx.tsx │ │ └── formatLabel.tsx │ ├── ui │ │ ├── Number.tsx │ │ ├── H2.tsx │ │ ├── Link.tsx │ │ ├── Section.tsx │ │ ├── Error.tsx │ │ ├── H3.tsx │ │ ├── TagBadge.tsx │ │ ├── Card.tsx │ │ ├── Badge.tsx │ │ ├── Input.tsx │ │ ├── CommentCard.tsx │ │ ├── Button.tsx │ │ ├── Search.tsx │ │ ├── CategoryCard.tsx │ │ ├── UserCard.tsx │ │ ├── CreatePost.tsx │ │ └── EventCard.tsx │ ├── user │ │ ├── AuthClient.tsx │ │ └── SignIn.tsx │ ├── routes │ │ ├── SearchRoute.tsx │ │ ├── SignInRoute.tsx │ │ ├── PostRoute.tsx │ │ └── CategoryRoute.tsx │ ├── index.tsx │ └── App.css │ ├── index.html │ ├── vite.config.ts │ ├── package.json │ └── public │ └── icon.svg ├── CHANGELOG.md ├── git-hooks └── pre-commit ├── docker-compose.yml ├── .vscode ├── extensions.json └── settings.json ├── vitest.config.ts ├── pnpm-workspace.yaml ├── .gitignore ├── .devcontainer ├── start-dev.sh ├── devcontainer.json └── setup.sh ├── .oxfmtrc.jsonc ├── .vitepress ├── theme │ ├── index.ts │ └── docs.css ├── nkzw-logo.svg └── config.ts ├── typedoc.json ├── tsconfig.json ├── LICENSE ├── docs ├── guide │ ├── core-concepts.md │ ├── getting-started.md │ ├── list-views.md │ └── requests.md ├── parts │ ├── outro.md │ └── intro.md └── index.md ├── scripts └── generate-readme.ts ├── eslint.config.js ├── CONTRIBUTING.md └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: cpojer 2 | -------------------------------------------------------------------------------- /packages/react-fate/src/cli.ts: -------------------------------------------------------------------------------- 1 | import '@nkzw/fate/cli'; 2 | -------------------------------------------------------------------------------- /.oxlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": ["**/*.ts", "**/*.tsx"] 3 | } 4 | -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nkzw-tech/fate/HEAD/public/og-image.png -------------------------------------------------------------------------------- /example/server/src/lib/env.ts: -------------------------------------------------------------------------------- 1 | import defineEnv from '@nkzw/define-env'; 2 | 3 | export default defineEnv(['BETTER_AUTH_SECRET', 'CLIENT_DOMAIN', 'DATABASE_URL']); 4 | -------------------------------------------------------------------------------- /example/client/src/lib/env.tsx: -------------------------------------------------------------------------------- 1 | import defineEnv from '@nkzw/define-env'; 2 | 3 | export default defineEnv(['SERVER_URL'], { 4 | SERVER_URL: import.meta.env.VITE_SERVER_URL, 5 | }); 6 | -------------------------------------------------------------------------------- /example/server/src/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /example/client/src/ui/Number.tsx: -------------------------------------------------------------------------------- 1 | const numberFormatter = new Intl.NumberFormat(undefined); 2 | 3 | export default function Number({ value }: { value: number }) { 4 | return <>{numberFormatter.format(value)}; 5 | } 6 | -------------------------------------------------------------------------------- /example/client/src/user/AuthClient.tsx: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from 'better-auth/react'; 2 | import env from '../lib/env.tsx'; 3 | 4 | export default createAuthClient({ 5 | baseURL: env('SERVER_URL'), 6 | }); 7 | -------------------------------------------------------------------------------- /packages/fate/src/record.ts: -------------------------------------------------------------------------------- 1 | import { AnyRecord } from './types.ts'; 2 | 3 | export const isRecord = (value: unknown): value is AnyRecord => 4 | Boolean(value) && typeof value === 'object' && !Array.isArray(value); 5 | -------------------------------------------------------------------------------- /example/client/src/lib/cx.tsx: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export default function cx(...inputs: Array) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /example/client/src/lib/formatLabel.tsx: -------------------------------------------------------------------------------- 1 | export default function formatLabel(value: string) { 2 | return value 3 | .split('_') 4 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) 5 | .join(' '); 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.0 2 | 3 | - Suspend optimistic entities in `useView` while waiting for mutation results, see [#7059ae](https://github.com/nkzw-tech/fate/commit/7059ae8399b3d9229ed8e872a69372aa0d949785). 4 | 5 | ## 0.1.1 6 | 7 | - Initial Release 8 | -------------------------------------------------------------------------------- /git-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') 3 | [ -z "$FILES" ] && exit 0 4 | echo "$FILES" | xargs pnpm format --no-error-on-unmatched-pattern 5 | echo "$FILES" | xargs git add 6 | 7 | exit 0 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:17 4 | restart: always 5 | container_name: fate-template-db 6 | ports: 7 | - '5432:5432' 8 | 9 | environment: 10 | POSTGRES_USER: fate 11 | POSTGRES_PASSWORD: echo 12 | -------------------------------------------------------------------------------- /example/client/src/routes/SearchRoute.tsx: -------------------------------------------------------------------------------- 1 | import Search from '../ui/Search.tsx'; 2 | import Section from '../ui/Section.tsx'; 3 | 4 | export default function SearchRoute() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /example/server/src/trpc/connection.ts: -------------------------------------------------------------------------------- 1 | import { withConnection } from '@nkzw/fate/server'; 2 | import type { AppContext } from './context.ts'; 3 | import { procedure } from './init.ts'; 4 | 5 | export const createConnectionProcedure = withConnection(procedure); 6 | -------------------------------------------------------------------------------- /example/client/src/routes/SignInRoute.tsx: -------------------------------------------------------------------------------- 1 | import Section from '../ui/Section.tsx'; 2 | import SignIn from '../user/SignIn.tsx'; 3 | 4 | export default function SignInRoute() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /packages/fate/src/server/input.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { connectionArgs } from './connection.ts'; 3 | 4 | export const byIdInput = z.object({ 5 | args: connectionArgs, 6 | ids: z.array(z.string().min(1)).nonempty(), 7 | select: z.array(z.string()), 8 | }); 9 | -------------------------------------------------------------------------------- /example/server/.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://fate:echo@localhost:5432/fate 2 | 3 | BETTER_AUTH_SECRET=="8Mw3ZRkxkem0BBsSeem7/tnC+xaYjFzs5lDiXgNWcLw=" 4 | BETTER_AUTH_URL="http://localhost:9020" 5 | CLIENT_DOMAIN="http://localhost:6001" 6 | VITE_SERVER_URL="http://localhost:9020" 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "styled-components.vscode-styled-components", 6 | "sysoev.vscode-open-in-github", 7 | "usernamehw.errorlens", 8 | "wix.vscode-import-cost" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /example/server/src/prisma/migrations/20251201035654_remove_resources/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `resources` on the `Event` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Event" DROP COLUMN "resources"; 9 | -------------------------------------------------------------------------------- /example/server/src/trpc/init.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server'; 2 | import type { AppContext } from './context.ts'; 3 | 4 | const t = initTRPC.context().create(); 5 | 6 | export const router = t.router; 7 | export const procedure = t.procedure; 8 | export const middleware = t.middleware; 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { join } from 'node:path'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | const root = process.cwd(); 6 | 7 | dotenv.config({ 8 | path: join(root, './server', '.env'), 9 | quiet: true, 10 | }); 11 | 12 | export default defineConfig({}); 13 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - example/* 3 | - packages/* 4 | 5 | onlyBuiltDependencies: 6 | - '@prisma/client' 7 | - '@prisma/engines' 8 | - '@swc/core' 9 | - '@tailwindcss/oxide' 10 | - esbuild 11 | - msgpackr-extract 12 | - prisma 13 | - unrs-resolver 14 | 15 | overrides: 16 | vite: ^8.0.0-beta.1 17 | -------------------------------------------------------------------------------- /example/server/src/prisma/prisma.tsx: -------------------------------------------------------------------------------- 1 | import { PrismaPg } from '@prisma/adapter-pg'; 2 | import env from '../lib/env.ts'; 3 | import { PrismaClient } from './prisma-client/client.ts'; 4 | 5 | const adapter = new PrismaPg({ 6 | connectionString: env('DATABASE_URL'), 7 | }); 8 | 9 | export default new PrismaClient({ adapter }); 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .eslintcache 2 | .pnpm-debug.log 3 | .vitepress/cache 4 | **/node_modules/ 5 | /node_modules/ 6 | coverage/ 7 | dist/ 8 | docs/api 9 | example/client/dist/ 10 | example/server/src/prisma/prisma-client/ 11 | packages/**/lib/ 12 | packages/**/LICENSE 13 | packages/**/README.md 14 | tsconfig.tsbuildinfo 15 | vite.config.ts.timestamp-* 16 | -------------------------------------------------------------------------------- /example/client/src/ui/H2.tsx: -------------------------------------------------------------------------------- 1 | import cx from '../lib/cx.tsx'; 2 | 3 | export default function H2({ 4 | children, 5 | className, 6 | }: { 7 | children: React.ReactNode; 8 | className?: string; 9 | }) { 10 | return ( 11 |

12 | {children} 13 |

14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /packages/fate/src/root.ts: -------------------------------------------------------------------------------- 1 | import type { RootDefinition, TypeName } from './types.ts'; 2 | import { RootKind } from './types.ts'; 3 | 4 | /** 5 | * Defines a root query for an entity type, capturing the response shape. 6 | */ 7 | export function clientRoot( 8 | type: Type, 9 | ): RootDefinition { 10 | return Object.freeze({ 11 | [RootKind]: true, 12 | type, 13 | }) as RootDefinition; 14 | } 15 | -------------------------------------------------------------------------------- /example/client/src/ui/Link.tsx: -------------------------------------------------------------------------------- 1 | import { AnchorHTMLAttributes } from 'react'; 2 | import { LinkProps as LinkPropsT, Link as ReactRouterLink, useLocation } from 'react-router'; 3 | 4 | export type LinkProps = LinkPropsT & AnchorHTMLAttributes; 5 | 6 | export default function Link({ ...props }: LinkPropsT) { 7 | const { pathname: previousPathname } = useLocation(); 8 | 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /packages/fate/src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The fate server library. 3 | * 4 | * @example 5 | * import { dataView } from '@nkzw/fate/server'; 6 | * 7 | * @module @nkzw/fate/server 8 | */ 9 | 10 | export type { Entity } from './server/dataView.ts'; 11 | 12 | export { createResolver, dataView, list, resolver } from './server/dataView.ts'; 13 | export { withConnection, connectionArgs } from './server/connection.ts'; 14 | export { byIdInput } from './server/input.ts'; 15 | -------------------------------------------------------------------------------- /example/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | fate 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.devcontainer/start-dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 5 | LOG_FILE="$ROOT_DIR/.devcontainer/dev.log" 6 | 7 | if pgrep -f "pnpm dev -- --host" > /dev/null; then 8 | echo "Dev servers already running. Logs: $LOG_FILE" 9 | exit 0 10 | fi 11 | 12 | cd "$ROOT_DIR" 13 | nohup pnpm dev -- --host > "$LOG_FILE" 2>&1 < /dev/null & 14 | echo "Starting client and server with 'pnpm dev -- --host'. Logs: $LOG_FILE" 15 | -------------------------------------------------------------------------------- /.oxfmtrc.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/oxfmt/configuration_schema.json", 3 | "singleQuote": true, 4 | "experimentalSortImports": { 5 | "newlinesBetween": false, 6 | }, 7 | "ignorePatterns": [ 8 | ".vitepress/cache", 9 | ".vitepress/dist", 10 | "coverage/", 11 | "dist/", 12 | "example/client/dist/", 13 | "example/client/src/fate.ts", 14 | "example/client/src/translations/", 15 | "example/server/dist", 16 | "pnpm-lock.yaml", 17 | ], 18 | } 19 | -------------------------------------------------------------------------------- /.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme'; 2 | import '@fontsource/fira-code'; 3 | import './docs.css'; 4 | 5 | const listener = (event: DragEvent) => { 6 | const target = event.target; 7 | if (target && 'tagName' in target && target.tagName === 'A') { 8 | event.preventDefault(); 9 | } 10 | }; 11 | 12 | if (typeof document !== 'undefined') { 13 | document.addEventListener('dragstart', listener); 14 | } 15 | 16 | export default { 17 | ...DefaultTheme, 18 | }; 19 | -------------------------------------------------------------------------------- /example/server/src/user/SessionUser.tsx: -------------------------------------------------------------------------------- 1 | import { User } from '../prisma/prisma-client/client.ts'; 2 | 3 | export type SessionUser = Pick; 4 | 5 | export function toSessionUser({ 6 | email, 7 | id, 8 | role, 9 | username, 10 | }: Readonly<{ 11 | email: string | null | undefined; 12 | id: string; 13 | role?: string | null | undefined; 14 | username?: string | null | undefined; 15 | }>): SessionUser { 16 | return { id, role: role || '', username: username || email || '' }; 17 | } 18 | -------------------------------------------------------------------------------- /example/client/src/ui/Section.tsx: -------------------------------------------------------------------------------- 1 | import { Gap, VStack } from '@nkzw/stack'; 2 | import { ReactNode } from 'react'; 3 | import cx from '../lib/cx.tsx'; 4 | 5 | export default function Section({ 6 | children, 7 | className, 8 | gap, 9 | }: { 10 | children: ReactNode; 11 | className?: string; 12 | gap?: Gap; 13 | }) { 14 | return ( 15 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /example/client/src/routes/PostRoute.tsx: -------------------------------------------------------------------------------- 1 | import { useRequest } from 'react-fate'; 2 | import { useParams } from 'react-router'; 3 | import { PostCard, PostView } from '../ui/PostCard.tsx'; 4 | import Section from '../ui/Section.tsx'; 5 | 6 | export default function PostRoute() { 7 | const { id } = useParams(); 8 | 9 | if (!id) { 10 | throw new Error('fate: Post ID is required.'); 11 | } 12 | 13 | const { post } = useRequest({ post: { id, view: PostView } }); 14 | 15 | return ( 16 |
17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /example/client/src/ui/Error.tsx: -------------------------------------------------------------------------------- 1 | import { VStack } from '@nkzw/stack'; 2 | 3 | export default function Error({ error }: { error: Error }) { 4 | return ( 5 | 6 |

Error

7 | {error.stack || `fate Error: ${error.message}`} 8 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /example/client/src/ui/H3.tsx: -------------------------------------------------------------------------------- 1 | import cx from '../lib/cx.tsx'; 2 | 3 | export default function H2({ 4 | children, 5 | className, 6 | }: { 7 | children: React.ReactNode; 8 | className?: string; 9 | }) { 10 | return ( 11 |

17 | {children} 18 |

19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /example/server/prisma.config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { join } from 'node:path'; 3 | import { defineConfig } from 'prisma/config'; 4 | 5 | const root = process.cwd(); 6 | dotenv.config({ 7 | path: join(root, '.env'), 8 | quiet: true, 9 | }); 10 | 11 | const { default: env } = await import('./src/lib/env.ts'); 12 | 13 | export default defineConfig({ 14 | datasource: { 15 | url: env('DATABASE_URL'), 16 | }, 17 | migrations: { 18 | seed: `node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm --env-file .env src/prisma/seed.tsx`, 19 | }, 20 | schema: './src/prisma/schema.prisma', 21 | }); 22 | -------------------------------------------------------------------------------- /example/client/src/ui/TagBadge.tsx: -------------------------------------------------------------------------------- 1 | import { Tag } from '@nkzw/fate-server/src/trpc/router.ts'; 2 | import { useView, view, ViewRef } from 'react-fate'; 3 | import { Badge } from './Badge.tsx'; 4 | 5 | export const TagView = view()({ 6 | description: true, 7 | id: true, 8 | name: true, 9 | }); 10 | 11 | export default function TagBadge({ tag: tagRef }: { tag: ViewRef<'Tag'> }) { 12 | const tag = useView(TagView, tagRef); 13 | 14 | if (!tag) { 15 | return null; 16 | } 17 | 18 | return ( 19 | 20 | #{tag.name} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /example/client/src/routes/CategoryRoute.tsx: -------------------------------------------------------------------------------- 1 | import { useRequest } from 'react-fate'; 2 | import { useParams } from 'react-router'; 3 | import CategoryCard, { CategoryView } from '../ui/CategoryCard.tsx'; 4 | import Section from '../ui/Section.tsx'; 5 | 6 | export default function CategoryRoute() { 7 | const { id } = useParams(); 8 | 9 | if (!id) { 10 | throw new Error('fate: Category ID is required.'); 11 | } 12 | 13 | const { category } = useRequest( 14 | { category: { id, view: CategoryView } }, 15 | { mode: 'stale-while-revalidate' }, 16 | ); 17 | 18 | return ( 19 |
20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /example/server/src/trpc/router.ts: -------------------------------------------------------------------------------- 1 | import { router } from './init.ts'; 2 | import { categoryRouter } from './routers/category.ts'; 3 | import { commentRouter } from './routers/comment.ts'; 4 | import { eventRouter } from './routers/event.ts'; 5 | import { postRouter } from './routers/post.ts'; 6 | import { tagRouter } from './routers/tag.ts'; 7 | import { userRouter } from './routers/user.ts'; 8 | 9 | export const appRouter = router({ 10 | category: categoryRouter, 11 | comment: commentRouter, 12 | event: eventRouter, 13 | post: postRouter, 14 | tags: tagRouter, 15 | user: userRouter, 16 | }); 17 | 18 | export type AppRouter = typeof appRouter; 19 | 20 | export * from './views.ts'; 21 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "docsRoot": "./docs", 4 | "entryPoints": [ 5 | "packages/fate/src/index.ts", 6 | "packages/fate/src/server.ts", 7 | "packages/react-fate/src/index.tsx" 8 | ], 9 | "excludeExternals": true, 10 | "excludeInternal": true, 11 | "excludePrivate": true, 12 | "excludeProtected": true, 13 | "hideBreadcrumbs": true, 14 | "navigation": { 15 | "includeFolders": false 16 | }, 17 | "out": "docs/api", 18 | "packageOptions": { 19 | "basePath": "./src" 20 | }, 21 | "plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"], 22 | "readme": "none", 23 | "sidebar": { 24 | "pretty": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/server/src/trpc/routers/tag.ts: -------------------------------------------------------------------------------- 1 | import { byIdInput, createResolver } from '@nkzw/fate/server'; 2 | import type { TagFindManyArgs } from '../../prisma/prisma-client/models.ts'; 3 | import { procedure, router } from '../init.ts'; 4 | import { tagDataView } from '../views.ts'; 5 | 6 | export const tagRouter = router({ 7 | byId: procedure.input(byIdInput).query(async ({ ctx, input }) => { 8 | const { resolveMany, select } = createResolver({ 9 | ...input, 10 | ctx, 11 | view: tagDataView, 12 | }); 13 | 14 | return await resolveMany( 15 | await ctx.prisma.tag.findMany({ 16 | select, 17 | where: { id: { in: input.ids } }, 18 | } as TagFindManyArgs), 19 | ); 20 | }), 21 | }); 22 | -------------------------------------------------------------------------------- /example/server/src/trpc/context.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'hono'; 2 | import { auth } from '../lib/auth.tsx'; 3 | import prisma from '../prisma/prisma.tsx'; 4 | import { toSessionUser } from '../user/SessionUser.tsx'; 5 | 6 | type CreateContextOptions = { 7 | context: Context; 8 | }; 9 | 10 | export const createContext = async (options?: CreateContextOptions) => { 11 | const session = options 12 | ? await auth.api.getSession({ headers: options.context.req.raw.headers }) 13 | : null; 14 | 15 | return { 16 | headers: options ? options.context.req.raw.headers : {}, 17 | prisma, 18 | sessionUser: session?.user ? toSessionUser(session.user) : null, 19 | }; 20 | }; 21 | 22 | export type AppContext = Awaited>; 23 | -------------------------------------------------------------------------------- /packages/fate/src/node-ref.ts: -------------------------------------------------------------------------------- 1 | import type { EntityId } from './types.ts'; 2 | import { NodeRefTag } from './types.ts'; 3 | 4 | export type NodeRef = Readonly<{ 5 | [NodeRefTag]: EntityId; 6 | }>; 7 | 8 | export function createNodeRef(id: EntityId): NodeRef { 9 | const ref: Record = {}; 10 | 11 | Object.defineProperty(ref, NodeRefTag, { 12 | configurable: false, 13 | enumerable: false, 14 | value: id, 15 | writable: false, 16 | }); 17 | 18 | return Object.freeze(ref) as NodeRef; 19 | } 20 | 21 | export function isNodeRef(value: unknown): value is NodeRef { 22 | return !value || typeof value !== 'object' ? false : NodeRefTag in value; 23 | } 24 | 25 | export function getNodeRefId(ref: NodeRef): EntityId { 26 | return ref[NodeRefTag]; 27 | } 28 | -------------------------------------------------------------------------------- /example/server/src/prisma/migrations/20251201025023_remove_projects/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Project` table. If the table is not empty, all the data it contains will be lost. 5 | - You are about to drop the `ProjectUpdate` table. If the table is not empty, all the data it contains will be lost. 6 | 7 | */ 8 | -- DropForeignKey 9 | ALTER TABLE "Project" DROP CONSTRAINT "Project_ownerId_fkey"; 10 | 11 | -- DropForeignKey 12 | ALTER TABLE "ProjectUpdate" DROP CONSTRAINT "ProjectUpdate_authorId_fkey"; 13 | 14 | -- DropForeignKey 15 | ALTER TABLE "ProjectUpdate" DROP CONSTRAINT "ProjectUpdate_projectId_fkey"; 16 | 17 | -- DropTable 18 | DROP TABLE "Project"; 19 | 20 | -- DropTable 21 | DROP TABLE "ProjectUpdate"; 22 | 23 | -- DropEnum 24 | DROP TYPE "ProjectStatus"; 25 | -------------------------------------------------------------------------------- /packages/react-fate/src/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * The react fate library. 3 | * 4 | * @example 5 | * import { useView, view } from 'react-fate'; 6 | * 7 | * @module react-fate 8 | */ 9 | 10 | export { 11 | clientRoot, 12 | createClient, 13 | createTRPCTransport, 14 | mutation, 15 | toEntityId, 16 | type ConnectionRef, 17 | type ViewRef, 18 | view, 19 | } from '@nkzw/fate'; 20 | 21 | export { FateClient, useFateClient } from './context.tsx'; 22 | export { useView } from './useView.tsx'; 23 | export { useRequest } from './useRequest.tsx'; 24 | export { useListView } from './useListView.tsx'; 25 | 26 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 27 | export interface ClientMutations {} 28 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 29 | export interface ClientRoots {} 30 | -------------------------------------------------------------------------------- /example/server/src/lib/auth.tsx: -------------------------------------------------------------------------------- 1 | import { betterAuth } from 'better-auth'; 2 | import { prismaAdapter } from 'better-auth/adapters/prisma'; 3 | import { admin, username } from 'better-auth/plugins'; 4 | import prisma from '../prisma/prisma.tsx'; 5 | import env from './env.ts'; 6 | 7 | export const auth = betterAuth({ 8 | advanced: { 9 | database: { 10 | generateId: false, 11 | }, 12 | }, 13 | database: prismaAdapter(prisma, { 14 | provider: 'postgresql', 15 | }), 16 | emailAndPassword: { 17 | autoSignIn: true, 18 | enabled: true, 19 | maxPasswordLength: 128, 20 | minPasswordLength: 8, 21 | }, 22 | plugins: [admin(), username()], 23 | session: { 24 | cookieCache: { 25 | enabled: true, 26 | maxAge: 15 * 24 * 60 * 60, 27 | }, 28 | }, 29 | telemetry: { enabled: false }, 30 | trustedOrigins: [env('CLIENT_DOMAIN')], 31 | }); 32 | -------------------------------------------------------------------------------- /example/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import reactCompiler from 'babel-plugin-react-compiler'; 4 | import dotenv from 'dotenv'; 5 | import { join } from 'node:path'; 6 | import { defineConfig } from 'vite'; 7 | 8 | const root = process.cwd(); 9 | const isDevelopment = process.env.NODE_ENV === 'development' || process.env.DEV; 10 | 11 | dotenv.config({ 12 | path: join(root, '../server', isDevelopment ? '.env' : '.prod.env'), 13 | quiet: true, 14 | }); 15 | 16 | if (!process.env.VITE_SERVER_URL) { 17 | throw new Error(`client-build, vite.config: 'VITE_SERVER_URL' is missing.`); 18 | } 19 | 20 | export default defineConfig({ 21 | build: { outDir: join(root, '../dist/client') }, 22 | plugins: [ 23 | tailwindcss(), 24 | react({ 25 | babel: { 26 | plugins: [reactCompiler], 27 | }, 28 | }), 29 | ], 30 | resolve: { conditions: ['@nkzw/source'] }, 31 | server: { port: 6001 }, 32 | }); 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowImportingTsExtensions": true, 4 | "allowJs": true, 5 | "customConditions": ["@nkzw/source"], 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "incremental": true, 9 | "isolatedModules": true, 10 | "jsx": "react-jsx", 11 | "lib": ["dom", "dom.iterable", "esnext"], 12 | "module": "nodenext", 13 | "moduleResolution": "nodenext", 14 | "noEmit": true, 15 | "noImplicitOverride": true, 16 | "noUnusedLocals": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "es2017", 21 | "types": ["vite/client"] 22 | }, 23 | "exclude": ["node_modules"], 24 | "include": ["**/*.ts", "**/*.tsx"], 25 | "ts-node": { 26 | "transpileOnly": true, 27 | "transpiler": "ts-node/transpilers/swc", 28 | "files": true, 29 | "compilerOptions": { 30 | "module": "esnext", 31 | "isolatedModules": false 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /example/client/src/ui/Card.tsx: -------------------------------------------------------------------------------- 1 | import { VStack } from '@nkzw/stack'; 2 | import cx from '../lib/cx.tsx'; 3 | 4 | export default function Card({ 5 | children, 6 | className, 7 | }: { 8 | children: React.ReactNode; 9 | className?: string; 10 | }) { 11 | return ( 12 |
18 |
19 | 25 | {children} 26 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "files.insertFinalNewline": true, 4 | "files.trimFinalNewlines": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit" 7 | }, 8 | "[javascript]": { 9 | "editor.defaultFormatter": "oxc.oxc-vscode" 10 | }, 11 | "[typescriptreact]": { 12 | "editor.defaultFormatter": "oxc.oxc-vscode" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "oxc.oxc-vscode" 16 | }, 17 | "[json]": { 18 | "editor.defaultFormatter": "oxc.oxc-vscode" 19 | }, 20 | "[jsonc]": { 21 | "editor.defaultFormatter": "oxc.oxc-vscode" 22 | }, 23 | "typescript.preferences.importModuleSpecifierEnding": "js", 24 | "typescript.reportStyleChecksAsWarnings": false, 25 | "typescript.updateImportsOnFileMove.enabled": "always", 26 | "typescript.preferences.autoImportFileExcludePatterns": ["**/node_modules/vitest"], 27 | "typescript.tsdk": "node_modules/typescript/lib", 28 | "typescript.experimental.useTsgo": true, 29 | "oxc.fmt.experimental": true 30 | } 31 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fate", 3 | "image": "mcr.microsoft.com/devcontainers/base:bookworm", 4 | "features": { 5 | "ghcr.io/devcontainers/features/node:1": { 6 | "version": "24", 7 | "pnpm": "true" 8 | }, 9 | "ghcr.io/itsmechlark/features/postgresql:1": { 10 | "version": "16", 11 | "database": "fate", 12 | "user": "postgres", 13 | "password": "postgres" 14 | } 15 | }, 16 | "forwardPorts": [6001, 9020], 17 | "portsAttributes": { 18 | "6001": { 19 | "label": "fate client", 20 | "onAutoForward": "openPreview" 21 | }, 22 | "9020": { 23 | "label": "fate server", 24 | "onAutoForward": "openBrowser" 25 | } 26 | }, 27 | "postCreateCommand": "bash .devcontainer/setup.sh", 28 | "postAttachCommand": "bash .devcontainer/start-dev.sh", 29 | "customizations": { 30 | "vscode": { 31 | "extensions": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] 32 | }, 33 | "codespaces": { 34 | "openFiles": ["README.md"] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/react-fate/src/context.tsx: -------------------------------------------------------------------------------- 1 | import type { FateClient as FateClientT, FateMutations } from '@nkzw/fate'; 2 | import { createContext, ReactNode, use } from 'react'; 3 | import type { ClientMutations } from './index.tsx'; 4 | import { Roots } from './useRequest.tsx'; 5 | 6 | type Mutations = keyof ClientMutations extends never ? FateMutations : ClientMutations; 7 | 8 | const FateContext = createContext | null>(null); 9 | 10 | /** 11 | * Provider component that supplies a configured `FateClient` to React hooks. 12 | */ 13 | export function FateClient({ 14 | children, 15 | client, 16 | }: { 17 | children: ReactNode; 18 | client: FateClientT; 19 | }) { 20 | return {children}; 21 | } 22 | 23 | /** 24 | * Returns the nearest `FateClient` from context. 25 | */ 26 | export function useFateClient(): FateClientT { 27 | const context = use(FateContext); 28 | if (!context) { 29 | throw new Error(`react-fate: '' is missing.`); 30 | } 31 | return context; 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Nakazawa Tech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nkzw/fate-server", 3 | "version": "0.0.0", 4 | "private": true, 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/nkzw-tech/fate.git" 8 | }, 9 | "author": "Christoph Nakazawa ", 10 | "type": "module", 11 | "scripts": { 12 | "dev": "NODE_ENV=development node_modules/.bin/nodemon -q -I --exec node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm --env-file .env src/index.tsx", 13 | "dev:setup": "pnpm prisma generate" 14 | }, 15 | "dependencies": { 16 | "@hono/node-server": "^1.19.7", 17 | "@hono/trpc-server": "^0.4.1", 18 | "@nkzw/core": "^1.3.1", 19 | "@nkzw/define-env": "^1.1.0", 20 | "@nkzw/fate": "workspace:*", 21 | "@prisma/adapter-pg": "^7.1.0", 22 | "@prisma/client": "^7.1.0", 23 | "@trpc/server": "^11.8.0", 24 | "better-auth": "^1.4.7", 25 | "hono": "^4.11.1", 26 | "zod": "^4.2.1" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^25.0.2", 30 | "nodemon": "^3.1.11", 31 | "prisma": "^7.1.0" 32 | }, 33 | "nodemonConfig": { 34 | "ext": "ts,tsx", 35 | "watch": [ 36 | "src/" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /example/client/src/ui/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority'; 2 | import { HTMLAttributes } from 'react'; 3 | import cx from '../lib/cx.tsx'; 4 | 5 | const badgeVariants = cva( 6 | 'squircle inline-flex items-center border px-1.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none', 7 | { 8 | defaultVariants: { 9 | variant: 'default', 10 | }, 11 | variants: { 12 | variant: { 13 | default: 'bg-primary text-primary-foreground border-transparent hover:bg-primary/80', 14 | destructive: 15 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 16 | outline: 'text-foreground', 17 | secondary: 18 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 19 | }, 20 | }, 21 | }, 22 | ); 23 | 24 | export interface BadgeProps 25 | extends HTMLAttributes, VariantProps {} 26 | 27 | function Badge({ className, variant, ...props }: BadgeProps) { 28 | return
; 29 | } 30 | 31 | export { Badge, badgeVariants }; 32 | -------------------------------------------------------------------------------- /packages/react-fate/src/useRequest.tsx: -------------------------------------------------------------------------------- 1 | import { RequestResult, type Request, type RequestOptions } from '@nkzw/fate'; 2 | import { FateRoots } from '@nkzw/fate'; 3 | import { use, useDeferredValue, useEffect } from 'react'; 4 | import { useFateClient } from './context.tsx'; 5 | import { ClientRoots } from './index.tsx'; 6 | 7 | export type Roots = keyof ClientRoots extends never ? FateRoots : ClientRoots; 8 | 9 | /** 10 | * Declares the data a screen needs and kicks off fetching, suspending while the 11 | * request resolves. 12 | * 13 | * @example 14 | * const { posts } = useRequest({ posts: { list: PostView } }); 15 | */ 16 | export function useRequest( 17 | request: R, 18 | options?: RequestOptions, 19 | ): RequestResult { 20 | const client = useFateClient(); 21 | const promise = client.request(request, options); 22 | const mode = options?.mode ?? 'cache-first'; 23 | 24 | useEffect(() => { 25 | if (mode === 'network-only' || mode === 'stale-while-revalidate') { 26 | return () => { 27 | client.releaseRequest(request, mode); 28 | }; 29 | } 30 | }, [client, mode, request]); 31 | 32 | return use(useDeferredValue(promise)) as unknown as RequestResult; 33 | } 34 | -------------------------------------------------------------------------------- /packages/react-fate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-fate", 3 | "version": "0.1.1", 4 | "description": "fate is a modern data client for React.", 5 | "homepage": "https://github.com/nkzw-tech/fate", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/nkzw-tech/fate" 9 | }, 10 | "author": { 11 | "name": "Christoph Nakazawa", 12 | "email": "christoph.pojer@gmail.com" 13 | }, 14 | "license": "MIT", 15 | "type": "module", 16 | "main": "./lib/index.mjs", 17 | "exports": { 18 | ".": { 19 | "types": "./lib/index.d.mts", 20 | "@nkzw/source": "./src/index.tsx", 21 | "default": "./lib/index.mjs" 22 | } 23 | }, 24 | "types": "./lib/index.d.mts", 25 | "bin": { 26 | "fate": "./lib/cli.mjs" 27 | }, 28 | "files": [ 29 | "lib" 30 | ], 31 | "scripts": { 32 | "build": "tsdown -d lib --target=node24 src/index.tsx src/cli.ts" 33 | }, 34 | "dependencies": { 35 | "@nkzw/fate": "workspace:^" 36 | }, 37 | "devDependencies": { 38 | "@types/react": "^19.2.7", 39 | "@types/react-dom": "^19.2.3", 40 | "react": "^19.2.3", 41 | "react-dom": "^19.2.3" 42 | }, 43 | "peerDependencies": { 44 | "react": "^19.2.0", 45 | "react-dom": "^19.2.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/fate/src/server/__tests__/prismaSelect.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { prismaSelect } from '../prismaSelect.ts'; 3 | 4 | test('prismaSelect applies pagination args to relation selections', () => { 5 | const select = prismaSelect(['comments.id'], { comments: { first: 2 } }); 6 | 7 | expect(select).toEqual({ 8 | comments: { 9 | select: { id: true }, 10 | take: 3, 11 | }, 12 | id: true, 13 | }); 14 | }); 15 | 16 | test('prismaSelect maps cursor args to Prisma pagination options', () => { 17 | const select = prismaSelect(['comments.id'], { 18 | comments: { after: 'cursor-1', first: 3 }, 19 | }); 20 | 21 | expect(select).toEqual({ 22 | comments: { 23 | cursor: { id: 'cursor-1' }, 24 | select: { id: true }, 25 | skip: 1, 26 | take: 4, 27 | }, 28 | id: true, 29 | }); 30 | }); 31 | 32 | test('prismaSelect maps backward pagination args to Prisma options', () => { 33 | const select = prismaSelect(['comments.id'], { 34 | comments: { before: 'cursor-2', last: 2 }, 35 | }); 36 | 37 | expect(select).toEqual({ 38 | comments: { 39 | cursor: { id: 'cursor-2' }, 40 | select: { id: true }, 41 | skip: 1, 42 | take: -3, 43 | }, 44 | id: true, 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/fate/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The fate core library. 3 | * 4 | * @example 5 | * import { view } from '@nkzw/fate'; 6 | * 7 | * @module @nkzw/fate 8 | */ 9 | 10 | export type { 11 | AnyRecord as FateRecord, 12 | ConnectionMetadata, 13 | ConnectionRef, 14 | Entity, 15 | EntityId, 16 | FateThenable, 17 | FateRoots, 18 | ListItem, 19 | Mask, 20 | MutationDefinition, 21 | MutationEntity, 22 | MutationIdentifier, 23 | MutationInput, 24 | MutationResult, 25 | NodesItem, 26 | Pagination, 27 | Request, 28 | RequestResult, 29 | Selection, 30 | Snapshot, 31 | TypeConfig, 32 | View, 33 | ViewData, 34 | ViewEntity, 35 | ViewEntityName, 36 | ViewRef, 37 | ViewSelection, 38 | ViewSnapshot, 39 | ViewTag, 40 | } from './types.ts'; 41 | export type { RequestMode, RequestOptions } from './client.ts'; 42 | export type { FateMutations } from './mutation.ts'; 43 | export type { Transport } from './transport.ts'; 44 | 45 | export { createClient, FateClient } from './client.ts'; 46 | export { ConnectionTag, isViewTag } from './types.ts'; 47 | export { createTRPCTransport } from './transport.ts'; 48 | export { getSelectionPlan } from './selection.ts'; 49 | export { mutation } from './mutation.ts'; 50 | export { clientRoot } from './root.ts'; 51 | export { toEntityId } from './ref.ts'; 52 | export { view } from './view.ts'; 53 | -------------------------------------------------------------------------------- /example/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nkzw/fate-client", 3 | "version": "0.0.0", 4 | "private": true, 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/nkzw-tech/fate.git" 8 | }, 9 | "author": "Christoph Nakazawa ", 10 | "type": "module", 11 | "scripts": { 12 | "dev": "vite dev" 13 | }, 14 | "dependencies": { 15 | "@nkzw/core": "^1.3.1", 16 | "@nkzw/define-env": "^1.1.0", 17 | "@nkzw/fate-server": "workspace:*", 18 | "@nkzw/stack": "^2.3.2", 19 | "@radix-ui/react-slot": "^1.2.4", 20 | "@trpc/client": "^11.8.0", 21 | "better-auth": "^1.4.7", 22 | "class-variance-authority": "^0.7.1", 23 | "clsx": "^2.1.1", 24 | "lucide-react": "^0.561.0", 25 | "react": "^19.2.3", 26 | "react-dom": "^19.2.3", 27 | "react-error-boundary": "^6.0.0", 28 | "react-fate": "workspace:*", 29 | "react-router": "^7.10.1", 30 | "tailwind-merge": "^3.4.0" 31 | }, 32 | "devDependencies": { 33 | "@tailwindcss/vite": "^4.1.18", 34 | "@trpc/server": "^11.8.0", 35 | "@types/node": "^25.0.2", 36 | "@types/react": "^19.2.7", 37 | "@types/react-dom": "^19.2.3", 38 | "@vitejs/plugin-react": "^5.1.2", 39 | "tailwindcss": "^4.1.18", 40 | "vite": "8.0.0-beta.2" 41 | }, 42 | "browserslist": [ 43 | ">0.2%", 44 | "not dead", 45 | "not op_mini all" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /example/client/src/ui/Input.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes, Ref } from 'react'; 2 | import cx from '../lib/cx.tsx'; 3 | 4 | export default function Input({ 5 | className, 6 | ...props 7 | }: InputHTMLAttributes & { ref?: Ref }) { 8 | return ( 9 | 16 | ); 17 | } 18 | 19 | export function CheckBox({ 20 | className, 21 | ...props 22 | }: InputHTMLAttributes & { ref?: Ref }) { 23 | return ( 24 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /packages/fate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nkzw/fate", 3 | "version": "0.1.1", 4 | "description": "fate is a modern data client for React.", 5 | "homepage": "https://github.com/nkzw-tech/fate", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/nkzw-tech/fate" 9 | }, 10 | "author": { 11 | "name": "Christoph Nakazawa", 12 | "email": "christoph.pojer@gmail.com" 13 | }, 14 | "license": "MIT", 15 | "type": "module", 16 | "main": "./lib/index.mjs", 17 | "exports": { 18 | ".": { 19 | "types": "./lib/index.d.mts", 20 | "@nkzw/source": "./src/index.ts", 21 | "default": "./lib/index.mjs" 22 | }, 23 | "./cli": { 24 | "types": "./lib/cli.d.mts", 25 | "@nkzw/source": "./src/cli.ts", 26 | "default": "./lib/cli.mjs" 27 | }, 28 | "./server": { 29 | "types": "./lib/server.d.mts", 30 | "@nkzw/source": "./src/server.ts", 31 | "default": "./lib/server.mjs" 32 | } 33 | }, 34 | "types": "./lib/index.d.mts", 35 | "bin": { 36 | "fate": "./lib/cli.mjs" 37 | }, 38 | "files": [ 39 | "lib" 40 | ], 41 | "scripts": { 42 | "build": "tsdown -d lib --target=node24 src/index.ts src/server.ts src/cli.ts" 43 | }, 44 | "dependencies": { 45 | "superjson": "^2.2.6", 46 | "zod": "^4.2.1" 47 | }, 48 | "devDependencies": { 49 | "@trpc/client": "^11.8.0", 50 | "@trpc/server": "^11.8.0" 51 | }, 52 | "peerDependencies": { 53 | "@trpc/client": "^11.6.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /docs/guide/core-concepts.md: -------------------------------------------------------------------------------- 1 | # Core Concepts 2 | 3 | **_fate_** has a minimal API surface and is aimed at reducing data fetching complexity. 4 | 5 | ## Thinking in Views 6 | 7 | In fate, each component declares the data it needs using views. Views are composed upward through the component tree until they reach a root, where the actual request is made. fate fetches all required data in a single request. React Suspense manages loading states, and any data-fetching errors naturally bubble up to React error boundaries. This eliminates the need for imperative loading logic or manual error handling. 8 | 9 | Traditionally, React apps are built with components and hooks. fate introduces a third primitive: views – a declarative way for components to express their data requirements. An app built with fate looks more like this: 10 | 11 |

12 | 13 | 14 | 15 | Tree 16 | 17 |

18 | 19 | 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. 20 | 21 | > [!NOTE] 22 | > Views in _fate_ are what fragments are in GraphQL. 23 | -------------------------------------------------------------------------------- /example/server/src/trpc/routers/__tests__/post.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, vi } from 'vitest'; 2 | import { router } from '../../init.ts'; 3 | import { postRouter } from '../post.ts'; 4 | 5 | test('returns pagination metadata for comment connections', async () => { 6 | const findMany = vi.fn().mockResolvedValue([ 7 | { 8 | comments: [{ id: 'comment-1' }, { id: 'comment-2' }], 9 | id: 'post-1', 10 | }, 11 | ]); 12 | 13 | const appRouter = router({ post: postRouter }); 14 | const caller = appRouter.createCaller({ 15 | headers: {}, 16 | prisma: { post: { findMany } } as never, 17 | sessionUser: null, 18 | }); 19 | 20 | const result = await caller.post.byId({ 21 | args: { comments: { first: 1 } }, 22 | ids: ['post-1'], 23 | select: ['comments.id'], 24 | }); 25 | 26 | expect(findMany).toHaveBeenCalledWith( 27 | expect.objectContaining({ 28 | select: expect.objectContaining({ 29 | comments: expect.objectContaining({ take: 2 }), 30 | }), 31 | }), 32 | ); 33 | 34 | expect(result).toEqual([ 35 | expect.objectContaining({ 36 | comments: { 37 | items: [ 38 | { 39 | cursor: 'comment-1', 40 | node: { id: 'comment-1' }, 41 | }, 42 | ], 43 | pagination: { 44 | hasNext: true, 45 | hasPrevious: false, 46 | nextCursor: 'comment-1', 47 | previousCursor: undefined, 48 | }, 49 | }, 50 | id: 'post-1', 51 | }), 52 | ]); 53 | }); 54 | -------------------------------------------------------------------------------- /example/server/src/prisma/migrations/20251022001236_comments/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Post" ( 3 | "id" TEXT NOT NULL, 4 | "title" TEXT NOT NULL, 5 | "content" TEXT NOT NULL, 6 | "likes" INTEGER NOT NULL DEFAULT 0, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3) NOT NULL, 9 | "authorId" TEXT NOT NULL, 10 | 11 | CONSTRAINT "Post_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateTable 15 | CREATE TABLE "Comment" ( 16 | "id" TEXT NOT NULL, 17 | "content" TEXT NOT NULL, 18 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 19 | "postId" TEXT NOT NULL, 20 | "authorId" TEXT, 21 | 22 | CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateIndex 26 | CREATE INDEX "Post_authorId_idx" ON "Post"("authorId" ASC); 27 | 28 | -- CreateIndex 29 | CREATE INDEX "Comment_authorId_idx" ON "Comment"("authorId" ASC); 30 | 31 | -- CreateIndex 32 | CREATE INDEX "Comment_postId_idx" ON "Comment"("postId" ASC); 33 | 34 | -- AddForeignKey 35 | ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; 36 | 37 | -- AddForeignKey 38 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; 39 | 40 | -- AddForeignKey 41 | ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; 42 | -------------------------------------------------------------------------------- /.devcontainer/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 5 | SERVER_ENV="$ROOT_DIR/example/server/.env" 6 | CLIENT_ENV="$ROOT_DIR/example/client/.env" 7 | 8 | POSTGRES_USER="${POSTGRES_USER:-postgres}" 9 | POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-postgres}" 10 | POSTGRES_DB="${POSTGRES_DB:-fate}" 11 | POSTGRES_HOST="${POSTGRES_HOST:-localhost}" 12 | POSTGRES_PORT="${POSTGRES_PORT:-5432}" 13 | 14 | if [[ -n "${CODESPACE_NAME:-}" ]]; then 15 | CLIENT_DOMAIN="https://${CODESPACE_NAME}-6001.app.github.dev" 16 | SERVER_URL="https://${CODESPACE_NAME}-9020.app.github.dev" 17 | else 18 | CLIENT_DOMAIN="${CLIENT_DOMAIN:-http://localhost:6001}" 19 | SERVER_URL="${SERVER_URL:-http://localhost:9020}" 20 | fi 21 | 22 | BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-}" 23 | if [[ -z "$BETTER_AUTH_SECRET" ]]; then 24 | BETTER_AUTH_SECRET="$(openssl rand -hex 32)" 25 | fi 26 | 27 | DATABASE_URL="${DATABASE_URL:-postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}}" 28 | 29 | if [[ ! -f "$SERVER_ENV" ]]; then 30 | cat > "$SERVER_ENV" < "$CLIENT_ENV" < { 9 | const { resolveMany, select } = createResolver({ 10 | ...input, 11 | ctx, 12 | view: eventDataView, 13 | }); 14 | return resolveMany( 15 | await ctx.prisma.event.findMany({ 16 | select: select as EventSelect, 17 | where: { id: { in: input.ids } }, 18 | }), 19 | ); 20 | }), 21 | list: createConnectionProcedure({ 22 | defaultSize: 3, 23 | query: async ({ ctx, cursor, direction, input, skip, take }) => { 24 | const { resolveMany, select } = createResolver({ 25 | ...input, 26 | ctx, 27 | view: eventDataView, 28 | }); 29 | 30 | const items = await ctx.prisma.event.findMany({ 31 | orderBy: { startAt: 'asc' }, 32 | select: select as EventSelect, 33 | take: direction === 'forward' ? take : -take, 34 | ...(cursor 35 | ? ({ 36 | cursor: { id: cursor }, 37 | skip, 38 | } as const) 39 | : null), 40 | }); 41 | 42 | return resolveMany(direction === 'forward' ? items : items.reverse()); 43 | }, 44 | }), 45 | }); 46 | -------------------------------------------------------------------------------- /example/server/src/trpc/routers/category.ts: -------------------------------------------------------------------------------- 1 | import { byIdInput, createResolver } from '@nkzw/fate/server'; 2 | import type { CategoryFindManyArgs } from '../../prisma/prisma-client/models.ts'; 3 | import { createConnectionProcedure } from '../connection.ts'; 4 | import { procedure, router } from '../init.ts'; 5 | import { categoryDataView } from '../views.ts'; 6 | 7 | export const categoryRouter = router({ 8 | byId: procedure.input(byIdInput).query(async ({ ctx, input }) => { 9 | const { resolveMany, select } = createResolver({ 10 | ...input, 11 | ctx, 12 | view: categoryDataView, 13 | }); 14 | return resolveMany( 15 | await ctx.prisma.category.findMany({ 16 | select, 17 | where: { id: { in: input.ids } }, 18 | } as CategoryFindManyArgs), 19 | ); 20 | }), 21 | list: createConnectionProcedure({ 22 | query: async ({ ctx, cursor, direction, input, skip, take }) => { 23 | const { resolveMany, select } = createResolver({ 24 | ...input, 25 | ctx, 26 | view: categoryDataView, 27 | }); 28 | 29 | const findOptions: CategoryFindManyArgs = { 30 | orderBy: { createdAt: 'asc' }, 31 | select, 32 | take: direction === 'forward' ? take : -take, 33 | }; 34 | 35 | if (cursor) { 36 | findOptions.cursor = { id: cursor }; 37 | findOptions.skip = skip; 38 | } 39 | 40 | const items = await ctx.prisma.category.findMany(findOptions); 41 | return resolveMany(direction === 'forward' ? items : items.reverse()); 42 | }, 43 | }), 44 | }); 45 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: push 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 20 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | permissions: 19 | contents: read 20 | deployments: write 21 | strategy: 22 | matrix: 23 | node-version: [24] 24 | 25 | steps: 26 | - uses: actions/checkout@v5 27 | 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v6 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | 33 | - name: Install pnpm 34 | uses: pnpm/action-setup@v4 35 | id: pnpm-install 36 | with: 37 | version: 10.* 38 | run_install: false 39 | 40 | - name: Get pnpm store directory 41 | id: pnpm-cache 42 | shell: bash 43 | run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 44 | 45 | - uses: actions/cache@v4 46 | name: Setup pnpm cache 47 | with: 48 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 49 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 50 | restore-keys: ${{ runner.os }}-pnpm-store- 51 | 52 | - name: Run setup 53 | run: pnpm install && pnpm dev:setup && pnpm build && pnpm typedoc 54 | 55 | - name: Run tests 56 | run: pnpm test 57 | 58 | - name: Publish 59 | run: pnpx pkg-pr-new publish './packages/*' --compact 60 | -------------------------------------------------------------------------------- /scripts/generate-readme.ts: -------------------------------------------------------------------------------- 1 | // scripts/build-readme.mjs 2 | import { promises as fs } from 'node:fs'; 3 | import path from 'node:path'; 4 | import { styleText } from 'node:util'; 5 | 6 | const root = process.cwd(); 7 | const README = path.join(root, 'README.md'); 8 | 9 | const files = [ 10 | 'docs/parts/intro.md', 11 | 'docs/guide/getting-started.md', 12 | 'docs/guide/core-concepts.md', 13 | 'docs/guide/views.md', 14 | 'docs/guide/requests.md', 15 | 'docs/guide/list-views.md', 16 | 'docs/guide/actions.md', 17 | 'docs/guide/server-integration.md', 18 | 'docs/parts/outro.md', 19 | ].map((file) => path.join(root, file)); 20 | 21 | const shiftHeadings = (content: string) => 22 | content.replaceAll(/^(#{1,6})\s+/gm, (match, hashes) => `${'#'.repeat(hashes.length + 1)} `); 23 | 24 | const stripFrontmatter = (content: string) => { 25 | if (!content.startsWith('---\n')) { 26 | return content; 27 | } 28 | 29 | const end = content.indexOf('\n---', 4); 30 | return end === -1 ? content : content.slice(end + 4).replace(/^\n+/, ''); 31 | }; 32 | 33 | console.log(styleText('bold', `Generating README.md…\n`)); 34 | 35 | const segments = []; 36 | 37 | for (const file of files) { 38 | let content = await fs.readFile(file, 'utf8'); 39 | 40 | if (content.includes(`(/guide/`)) { 41 | content = content.replaceAll(/\(\/guide\/([^\s)]+?)\)/g, '(/docs/guide/$1.md)'); 42 | } 43 | 44 | segments.push(shiftHeadings(stripFrontmatter(content).trim())); 45 | } 46 | 47 | const banner = '\n\n'; 48 | 49 | await fs.writeFile(README, banner + segments.join('\n\n') + '\n', 'utf8'); 50 | 51 | console.log(styleText('green', ` \u2713 'README.md' generated.`)); 52 | -------------------------------------------------------------------------------- /docs/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Template 4 | 5 | Get started with [a ready-made template](https://github.com/nkzw-tech/fate-template#readme) quickly: 6 | 7 | ::: code-group 8 | 9 | ```bash [npm] 10 | npx giget@latest gh:nkzw-tech/fate-template 11 | ``` 12 | 13 | ```bash [pnpm] 14 | pnpx giget@latest gh:nkzw-tech/fate-template 15 | ``` 16 | 17 | ```bash [yarn] 18 | yarn dlx giget@latest gh:nkzw-tech/fate-template 19 | ``` 20 | 21 | ::: 22 | 23 | `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. 24 | 25 | ## Manual Installation 26 | 27 | **_fate_** requires React 19.2+. For your client you need to install `react-fate`: 28 | 29 | ::: code-group 30 | 31 | ```bash [npm] 32 | npm add react-fate 33 | ``` 34 | 35 | ```bash [pnpm] 36 | pnpm add react-fate 37 | ``` 38 | 39 | ```bash [yarn] 40 | yarn add react-fate 41 | ``` 42 | 43 | ::: 44 | 45 | And for your server, install the core `@nkzw/fate` package: 46 | 47 | ::: code-group 48 | 49 | ```bash [npm] 50 | npm add @nkzw/fate 51 | ``` 52 | 53 | ```bash [pnpm] 54 | pnpm add @nkzw/fate 55 | ``` 56 | 57 | ```bash [yarn] 58 | yarn add @nkzw/fate 59 | ``` 60 | 61 | ::: 62 | 63 | > [!WARNING] 64 | > 65 | > **_fate_** is currently in alpha and not production ready. If something doesn't work for you, please open a pull request. 66 | 67 | If you'd like to try the example app in GitHub Codespaces, click the button below: 68 | 69 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?repo=nkzw-tech/fate) 70 | -------------------------------------------------------------------------------- /packages/react-fate/src/__tests__/context.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment happy-dom 3 | */ 4 | 5 | import React, { act } from 'react'; 6 | import { createRoot } from 'react-dom/client'; 7 | import { expect, test, vi } from 'vitest'; 8 | import { useFateClient } from '../context.tsx'; 9 | 10 | // @ts-expect-error React 🤷‍♂️ 11 | global.IS_REACT_ACT_ENVIRONMENT = true; 12 | 13 | const Component = () => { 14 | useFateClient(); 15 | return null; 16 | }; 17 | 18 | test('fails when the context was not provided', () => { 19 | const container = document.createElement('div'); 20 | const root = createRoot(container); 21 | 22 | let caught: unknown; 23 | 24 | class ErrorBoundary extends React.Component< 25 | React.PropsWithChildren<{ onError?: (error: unknown) => void }>, 26 | { error: unknown } 27 | > { 28 | override state = { error: null as unknown }; 29 | static getDerivedStateFromError(error: unknown) { 30 | return { error }; 31 | } 32 | override componentDidCatch(error: unknown) { 33 | this.props.onError?.(error); 34 | } 35 | override render() { 36 | if (this.state.error) { 37 | return null; 38 | } 39 | return this.props.children; 40 | } 41 | } 42 | 43 | const consoleError = console.error; 44 | console.error = vi.fn(); 45 | 46 | try { 47 | act(() => { 48 | root.render( 49 | (caught = e)}> 50 | 51 | , 52 | ); 53 | }); 54 | 55 | expect(caught).toBeInstanceOf(Error); 56 | expect((caught as Error).message).toBe("react-fate: '' is missing."); 57 | } finally { 58 | console.error = consoleError; 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /packages/fate/src/__tests__/args.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import type { AnyRecord } from '../types.ts'; 3 | import { cloneArgs, hashArgs } from '../args.ts'; 4 | 5 | describe('cloneArgs', () => { 6 | test('clones nested arrays and objects', () => { 7 | const source = { 8 | filter: { 9 | range: { end: 100, start: 1 }, 10 | tags: [{ label: 'important', value: 't-1' }], 11 | }, 12 | limit: 10, 13 | }; 14 | 15 | const cloned = cloneArgs(source, '__root'); 16 | 17 | expect(cloned).toEqual(source); 18 | expect(cloned).not.toBe(source); 19 | const clonedFilter = cloned.filter as AnyRecord; 20 | const clonedTags = clonedFilter.tags as Array; 21 | 22 | expect(clonedFilter).not.toBe(source.filter); 23 | expect(clonedFilter.range).not.toBe(source.filter.range); 24 | expect(clonedTags).not.toBe(source.filter.tags); 25 | expect(clonedTags[0]).not.toBe(source.filter.tags[0]); 26 | }); 27 | 28 | test('throws when encountering non-serializable values', () => { 29 | expect(() => 30 | cloneArgs( 31 | { 32 | handler: () => undefined, 33 | }, 34 | '__root', 35 | ), 36 | ).toThrow(/must be serializable/); 37 | }); 38 | }); 39 | 40 | describe('hashArgs', () => { 41 | test('produces stable hashes and supports ignoring keys', () => { 42 | const args = { after: 'cursor-1', first: 2, id: 'post-1' }; 43 | const sameArgs = { after: 'cursor-1', first: 2, id: 'post-1' }; 44 | 45 | expect(hashArgs(args)).toEqual(hashArgs(sameArgs)); 46 | 47 | const ignored = hashArgs(args, { ignoreKeys: new Set(['after']) }); 48 | expect(ignored).not.toEqual(hashArgs(args)); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /docs/guide/list-views.md: -------------------------------------------------------------------------------- 1 | # List Views 2 | 3 | ## Pagination with `useListView` 4 | 5 | You can wrap a list of references using `useListView` to enable connection-style lists with pagination support. 6 | 7 | For example, you can define a `CommentView` and reuse it inside of a `CommentConnectionView`: 8 | 9 | ```tsx 10 | import { useListView, ViewRef } from 'react-fate'; 11 | 12 | const CommentView = view()({ 13 | content: true, 14 | id: true, 15 | }); 16 | 17 | const CommentConnectionView = { 18 | args: { first: 10 }, 19 | items: { 20 | node: CommentView, 21 | }, 22 | }; 23 | 24 | const PostView = view()({ 25 | comments: CommentConnectionView, 26 | }); 27 | ``` 28 | 29 | Now you can apply the `useListView` hook inside of your `PostCard` component to read the list of comments and load more comments when needed: 30 | 31 | ```tsx 32 | export function PostCard({ 33 | detail, 34 | post: postRef, 35 | }: { 36 | detail?: boolean; 37 | post: ViewRef<'Post'>; 38 | }) { 39 | const post = useView(PostView, postRef); 40 | const [comments, loadNext] = useListView( 41 | CommentConnectionView, 42 | post.comments, 43 | ); 44 | 45 | return ( 46 |
47 | {comments.map(({ node }) => ( 48 | 49 | ))} 50 | {loadNext ? ( 51 | 54 | ) : null} 55 |
56 | ); 57 | } 58 | ``` 59 | 60 | If `loadNext` is undefined, it means there are no more comments to load. If you want to instead load previous comments, you can use the third argument returned by `useListView`, which is `loadPrevious`. Similarly, if there are no previous comments to load, `loadPrevious` will be undefined. 61 | -------------------------------------------------------------------------------- /example/server/src/index.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env NODE_ENV=development node_modules/.bin/nodemon -q -I --exec node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm --env-file .env 2 | import { serve } from '@hono/node-server'; 3 | import { trpcServer } from '@hono/trpc-server'; 4 | import parseInteger from '@nkzw/core/parseInteger.js'; 5 | import { Hono } from 'hono'; 6 | import { cors } from 'hono/cors'; 7 | import { parseArgs, styleText } from 'node:util'; 8 | import { auth } from './lib/auth.tsx'; 9 | import env from './lib/env.ts'; 10 | import prisma from './prisma/prisma.tsx'; 11 | import { createContext } from './trpc/context.ts'; 12 | import { appRouter } from './trpc/router.ts'; 13 | 14 | try { 15 | await prisma.$connect(); 16 | } catch (error) { 17 | console.error(`${styleText(['red', 'bold'], 'Prisma Database Connection Error')}\n`, error); 18 | process.exit(1); 19 | } 20 | 21 | const { 22 | values: { port: portArg }, 23 | } = parseArgs({ 24 | options: { 25 | port: { 26 | default: '9020', 27 | short: 'p', 28 | type: 'string', 29 | }, 30 | }, 31 | }); 32 | 33 | const origin = env('CLIENT_DOMAIN'); 34 | const port = (portArg && parseInteger(portArg)) || 9020; 35 | const app = new Hono(); 36 | 37 | app.use( 38 | cors({ 39 | credentials: true, 40 | origin, 41 | }), 42 | ); 43 | 44 | app.use( 45 | '/trpc/*', 46 | trpcServer({ 47 | createContext: (_, context) => createContext({ context }), 48 | router: appRouter, 49 | }), 50 | ); 51 | 52 | app.on(['POST', 'GET'], '/api/auth/*', ({ req }) => auth.handler(req.raw)); 53 | 54 | app.all('/*', (context) => context.redirect(origin)); 55 | 56 | serve({ fetch: app.fetch, port }, () => 57 | console.log( 58 | `${styleText(['green', 'bold'], ` ➜`)} Server running on port ${styleText('bold', String(port))}.\n`, 59 | ), 60 | ); 61 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy-docs 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: pages 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | node-version: [24] 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | 33 | - name: Install pnpm 34 | uses: pnpm/action-setup@v4 35 | id: pnpm-install 36 | with: 37 | version: 10.* 38 | run_install: false 39 | 40 | - name: Get pnpm store directory 41 | id: pnpm-cache 42 | shell: bash 43 | run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 44 | 45 | - uses: actions/cache@v4 46 | name: Setup pnpm cache 47 | with: 48 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 49 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 50 | restore-keys: ${{ runner.os }}-pnpm-store- 51 | 52 | - name: Setup Pages 53 | uses: actions/configure-pages@v4 54 | 55 | - name: Install dependencies 56 | run: pnpm install && pnpm dev:setup 57 | 58 | - name: Build with VitePress 59 | run: pnpm build && pnpm build:docs 60 | 61 | - name: Upload artifact 62 | uses: actions/upload-pages-artifact@v3 63 | with: 64 | path: .vitepress/dist 65 | 66 | deploy: 67 | environment: 68 | name: github-pages 69 | url: ${{ steps.deployment.outputs.page_url }} 70 | needs: build 71 | runs-on: ubuntu-latest 72 | name: Deploy 73 | steps: 74 | - name: Deploy to GitHub Pages 75 | id: deployment 76 | uses: actions/deploy-pages@v4 77 | -------------------------------------------------------------------------------- /packages/fate/src/ref.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnyRecord, 3 | Entity, 4 | EntityId, 5 | Selection, 6 | TypeName, 7 | View, 8 | ViewRef, 9 | ViewsTag, 10 | } from './types.ts'; 11 | import { getSelectionViewNames, getViewNames, getViewPayloads } from './view.ts'; 12 | 13 | /** 14 | * Builds the canonical cache ID for an entity. 15 | */ 16 | export const toEntityId = (type: TypeName, rawId: string | number): EntityId => 17 | `${type}:${String(rawId)}`; 18 | 19 | /** 20 | * Splits a cache entity ID back into its type and raw identifier. 21 | */ 22 | export function parseEntityId(id: EntityId) { 23 | const idx = id.indexOf(':'); 24 | return idx < 0 ? { id, type: '' } : { id: id.slice(idx + 1), type: id.slice(0, idx) }; 25 | } 26 | 27 | /** 28 | * Attaches view tags to a ref without leaking the symbol. 29 | */ 30 | export function assignViewTag(target: AnyRecord, value: ReadonlySet) { 31 | Object.defineProperty(target, ViewsTag, { 32 | configurable: false, 33 | enumerable: false, 34 | value, 35 | writable: false, 36 | }); 37 | } 38 | 39 | const getRootViewNames = (view: View) => { 40 | const names = new Set(getViewNames(view)); 41 | const payloads = getViewPayloads(view, null); 42 | for (const payload of payloads) { 43 | for (const name of getSelectionViewNames(payload.select)) { 44 | names.add(name); 45 | } 46 | } 47 | return names; 48 | }; 49 | 50 | /** 51 | * Creates an immutable `ViewRef` for an entity, tagging it with all views from 52 | * the provided composition so `useView` can resolve the ref against a view. 53 | */ 54 | export default function createRef, V extends View>( 55 | __typename: string, 56 | id: string | number, 57 | view: V, 58 | options?: { root?: boolean }, 59 | ): ViewRef { 60 | const ref = { __typename, id }; 61 | 62 | const names = options?.root ? getRootViewNames(view) : getViewNames(view); 63 | assignViewTag(ref, names); 64 | 65 | return Object.freeze(ref) as ViewRef; 66 | } 67 | -------------------------------------------------------------------------------- /example/server/src/trpc/routers/user.ts: -------------------------------------------------------------------------------- 1 | import { connectionArgs, createResolver } from '@nkzw/fate/server'; 2 | import { TRPCError } from '@trpc/server'; 3 | import { z } from 'zod'; 4 | import type { UserFindUniqueArgs } from '../../prisma/prisma-client/models.ts'; 5 | import { auth } from '../../lib/auth.tsx'; 6 | import { procedure, router } from '../init.ts'; 7 | import { User, userDataView } from '../views.ts'; 8 | 9 | export const userRouter = router({ 10 | update: procedure 11 | .input( 12 | z.object({ 13 | args: connectionArgs, 14 | name: z 15 | .string() 16 | .trim() 17 | .min(2, 'Name must be at least 2 characters.') 18 | .max(50, 'Name must be at most 32 characters.'), 19 | select: z.array(z.string()), 20 | }), 21 | ) 22 | .mutation(async ({ ctx, input }) => { 23 | if (!ctx.sessionUser) { 24 | throw new TRPCError({ 25 | code: 'UNAUTHORIZED', 26 | message: 'You must be logged in to update your name.', 27 | }); 28 | } 29 | 30 | const { resolve, select } = createResolver({ 31 | ...input, 32 | ctx, 33 | view: userDataView, 34 | }); 35 | 36 | await auth.api.updateUser({ 37 | body: { name: input.name }, 38 | headers: ctx.headers, 39 | }); 40 | 41 | return resolve( 42 | await ctx.prisma.user.findUniqueOrThrow({ 43 | select, 44 | where: { id: ctx.sessionUser.id }, 45 | } as UserFindUniqueArgs), 46 | ); 47 | }), 48 | viewer: procedure 49 | .input( 50 | z.object({ 51 | select: z.array(z.string()), 52 | }), 53 | ) 54 | .query(async ({ ctx, input }) => { 55 | if (!ctx.sessionUser) { 56 | return null; 57 | } 58 | 59 | const { resolve, select } = createResolver({ 60 | ...input, 61 | ctx, 62 | view: userDataView, 63 | }); 64 | 65 | const user = await ctx.prisma.user.findUnique({ 66 | select, 67 | where: { id: ctx.sessionUser.id }, 68 | } as UserFindUniqueArgs); 69 | return user ? ((await resolve(user)) as User) : null; 70 | }), 71 | }); 72 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import nkzw from '@nkzw/eslint-config'; 2 | import findWorkspaces from '@nkzw/find-workspaces'; 3 | import eslintPluginBetterTailwindCSS from 'eslint-plugin-better-tailwindcss'; 4 | import workspaces from 'eslint-plugin-workspaces'; 5 | 6 | export default [ 7 | ...nkzw, 8 | { 9 | ignores: [ 10 | '.vitepress/cache', 11 | '.vitepress/dist', 12 | 'coverage', 13 | 'dist', 14 | 'example/client/src/fate.ts', 15 | 'example/server/src/prisma/pothos-types.ts', 16 | 'example/server/src/prisma/prisma-client/*', 17 | 'packages/**/lib', 18 | ], 19 | }, 20 | { 21 | files: [ 22 | './example/server/scripts/**/*.tsx', 23 | './example/server/src/index.tsx', 24 | './example/server/src/prisma/seed.tsx', 25 | './packages/fate/src/cli.ts', 26 | './scripts/**', 27 | '**/__tests__/**', 28 | ], 29 | rules: { 30 | 'no-console': 0, 31 | }, 32 | }, 33 | { 34 | files: ['example/server/**/*.tsx'], 35 | rules: { 36 | 'react-hooks/rules-of-hooks': 0, 37 | }, 38 | }, 39 | { 40 | plugins: { 41 | 'better-tailwindcss': eslintPluginBetterTailwindCSS, 42 | workspaces, 43 | }, 44 | rules: { 45 | '@typescript-eslint/array-type': [2, { default: 'generic' }], 46 | '@typescript-eslint/no-explicit-any': 0, 47 | 'better-tailwindcss/enforce-consistent-class-order': 2, 48 | 'better-tailwindcss/no-conflicting-classes': 2, 49 | 'import-x/no-extraneous-dependencies': [ 50 | 2, 51 | { 52 | devDependencies: [ 53 | './.vitepress/**', 54 | './eslint.config.js', 55 | './example/client/vite.config.ts', 56 | './example/server/prisma.config.ts', 57 | './example/server/scripts/**/*.tsx', 58 | '**/__tests__/**', 59 | '**/tsdown.config.js', 60 | 'vitest.config.ts', 61 | ], 62 | packageDir: findWorkspaces(import.meta.dirname), 63 | }, 64 | ], 65 | 'workspaces/no-absolute-imports': 2, 66 | 'workspaces/no-relative-imports': 2, 67 | }, 68 | settings: { 69 | 'better-tailwindcss': { 70 | entryPoint: './example/client/src/App.css', 71 | }, 72 | }, 73 | }, 74 | ]; 75 | -------------------------------------------------------------------------------- /example/client/src/ui/CommentCard.tsx: -------------------------------------------------------------------------------- 1 | import type { Comment } from '@nkzw/fate-server/src/trpc/views.ts'; 2 | import Stack from '@nkzw/stack'; 3 | import { X } from 'lucide-react'; 4 | import { useFateClient, useView, view, ViewRef } from 'react-fate'; 5 | import { Link } from 'react-router'; 6 | import { Button } from './Button.tsx'; 7 | 8 | export const CommentView = view()({ 9 | author: { 10 | id: true, 11 | name: true, 12 | username: true, 13 | }, 14 | content: true, 15 | id: true, 16 | }); 17 | 18 | export const CommentViewWithPostCount = view()({ 19 | ...CommentView, 20 | post: { commentCount: true }, 21 | }); 22 | 23 | export default function CommentCard({ 24 | comment: commentRef, 25 | link, 26 | post, 27 | }: { 28 | comment: ViewRef<'Comment'>; 29 | link?: boolean; 30 | post: { commentCount: number; id: string; title: string }; 31 | }) { 32 | const fate = useFateClient(); 33 | const comment = useView(CommentView, commentRef); 34 | const { author } = comment; 35 | 36 | return ( 37 |
41 | 42 |

43 | {author?.name ?? 'Anonymous'} 44 |

45 | 65 |
66 |

{comment.content}

67 | {link && ( 68 | 69 | {post.title} 70 | 71 | )} 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /example/client/src/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from '@radix-ui/react-slot'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import { ButtonHTMLAttributes, useTransition } from 'react'; 4 | import cx from '../lib/cx.tsx'; 5 | 6 | const buttonVariants = cva( 7 | 'squircle inline-flex cursor-pointer items-center justify-center gap-2 text-sm font-medium whitespace-nowrap ring-offset-background transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 8 | { 9 | defaultVariants: { 10 | size: 'default', 11 | variant: 'default', 12 | }, 13 | variants: { 14 | size: { 15 | default: 'h-10 px-3 py-2 active:pt-[11px] active:pb-[9px]', 16 | icon: 'h-10 w-10', 17 | lg: 'squircle h-11 px-6 active:pt-[11px] active:pb-[9px]', 18 | sm: 'squircle h-9 px-2 active:pt-[11px] active:pb-[9px]', 19 | }, 20 | variant: { 21 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 22 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 23 | ghost: 'hover:bg-accent hover:text-accent-foreground', 24 | link: 'text-primary underline-offset-4 hover:underline', 25 | outline: 'border-input border bg-background hover:bg-accent hover:text-accent-foreground', 26 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 27 | }, 28 | }, 29 | }, 30 | ); 31 | 32 | const Button = ({ 33 | action, 34 | asChild = false, 35 | className, 36 | disabled, 37 | onClick: initialOnClick, 38 | size, 39 | variant, 40 | ...props 41 | }: ButtonHTMLAttributes & 42 | VariantProps & { 43 | action?: () => void; 44 | asChild?: boolean; 45 | }) => { 46 | const Component = asChild ? Slot : 'button'; 47 | 48 | const [isPending, startTransition] = useTransition(); 49 | 50 | const onClick = initialOnClick || (action ? () => startTransition(action) : undefined); 51 | 52 | return ( 53 | 59 | ); 60 | }; 61 | 62 | export { Button, buttonVariants }; 63 | -------------------------------------------------------------------------------- /example/server/src/prisma/migrations/20251021121731_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "user" ( 3 | "id" TEXT NOT NULL, 4 | "banExpires" TIMESTAMP(3), 5 | "banned" BOOLEAN, 6 | "banReason" TEXT, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "displayUsername" TEXT, 9 | "email" TEXT NOT NULL, 10 | "emailVerified" BOOLEAN NOT NULL, 11 | "image" TEXT, 12 | "name" TEXT NOT NULL, 13 | "password" TEXT, 14 | "role" TEXT NOT NULL DEFAULT 'user', 15 | "updatedAt" TIMESTAMP(3) NOT NULL, 16 | "username" TEXT, 17 | 18 | CONSTRAINT "user_pkey" PRIMARY KEY ("id") 19 | ); 20 | 21 | -- CreateTable 22 | CREATE TABLE "session" ( 23 | "id" TEXT NOT NULL, 24 | "expiresAt" TIMESTAMP(3) NOT NULL, 25 | "token" TEXT NOT NULL, 26 | "createdAt" TIMESTAMP(3) NOT NULL, 27 | "updatedAt" TIMESTAMP(3) NOT NULL, 28 | "ipAddress" TEXT, 29 | "userAgent" TEXT, 30 | "userId" TEXT NOT NULL 31 | ); 32 | 33 | -- CreateTable 34 | CREATE TABLE "account" ( 35 | "id" TEXT NOT NULL, 36 | "accountId" TEXT NOT NULL, 37 | "providerId" TEXT NOT NULL, 38 | "userId" TEXT NOT NULL, 39 | "accessToken" TEXT, 40 | "refreshToken" TEXT, 41 | "idToken" TEXT, 42 | "accessTokenExpiresAt" TIMESTAMP(3), 43 | "refreshTokenExpiresAt" TIMESTAMP(3), 44 | "scope" TEXT, 45 | "password" TEXT, 46 | "createdAt" TIMESTAMP(3) NOT NULL, 47 | "updatedAt" TIMESTAMP(3) NOT NULL 48 | ); 49 | 50 | -- CreateIndex 51 | CREATE UNIQUE INDEX "user_id_key" ON "user"("id"); 52 | 53 | -- CreateIndex 54 | CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); 55 | 56 | -- CreateIndex 57 | CREATE UNIQUE INDEX "user_username_key" ON "user"("username"); 58 | 59 | -- CreateIndex 60 | CREATE INDEX "user_id_idx" ON "user"("id" ASC); 61 | 62 | -- CreateIndex 63 | CREATE UNIQUE INDEX "session_id_key" ON "session"("id"); 64 | 65 | -- CreateIndex 66 | CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); 67 | 68 | -- CreateIndex 69 | CREATE UNIQUE INDEX "account_id_key" ON "account"("id"); 70 | 71 | -- CreateIndex 72 | CREATE UNIQUE INDEX "account_providerId_accountId_key" ON "account"("providerId", "accountId"); 73 | 74 | -- AddForeignKey 75 | ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; 76 | 77 | -- AddForeignKey 78 | ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; 79 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | ## AI Assistance Notice 4 | 5 | > [!IMPORTANT] 6 | > 7 | > If you are using **any kind of AI assistance** to contribute to **_fate_**, 8 | > it must be disclosed in the pull request. 9 | 10 | If you are using any kind of AI assistance while contributing to **_fate_**, 11 | **this must be disclosed in the pull request**, along with the extent to 12 | which AI assistance was used (e.g. docs only vs. code generation). 13 | 14 | If PR responses are being generated by an AI, disclose that as well. 15 | As a small exception, trivial tab-completion doesn't need to be disclosed, 16 | so long as it is limited to single keywords or short phrases. 17 | 18 | An example disclosure: 19 | 20 | > This PR was written primarily by Claude Code. 21 | 22 | Or a more detailed disclosure: 23 | 24 | > I consulted ChatGPT to understand the codebase but the solution 25 | > was fully authored manually by myself. 26 | 27 | When using AI assistance, we expect contributors to understand the code 28 | that is produced and be able to answer critical questions about it. It 29 | isn't a maintainer's job to review a PR so broken that it requires 30 | significant rework to be acceptable. 31 | 32 | Please be respectful to maintainers and disclose AI assistance. 33 | 34 | _Thanks to [Ghostty's Contribution Guidelines](https://github.com/ghostty-org/ghostty/blob/main/CONTRIBUTING.md) for inspiring this policy._ 35 | 36 | ## Initial Setup 37 | 38 | You'll need Node.js 24+ and pnpm 10+. 39 | 40 | - Run `pnpm install && pnpm dev:setup`. 41 | - Set up a Postgres database locally and add the connection string to `example/server/.env` as `DATABASE_URL` or run `docker-compose up -d` to start postgres in a docker container. 42 | - Postgres setup: 43 | 44 | ```SQL 45 | CREATE ROLE fate WITH LOGIN PASSWORD 'echo'; 46 | CREATE DATABASE fate; 47 | ALTER DATABASE fate OWNER TO fate; 48 | ``` 49 | 50 | Then, at the root of the project, run: 51 | 52 | - `pnpm prisma migrate dev` to create the database and run the migrations. 53 | - You might want to run `pnpm prisma migrate reset` and `pnpm prisma db seed` to seed the database with initial data. 54 | - Run `pnpm dev` to run the example. 55 | - Run `pnpm fate:generate` to regenerate the fate client code. 56 | 57 | ## Running Tests 58 | 59 | - When changing framework code, you need to run `pnpm build`. 60 | - Run `pnpm test` to run all tests. 61 | - Run `pnpm tsgo` to run TypeScript, and `pnpm vitest` to run JavaScript tests. 62 | - If `@nkzw/fate` or `react-fate` modules cannot be resolved it means you forgot to run `pnpm build`. 63 | -------------------------------------------------------------------------------- /example/client/src/ui/Search.tsx: -------------------------------------------------------------------------------- 1 | import type { Comment, Post } from '@nkzw/fate-server/src/trpc/views.ts'; 2 | import Stack, { VStack } from '@nkzw/stack'; 3 | import { Suspense, useDeferredValue, useState } from 'react'; 4 | import { ErrorBoundary } from 'react-error-boundary'; 5 | import { useRequest, useView, view, ViewRef } from 'react-fate'; 6 | import cx from '../lib/cx.tsx'; 7 | import Card from './Card.tsx'; 8 | import CommentCard, { CommentView } from './CommentCard.tsx'; 9 | import Error from './Error.tsx'; 10 | import Input from './Input.tsx'; 11 | 12 | const CommentPostView = view()({ 13 | commentCount: true, 14 | id: true, 15 | title: true, 16 | }); 17 | 18 | const CommentSearchView = view()({ 19 | ...CommentView, 20 | id: true, 21 | post: CommentPostView, 22 | }); 23 | 24 | const CommentResult = ({ comment: commentRef }: { comment: ViewRef<'Comment'> }) => { 25 | const comment = useView(CommentSearchView, commentRef); 26 | const post = useView(CommentPostView, comment.post); 27 | 28 | return ; 29 | }; 30 | 31 | const SearchResults = ({ isStale, query }: { isStale: boolean; query: string }) => { 32 | const { commentSearch } = useRequest({ 33 | commentSearch: { args: { query }, list: CommentSearchView }, 34 | }); 35 | 36 | if (commentSearch.length === 0) { 37 | return ( 38 |

39 | No matches for "{query}" 40 |

41 | ); 42 | } 43 | 44 | return ( 45 | 46 | {commentSearch.map((comment) => ( 47 | 48 | ))} 49 | 50 | ); 51 | }; 52 | 53 | export default function Search() { 54 | const [query, setQuery] = useState(''); 55 | const deferredQuery = useDeferredValue(query); 56 | const isStale = query !== deferredQuery; 57 | 58 | return ( 59 | 60 | 61 | setQuery(e.target.value)} 64 | placeholder="Search comments..." 65 | ref={(ref) => ref?.focus()} 66 | value={query} 67 | /> 68 |
500ms artificial slowdown
69 |
70 | 71 | 72 | Thinking…}> 73 | {query.trim().length > 0 ? : null} 74 | 75 | 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /docs/parts/outro.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## Is this serious software? 4 | 5 | [In an alternate reality](https://github.com/phacility/javelin), _fate_ can be described like this: 6 | 7 | **_fate_** is an ambitious React data library that tries to blend Relay-style ideas with tRPC, held together by equal parts vision and vibes. It aims to fix problems you definitely wouldn't have if you enjoy writing the same fetch logic in three different places with imperative loading state and error handling. fate promises predictable data flow, minimal APIs, and "no magic", though you may occasionally suspect otherwise. 8 | 9 | **_fate_** is almost certainly worse than actual sync engines, but will hopefully be better than existing React data-fetching libraries eventually. Use it if you have a high tolerance for pain and want to help shape the future of data fetching in React. 10 | 11 | ## Is _fate_ better than Relay? 12 | 13 | Absolutely not. 14 | 15 | ## Is _fate_ better than using GraphQL? 16 | 17 | Probably. One day. _Maybe._ 18 | 19 | ## How was fate built? 20 | 21 | > [!NOTE] 22 | > 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. 23 | > 24 | > 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). 25 | 26 | # Future 27 | 28 | **_fate_** is not complete yet. The library lacks core features such as garbage collection, a compiler to extract view definitions statically ahead of time, and there is too much backend boilerplate. The current implementation of _fate_ is not tied to tRPC or Prisma, those are just the ones we are starting with. We welcome contributions and ideas to improve fate. Here are some features we'd like to add: 29 | 30 | - Support for Drizzle 31 | - Support backends other than tRPC 32 | - Persistent storage for offline support 33 | - Implement garbage collection for the cache 34 | - Better code generation and less type repetition 35 | - Support for live views and real-time updates via `useLiveView` and SSE 36 | 37 | # Acknowledgements 38 | 39 | - [Relay](https://relay.dev/), [Isograph](https://isograph.dev/) & [GraphQL](https://graphql.org/) for inspiration 40 | - [Ricky Hanlon](https://x.com/rickyfm) for guidance on Async React 41 | - [Anthony Powell](https://x.com/Cephalization) for testing fate and providing feedback 42 | 43 | **_fate_** was created by [@cnakazawa](https://x.com/cnakazawa) and is maintained by [Nakazawa Tech](https://nakazawa.tech/). 44 | -------------------------------------------------------------------------------- /example/client/src/user/SignIn.tsx: -------------------------------------------------------------------------------- 1 | import Stack, { VStack } from '@nkzw/stack'; 2 | import { ExternalLinkIcon } from 'lucide-react'; 3 | import { FormEvent, useState } from 'react'; 4 | import { Navigate } from 'react-router'; 5 | import { Button } from '../ui/Button.tsx'; 6 | import Card from '../ui/Card.tsx'; 7 | import H2 from '../ui/H2.tsx'; 8 | import Input from '../ui/Input.tsx'; 9 | import AuthClient from './AuthClient.tsx'; 10 | 11 | export default function SignIn() { 12 | const [email, setEmail] = useState(''); 13 | const [password, setPassword] = useState(''); 14 | const { data: session } = AuthClient.useSession(); 15 | 16 | const signIn = async (event: FormEvent) => { 17 | event.preventDefault(); 18 | 19 | await AuthClient.signIn.email( 20 | { 21 | email, 22 | password, 23 | }, 24 | { 25 | onError: () => {}, 26 | onRequest: () => {}, 27 | onSuccess: () => {}, 28 | }, 29 | ); 30 | }; 31 | 32 | if (session) { 33 | return ; 34 | } 35 | 36 | return ( 37 | 38 |

Sign In

39 | 40 | 41 | 42 | 43 | setEmail(e.target.value)} 46 | placeholder="email" 47 | type="email" 48 | value={email} 49 | /> 50 | setPassword(e.target.value)} 53 | placeholder="password" 54 | type="password" 55 | value={password} 56 | /> 57 |
58 | 61 |
62 |
63 |
64 |
65 | 66 |

67 | Try one of the 68 | 77 | Example Accounts 78 | 79 | {' '} 80 | in the seed data. 81 |

82 |
83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /docs/guide/requests.md: -------------------------------------------------------------------------------- 1 | # Requests 2 | 3 | ## Requesting Lists 4 | 5 | The `useRequest` hook can be used to declare our data needs for a specific screen or component tree. At the root of our app, we can request a list of posts like this: 6 | 7 | ```tsx 8 | import { useRequest } from 'react-fate'; 9 | import { PostCard, PostView } from './PostCard.tsx'; 10 | 11 | export function App() { 12 | const { posts } = useRequest({ posts: { list: PostView } }); 13 | return posts.map((post) => ); 14 | } 15 | ``` 16 | 17 | This component suspends or throws errors, which bubble up to the nearest error boundary. Wrap your component tree with `ErrorBoundary` and `Suspense` components to show error and loading states: 18 | 19 | ```tsx 20 | 21 | Loading…
}> 22 | 23 | 24 | 25 | ``` 26 | 27 | > [!NOTE] 28 | > 29 | > `useRequest` might issue multiple requests which are automatically batched together by tRPC's [HTTP Batch Link](https://trpc.io/docs/client/links/httpBatchLink) into a single network request. 30 | 31 | ## Requesting Objects by ID 32 | 33 | If you want to fetch data for a single object instead of a list, you can specify the `id` and the associated `view` like this: 34 | 35 | ```tsx 36 | const { post } = useRequest({ 37 | post: { id: '12', view: PostView }, 38 | }); 39 | ``` 40 | 41 | If you want to fetch multiple objects by their IDs, you can use the `ids` field: 42 | 43 | ```tsx 44 | const { posts } = useRequest({ 45 | posts: { ids: ['6', '7'], view: PostView }, 46 | }); 47 | ``` 48 | 49 | ## Other Types of Requests 50 | 51 | For any other queries, pass only the `type` and `view`: 52 | 53 | ```tsx 54 | const { viewer } = useRequest({ 55 | viewer: { view: UserView }, 56 | }); 57 | ``` 58 | 59 | ## Request Arguments 60 | 61 | You can pass arguments to `useRequest` calls. This is useful for pagination, filtering, or sorting. For example, to fetch the first 10 posts, you can do the following: 62 | 63 | ```tsx 64 | const { posts } = useRequest({ 65 | posts: { 66 | args: { first: 10 }, 67 | list: PostView, 68 | }, 69 | }); 70 | ``` 71 | 72 | ## Request Modes 73 | 74 | `useRequest` supports different request modes to control caching and data freshness. The available modes are: 75 | 76 | - `cache-first` (_default_): Returns data from the cache if available, otherwise fetches from the network. 77 | - `stale-while-revalidate`: Returns data from the cache and simultaneously fetches fresh data from the network. 78 | - `network-only`: Always fetches data from the network, bypassing the cache. 79 | 80 | You can pass the request mode as an option to `useRequest`: 81 | 82 | ```tsx 83 | const { posts } = useRequest( 84 | { 85 | posts: { list: PostView }, 86 | }, 87 | { 88 | mode: 'stale-while-revalidate', 89 | }, 90 | ); 91 | ``` 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nkzw/fate-private", 3 | "version": "0.1.1", 4 | "private": true, 5 | "description": "fate is a modern data client for React.", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/nkzw-tech/fate.git" 9 | }, 10 | "author": "Christoph Nakazawa ", 11 | "type": "module", 12 | "scripts": { 13 | "build:docs": "typedoc && vitepress build", 14 | "build": "pnpm run -r build && pnpm generate-readme && pnpm copy-files", 15 | "dev:client": "cd example/client && pnpm dev", 16 | "copy-files": "find packages/* -type d -maxdepth 0 -exec cp README.md LICENSE {} \\;", 17 | "dev:docs": "typedoc && vitepress dev", 18 | "dev:server": "cd example/server && pnpm dev", 19 | "dev:setup": "(cd example/server && pnpm dev:setup)", 20 | "dev": "npm-run-all --parallel dev:client dev:server", 21 | "fate:generate": "node --conditions=development --no-warnings --loader ts-node/esm --env-file example/server/.env packages/fate/src/cli.ts generate @nkzw/fate-server/src/trpc/router.ts example/client/src/fate.ts", 22 | "format": "oxfmt", 23 | "generate-readme": "node --conditions=development --no-warnings --loader ts-node/esm scripts/generate-readme.ts", 24 | "lint:format": "oxfmt --check .", 25 | "lint": "eslint --cache .", 26 | "preinstall": "command -v git >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0", 27 | "prisma": "cd example/server && pnpm prisma", 28 | "ship": "pnpm build && pnpm test && pnpm publish -r --access public", 29 | "test": "npm-run-all --parallel tsc:check vitest:run lint lint:format", 30 | "tsc:check": "tsgo", 31 | "tsc": "pnpm tsgo", 32 | "vitest:run": "vitest run" 33 | }, 34 | "devDependencies": { 35 | "@fontsource/fira-code": "^5.2.7", 36 | "@nkzw/eslint-config": "^3.2.1", 37 | "@nkzw/fate-server": "workspace:*", 38 | "@nkzw/find-workspaces": "^1.0.0", 39 | "@paper-design/shaders": "^0.0.68", 40 | "@swc/core": "^1.15.5", 41 | "@types/node": "^25.0.2", 42 | "@typescript/native-preview": "7.0.0-dev.20251215.1", 43 | "@vitest/coverage-v8": "4.0.15", 44 | "babel-plugin-react-compiler": "^1.0.0", 45 | "dotenv": "^17.2.3", 46 | "eslint": "^9.39.2", 47 | "eslint-plugin-better-tailwindcss": "^3.8.0", 48 | "eslint-plugin-workspaces": "^0.11.1", 49 | "happy-dom": "^20.0.11", 50 | "npm-run-all2": "^8.0.4", 51 | "oxc-minify": "^0.103.0", 52 | "oxfmt": "^0.18.0", 53 | "prisma": "^7.1.0", 54 | "tailwindcss": "^4.1.18", 55 | "ts-node": "^10.9.2", 56 | "tsdown": "0.18.0", 57 | "typedoc": "^0.28.15", 58 | "typedoc-plugin-markdown": "^4.9.0", 59 | "typedoc-vitepress-theme": "^1.1.2", 60 | "vite": "8.0.0-beta.2", 61 | "vitepress": "2.0.0-alpha.15", 62 | "vitest": "^4.0.15" 63 | }, 64 | "engines": { 65 | "node": ">=24.0.0", 66 | "pnpm": ">=10.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/fate/src/mask.ts: -------------------------------------------------------------------------------- 1 | export type FieldMask = { 2 | all: boolean; 3 | children: Map; 4 | }; 5 | 6 | export function emptyMask(): FieldMask { 7 | return { all: false, children: new Map() }; 8 | } 9 | 10 | export function cloneMask(m: FieldMask): FieldMask { 11 | const clone = { all: m.all, children: new Map() }; 12 | for (const [key, value] of m.children) { 13 | clone.children.set(key, cloneMask(value)); 14 | } 15 | return clone; 16 | } 17 | 18 | export function addPath(mask: FieldMask, path: string) { 19 | if (mask.all) { 20 | return; 21 | } 22 | 23 | const parts = path.split('.'); 24 | let current = mask; 25 | for (let i = 0; i < parts.length; i++) { 26 | const seg = parts[i]; 27 | let child = current.children.get(seg); 28 | if (!child) { 29 | child = emptyMask(); 30 | current.children.set(seg, child); 31 | } 32 | current = child; 33 | } 34 | 35 | current.all = true; 36 | current.children.clear(); 37 | } 38 | 39 | export function union(into: FieldMask, b: FieldMask) { 40 | if (into.all || b.all) { 41 | into.all = true; 42 | into.children.clear(); 43 | return; 44 | } 45 | 46 | for (const [key, child] of b.children) { 47 | const exist = into.children.get(key); 48 | if (!exist) { 49 | into.children.set(key, cloneMask(child)); 50 | } else { 51 | union(exist, child); 52 | } 53 | } 54 | } 55 | 56 | export function fromPaths(paths: Iterable): FieldMask { 57 | const mask = emptyMask(); 58 | for (const path of paths) { 59 | addPath(mask, path); 60 | } 61 | return mask; 62 | } 63 | 64 | export function isCovered(mask: FieldMask, path: string): boolean { 65 | if (mask.all) { 66 | return true; 67 | } 68 | 69 | const parts = path.split('.'); 70 | let current: FieldMask | undefined = mask; 71 | for (let i = 0; i < parts.length; i++) { 72 | if (!current) { 73 | return false; 74 | } 75 | 76 | if (current.all) { 77 | return true; 78 | } 79 | 80 | current = current.children.get(parts[i]); 81 | } 82 | 83 | return !!current && (current.all || current.children.size === 0); 84 | } 85 | 86 | export function diffPaths(paths: Iterable, mask: FieldMask): Set { 87 | const missing = new Set(); 88 | for (const path of paths) { 89 | if (!isCovered(mask, path)) { 90 | missing.add(path); 91 | } 92 | } 93 | return missing; 94 | } 95 | 96 | export function intersects(a: FieldMask, b: FieldMask): boolean { 97 | if (a.all || b.all) { 98 | return true; 99 | } 100 | 101 | for (const [key, child] of a.children) { 102 | const otherChild = b.children.get(key); 103 | if (!otherChild) { 104 | continue; 105 | } 106 | 107 | if (child.all || otherChild.all) { 108 | return true; 109 | } 110 | 111 | if (intersects(child, otherChild)) { 112 | return true; 113 | } 114 | } 115 | 116 | return false; 117 | } 118 | -------------------------------------------------------------------------------- /packages/fate/src/server/prismaSelect.ts: -------------------------------------------------------------------------------- 1 | const toPrismaArgs = (args: Record): Record => { 2 | const prismaArgs: Record = {}; 3 | 4 | const isBackward = args.before !== undefined || typeof args.last === 'number'; 5 | 6 | if (typeof args.first === 'number') { 7 | prismaArgs.take = args.first + 1; 8 | } 9 | 10 | if (typeof args.last === 'number') { 11 | prismaArgs.take = -(args.last + 1); 12 | } 13 | 14 | const cursor = isBackward ? args.before : args.after; 15 | 16 | if (cursor !== undefined) { 17 | prismaArgs.cursor = { id: cursor }; 18 | prismaArgs.skip = 1; 19 | } 20 | 21 | return prismaArgs; 22 | }; 23 | 24 | const isRecord = (value: unknown): value is Record => 25 | Boolean(value) && typeof value === 'object' && !Array.isArray(value); 26 | 27 | /** 28 | * Narrows nested args to the slice relevant for a particular selection path. 29 | */ 30 | export function getScopedArgs( 31 | args: Record | undefined, 32 | path: string, 33 | ): Record | undefined { 34 | if (!args) { 35 | return undefined; 36 | } 37 | 38 | const segments = path.split('.'); 39 | let current: unknown = args; 40 | 41 | for (const segment of segments) { 42 | if (!isRecord(current)) { 43 | return undefined; 44 | } 45 | current = current[segment]; 46 | } 47 | 48 | return isRecord(current) ? current : undefined; 49 | } 50 | 51 | /** 52 | * Builds a Prisma `select` object from flattened selection paths and optional 53 | * scoped args, always including the `id` field. 54 | */ 55 | export function prismaSelect( 56 | paths: Array, 57 | args?: Record, 58 | ): Record { 59 | const allPaths = [...new Set([...paths, 'id'])]; 60 | const select: Record = {}; 61 | 62 | for (const path of allPaths) { 63 | const segments = path.split('.'); 64 | let current = select; 65 | let currentPath = ''; 66 | 67 | segments.forEach((segment, index) => { 68 | currentPath = currentPath ? `${currentPath}.${segment}` : segment; 69 | 70 | if (index === segments.length - 1) { 71 | if (segment === 'cursor') { 72 | return; 73 | } 74 | 75 | current[segment] = true; 76 | return; 77 | } 78 | 79 | const existing = current[segment]; 80 | const relation = 81 | existing && typeof existing === 'object' && existing !== null && 'select' in existing 82 | ? (existing as Record & { 83 | select: Record; 84 | }) 85 | : ({ select: {} } as Record & { 86 | select: Record; 87 | }); 88 | 89 | const scopedArgs = getScopedArgs(args, currentPath); 90 | if (scopedArgs) { 91 | Object.assign(relation, toPrismaArgs(scopedArgs)); 92 | } 93 | 94 | current[segment] = relation; 95 | current = relation.select; 96 | }); 97 | } 98 | 99 | return select; 100 | } 101 | -------------------------------------------------------------------------------- /.vitepress/nkzw-logo.svg: -------------------------------------------------------------------------------- 1 | 6 | 11 | 16 | 21 | 26 | 31 | 36 | 41 | 46 | 51 | 52 | -------------------------------------------------------------------------------- /packages/fate/src/cache.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FateThenable, 3 | ViewSnapshot, 4 | type Entity, 5 | type EntityId, 6 | type Selection, 7 | type View, 8 | type ViewRef, 9 | } from './types.ts'; 10 | 11 | export default class ViewDataCache { 12 | private cache = new Map< 13 | string, 14 | WeakMap, WeakMap, FateThenable>>> 15 | >(); 16 | 17 | private rootDependencies = new Map>(); 18 | private dependencyIndex = new Map>(); 19 | 20 | get, V extends View>( 21 | entityId: EntityId, 22 | view: V, 23 | ref: ViewRef, 24 | ): FateThenable> | null { 25 | return this.cache.get(entityId)?.get(view)?.get(ref) ?? null; 26 | } 27 | 28 | set, V extends View>( 29 | entityId: EntityId, 30 | view: V, 31 | ref: ViewRef, 32 | thenable: FateThenable>, 33 | dependencies: ReadonlySet, 34 | ) { 35 | let entityMap = this.cache.get(entityId); 36 | if (!entityMap) { 37 | entityMap = new WeakMap(); 38 | this.cache.set(entityId, entityMap); 39 | } 40 | 41 | let viewMap = entityMap.get(view); 42 | if (!viewMap) { 43 | viewMap = new WeakMap(); 44 | entityMap.set(view, viewMap); 45 | } 46 | 47 | viewMap.set(ref, thenable); 48 | 49 | let roots = this.rootDependencies.get(entityId); 50 | if (!roots) { 51 | roots = new Set(); 52 | this.rootDependencies.set(entityId, roots); 53 | } 54 | 55 | for (const dependency of dependencies) { 56 | if (!roots.has(dependency)) { 57 | roots.add(dependency); 58 | let dependents = this.dependencyIndex.get(dependency); 59 | if (!dependents) { 60 | dependents = new Set(); 61 | this.dependencyIndex.set(dependency, dependents); 62 | } 63 | dependents.add(entityId); 64 | } 65 | } 66 | } 67 | 68 | invalidate(entityId: EntityId) { 69 | this.invalidateDependents(entityId, new Set()); 70 | } 71 | 72 | private invalidateDependents(entityId: EntityId, visited: Set) { 73 | if (visited.has(entityId)) { 74 | return; 75 | } 76 | 77 | visited.add(entityId); 78 | 79 | const dependents = this.dependencyIndex.get(entityId); 80 | if (dependents) { 81 | for (const dependent of dependents) { 82 | this.invalidateDependents(dependent, visited); 83 | } 84 | } 85 | 86 | this.delete(entityId); 87 | } 88 | 89 | private delete(entityId: EntityId) { 90 | const roots = this.rootDependencies.get(entityId); 91 | if (roots) { 92 | for (const dependency of roots) { 93 | const dependents = this.dependencyIndex.get(dependency); 94 | if (!dependents) { 95 | continue; 96 | } 97 | 98 | dependents.delete(entityId); 99 | if (dependents.size === 0) { 100 | this.dependencyIndex.delete(dependency); 101 | } 102 | } 103 | this.rootDependencies.delete(entityId); 104 | } 105 | 106 | this.cache.delete(entityId); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /example/client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import Stack from '@nkzw/stack'; 3 | import { httpBatchLink } from '@trpc/client'; 4 | import { StrictMode, Suspense, useMemo } from 'react'; 5 | import { createRoot } from 'react-dom/client'; 6 | import { ErrorBoundary } from 'react-error-boundary'; 7 | import { FateClient } from 'react-fate'; 8 | import { BrowserRouter, Route, Routes } from 'react-router'; 9 | import { createFateClient } from './fate.ts'; 10 | import env from './lib/env.tsx'; 11 | import CategoryRoute from './routes/CategoryRoute.tsx'; 12 | import HomeRoute from './routes/HomeRoute.tsx'; 13 | import PostRoute from './routes/PostRoute.tsx'; 14 | import SearchRoute from './routes/SearchRoute.tsx'; 15 | import SignInRoute from './routes/SignInRoute.tsx'; 16 | import Card from './ui/Card.tsx'; 17 | import Error from './ui/Error.tsx'; 18 | import Header from './ui/Header.tsx'; 19 | import Section from './ui/Section.tsx'; 20 | import AuthClient from './user/AuthClient.tsx'; 21 | 22 | const App = () => { 23 | const { data: session } = AuthClient.useSession(); 24 | const userId = session?.user.id; 25 | 26 | const fate = useMemo( 27 | () => 28 | createFateClient({ 29 | links: [ 30 | httpBatchLink({ 31 | fetch: (input, init) => 32 | fetch(input, { 33 | ...init, 34 | credentials: userId ? 'include' : undefined, 35 | }), 36 | url: `${env('SERVER_URL')}/trpc`, 37 | }), 38 | ], 39 | }), 40 | [userId], 41 | ); 42 | 43 | return ( 44 | 45 | 46 |
47 |
48 |
49 | ( 51 |
52 | 53 | 54 | 55 |
56 | )} 57 | > 58 | 61 | 66 | Thinking… 67 | 68 | 69 | } 70 | > 71 | 72 | } path="/" /> 73 | } path="/post/:id" /> 74 | } path="/category/:id" /> 75 | } path="/search" /> 76 | } path="/login" /> 77 | 78 | 79 |
80 |
81 |
82 |
83 |
84 | ); 85 | }; 86 | 87 | createRoot(document.getElementById('root')!).render( 88 | 89 | 90 | , 91 | ); 92 | -------------------------------------------------------------------------------- /.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import { join } from 'node:path'; 3 | import { defineConfig } from 'vitepress'; 4 | import apiItems from '../docs/api/typedoc-sidebar.json'; 5 | import pkg from '../packages/fate/package.json' with { type: 'json' }; 6 | import dunkel from './theme/dunkel.json'; 7 | import licht from './theme/licht.json'; 8 | 9 | const origin = 'https://fate.technology'; 10 | const nkzwLogo = readFileSync(join(import.meta.dirname, './nkzw-logo.svg'), 'utf8'); 11 | 12 | export default defineConfig({ 13 | cleanUrls: true, 14 | description: 'A Modern React Data Framework', 15 | head: [ 16 | ['link', { href: '/icon.svg', rel: 'icon' }], 17 | ['meta', { content: `${origin}/og-image.png`, name: 'og:image' }], 18 | ], 19 | markdown: { 20 | theme: { 21 | dark: dunkel, 22 | // @ts-expect-error 23 | light: licht, 24 | }, 25 | }, 26 | rewrites: { 27 | 'docs/:path*': ':path*', 28 | }, 29 | sitemap: { 30 | hostname: 'https://fate.technology', 31 | }, 32 | srcExclude: ['docs/parts/**.', 'packages/**/README.md', 'scripts/**'], 33 | themeConfig: { 34 | footer: { 35 | copyright: `Copyright © 2025-present Nakazawa Tech`, 36 | message: `Released under the MIT License`, 37 | }, 38 | logo: { 39 | dark: '/fate-logo-dark.svg', 40 | light: '/fate-logo.svg', 41 | }, 42 | nav: [ 43 | { link: '/', text: 'Home' }, 44 | { link: '/guide/getting-started', text: 'Guide' }, 45 | { link: '/api', text: 'API' }, 46 | { link: '/posts/introducing-fate', text: 'Blog' }, 47 | { 48 | items: [ 49 | { 50 | link: 'https://github.com/nkzw-tech/fate/blob/main/CHANGELOG.md', 51 | text: 'Changelog', 52 | }, 53 | { 54 | link: 'https://github.com/nkzw-tech/fate/blob/main/CONTRIBUTING.md', 55 | text: 'Contributing', 56 | }, 57 | ], 58 | text: `v${pkg.version}`, 59 | }, 60 | ], 61 | outline: { 62 | label: 'On this page', 63 | }, 64 | search: { 65 | provider: 'local', 66 | }, 67 | sidebar: [ 68 | { 69 | collapsed: false, 70 | items: [ 71 | { link: '/guide/getting-started', text: 'Getting Started' }, 72 | { link: '/guide/core-concepts', text: 'Core Concepts' }, 73 | { link: '/guide/views', text: 'Views' }, 74 | { link: '/guide/list-views', text: 'List Views' }, 75 | { link: '/guide/actions', text: 'Actions' }, 76 | { link: '/guide/requests', text: 'Requests' }, 77 | { link: '/guide/server-integration', text: 'Server Integration' }, 78 | ], 79 | text: 'Guide', 80 | }, 81 | { collapsed: false, items: apiItems, text: 'API' }, 82 | { 83 | collapsed: true, 84 | items: [{ link: '/posts/introducing-fate', text: 'Introducing Fate' }], 85 | text: 'Blog', 86 | }, 87 | ], 88 | siteTitle: false, 89 | socialLinks: [ 90 | { 91 | icon: { 92 | svg: nkzwLogo, 93 | }, 94 | link: 'https://nakazawa.tech', 95 | }, 96 | { icon: 'x', link: 'https://twitter.com/cnakazawa' }, 97 | { icon: 'github', link: 'https://github.com/nkzw-tech/fate' }, 98 | ], 99 | }, 100 | title: 'fate', 101 | }); 102 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/client/public/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/client/src/ui/CategoryCard.tsx: -------------------------------------------------------------------------------- 1 | import type { Category, Post } from '@nkzw/fate-server/src/trpc/views.ts'; 2 | import Stack, { VStack } from '@nkzw/stack'; 3 | import { useView, view, ViewRef } from 'react-fate'; 4 | import { Link } from 'react-router'; 5 | import { Badge } from '../ui/Badge.tsx'; 6 | import Card from '../ui/Card.tsx'; 7 | import TagBadge, { TagView } from '../ui/TagBadge.tsx'; 8 | import { UserView } from '../ui/UserCard.tsx'; 9 | import H3 from './H3.tsx'; 10 | 11 | const CategoryPostView = view()({ 12 | author: UserView, 13 | id: true, 14 | likes: true, 15 | tags: { 16 | items: { 17 | node: TagView, 18 | }, 19 | }, 20 | title: true, 21 | }); 22 | 23 | const CategoryPost = ({ post: postRef }: { post: ViewRef<'Post'> }) => { 24 | const post = useView(CategoryPostView, postRef); 25 | const author = useView(UserView, post.author); 26 | const tags = post.tags?.items ?? []; 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | {post.title} 34 | 35 | 36 | {post.likes} likes 37 | 38 | 39 | 40 | {author?.name ? `by ${author.name}` : 'By an anonymous collaborator'} 41 | 42 | {tags.length ? ( 43 | 44 | {tags.map(({ node }) => ( 45 | 46 | ))} 47 | 48 | ) : null} 49 | 50 | 51 | ); 52 | }; 53 | 54 | export const CategoryView = view()({ 55 | description: true, 56 | id: true, 57 | name: true, 58 | postCount: true, 59 | posts: { 60 | items: { 61 | cursor: true, 62 | node: CategoryPostView, 63 | }, 64 | pagination: { 65 | hasNext: true, 66 | nextCursor: true, 67 | }, 68 | }, 69 | }); 70 | 71 | export default function CategoryCard({ category: categoryRef }: { category: ViewRef<'Category'> }) { 72 | const category = useView(CategoryView, categoryRef); 73 | const posts = category.posts?.items ?? []; 74 | 75 | return ( 76 | 77 | 78 | 79 |
80 | 81 |

{category.name}

82 | 83 |

{category.description}

84 |
85 | 86 | {category.postCount} posts 87 | 88 |
89 | 90 | {posts.map(({ cursor, node }) => { 91 | if (cursor !== node.id) { 92 | throw new Error(`fate: Cursor '${cursor}' does not match node ID '${node.id}'.`); 93 | } 94 | return ; 95 | })} 96 | 97 | {category.posts?.pagination?.hasNext ? ( 98 | 99 | More posts available in this category... 100 | 101 | ) : null} 102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /packages/fate/src/view.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | __FateEntityBrand, 3 | __FateSelectionBrand, 4 | Entity, 5 | Selection, 6 | View, 7 | ViewPayload, 8 | ViewRef, 9 | ViewTag, 10 | } from './types.ts'; 11 | import { getViewTag, isViewTag, ViewKind, ViewsTag } from './types.ts'; 12 | 13 | /** 14 | * Collects all view payloads that apply to the given ref. 15 | */ 16 | export const getViewPayloads = , V extends View>( 17 | view: V, 18 | ref: ViewRef | null, 19 | ): ReadonlyArray> => { 20 | const result: Array> = []; 21 | for (const [key, value] of Object.entries(view)) { 22 | if (isViewTag(key) && (!ref || ref[ViewsTag]?.has(key))) { 23 | result.push(value); 24 | } 25 | } 26 | return result; 27 | }; 28 | 29 | /** 30 | * Returns the set of view tags defined on a view composition. 31 | */ 32 | export const getViewNames = , V extends View>( 33 | view: V, 34 | ): ReadonlySet => { 35 | const result = new Set(); 36 | for (const key of Object.keys(view)) { 37 | if (isViewTag(key)) { 38 | result.add(key); 39 | } 40 | } 41 | return result; 42 | }; 43 | 44 | /** 45 | * Extracts view tags from a nested selection object. 46 | */ 47 | export const getSelectionViewNames = >( 48 | selection: S, 49 | ): ReadonlySet => { 50 | return getViewNames(selection as unknown as View); 51 | }; 52 | 53 | let id = 0; 54 | const isDevelopment = import.meta?.env?.DEV || import.meta?.env?.NODE_ENV !== 'production'; 55 | let viewModulePath: string | null = null; 56 | 57 | const getStableId = () => { 58 | if (isDevelopment) { 59 | try { 60 | if (viewModulePath == null) { 61 | viewModulePath = new URL(import.meta.url).pathname; 62 | } 63 | 64 | const stack = new Error().stack?.split('\n'); 65 | if (stack) { 66 | for (let i = 1; i < stack.length; i++) { 67 | const frame = stack[i].trim(); 68 | const match = frame.match(/\(?([^()]+):(\d+):(\d+)\)?$/); 69 | if (!match) { 70 | continue; 71 | } 72 | 73 | const [, source, line, column] = match; 74 | if (!source.includes(viewModulePath)) { 75 | const file = source.startsWith('at ') ? source.slice(3) : source; 76 | return `${/^[A-Za-z][\d+.A-Za-z-]*:/.test(file) ? new URL(file).pathname : file}:${line}:${column}`; 77 | } 78 | } 79 | } 80 | } catch { 81 | /* empty */ 82 | } 83 | } 84 | 85 | return String(id++); 86 | }; 87 | 88 | type SelectionValidation> = 89 | Exclude< 90 | keyof Omit, 91 | keyof Selection 92 | > extends never 93 | ? unknown 94 | : never; 95 | 96 | /** 97 | * Creates a reusable view for an object using the declared selection. 98 | * 99 | * @example 100 | * const PostView = view()({ 101 | * id: true, 102 | * title: true, 103 | * }); 104 | */ 105 | export function view() { 106 | const viewId = getStableId(); 107 | 108 | return >(select: S & SelectionValidation): View => { 109 | const payload = Object.freeze({ 110 | select, 111 | [ViewKind]: true, 112 | }) as ViewPayload; 113 | 114 | const viewComposition = Object.defineProperty({}, getViewTag(viewId), { 115 | configurable: false, 116 | enumerable: true, 117 | value: payload, 118 | writable: false, 119 | }); 120 | 121 | return Object.freeze(viewComposition) as View; 122 | }; 123 | } 124 | -------------------------------------------------------------------------------- /packages/fate/src/codegen/schema.ts: -------------------------------------------------------------------------------- 1 | import type { DataView } from '../server/dataView.ts'; 2 | 3 | type AnyRecord = Record; 4 | 5 | type RelationDescriptor = { listOf: string } | { type: string }; 6 | 7 | type FateTypeConfig = { 8 | fields?: Record; 9 | type: string; 10 | }; 11 | 12 | const isDataViewField = (field: unknown): field is DataView => 13 | Boolean(field) && typeof field === 'object' && 'fields' in (field as AnyRecord); 14 | 15 | type RootConfig = 16 | | DataView 17 | | { 18 | procedure?: string; 19 | router?: string; 20 | view: DataView; 21 | }; 22 | 23 | /** 24 | * Builds the schema object used by the CLI generator from your data views and 25 | * root resolver configs. 26 | */ 27 | export const createSchema = ( 28 | dataViews: ReadonlyArray>, 29 | roots: Record, 30 | ) => { 31 | const canonicalViews = new Map>(); 32 | const rootSchema: Record< 33 | string, 34 | { 35 | kind: 'list' | 'query'; 36 | procedure: string; 37 | router: string; 38 | type: string; 39 | } 40 | > = {}; 41 | const fateTypes = new Map(); 42 | const processing = new Set(); 43 | 44 | const ensureType = (view: DataView): string => { 45 | const typeName = view.typeName; 46 | 47 | const canonicalView = canonicalViews.get(typeName) ?? view; 48 | const existing = fateTypes.get(typeName); 49 | 50 | if (existing && !processing.has(typeName)) { 51 | return typeName; 52 | } 53 | 54 | if (processing.has(typeName)) { 55 | return typeName; 56 | } 57 | 58 | processing.add(typeName); 59 | 60 | const fields: FateTypeConfig['fields'] = existing?.fields ?? {}; 61 | 62 | for (const [field, child] of Object.entries(canonicalView.fields)) { 63 | if (isDataViewField(child)) { 64 | const relationType = ensureType(child); 65 | fields[field] = child.kind === 'list' ? { listOf: relationType } : { type: relationType }; 66 | } 67 | } 68 | 69 | const descriptor: FateTypeConfig = { type: typeName }; 70 | if (Object.keys(fields).length) { 71 | descriptor.fields = fields; 72 | } 73 | 74 | fateTypes.set(typeName, descriptor); 75 | 76 | processing.delete(typeName); 77 | 78 | return typeName; 79 | }; 80 | 81 | for (const view of dataViews) { 82 | const typeName = view.typeName; 83 | 84 | if (!typeName) { 85 | throw new Error('Data view is missing a type name.'); 86 | } 87 | 88 | if (!canonicalViews.has(typeName)) { 89 | canonicalViews.set(typeName, view); 90 | } 91 | } 92 | 93 | for (const view of dataViews) { 94 | ensureType(view); 95 | } 96 | 97 | for (const [name, root] of Object.entries(roots)) { 98 | const config = 'fields' in root ? { view: root } : root; 99 | const view = config.view; 100 | const type = ensureType(view); 101 | 102 | if (!view.typeName) { 103 | throw new Error(`Root "${name}" is missing a data view.`); 104 | } 105 | 106 | const router = config.router ?? view.typeName[0]?.toLowerCase() + view.typeName.slice(1); 107 | 108 | if (!router) { 109 | throw new Error(`Root "${name}" is missing a router name.`); 110 | } 111 | 112 | rootSchema[name] = { 113 | kind: view.kind === 'list' ? 'list' : 'query', 114 | procedure: config.procedure ?? (view.kind === 'list' ? 'list' : name), 115 | router, 116 | type, 117 | }; 118 | } 119 | 120 | for (const view of dataViews) { 121 | ensureType(view); 122 | } 123 | 124 | return { 125 | roots: rootSchema, 126 | types: Array.from(fateTypes.values()), 127 | } as const; 128 | }; 129 | -------------------------------------------------------------------------------- /packages/fate/src/__tests__/store.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, vi } from 'vitest'; 2 | import ViewDataCache from '../cache.ts'; 3 | import { Store } from '../store.ts'; 4 | 5 | test('keeps cursor alignment when removing ids with undefined cursors', () => { 6 | const store = new Store(); 7 | const cache = new ViewDataCache(); 8 | const listKey = 'list'; 9 | 10 | store.setList(listKey, { 11 | cursors: ['cursor-one', undefined], 12 | ids: ['one', 'two'], 13 | }); 14 | 15 | store.removeReferencesTo('one', cache); 16 | 17 | const list = store.getList(listKey); 18 | expect(list).toEqual(['two']); 19 | 20 | const state = list ? store.getListState(listKey) : undefined; 21 | expect(state?.cursors).toEqual([undefined]); 22 | }); 23 | 24 | test('does not update records or notify subscribers for shallow equal merges', () => { 25 | const store = new Store(); 26 | const entityId = 'Post:1'; 27 | 28 | store.merge( 29 | entityId, 30 | { 31 | __typename: 'Post', 32 | id: 'post-1', 33 | title: 'Apple', 34 | }, 35 | new Set(['__typename', 'id', 'title']), 36 | ); 37 | 38 | const initialRecord = store.read(entityId); 39 | const subscriber = vi.fn(); 40 | store.subscribe(entityId, subscriber); 41 | 42 | store.merge(entityId, {}, new Set()); 43 | 44 | expect(store.read(entityId)).toBe(initialRecord); 45 | expect(subscriber).not.toHaveBeenCalled(); 46 | 47 | store.merge( 48 | entityId, 49 | { 50 | title: 'Apple', 51 | }, 52 | new Set(['title']), 53 | ); 54 | 55 | expect(store.read(entityId)).toBe(initialRecord); 56 | expect(subscriber).not.toHaveBeenCalled(); 57 | 58 | store.merge( 59 | entityId, 60 | { 61 | title: 'Banana', 62 | }, 63 | new Set(['title']), 64 | ); 65 | 66 | expect(store.read(entityId)).not.toBe(initialRecord); 67 | expect(subscriber).toHaveBeenCalled(); 68 | }); 69 | 70 | test('only notifies subscribers with intersecting selections', () => { 71 | const store = new Store(); 72 | const entityId = 'Post:1'; 73 | 74 | store.merge( 75 | entityId, 76 | { 77 | __typename: 'Post', 78 | id: 'post-1', 79 | likes: 1, 80 | title: 'Initial', 81 | }, 82 | new Set(['__typename', 'id', 'likes', 'title']), 83 | ); 84 | 85 | const likesSubscriber = vi.fn(); 86 | const titleSubscriber = vi.fn(); 87 | const catchAllSubscriber = vi.fn(); 88 | 89 | store.subscribe(entityId, new Set(['likes']), likesSubscriber); 90 | store.subscribe(entityId, new Set(['title']), titleSubscriber); 91 | store.subscribe(entityId, catchAllSubscriber); 92 | 93 | store.merge(entityId, { likes: 2 }, new Set(['likes'])); 94 | 95 | expect(likesSubscriber).toHaveBeenCalledTimes(1); 96 | expect(titleSubscriber).not.toHaveBeenCalled(); 97 | expect(catchAllSubscriber).toHaveBeenCalledTimes(1); 98 | 99 | store.merge(entityId, { title: 'Updated' }, new Set(['title'])); 100 | 101 | expect(likesSubscriber).toHaveBeenCalledTimes(1); 102 | expect(titleSubscriber).toHaveBeenCalledTimes(1); 103 | expect(catchAllSubscriber).toHaveBeenCalledTimes(2); 104 | }); 105 | 106 | test('updates coverage when merging identical values', () => { 107 | const store = new Store(); 108 | const entityId = 'Post:1'; 109 | 110 | store.merge( 111 | entityId, 112 | { 113 | __typename: 'Post', 114 | id: 'post-1', 115 | subtitle: 'Sub', 116 | title: 'Title', 117 | }, 118 | new Set(['__typename', 'id', 'title']), 119 | ); 120 | 121 | expect(store.missingForSelection(entityId, new Set(['title', 'subtitle']))).toEqual( 122 | new Set(['subtitle']), 123 | ); 124 | 125 | store.merge(entityId, { subtitle: 'Sub' }, new Set(['subtitle'])); 126 | 127 | expect(store.missingForSelection(entityId, new Set(['title', 'subtitle']))).toEqual(new Set()); 128 | }); 129 | -------------------------------------------------------------------------------- /.vitepress/theme/docs.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-button-alt-hover-text: #111; 3 | --vp-c-brand-1: rgb(61, 135, 245); 4 | --vp-code-color: #111; 5 | --vp-home-hero-name-color: #c8c8c8; 6 | --vp-nav-bg-color: rgba(255, 255, 255, 0.6); 7 | --vp-post-headline: #111; 8 | } 9 | 10 | html.dark { 11 | --vp-button-alt-hover-text: #fff; 12 | --vp-c-brand-1: rgb(94 159 255); 13 | --vp-code-color: #c8c8c8; 14 | --vp-home-hero-name-color: #c8c8c8; 15 | --vp-nav-bg-color: rgba(24, 24, 24, 0.6); 16 | --vp-post-headline: #fff; 17 | } 18 | 19 | .VPNavBar { 20 | backdrop-filter: blur(12px); 21 | } 22 | 23 | .VPNavBar.top { 24 | backdrop-filter: unset; 25 | } 26 | 27 | code, 28 | pre { 29 | font-family: 'Fira Code', monospace; 30 | font-feature-settings: 31 | 'liga' on, 32 | 'calt' on; 33 | } 34 | 35 | .vp-doc a:hover { 36 | text-decoration: none; 37 | } 38 | 39 | .VPSocialLink[href='https://nakazawa.tech'] svg { 40 | transform: scale(1.3); 41 | } 42 | 43 | @media (min-width: 640px) { 44 | .VPHome .VPHero { 45 | padding-bottom: 42px; 46 | } 47 | } 48 | 49 | .VPHome.VPHome { 50 | margin-bottom: 0; 51 | } 52 | 53 | .VPHero .container .main { 54 | order: 2; 55 | } 56 | 57 | .VPHero .container .image { 58 | order: 1; 59 | } 60 | 61 | .VPHero .VPButton.medium { 62 | filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.15)); 63 | padding-left: 8px; 64 | padding-right: 8px; 65 | } 66 | 67 | .VPFeature.VPLink { 68 | background-color: rgba(175, 175, 175, 0.15); 69 | backdrop-filter: blur(12px); 70 | } 71 | 72 | .VPFeature.VPLink .icon { 73 | border-radius: 24px; 74 | margin-bottom: 8px; 75 | } 76 | 77 | .VPFeature.VPLink .link-text { 78 | padding-left: 12px; 79 | padding-bottom: 8px; 80 | } 81 | 82 | .VPHero .VPButton.medium { 83 | border-radius: 24px; 84 | transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1); 85 | } 86 | 87 | .VPHero .VPButton.medium:hover { 88 | transform: scale(1.05, 1.025); 89 | } 90 | 91 | .VPHero .VPButton.medium:active { 92 | transform: scale(0.975, 0.95); 93 | } 94 | 95 | .VPHero .heading { 96 | font-style: italic; 97 | } 98 | 99 | .VPFeature.VPLink.VPLink { 100 | border-radius: 24px; 101 | transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1); 102 | } 103 | 104 | .VPFeature.VPLink:hover { 105 | transform: scale(1.03); 106 | } 107 | 108 | .VPFeature.VPLink:active { 109 | transform: scale(0.97); 110 | } 111 | 112 | .VPFeature.VPLink .box { 113 | padding: 12px; 114 | } 115 | 116 | h2[id='why-fate'] { 117 | border-top: 0; 118 | margin-top: 12px; 119 | } 120 | 121 | picture.fetch-tree > img { 122 | content: url('/public/fetch-tree.svg'); 123 | } 124 | 125 | picture.fate-tree > img { 126 | content: url('/public/fate-tree.svg'); 127 | } 128 | 129 | html.dark picture.fetch-tree > img { 130 | content: url('/public/fetch-tree-dark.svg'); 131 | } 132 | 133 | html.dark picture.fate-tree > img { 134 | content: url('/public/fate-tree-dark.svg'); 135 | } 136 | 137 | picture.fate-logo > img { 138 | content: url('/fate-logo.svg'); 139 | } 140 | 141 | html.dark picture.fate-logo > img { 142 | content: url('/fate-logo-dark.svg'); 143 | } 144 | 145 | .vp-doc[class^='_posts_'] h2, 146 | .vp-doc[class*=' _posts_'] h2 { 147 | border-top: 0; 148 | margin-top: 12px; 149 | padding-top: 0; 150 | } 151 | .vp-doc[class^='_posts_'] h2 a.header-anchor, 152 | .vp-doc[class*=' _posts_'] h2 a.header-anchor { 153 | top: 0; 154 | } 155 | 156 | footer.VPFooter.VPFooter { 157 | background-color: transparent; 158 | border-top: 0; 159 | padding-top: 36px; 160 | } 161 | 162 | @supports (corner-shape: squircle) { 163 | .VPFeature.VPLink .icon.icon, 164 | .VPFeature.VPLink.VPLink { 165 | border-radius: 48px; 166 | corner-shape: squircle; 167 | } 168 | 169 | .VPHero .VPButton.medium.medium { 170 | corner-shape: squircle; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /packages/react-fate/src/useView.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | EntityId, 3 | FateThenable, 4 | View, 5 | ViewData, 6 | ViewEntity, 7 | ViewEntityName, 8 | ViewRef, 9 | ViewSelection, 10 | ViewSnapshot, 11 | ViewTag, 12 | } from '@nkzw/fate'; 13 | import { use, useCallback, useDeferredValue, useRef, useSyncExternalStore } from 'react'; 14 | import { useFateClient } from './context.tsx'; 15 | 16 | type ViewEntityWithTypename> = ViewEntity & { 17 | __typename: ViewEntityName; 18 | }; 19 | 20 | const nullSnapshot = { 21 | status: 'fulfilled', 22 | then( 23 | onfulfilled?: ((value: null) => TResult1 | PromiseLike) | null, 24 | onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, 25 | ) { 26 | return Promise.resolve(null).then(onfulfilled, onrejected); 27 | }, 28 | value: null, 29 | } satisfies FateThenable; 30 | 31 | /** 32 | * Resolves a reference against a view and subscribes to updates for that selection. 33 | * 34 | * @example 35 | * const post = useView(PostView, postRef); 36 | */ 37 | export function useView, R extends ViewRef> | null>( 38 | view: V, 39 | ref: R, 40 | ): R extends null ? null : ViewData, ViewSelection>; 41 | export function useView>( 42 | view: V, 43 | ref: ViewRef> | null, 44 | ): ViewData, ViewSelection> | null { 45 | const client = useFateClient(); 46 | 47 | const snapshotRef = useRef, V[ViewTag]['select']> | null>(null); 48 | 49 | const getSnapshot = useCallback(() => { 50 | if (ref === null) { 51 | snapshotRef.current = null; 52 | return nullSnapshot; 53 | } 54 | 55 | const snapshot = client.readView, V[ViewTag]['select'], V>(view, ref); 56 | snapshotRef.current = snapshot.status === 'fulfilled' ? snapshot.value : null; 57 | return snapshot; 58 | }, [client, view, ref]); 59 | 60 | const subscribe = useCallback( 61 | (onStoreChange: () => void) => { 62 | if (ref === null) { 63 | snapshotRef.current = null; 64 | return () => {}; 65 | } 66 | 67 | const subscriptions = new Map void>(); 68 | 69 | const onChange = () => { 70 | updateSubscriptions(); 71 | onStoreChange(); 72 | }; 73 | 74 | const subscribe = (entityId: EntityId, paths: ReadonlySet) => { 75 | if (!subscriptions.has(entityId)) { 76 | subscriptions.set(entityId, client.store.subscribe(entityId, paths, onChange)); 77 | } 78 | }; 79 | 80 | const cleanup = (nextIds: ReadonlySet) => { 81 | for (const [entityId, unsubscribe] of subscriptions) { 82 | if (!nextIds.has(entityId)) { 83 | unsubscribe(); 84 | subscriptions.delete(entityId); 85 | } 86 | } 87 | }; 88 | 89 | const updateSubscriptions = () => { 90 | if (snapshotRef.current) { 91 | for (const [entityId, paths] of snapshotRef.current.coverage) { 92 | subscribe(entityId, paths); 93 | } 94 | 95 | cleanup(new Set(snapshotRef.current.coverage.map(([id]) => id))); 96 | } 97 | }; 98 | 99 | updateSubscriptions(); 100 | 101 | return () => { 102 | for (const unsubscribe of subscriptions.values()) { 103 | unsubscribe(); 104 | } 105 | subscriptions.clear(); 106 | }; 107 | }, 108 | [client.store, ref], 109 | ); 110 | 111 | const snapshot = use( 112 | useDeferredValue(useSyncExternalStore(subscribe, getSnapshot, getSnapshot)), 113 | ) as ViewSnapshot, ViewSelection> | null; 114 | 115 | return snapshot ? snapshot.data : null; 116 | } 117 | -------------------------------------------------------------------------------- /example/client/src/App.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | body { 4 | font-family: 5 | system-ui, 6 | -apple-system, 7 | BlinkMacSystemFont, 8 | 'Segoe UI', 9 | 'Open Sans', 10 | 'Helvetica Neue', 11 | sans-serif; 12 | 13 | background-color: hsl(var(--background)); 14 | color: hsl(var(--foreground)); 15 | } 16 | 17 | @theme { 18 | --color-accent: hsl(var(--accent)); 19 | --color-accent-foreground: hsl(var(--accent-foreground)); 20 | 21 | --color-background: hsl(var(--background)); 22 | --color-foreground: hsl(var(--foreground)); 23 | --color-border: hsl(var(--border)); 24 | --color-ring: hsl(var(--ring)); 25 | 26 | --color-card: hsl(var(--card)); 27 | --color-card-foreground: hsl(var(--card-foreground)); 28 | 29 | --color-muted: hsl(var(--muted)); 30 | --color-muted-foreground: hsl(var(--muted-foreground)); 31 | 32 | --color-secondary: hsl(var(--secondary)); 33 | --color-secondary-foreground: hsl(var(--secondary-foreground)); 34 | 35 | --color-destructive: hsl(var(--destructive)); 36 | --color-destructive-foreground: hsl(var(--destructive-foreground)); 37 | } 38 | 39 | @layer utilities { 40 | .text-balance { 41 | text-wrap: balance; 42 | } 43 | } 44 | 45 | @layer base { 46 | :root { 47 | --background: 0 0% 100%; 48 | --foreground: 0 0% 3.9%; 49 | --card: 0 0% 100%; 50 | --card-foreground: 0 0% 3.9%; 51 | --popover: 0 0% 100%; 52 | --popover-foreground: 0 0% 3.9%; 53 | --primary: 0 0% 9%; 54 | --primary-foreground: 0 0% 98%; 55 | --secondary: 0 0% 96.1%; 56 | --secondary-foreground: 0 0% 9%; 57 | --muted: 0 0% 96.1%; 58 | --muted-foreground: 0 0 40%; 59 | --accent: 0 0% 96.1%; 60 | --accent-foreground: 0 0% 9%; 61 | --destructive: 0 84.2% 60.2%; 62 | --destructive-foreground: 0 0% 98%; 63 | --border: 0 0% 89.8%; 64 | --input: 0 0% 89.8%; 65 | --ring: 0 0% 3.9%; 66 | --chart-1: 12 76% 61%; 67 | --chart-2: 173 58% 39%; 68 | --chart-3: 197 37% 24%; 69 | --chart-4: 43 74% 66%; 70 | --chart-5: 27 87% 67%; 71 | --radius: 0.5rem; 72 | --sidebar-background: 0 0% 98%; 73 | --sidebar-foreground: 240 5.3% 26.1%; 74 | --sidebar-primary: 240 5.9% 10%; 75 | --sidebar-primary-foreground: 0 0% 98%; 76 | --sidebar-accent: 240 4.8% 95.9%; 77 | --sidebar-accent-foreground: 240 5.9% 10%; 78 | --sidebar-border: 220 13% 91%; 79 | --sidebar-ring: 217.2 91.2% 59.8%; 80 | } 81 | @media (prefers-color-scheme: dark) { 82 | :root { 83 | --background: 0 0% 3.9%; 84 | --foreground: 0 0% 98%; 85 | --card: 0 0% 3.9%; 86 | --card-foreground: 0 0% 98%; 87 | --popover: 0 0% 3.9%; 88 | --popover-foreground: 0 0% 98%; 89 | --primary: 0 0% 98%; 90 | --primary-foreground: 0 0% 9%; 91 | --secondary: 0 0% 14.9%; 92 | --secondary-foreground: 0 0% 98%; 93 | --muted: 0 0% 14.9%; 94 | --muted-foreground: 0 0% 63.9%; 95 | --accent: 0 0% 14.9%; 96 | --accent-foreground: 0 0% 98%; 97 | --destructive: 0 62.8% 30.6%; 98 | --destructive-foreground: 0 0% 98%; 99 | --border: 0 0% 14.9%; 100 | --input: 0 0% 14.9%; 101 | --ring: 0 0% 83.1%; 102 | --chart-1: 220 70% 50%; 103 | --chart-2: 160 60% 45%; 104 | --chart-3: 30 80% 55%; 105 | --chart-4: 280 65% 60%; 106 | --chart-5: 340 75% 55%; 107 | --sidebar-background: 240 5.9% 10%; 108 | --sidebar-foreground: 240 4.8% 95.9%; 109 | --sidebar-primary: 224.3 76.3% 48%; 110 | --sidebar-primary-foreground: 0 0% 100%; 111 | --sidebar-accent: 240 3.7% 15.9%; 112 | --sidebar-accent-foreground: 240 4.8% 95.9%; 113 | --sidebar-border: 240 3.7% 15.9%; 114 | --sidebar-ring: 217.2 91.2% 59.8%; 115 | } 116 | } 117 | 118 | * { 119 | @apply border-border; 120 | } 121 | } 122 | 123 | textarea { 124 | resize: none; 125 | } 126 | 127 | .squircle { 128 | border-radius: 32px; 129 | corner-shape: squircle; 130 | } 131 | -------------------------------------------------------------------------------- /example/client/src/ui/UserCard.tsx: -------------------------------------------------------------------------------- 1 | import safeParse from '@nkzw/core/safeParse.js'; 2 | import { User } from '@nkzw/fate-server/src/trpc/router.ts'; 3 | import Stack, { VStack } from '@nkzw/stack'; 4 | import { ChangeEvent, useActionState, useState } from 'react'; 5 | import { useFateClient, useView, view, ViewRef } from 'react-fate'; 6 | import { Button } from '../ui/Button.tsx'; 7 | import Card from '../ui/Card.tsx'; 8 | import AuthClient from '../user/AuthClient.tsx'; 9 | import H2 from './H2.tsx'; 10 | import Input from './Input.tsx'; 11 | 12 | export type SessionUser = { 13 | id?: string | null; 14 | name?: string | null; 15 | username?: string | null; 16 | }; 17 | 18 | export const UserView = view()({ 19 | id: true, 20 | name: true, 21 | username: true, 22 | }); 23 | 24 | export const UserCardView = view()({ 25 | email: true, 26 | id: true, 27 | name: true, 28 | username: true, 29 | }); 30 | 31 | const UserNameForm = ({ user }: { user: SessionUser }) => { 32 | const fate = useFateClient(); 33 | const [name, setName] = useState(user.name ?? ''); 34 | const [error, setError] = useState(null); 35 | 36 | const handleChange = (event: ChangeEvent) => { 37 | setError(null); 38 | setName(event.target.value); 39 | }; 40 | 41 | const [, submitAction, isPending] = useActionState(async () => { 42 | const id = user?.id; 43 | if (!id) { 44 | return; 45 | } 46 | 47 | const newName = name.trim(); 48 | setName(newName); 49 | 50 | if (newName === user.name) { 51 | setError(null); 52 | return; 53 | } 54 | 55 | try { 56 | setError(null); 57 | await fate.mutations.user.update({ 58 | input: { name: newName }, 59 | optimistic: { 60 | id, 61 | username: newName, 62 | }, 63 | view: UserView, 64 | }); 65 | await AuthClient.updateUser({ name }); 66 | } catch (error) { 67 | setError( 68 | (error instanceof Error && 69 | error.message && 70 | safeParse>(error.message)?.[0]?.message) || 71 | 'Failed to update user name.', 72 | ); 73 | } 74 | }, null); 75 | 76 | const trimmedName = name.trim(); 77 | const originalName = user.name ?? ''; 78 | const isSaveDisabled = !user.id || !trimmedName || trimmedName === originalName || isPending; 79 | 80 | return ( 81 |
82 | 83 |

Update Name

84 | 87 | 99 |
100 | 103 |
104 |
105 | {error ? {error} : null} 106 |
107 | ); 108 | }; 109 | 110 | export default function UserCard({ viewer: viewerRef }: { viewer: ViewRef<'User'> }) { 111 | const viewer = useView(UserCardView, viewerRef); 112 | 113 | return ( 114 | 115 | 116 | 117 |

Your account

118 | 119 |

120 | Signed in as {viewer.name} 121 | {viewer.email ? ` <${viewer.email}>` : null}. 122 |

123 |
124 |
125 | 126 |
127 |
128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /packages/react-fate/src/useListView.tsx: -------------------------------------------------------------------------------- 1 | import { ConnectionMetadata, ConnectionTag, isViewTag, Pagination, type View } from '@nkzw/fate'; 2 | import { useCallback, useDeferredValue, useMemo, useSyncExternalStore } from 'react'; 3 | import { useFateClient } from './context.tsx'; 4 | 5 | type ConnectionItems = C extends { items?: ReadonlyArray } 6 | ? ReadonlyArray 7 | : ReadonlyArray; 8 | 9 | type LoadMoreFn = () => Promise; 10 | 11 | type ConnectionSelection = { items?: { node?: unknown } }; 12 | 13 | const getNodeView = (view: ConnectionSelection) => { 14 | const maybeView = (view as ConnectionSelection)?.items?.node; 15 | 16 | if (maybeView) { 17 | for (const key of Object.keys(maybeView)) { 18 | if (isViewTag(key)) { 19 | return maybeView as View; 20 | } 21 | } 22 | } 23 | 24 | return view; 25 | }; 26 | 27 | /** 28 | * Subscribes to a connection field, returning the current items and pagination 29 | * helpers to load the next or previous page. 30 | */ 31 | export function useListView< 32 | C extends { items?: ReadonlyArray; pagination?: Pagination } | null | undefined, 33 | >( 34 | selection: ConnectionSelection, 35 | connection: C, 36 | ): [ConnectionItems>, LoadMoreFn | null, LoadMoreFn | null] { 37 | const client = useFateClient(); 38 | const nodeView = useMemo(() => getNodeView(selection), [selection]); 39 | const metadata = 40 | connection && typeof connection === 'object' 41 | ? ((connection as Record)[ConnectionTag] as ConnectionMetadata | undefined) 42 | : null; 43 | 44 | const subscribe = useCallback( 45 | (onStoreChange: () => void) => 46 | metadata ? client.store.subscribeList(metadata.key, onStoreChange) : () => {}, 47 | [client, metadata], 48 | ); 49 | 50 | const getSnapshot = useCallback( 51 | () => (metadata ? client.store.getListState(metadata.key) : undefined), 52 | [client, metadata], 53 | ); 54 | 55 | const listState = useDeferredValue(useSyncExternalStore(subscribe, getSnapshot, getSnapshot)); 56 | const pagination = listState?.pagination ?? connection?.pagination; 57 | const hasNext = Boolean(pagination?.hasNext); 58 | const hasPrevious = Boolean(pagination?.hasPrevious); 59 | const nextCursor = pagination?.nextCursor; 60 | const previousCursor = pagination?.previousCursor; 61 | 62 | const items = useMemo(() => { 63 | if (metadata?.root && listState) { 64 | return listState.ids.map((id, index) => ({ 65 | cursor: listState.cursors?.[index], 66 | node: client.rootListRef(id, nodeView), 67 | })); 68 | } 69 | 70 | return connection?.items; 71 | }, [client, connection?.items, listState, metadata?.root, nodeView]); 72 | 73 | const loadNext = useMemo(() => { 74 | if (!metadata || !hasNext || !nextCursor) { 75 | return null; 76 | } 77 | 78 | return async () => { 79 | const { before, first, last, ...values } = metadata.args || {}; 80 | const nextPageSize = first ?? last; 81 | 82 | await client.loadConnection( 83 | nodeView, 84 | metadata, 85 | { 86 | ...values, 87 | after: nextCursor, 88 | ...(nextPageSize !== undefined ? { first: nextPageSize } : null), 89 | }, 90 | { 91 | direction: 'forward', 92 | }, 93 | ); 94 | }; 95 | }, [client, hasNext, nodeView, metadata, nextCursor]); 96 | 97 | const loadPrevious = useMemo(() => { 98 | if (!metadata || !hasPrevious || !previousCursor) { 99 | return null; 100 | } 101 | 102 | return async () => { 103 | const { after, ...values } = metadata.args || {}; 104 | await client.loadConnection( 105 | nodeView, 106 | metadata, 107 | { 108 | ...values, 109 | before: previousCursor, 110 | }, 111 | { 112 | direction: 'backward', 113 | }, 114 | ); 115 | }; 116 | }, [client, hasPrevious, nodeView, metadata, previousCursor]); 117 | 118 | return [items as ConnectionItems>, loadNext, loadPrevious]; 119 | } 120 | -------------------------------------------------------------------------------- /packages/fate/src/codegen/__tests__/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { dataView, list } from '../../server/dataView.ts'; 3 | import { createSchema } from '../schema.ts'; 4 | 5 | type User = { id: string; name: string }; 6 | type Comment = { author: User; id: string; replies: Array }; 7 | type Post = { author: User; comments: Array; id: string }; 8 | type Event = { id: string; title: string }; 9 | type Category = { id: string; name: string }; 10 | 11 | test('derives types and roots from data views', () => { 12 | const userView = dataView('User')({ 13 | id: true, 14 | name: true, 15 | }); 16 | 17 | const commentView = dataView('Comment')({ 18 | author: userView, 19 | id: true, 20 | replies: list( 21 | dataView('Comment')({ 22 | author: userView, 23 | id: true, 24 | replies: list(dataView('Comment')({ id: true })), 25 | }), 26 | ), 27 | }); 28 | 29 | const postView = dataView('Post')({ 30 | author: userView, 31 | comments: list(commentView), 32 | id: true, 33 | }); 34 | 35 | const eventView = dataView('Event')({ 36 | id: true, 37 | title: true, 38 | }); 39 | 40 | const categoryView = dataView('Category')({ 41 | id: true, 42 | name: true, 43 | posts: list(postView), 44 | }); 45 | 46 | const { roots, types } = createSchema( 47 | [commentView, postView, userView, eventView, categoryView], 48 | { 49 | categories: list(categoryView), 50 | commentSearch: { procedure: 'search', view: list(commentView) }, 51 | events: list(eventView), 52 | posts: list(postView), 53 | viewer: userView, 54 | }, 55 | ); 56 | 57 | expect(roots).toEqual({ 58 | categories: { kind: 'list', procedure: 'list', router: 'category', type: 'Category' }, 59 | commentSearch: { kind: 'list', procedure: 'search', router: 'comment', type: 'Comment' }, 60 | events: { kind: 'list', procedure: 'list', router: 'event', type: 'Event' }, 61 | posts: { kind: 'list', procedure: 'list', router: 'post', type: 'Post' }, 62 | viewer: { kind: 'query', procedure: 'viewer', router: 'user', type: 'User' }, 63 | }); 64 | 65 | expect(types).toEqual([ 66 | { type: 'User' }, 67 | { 68 | fields: { 69 | author: { type: 'User' }, 70 | replies: { listOf: 'Comment' }, 71 | }, 72 | type: 'Comment', 73 | }, 74 | { 75 | fields: { 76 | author: { type: 'User' }, 77 | comments: { listOf: 'Comment' }, 78 | }, 79 | type: 'Post', 80 | }, 81 | { type: 'Event' }, 82 | { fields: { posts: { listOf: 'Post' } }, type: 'Category' }, 83 | ]); 84 | }); 85 | 86 | test('allows defining custom list procedure names', () => { 87 | const userView = dataView('User')({ 88 | id: true, 89 | name: true, 90 | }); 91 | 92 | const commentView = dataView('Comment')({ 93 | author: userView, 94 | id: true, 95 | replies: list(dataView('Comment')({ id: true })), 96 | }); 97 | 98 | const { roots } = createSchema([commentView, userView], { 99 | commentSearch: { procedure: 'search', view: list(commentView) }, 100 | user: userView, 101 | }); 102 | 103 | expect(roots.commentSearch).toEqual({ 104 | kind: 'list', 105 | procedure: 'search', 106 | router: 'comment', 107 | type: 'Comment', 108 | }); 109 | }); 110 | 111 | test('derives root type from lists when byId is missing', () => { 112 | const userProfileView = dataView('UserProfile')({ 113 | id: true, 114 | name: true, 115 | }); 116 | 117 | const commentView = dataView('Comment')({ 118 | author: userProfileView, 119 | id: true, 120 | }); 121 | 122 | const { roots, types } = createSchema([commentView, userProfileView], { 123 | userProfile: list(userProfileView), 124 | }); 125 | 126 | expect(types).toEqual([ 127 | { type: 'UserProfile' }, 128 | { fields: { author: { type: 'UserProfile' } }, type: 'Comment' }, 129 | ]); 130 | 131 | expect(roots).toEqual({ 132 | userProfile: { kind: 'list', procedure: 'list', router: 'userProfile', type: 'UserProfile' }, 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /docs/parts/intro.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | Logo 6 | 7 |

8 | 9 | **_fate_** is a modern data client for React and tRPC inspired by [Relay](https://relay.dev/) and [GraphQL](https://graphql.org/). It combines view composition, normalized caching, data masking, Async React features, and tRPC's type safety. 10 | 11 | ## Features 12 | 13 | - **View Composition:** Components declare their data requirements using co-located "views". Views are composed into a single request per screen, minimizing network requests and eliminating waterfalls. 14 | - **Normalized Cache:** fate maintains a normalized cache for all fetched data. This enables efficient data updates through actions and mutations and avoids stale or duplicated data. 15 | - **Data Masking & Strict Selection:** fate enforces strict data selection for each view, and masks (hides) data that components did not request. This prevents accidental coupling between components and reduces overfetching. 16 | - **Async React:** fate uses modern Async React features like Actions, Suspense, and `use` to support concurrent rendering and enable a seamless user experience. 17 | - **Lists & Pagination:** fate provides built-in support for connection-style lists with cursor-based pagination, making it easy to implement infinite scrolling and "load-more" functionality. 18 | - **Optimistic Updates:** fate supports declarative optimistic updates for mutations, allowing the UI to update immediately while the server request is in-flight. If the request fails, the cache and its associated views are rolled back to their previous state. 19 | - **AI-Ready:** fate's minimal, predictable API and explicit data selection enable local reasoning, enabling humans and AI tools to generate stable, type-safe data-fetching code. 20 | 21 | ## A modern data client for React & tRPC 22 | 23 | **_fate_** is designed to make data fetching and state management in React applications more composable, declarative, and predictable. The framework has a minimal API, no DSL, and no magic—_it's just JavaScript_. 24 | 25 | 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. 26 | 27 | [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. 28 | 29 | 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. 30 | 31 | 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. 32 | 33 | _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: 34 | 35 | ```tsx 36 | export const PostView = view()({ 37 | author: UserView, 38 | content: true, 39 | id: true, 40 | title: true, 41 | }); 42 | 43 | export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => { 44 | const post = useView(PostView, postRef); 45 | 46 | return ( 47 | 48 |

{post.title}

49 |

{post.content}

50 | 51 |
52 | ); 53 | }; 54 | ``` 55 | 56 | _[Learn more](/guide/getting-started) about fate's core concepts or get started with [a ready-made template](https://github.com/nkzw-tech/fate-template#readme)._ 57 | -------------------------------------------------------------------------------- /packages/fate/src/__tests__/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import ViewDataCache from '../cache.ts'; 3 | import { 4 | ViewsTag, 5 | type EntityId, 6 | type FateThenable, 7 | type View, 8 | type ViewRef, 9 | type ViewSnapshot, 10 | } from '../types.ts'; 11 | 12 | const createThenable = (value: T): FateThenable => 13 | ({ 14 | status: 'fulfilled', 15 | then(onfulfilled) { 16 | return Promise.resolve(onfulfilled ? onfulfilled(value) : value); 17 | }, 18 | value, 19 | }) as FateThenable; 20 | 21 | const createView = (): View => ({}) as View; 22 | 23 | const createViewRef = (__typename: TName, id: string): ViewRef => ({ 24 | __typename, 25 | id, 26 | [ViewsTag]: new Set(), 27 | }); 28 | 29 | test('evicts dependents and the dependency when invalidating an entity', () => { 30 | const cache = new ViewDataCache(); 31 | const dependencyId: EntityId = 'dependency'; 32 | const dependentId: EntityId = 'dependent'; 33 | 34 | const dependencyView = createView(); 35 | const dependencyRef = createViewRef('Dependency', 'dependency-ref'); 36 | const dependencySnapshot: ViewSnapshot = { 37 | coverage: [[dependencyId, new Set()]], 38 | data: { [ViewsTag]: new Set() }, 39 | }; 40 | const dependencyThenable = createThenable(dependencySnapshot); 41 | 42 | const dependentView = createView(); 43 | const dependentRef = createViewRef('Dependent', 'dependent-ref'); 44 | const dependentSnapshot: ViewSnapshot = { 45 | coverage: [ 46 | [dependentId, new Set()], 47 | [dependencyId, new Set()], 48 | ], 49 | data: { [ViewsTag]: new Set() }, 50 | }; 51 | const dependentThenable = createThenable(dependentSnapshot); 52 | 53 | cache.set(dependencyId, dependencyView, dependencyRef, dependencyThenable, new Set()); 54 | cache.set( 55 | dependentId, 56 | dependentView, 57 | dependentRef, 58 | dependentThenable, 59 | new Set([dependencyId]), 60 | ); 61 | 62 | expect(cache.get(dependencyId, dependencyView, dependencyRef)).toBe(dependencyThenable); 63 | expect(cache.get(dependentId, dependentView, dependentRef)).toBe(dependentThenable); 64 | 65 | cache.invalidate(dependencyId); 66 | 67 | expect(cache.get(dependencyId, dependencyView, dependencyRef)).toBeNull(); 68 | expect(cache.get(dependentId, dependentView, dependentRef)).toBeNull(); 69 | }); 70 | 71 | test('recursively invalidates transitive dependency chains', () => { 72 | const cache = new ViewDataCache(); 73 | const rootId: EntityId = 'root'; 74 | const middleId: EntityId = 'middle'; 75 | const leafId: EntityId = 'leaf'; 76 | 77 | const rootView = createView(); 78 | const rootRef = createViewRef('Root', 'root-ref'); 79 | const rootSnapshot: ViewSnapshot = { 80 | coverage: [[rootId, new Set()]], 81 | data: { [ViewsTag]: new Set() }, 82 | }; 83 | const rootThenable = createThenable(rootSnapshot); 84 | 85 | const middleView = createView(); 86 | const middleRef = createViewRef('Middle', 'middle-ref'); 87 | const middleSnapshot: ViewSnapshot = { 88 | coverage: [ 89 | [middleId, new Set()], 90 | [rootId, new Set()], 91 | ], 92 | data: { [ViewsTag]: new Set() }, 93 | }; 94 | const middleThenable = createThenable(middleSnapshot); 95 | 96 | const leafView = createView(); 97 | const leafRef = createViewRef('Leaf', 'leaf-ref'); 98 | const leafSnapshot: ViewSnapshot = { 99 | coverage: [ 100 | [leafId, new Set()], 101 | [middleId, new Set()], 102 | ], 103 | data: { [ViewsTag]: new Set() }, 104 | }; 105 | const leafThenable = createThenable(leafSnapshot); 106 | 107 | cache.set(rootId, rootView, rootRef, rootThenable, new Set()); 108 | cache.set(middleId, middleView, middleRef, middleThenable, new Set([rootId])); 109 | cache.set(leafId, leafView, leafRef, leafThenable, new Set([middleId])); 110 | 111 | expect(cache.get(rootId, rootView, rootRef)).toBe(rootThenable); 112 | expect(cache.get(middleId, middleView, middleRef)).toBe(middleThenable); 113 | expect(cache.get(leafId, leafView, leafRef)).toBe(leafThenable); 114 | 115 | cache.invalidate(rootId); 116 | 117 | expect(cache.get(rootId, rootView, rootRef)).toBeNull(); 118 | expect(cache.get(middleId, middleView, middleRef)).toBeNull(); 119 | expect(cache.get(leafId, leafView, leafRef)).toBeNull(); 120 | }); 121 | -------------------------------------------------------------------------------- /example/client/src/ui/CreatePost.tsx: -------------------------------------------------------------------------------- 1 | import Stack, { VStack } from '@nkzw/stack'; 2 | import { KeyboardEvent, startTransition, useActionState, useState } from 'react'; 3 | import { useFateClient, useView, ViewRef } from 'react-fate'; 4 | import { Button } from '../ui/Button.tsx'; 5 | import Card from './Card.tsx'; 6 | import H3 from './H3.tsx'; 7 | import Input, { CheckBox } from './Input.tsx'; 8 | import { PostView } from './PostCard.tsx'; 9 | import { UserCardView } from './UserCard.tsx'; 10 | 11 | export default function CreatePost({ user: userRef }: { user: ViewRef<'User'> | null }) { 12 | const fate = useFateClient(); 13 | const user = useView(UserCardView, userRef); 14 | const [contentValue, setContentValue] = useState(''); 15 | const [titleValue, setTitleValue] = useState(''); 16 | const [missingOptimisticContent, setMissingOptimisticContent] = useState(false); 17 | const [missingMutationSelection, setMissingMutationSelection] = useState(false); 18 | 19 | const [, createPost, isPending] = useActionState(async () => { 20 | const content = contentValue.trim(); 21 | const title = titleValue.trim(); 22 | 23 | if (!content || !title || !user) { 24 | return; 25 | } 26 | 27 | const result = await fate.mutations.post.add({ 28 | input: { content, title }, 29 | insert: 'before', 30 | optimistic: missingOptimisticContent 31 | ? { 32 | author: user, 33 | comments: [], 34 | id: `optimistic:${Date.now().toString(36)}`, 35 | title, 36 | } 37 | : { 38 | author: user, 39 | commentCount: 0, 40 | comments: [], 41 | content, 42 | id: `optimistic:${Date.now().toString(36)}`, 43 | likes: 0, 44 | title, 45 | }, 46 | ...(missingMutationSelection ? null : { view: PostView }), 47 | }); 48 | 49 | setContentValue(''); 50 | setTitleValue(''); 51 | 52 | return result; 53 | }, null); 54 | 55 | const maybeSubmitPost = (event: KeyboardEvent) => { 56 | if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { 57 | startTransition(createPost); 58 | } 59 | }; 60 | 61 | const postingIsDisabled = 62 | isPending || titleValue.trim().length === 0 || contentValue.trim().length === 0; 63 | 64 | return ( 65 | 66 | 67 |

Create a Post

68 | setTitleValue(event.target.value)} 72 | onKeyDown={maybeSubmitPost} 73 | placeholder="Post Title" 74 | value={titleValue} 75 | /> 76 |