Skip to content

Server Integration

Until now, we have focused on the client-side API of fate. You'll need a backend that can be wired into fate's typed request model so the Vite plugin can connect the typed fate APIs to your app. fate currently ships three integration paths:

  • The native fate protocol, which is transport-agnostic and can be hosted by any Fetch-compatible server.
  • The tRPC adapter, which keeps compatibility with existing tRPC backends.
  • The GraphQL transport, which maps fate views and roots to an existing GraphQL schema.

fate currently provides database adapters for Prisma and Drizzle, but the framework itself is not coupled to a particular ORM. The adapters plug into the same source execution runtime and can be exposed through the native protocol or through tRPC.

Conventions & Object Identity

fate expects that data is served by a backend that follows these conventions:

  • A byId query for each data type to fetch individual objects by their unique identifier (id).
  • A list query for fetching lists of objects with support for pagination.

Objects are identified by their ID and type name (__typename, e.g. Post, User), and stored by __typename:id (e.g. "Post:123") in the client cache. fate keeps list orderings under stable keys derived from the backend procedure and args. Relations are stored as IDs and returned to components as ViewRef tokens.

fate's type definitions might seem verbose at first glance. However, with fate's minimal API surface, AI tools can easily write this code for you, and the Vite plugin takes care of connecting it to your app.

NOTE

You can adopt fate incrementally in an existing tRPC codebase without changing your existing schema by adding these queries alongside your existing procedures.

Data Views

Since clients can send arbitrary selection objects to the server, we need to implement a way to translate these selection objects into database queries without exposing raw database queries and private data to the client. On the client, we define views to select fields on each type. We can do the same on the server using fate data views and the dataView function from @nkzw/fate/server.

Create a views.ts file next to your server entry that exports the data views for each type. The same data view shape works with both Prisma model types and Drizzle row types:

tsx
import { dataView, type Entity } from '@nkzw/fate/server';
import type { User as PrismaUser } from '../prisma/prisma-client/client.ts';

export const userDataView = dataView<PrismaUser>('User')({
  id: true,
  name: true,
  username: true,
});

export type User = Entity<typeof userDataView, 'User'>;
tsx
import { dataView, type Entity } from '@nkzw/fate/server';
import type { UserRow } from '../drizzle/schema.ts';

export const userDataView = dataView<UserRow>('User')({
  id: true,
  name: true,
  username: true,
});

export type User = Entity<typeof userDataView, 'User'>;

Now that we apply userDataView to the byId query, the server limits the selection to the fields defined in the data view, keeping private fields hidden from the client, and providing type safety for client views:

tsx
const UserData = view<User>()({
  // Type-error + ignored during runtime.
  password: true,
});

Data View Composition

Similar to client-side views, data views can be composed of other data views:

tsx
export const postDataView = dataView<PostItem>('Post')({
  author: userDataView,
  content: true,
  id: true,
  title: true,
});

Data View Lists

Use the list helper to define list fields:

tsx
import { list } from '@nkzw/fate/server';

export const commentDataView = dataView<CommentItem>('Comment')({
  content: true,
  id: true,
});

export const postDataView = dataView<PostItem>('Post')({
  author: userDataView,
  comments: list(commentDataView, { orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] }),
});

We can define extra root-level lists and queries by exporting a Root object from our views.ts file using the same view syntax as everywhere else:

tsx
export const Root = {
  categories: list(categoryDataView, { orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] }),
  commentSearch: {
    procedure: 'search',
    view: list(commentDataView, { orderBy: [{ createdAt: 'desc' }, { id: 'desc' }] }),
  },
  events: list(eventDataView, { orderBy: [{ startAt: 'asc' }, { id: 'asc' }] }),
  posts: list(postDataView, { orderBy: { createdAt: 'desc', id: 'desc' } }),
  viewer: userDataView,
};

Entries that wrap their view in list(...) are treated as list resolvers. In the native protocol, the root key is the operation name used by the client. In the tRPC adapter, procedure can point that root at a specific router procedure. If you omit list(...), fate treats the entry as a standard query.

You can pass default list options such as orderBy to list(...). Ordering is scoped to that specific list wrapper: Root.posts can order posts by createdAt desc, while categoryDataView.posts or postDataView.comments can choose their own order. If no order is provided, fate orders by id asc. fate always appends id asc as a tie-breaker when no id order is present; include id yourself when you need a different tie-breaker direction such as id desc. Use the array form when ordering by multiple fields so the priority is unambiguous.

For the above Root definitions, you can make the following requests using useRequest:

tsx
const query = 'Apple';

