Skip to content

Repository files navigation

Route Ink

Route Ink is a convention-driven code generator for monorepos that follow a specific style. It ships three independent tools in one package:

  1. CLI — generates fully typed React Query hooks from your Fastify route files.
  2. Prisma generator — generates Zod schemas and TypeScript types from your schema.prisma.
  3. Cube.js base generator — generates hidden Cube.js base schemas from Prisma's DMMF and Prisma /// comments.

Both are opinionated and tuned to a particular set of conventions. Use either, both, or neither. This is a personal-style tool — no support guarantees, PRs may be ignored.

Installation

Route Ink is published as a private package on GitHub Packages. Consuming it requires a one-time auth setup.

1. Add .npmrc to your consumer repo root (commit this file):

@codevuk:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}

2. Provide a GitHub PAT with read:packages scope via the NODE_AUTH_TOKEN env var.

  • Local development: export it from your shell profile.
  • GitHub Actions CI: the built-in secrets.GITHUB_TOKEN works (the workflow needs permissions: packages: read).
  • Other CI: store a PAT as a secret and inject it as NODE_AUTH_TOKEN.

3. Install:

pnpm add -D @codevuk/route-ink

This installs three binaries into node_modules/.bin/:

  • route-ink — the CLI
  • route-ink-prisma-generator — the Prisma generator (referenced from schema.prisma)
  • route-ink-cube-sync-generator — the Cube.js base-schema Prisma generator (referenced from schema.prisma)

CLI: Fastify routes → TanStack Query hooks

Given *.route.ts files with schema definitions, the CLI generates:

  • Query hooks for GET
  • Mutation hooks for POST, PUT, PATCH, DELETE
  • Utility files (injectParams, serializeSearchQuery, buildQueryKey, QueryError)

Generated hooks are typed with your schema package and parse responses with Zod where response schemas exist.

Core assumptions

The CLI is convention-driven. It expects:

  • Fastify instance is named fastify
  • Route files end with .route.ts
  • Route definitions use fastify.<method>(path, options, handler) style where options.schema is an object literal
  • operationId exists in each route schema
  • Shared schemas are imported from a single package (configured via schemaPackage)
  • Config file is named routeink.json and lives in the current working directory

If your codebase does not follow these conventions, parsing can skip endpoints.

Configuration

Create routeink.json in the project where you run the command:

{
  "routesDir": "../api/src/routes",
  "outputDir": "./src/generated",
  "name": "api-client",
  "schemaPackage": "@workspace/schemas",
  "exportQueryOptions": false
}
Field Default Description
routesDir ../api/src/routes Where Route Ink scans for *.route.ts
outputDir (required) Destination parent directory
name api-client Output folder name inside outputDir
schemaPackage @workspace/schemas Package to import schema symbols from
exportQueryOptions false Also export a queryOptions factory per query (see below)

Running

pnpm route-ink generate

Validation errors stop generation. CLI output uses colored status badges and table-formatted warnings/errors.

Generated structure

<outputDir>/<name>/
  index.ts
  queries.ts
  mutations.ts
  endpoints/
    index.ts
    ...generated endpoint hooks
  util/
    buildQueryKey.ts
    injectParams.ts
    serializeSearchQuery.ts
    QueryError.ts
    index.ts

Supported endpoint shapes

Queries (GET): basic, params only, query only, query + params.

Mutations (POST, PUT, PATCH, DELETE): basic, body only, params only, body + params. Mutation responses are optional.

Frontend usage

Wrap your tree with the generated RouteInkProvider and supply your own Axios instance:

import axios from "axios";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouteInkProvider } from "./generated/api-client/util";

const api = axios.create({ baseURL: import.meta.env.VITE_API_URL });
const queryClient = new QueryClient();

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <RouteInkProvider axios={api}>{children}</RouteInkProvider>
    </QueryClientProvider>
  );
}

A GET endpoint with operationId: "getUsers" becomes useGetUsersSuspenseQuery:

