All concepts
13 — Concept

Shared contracts

One zod package the API validates with and the UI and CLI parse with — no build step, so the three can never drift.

One package, three consumers, zero build

packages/shared holds the zod schemas that describe the API, and three things consume them: the server validates requests and responses with them, the browser parses responses with them, and the CLI parses the same responses with the same ones. There is exactly one definition of every shape.

It is consumed as TypeScript source rather than as a compiled artefact. The package's exports map points @rocketflare/shared at ./src/index.ts and @rocketflare/shared/* at ./src/*.ts, and every tool in the workspace — Vite, wrangler, tsx, vitest — resolves the .ts file directly through the workspace link.

Why no build step. A dist directory introduces a state where the source and the thing everyone imports disagree, and a class of confusing failures that all resolve to "rebuild the shared package". Removing the artefact removes the staleness: an edit is visible to the API, the UI and the CLI on the next type check, with nothing to run in between.

What lives there

The package is a vocabulary rather than a library. It contains the entity schemas and the request and query schemas for every route, the error envelope, the pagination shape, the permission vocabulary — actions, subjects, the ability type, the packed rules the server ships to the browser — and the AI contracts, which are large enough to sit in their own directory with deep imports.

Naming is conventional so that the reader never has to guess: <thing>Schema for a response or an entity, <thing>RequestSchema for a body, <thing>QuerySchema for query parameters, and an inferred type sitting next to each one.

export const memberSchema = z.object({ /* ... */ })
export type Member = z.infer<typeof memberSchema>

export const inviteMemberRequestSchema = z.object({ /* ... */ })
export type InviteMemberRequest = z.infer<typeof inviteMemberRequestSchema>

Types are inferred from schemas, never declared alongside them. A hand-written interface next to a schema is a second source of truth that compiles happily while diverging.

Contracts first, as a working order

A new or changed API surface starts here rather than arriving here. The sequence is fixed:

  1. Write or change the schema in packages/shared/src/ and export it.
  2. Validate with it in the route, which throws the shared error envelope on a bad request.
  3. Parse with it in the UI, so a response that drifted fails loudly at the boundary.
  4. Parse with it in the CLI, and only then add the command.

Errors are the same envelope everywhere — including validation failures, which is why the route wrapper throws a typed validation error rather than letting the validator emit its own shape. Pagination is one shape everywhere too, so a list endpoint added next year renders in the existing pagination control without a conversation.

The package has no test suite of its own; its schemas are exercised by the web and CLI tests that import them. A schema tested in isolation tells you it parses the fixture you wrote — testing it at the boundary tells you the server and the client agree, which is the property that matters.

The dependency rule, and why it is strict

packages/shared may import zod, its own siblings, and one type-only import for the ability type. That is the entire list. It may never import apps/web, and it may never import apps/cli; the CLI in turn never imports the web application.

Why the direction is enforced. This package has to bundle for the browser and load inside a Node CLI. One import reaching back into the server drags the whole server type graph — and eventually its runtime dependencies — across both boundaries. The symptom is unmistakable and arrives late: a browser bundle that suddenly contains a Postgres driver.

The linter and each package's TypeScript configuration keep the direction honest, so the rule is mechanical rather than a matter of reviewer memory.

The one exception, stated out loud

Database columns holding JSON take their type from a schema in this package, so the shape stored and the shape served cannot diverge. There is a single deliberate exception: the dashboard configuration column takes its type from the analytics library instead, imported type-only on the server side.

The reason is the dependency rule above — shared may only import zod, so it cannot hold that library's type. Its own schema for the column is therefore loose, and the precise type lives where the import is legal. Naming the exception is the point: an undocumented one becomes the precedent for the next.

Private, and staying that way

The package is marked private and has no publish configuration. It is not a library with users; it is the internal agreement between three parts of one repository, and it is meant to be edited freely by whoever forks the kit.

Publishing it would turn every field rename into someone else's breaking change, which is exactly the friction the kit exists to avoid. It travels with your copy of the repository and versions with it.