const { posts, categories, viewer } = useRequest({
  // Explicit Root queries:
  categories: { list: categoryView },
  commentSearch: { args: { query }, list: commentView },
  events: { list: eventView },
  posts: { list: postView },
  viewer: { view: userView },

  // Queries by id, if those entities have a `byId` query defined:
  post: { id: '12', view: postView },
  comment: { ids: ['6', '7'], view: commentView },
});

Native fate protocol

The native protocol keeps tRPC optional. Create a source adapter from your ORM integration, pass it to createFateServer, and expose the returned server through a Fetch-compatible handler.

tsx
import { createFateServer, createHonoFateHandler } from '@nkzw/fate/server';
import { createPrismaSourceAdapter } from '@nkzw/fate/server/prisma';
import { Hono } from 'hono';
import type { AppContext } from './context.ts';
import { prisma } from './prisma.ts';
import { Root, userDataView } from './views.ts';

export { Root } from './views.ts';

const sources = createPrismaSourceAdapter<AppContext>({
  prisma: (ctx) => ctx.prisma,
  views: Root,
});

export const fate = createFateServer({
  context: async ({ adapterContext }) => ({
    prisma,
    request: adapterContext.req.raw,
    sessionUser: await getSessionUser(adapterContext.req.raw),
  }),
  queries: {
    viewer: {
      resolve: ({ ctx, select }) =>
        sources.resolveById({
          ctx,
          id: ctx.sessionUser.id,
          input: { select },
          view: userDataView,
        }),
    },
  },
  roots: Root,
  sources,
});

const app = new Hono();
const handler = createHonoFateHandler(fate);

app.post('/fate', handler);
app.post('/fate/live', handler);

Configure the Vite plugin with the native transport:

tsx
import { fate } from 'react-fate/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    fate({
      module: '@your-org/server/fate.ts',
      transport: 'native',
    }),
  ],
});

With the native transport, the Vite plugin handles the HTTP transport setup. If you need to create a client manually, use createFateClient with the same route:

tsx
import { createFateClient } from 'react-fate/client';

const client = createFateClient({
  url: '/fate',
});

The HTTP transport batches operations issued in the same microtask into one POST /fate request. Live views use one GET /fate/live SSE stream per fate client and POST /fate/live control messages when views subscribe or unsubscribe.

Custom Queries

Root query entries such as viewer need an explicit resolver because fate cannot infer application-specific behavior like "current user" from a data view:

tsx
export const fate = createFateServer({
  context,
  queries: {
    viewer: {
      resolve: ({ ctx, select }) =>
        sources.resolveById({
          ctx,
          id: ctx.sessionUser.id,
          input: { select },
          view: userDataView,
        }),
    },
  },
  roots: Root,
  sources,
});

Custom Mutations

Mutations declare the entity type they return and receive the selected fields requested by the client. Resolve the updated record through the source adapter so the response has the same masking and relation behavior as regular view requests:

tsx
export const fate = createFateServer({
  mutations: {
    'post.like': {
      input: likeInput,
      resolve: async ({ ctx, input, select }) => {
        await ctx.prisma.post.update({
          data: { likes: { increment: 1 } },
          where: { id: input.id },
        });

        return sources.resolveById({
          ctx,
          id: input.id,
          input: { select },
          view: postDataView,
        });
      },
      type: 'Post',
    },
  },
  roots: Root,
  sources,
});

Live Views

Pass a live event bus to enable useLiveView over the native SSE endpoint:

tsx
import { createLiveEventBus } from '@nkzw/fate/server';

export const live = createLiveEventBus();

export const fate = createFateServer({
  live,
  queries: {
    viewer: {
      resolve: ({ ctx, select }) =>
        sources.resolveById({
          ctx,
          id: ctx.sessionUser.id,
          input: { select },
          view: userDataView,
        }),
    },
  },
  roots: Root,
  sources,
});

live.update('Post', post.id, {
  changed: ['likes'],
  eventId: `post:${post.id}:${Date.now()}`,
});

changed is optional. When provided, fate resolves only the changed fields selected by each live subscription and skips subscriptions that do not select those fields. createLiveEventBus is an in-memory fanout bus. It forwards eventId to SSE clients, but it does not replay events after reconnects. If your app needs lossless reconnect behavior, provide a durable live bus implementation that uses the lastEventId passed to listen, listenConnection, subscribe, and subscribeConnection.

Native SSE connections keep a bounded in-memory queue while events are waiting to be resolved and sent. The default is 1000 queued events per connection. If a client falls behind and exceeds that limit, fate closes the live connection instead of buffering indefinitely. Configure it with live: { bus: live, maxQueueSize: 500 }.