const { data } = useGetUsersSuspenseQuery();
useGetUserByIdSuspenseQuery({ params: { userId: "42" } });
useSearchUsersSuspenseQuery({ query: { page: 1, search: "sam" } });

A mutation endpoint with operationId: "createUser" becomes useCreateUserMutation:

const createUser = useCreateUserMutation();
createUser.mutate({ body: { name: "Sam", email: "sam@example.com" } });
updateUser.mutate({ params: { userId: "42" }, body: { name: "Updated" } });

Query options export

With "exportQueryOptions": true, every query file additionally exports a queryOptions factory. It is a plain function (not a hook) taking your Axios instance, so it also works outside React — router loaders, prefetching, queryClient.ensureQueryData:

import { getUserQueryOptions } from "./generated/api-client";

// In a route loader
await queryClient.ensureQueryData(getUserQueryOptions(api, { params: { userId: "42" } }));

// With useQuery / useQueries inside a component
const { data } = useQuery(getUserQueryOptions(api, { params: { userId: "42" } }));

The factory is named after the operationId with the first letter lowercased plus a QueryOptions suffix (GetUsergetUserQueryOptions). The generated suspense hook consumes the same factory internally, so query keys and fetch logic stay in one place.

Troubleshooting

  • Configuration file not found: ensure routeink.json exists in the current directory.
  • Invalid configuration: check config keys and value types.
  • Missing generated endpoint: verify file naming, Fastify instance name (fastify), and operationId presence.
  • Missing schema imports in output: ensure route schemas reference symbols imported from schemaPackage.

Prisma generator: schema.prisma → Zod schemas

Generates one Zod schema file per Prisma model and enum, plus barrel re-exports. Designed for monorepos where Prisma lives in one package and the Zod schemas are consumed from another.

Setup

// schema.prisma
generator zod {
  provider = "route-ink-prisma-generator"
  output   = "./generated"

  modelOutputDir = "../../../schemas/src/zod/models"
  enumOutputDir  = "../../../schemas/src/zod/enums"
}

Then run prisma generate. The output field is required by Prisma but is only used as the anchor for resolving modelOutputDir and enumOutputDir — nothing is written to it. Your Prisma package stays clean.

What it generates

For each model: a Zod object schema, a scalar-fields enum, and a derived TypeScript type. Only scalar and enum fields are emitted — object relations (@relation) are skipped. Foreign-key scalars (authorId, etc.) are still included since they are scalar fields on the model.

For each enum: a Zod enum schema and a derived type.

Plus a barrel index.ts in each output directory and — when models and enums live in different directories — a top-level barrel at their common ancestor.

Example output for model User { ... role: Role } and enum Role { ... }:

// user.model.ts
import { z } from "zod/v4";
import { RoleSchema } from "../enums/index.js";

export const UserSchema = z.object({
  id: z.string(),
  email: z.string(),
  name: z.string().nullable(),
  role: RoleSchema,
  createdAt: z.coerce.date(),
});

export const UserScalarFieldsSchema = z.enum([
  "id",
  "email",
  "name",
  "role",
  "createdAt",
]);

export type UserType = z.output<typeof UserSchema>;
// role.enum.ts
import { z } from "zod/v4";

export const RoleSchema = z.enum(["ADMIN", "USER", "MODERATOR"]);

export type RoleType = z.output<typeof RoleSchema>;

Config reference

All options go in the generator block in schema.prisma. All are optional unless noted.

Option Type Default Description
output string (required by Prisma) Anchor for resolving relative modelOutputDir / enumOutputDir. Not written to.
modelOutputDir string . Where model files go. Relative to output, or absolute.
enumOutputDir string . Where enum files go. Relative to output, or absolute.
modelFileNamingStyle string [model-kebab].model.ts File naming pattern for models.
enumFileNamingStyle string [enum-kebab].enum.ts File naming pattern for enums.
modelSchemaNaming string [Model]Schema Exported Zod schema variable name.
enumSchemaNaming string [Enum]Schema Exported Zod enum schema variable name.
modelTypeNaming string [Model]Type Exported TypeScript type alias name.
enumTypeNaming string [Enum]Type Exported TypeScript type alias name.
nullStrategy "null" | "nullish" "null" Whether optional Prisma fields use .nullable() or .nullish().
bigIntStrategy "string" | "bigint" "string" How to map BigInt.
bytesStrategy "string" | "uint8array" "string" How to map Bytes.
importStyle "esm" | "cjs" "esm" ESM appends .js to relative imports; CJS does not.
topLevelBarrel boolean true When model and enum dirs differ, emit a barrel at their common ancestor.

Naming pattern tokens

*FileNamingStyle, *SchemaNaming, and *TypeNaming options support these tokens. Inputs come from the Prisma model/enum name (always PascalCase).

Token Casing Example: UserStatus
[Model] / [Enum] PascalCase UserStatus
[model] / [enum] camelCase userStatus
[MODEL] / [ENUM] UPPER_SNAKE_CASE USER_STATUS
[model-kebab] / [enum-kebab] kebab-case user-status

Acronyms are preserved as units (e.g. HTTPLoghttpLog / HTTP_LOG / http-log).

Prisma scalar → Zod mapping

Prisma Zod Notes
String z.string()
Int z.number().int()
Float z.number()
Boolean z.boolean()
DateTime z.coerce.date() Always coerced.
Json z.any()
Decimal z.string()
BigInt z.string() or z.bigint() Per bigIntStrategy.
Bytes z.string() or z.instanceof(Uint8Array) Per bytesStrategy.
String[] z.array(z.string()) List fields wrap in z.array(...).
String? z.string().nullable() Optional fields per nullStrategy.
Enum field The configured enum schema name e.g. RoleSchema.

Output structure

With defaults (modelOutputDir = ".", enumOutputDir = "."), files live side-by-side and share one barrel:

<output>/
  index.ts          # combined: all models + enums
  user.model.ts
  role.enum.ts

With separate model and enum directories (typical monorepo case):

packages/schemas/src/zod/
  index.ts          # top-level barrel (when topLevelBarrel = true)
  models/
    index.ts
    user.model.ts
  enums/
    index.ts
    role.enum.ts

Consumers can then import from any level:

import { UserSchema, RoleSchema } from "@workspace/schemas/zod";        // top-level
import { UserSchema } from "@workspace/schemas/zod/models";             // models only
import { RoleSchema } from "@workspace/schemas/zod/enums";              // enums only

CI/CD

@codevuk/route-ink must be installed (and authenticated against GitHub Packages) before prisma generate runs. Typical GitHub Actions workflow:

permissions:
  packages: read

jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          registry-url: https://npm.pkg.github.com
      - run: pnpm install
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - run: pnpm --filter db exec prisma generate

The default GITHUB_TOKEN carries read:packages permission when permissions.packages: read is set in the workflow.

With Turborepo, declare the install dependency so prisma generate runs after all installs:

// turbo.json
{
  "tasks": {
    "db#prisma:generate": { "dependsOn": ["^install"] }
  }
}

No special handling is required for multi-file Prisma schemas (prismaSchemaFolder) — Prisma merges them into one DMMF before the generator sees it.


Cube.js base generator: schema.prisma → generated Cube YAML

Generates hidden Prisma-backed Cube.js base cubes from Prisma's resolved DMMF. Use that generated layer with manual public cubes that extends the base cube, so custom measures such as revenue_ex_gst, avg_order_value, or cohort metrics stay human-owned.

The generator does not read or patch hand-authored Cube YAML files. Manual cubes are only for query-facing choices Prisma cannot know: custom measures, custom dimensions, public exposure, and Cube-specific modelling.

Setup

// schema.prisma
generator cubeSchema {
  provider = "route-ink-cube-sync-generator"
  output   = "./generated"

  // This directory is fully owned by route-ink.
  generatedCubeModelDir = "../../apps/cube-core/model/generated/cubes"
}

Then run prisma generate. The output field is required by Prisma but is not written to. generatedCubeModelDir is resolved relative to schema.prisma unless it is an absolute path.

Generated base cubes