tRPC fate setup

The Prisma and Drizzle tRPC integrations connect your data views to your database, bind fate's standard tRPC procedures, and expose helpers for custom queries and mutations.

Pass the Root export from views.ts to fate in your tRPC init.ts file. fate walks that view graph to find the data views it needs. id defaults to "id", and fate uses it as the fallback ordering for cursor pagination. Relations are inferred from the data view and ORM schema: a nested data view is loaded as a singular relation, list(view) is loaded as a list relation, and Drizzle join tables are discovered from relation metadata.

Prisma

Use createPrismaFate from @nkzw/fate/server/prisma next to your tRPC helpers. By default, fate reads Prisma delegates from ctx.prisma using each data view's type name:

tsx
import { initTRPC } from '@trpc/server';
import { createPrismaFate } from '@nkzw/fate/server/prisma';
import type { AppContext } from './context.ts';
import { Root } from './views.ts';

const t = initTRPC.context<AppContext>().create();

export const router = t.router;
export const procedure = t.procedure;

export const fate = createPrismaFate<AppContext, typeof procedure>({
  procedure,
  views: Root,
});

If your Prisma client is not stored at ctx.prisma, pass prisma: (ctx) => ctx.db.

The Prisma integration translates view requests into Prisma select, where, cursor, skip, and take options. It also hydrates computed count(...) dependencies using Prisma groupBy when needed.

For custom Prisma queries and mutations, use fate.createPlan with toPrismaSelect:

tsx
import { toPrismaSelect } from '@nkzw/fate/server';

const plan = fate.createPlan({
  ...input,
  ctx,
  view: postDataView,
});

const post = await ctx.prisma.post.update({
  data: {
    likes: {
      increment: 1,
    },
  },
  select: toPrismaSelect(plan),
  where: { id: input.id },
});

return plan.resolve(post);

Drizzle

Use createDrizzleFate from @nkzw/fate/server/drizzle. fate matches data view type names to Drizzle tables from your schema. The db option can be a Drizzle database object or a function that receives your tRPC context and returns a request-scoped database object:

tsx
import { initTRPC } from '@trpc/server';
import { createDrizzleFate } from '@nkzw/fate/server/drizzle';
import db from '../drizzle/db.ts';
import schema from '../drizzle/schema.ts';
import type { AppContext } from './context.ts';
import { Root } from './views.ts';

const t = initTRPC.context<AppContext>().create();

export const router = t.router;
export const procedure = t.procedure;

export const fate = createDrizzleFate<AppContext, typeof procedure>({
  db,
  procedure,
  schema,
  views: Root,
});

If your database lives on the request context, pass a function instead:

tsx
import schema from '../drizzle/schema.ts';
import { Root } from './views.ts';

export const fate = createDrizzleFate<AppContext, typeof procedure>({
  db: (ctx) => ctx.db,
  procedure,
  schema,
  views: Root,
});

The Drizzle adapter builds SQL queries from your registered data views. It selects only requested columns, hydrates singular, list, and many-to-many relations, supports nested cursor pagination, and hydrates computed count(...) dependencies with SQL grouped counts. Count filters may be plain equality objects or Drizzle SQL predicates written as (columns) => eq(columns.status, 'GOING').

Nested paginated relations are resolved with one child-page query per parent row. fate runs those child queries with a default concurrency limit of 10 so a single request cannot flood the database connection pool. Tune this with nestedPaginationConcurrency if your database pool or workload needs a different limit:

tsx
export const fate = createDrizzleFate<AppContext, typeof procedure>({
  db,
  nestedPaginationConcurrency: 5,
  procedure,
  schema,
  views: Root,
});

For request-specific sorting, prefer a custom root query that validates and translates explicit sort args.

For many-to-many relations, define the join table relations in your Drizzle schema. fate discovers a join table that points at both the source table and the target table:

tsx
export const fate = createDrizzleFate<AppContext, typeof procedure>({
  db,
  procedure,
  schema,
  views: Root,
});

You can still provide explicit join metadata when the schema is ambiguous:

tsx
{
  manyToMany: {
    tags: {
      foreignColumn: postToTag.tagId,
      localColumn: postToTag.postId,
      table: postToTag,
    },
  },
  relations: {
    tags: {
      foreignKey: 'id',
      localKey: 'id',
      through: {
        foreignKey: 'tagId',
        localKey: 'postId',
      },
    },
  },
  table: post,
  view: postDataView,
}

Drizzle writes should stay ordinary Drizzle code. After creating or updating a row, use fate.resolveById to return the selected shape that the client asked for:

tsx
const postId = await createPostRecord({
  authorId: ctx.sessionUser.id,
  content: input.content,
  title: input.title,
});

const post = await fate.resolveById({
  ctx,
  id: postId,
  input,
  view: postDataView,
});

return post;

tRPC Procedures

Use fate.procedures to build the standard byId and list procedures expected by fate's request APIs:

tsx
import { fate, router } from '../init.ts';
import { postDataView } from '../views.ts';

export const postRouter = router({
  ...fate.procedures(postDataView),
});

You can disable the generated list procedure if a view should only be fetched by id:

tsx
export const commentRouter = router({
  ...fate.procedures({
    list: false,
    view: commentDataView,
  }),
});

Custom Queries

You can add custom root queries next to generated procedures. Define the root in Root, implement a matching tRPC procedure, and call fate.resolveConnection:

tsx
export const Root = {
  commentSearch: { procedure: 'search', view: list(commentDataView) },
};
tsx
import { ilike } from 'drizzle-orm';
import { fate } from '../init.ts';

export const commentRouter = router({
  ...fate.procedures({
    list: false,
    view: commentDataView,
  }),
  search: fate.connection({
    input: z.object({
      query: z.string().min(1, 'Search query is required'),
    }),
    query: ({ ctx, cursor, direction, input, take }) =>
      fate.resolveConnection({
        ctx,
        cursor,
        direction,
        extra: {
          where: ilike(comment.content, `%${input.args.query}%`),
        },
        input,
        take,
        view: commentDataView,
      }),
  }),
});

For Prisma, pass Prisma query options such as { where: { ... } } in extra instead of a Drizzle SQL expression.

Data View Resolvers

fate data views support computed fields. Use computed, field, and count to describe the hidden data needed to resolve a public field:

tsx
import { computed, count, field } from '@nkzw/fate/server';

export const userDataView = dataView<UserItem>('User')({
  email: computed<UserItem, string | null, AppContext>({
    authorize: ({ id }, context) => context?.sessionUser?.id === id,
    select: {
      email: field('email'),
    },
    resolve: (_item, deps) => (deps.email as string | null) ?? null,
  }),
  id: true,
});

export const postDataView = dataView<PostItem>('Post')({
  commentCount: computed<PostItem, number>({
    select: {
      count: count('comments'),
    },
    resolve: (_item, deps) => (deps.count as number) ?? 0,
  }),
  id: true,
});

The adapters fetch the hidden field(...) and count(...) dependencies for you. This keeps private fields like email available to the resolver without exposing them to the client selection.

Connecting the Client

Now that we have defined our client views and our server module, add fate's Vite plugin to the client app. The plugin reads your server exports and wires the typed fate APIs into your app.

For tRPC, make sure the router.ts file exports the appRouter object, AppRouter type and all the views we have defined:

tsx
import { router } from './init.ts';
import { postRouter } from './routers/post.ts';
import { userRouter } from './routers/user.ts';

export const appRouter = router({
  post: postRouter,
  user: userRouter,
});

export type AppRouter = typeof appRouter;

export * from './views.ts';

Configure the fate Vite plugin with your server module:

tsx
import { fate } from 'react-fate/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    fate({
      module: '@your-org/server/trpc/router.ts',
    }),
  ],
});

Note: fate uses the specified server module name to find the server types it needs. Make sure that the module is available to the client package's Vite config.

During development, the plugin watches the server module and the files it imports. When one of those files changes, fate updates the internal client wiring and invalidates @nkzw/fate/client in Vite's module graph.

For a barebones client without React, import the plugin from @nkzw/fate/vite and the client APIs from @nkzw/fate/client. The plugin wires the same server types for the selected import path.

The plugin writes project-local types under .fate/. If your TypeScript config does not already include dot-directories, extend the generated config:

json
{
  "extends": "./.fate/tsconfig.json"
}

Creating a fate Client

Now that the Vite plugin has connected the types, create a fate client instance and provide it to your React app with the FateClient context provider:

tsx
import { httpBatchLink } from '@trpc/client';
import { FateClient } from 'react-fate';
import { createFateClient } from 'react-fate/client';

export function App() {
  const fate = useMemo(
    () =>
      createFateClient({
        links: [
          httpBatchLink({
            fetch: (input, init) =>
              fetch(input, {
                ...init,
                credentials: 'include',
              }),
            url: `${env('SERVER_URL')}/trpc`,
          }),
        ],
      }),
    [],
  );
  return <FateClient client={fate}>{/* Components go here */}</FateClient>;
}

And you are all set. Happy building!

Released under the MIT License