Route Ink writes one hidden base cube per Prisma model:

apps/cube-core/model/generated/cubes/
  order_base.yml
  customer_base.yml
  subscription_base.yml

Generated base cubes are deliberately mechanical:

  • sql_table from Prisma's resolved table name
  • cube description and meta.ai_context from Prisma model documentation
  • scalar and enum dimensions from Prisma fields
  • member-level public: true for generated dimensions, unless a Prisma annotation overrides it
  • enum meta.enum values
  • basic relation joins when there is a single unambiguous cube name
  • generated meta.relationships for those relation joins
  • a default count measure
  • public: false

Then keep the query-facing cube manual:

cubes:
  - name: order
    extends: order_base
    public: true

    dimensions:
      - name: quarter_number
        sql: "({CUBE}.week_number - 538) / 13 + 43"
        type: number

    measures:
      - name: revenue_ex_gst
        sql: "({CUBE}.total_price_paid + {CUBE}.discount_amount) / 1.15"
        type: sum

Because the base cube is hidden, the manual cube should set public: true explicitly. Generated dimensions set member-level public: true so they remain visible when inherited by a public manual cube. Annotate sensitive Prisma fields before moving a manual cube to extends.

Generated files are compared against the Prisma schema and rewritten when out of date. Route Ink does not delete stale generated files yet; remove obsolete generated files manually after table renames or drops.

Prisma /// annotations for generated base cubes

Generated base cubes can read Cube-specific annotations from Prisma model and field documentation. Model-level annotations become generated cube metadata. Plain field /// comments become generated dimension descriptions; field-level @cube.* lines control mechanical dimension output.

Model-level annotations:

/// @cube.description One row per customer.
/// @cube.ai_context Use this cube for customer base, acquisition, status, and lifetime box counts.
model Customer {
}

Field-level annotations:

/// Customer email snapshot. Direct customer PII.
/// @cube.visibility pii_export
customerEmail String? @map("customer_email")

/// @cube.name executed_sql
/// @cube.description Raw SQL generated by the MCP tool call.
/// @cube.ai_context Internal diagnostic payload. Ignore for business queries.
sql String?
Annotation Description
@cube.name <name> Override the generated dimension name. Useful for reserved member names such as sql.
@cube.description <text> Override the generated dimension description, or set generated cube description when used on a model.
@cube.ai_context <text> Add meta.ai_context to the generated dimension, or generated cube when used on a model.
@cube.public true Mark the generated dimension public.
@cube.public false Hide the generated dimension.
@cube.visibility pii_export Emit the PII export visibility expression.

Running

Run Prisma generate:

pnpm --filter db exec prisma generate

The Cube generator rewrites out-of-date generated base cubes. It does not modify manual public cubes.

Config reference

Option Type Default Description
output string (required by Prisma) Required generator output placeholder. Not written to.
generatedCubeModelDir string (required) Owned directory where hidden generated base cubes are written. Relative to schema.prisma, or absolute.
generatedCubeNameSuffix string _base Suffix for generated base cube names and file names.
generatedCubeSqlSchema string public SQL schema prefix used in generated sql_table values.

Troubleshooting

  • Missing enum metadata after generate: ensure the field is an enum in Prisma and the generated base cube is imported by Cube.
  • Missing relationship metadata after generate: ensure the relation is represented unambiguously in Prisma. Multiple relations to the same target table are skipped and should be modelled manually with alias cubes.
  • Stale generated files after table renames or drops: remove obsolete generated files manually; Route Ink rewrites current model files but does not delete old files yet.

Development

pnpm install
pnpm build              # builds dist/
pnpm dev                # watch mode
pnpm test               # vitest

To test against a real consuming project without publishing:

pnpm link --global      # from this repo

The route-ink-prisma-generator bin is then available globally. Unlink when done:

pnpm unlink --global route-ink

Commands

route-ink generate      # generate Fastify → TanStack hooks
route-ink --help

prisma generate         # runs configured Prisma generators

About

CLI utility for mapping fastify routes into a fully typesafe api client.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages