Cross-tier codegen
Zigapagos generates typed TypeScript API clients so islands and the SPA can’t drift from the ZigBase backend. A drift gate catches mismatches before they reach production.
There are two codegen modes, selected by contract/codegen.config.json:
| Mode | Source of truth | Typed client | Drift gate |
|---|---|---|---|
openapi (default) | a hand-authored contract/zigbase.openapi.json | emitted by apigen.ts into contract/generated/ | regen + git diff |
zigbase | the backend’s own zig build gen-client output | that output, vendored into the app tree | regen from backend + diff |
openapi is the bootstrap mode for a backend with no schema emitter; the bulk of this document (contract authoring, apigen.ts, the _assert.ts tripwire) describes it. When the config is absent the mode is openapi, so nothing below changes for an existing consumer. A ZigBase backend already emits a fully-typed client from its comptime config, so zigbase mode adopts that directly and retires the parallel OpenAPI doc — see ZigBase-native mode below.
Contract authoring
File: contract/zigbase.openapi.json
The contract is an OpenAPI 3.0.3 document. The emitter (apigen.ts) supports a strict subset; anything outside that subset throws loudly at codegen time.
Supported OpenAPI / JSON Schema subset
| Construct | TS output |
|---|---|
type: "string" | string |
type: "number" / "integer" | number |
type: "boolean" | boolean |
type: "array", items: S | T[] |
type: "object", additionalProperties: S (no declared properties) | Record<string, T> |
type: "object", properties: {...} (with or without additionalProperties) | export interface Name { ... } |
type: "string", enum: [...] | "a" | "b" | ... (string-literal union) |
$ref: "#/components/schemas/Name" | Name (the referenced interface) |
Unsupported constructs (loud-fail)
oneOf/allOf/anyOf— throws at codegen time.$refto a target other than#/components/schemas/<Name>— throws.- Nested inline objects (a schema with
type: "object"+propertiesinside another schema’s property) — use a top-level$reffor nested objects. - Unknown or missing
typeon a non-$ref, non-enum schema — throws.
Contract version
Add "x-zigbase-contract-version": "<date>.<rev>" at the document root. This is emitted as export const CONTRACT_VERSION = "..." in types.ts.
The code generator
Script: runtime/scripts/apigen.ts
bun runtime/scripts/apigen.ts \
--schema <openapi.json> \
--out <output-dir> \
[--validators=post|all|none]
| Flag | Default | Description |
|---|---|---|
--schema <f> | (required) | Path to the OpenAPI JSON file |
--out <dir> | (required) | Directory to write generated files into |
--validators=post | post | Emit loud-fail assertX() shape-checkers for POST response schemas and call them before returning |
--validators=all | — | Same as post; GET validators are out of scope |
--validators=none | — | No runtime shape-checkers; POST wrappers cast and return directly |
Generated files
| File | Contents |
|---|---|
types.ts | export interface/export type for every component schema + CONTRACT_VERSION const |
client.ts | Typed wrappers that call @z/runtime/host; one function per endpoint |
_assert.ts | Compile-time structural-identity check between generated ResolvedState and the runtime type |
The client imports only @z/runtime/host and ./types.ts — never bare @z/runtime or Preact.
Wiring it into your project
Codegen is a Bun script, not a build step — your project needs no build graph and no Zig toolchain to run it. Two commands, which belong in your project’s own scripts:
Regenerate
Run apigen from the project root after editing contract/zigbase.openapi.json:
bun runtime/scripts/apigen.ts \
--schema contract/zigbase.openapi.json \
--out contract/generated
Commit contract/generated/. The generated client is checked in on purpose: it is what the drift gate below compares against.
The drift gate
Rerun apigen, then fail if the committed output no longer matches a fresh regeneration:
bun runtime/scripts/apigen.ts \
--schema contract/zigbase.openapi.json \
--out contract/generated
git add contract/generated
git diff --cached --exit-code contract/generated
Run that in CI. It exits non-zero the moment the committed generated directory and the contract disagree, which is the whole point — a stale client that still typechecks is the failure this catches.
In this repository the same two commands are wrapped as
zig build apigenandzig build api-check(seebuild/codegen.zig), because zigapagos’s owncontract/tree is regenerated alongside its other build steps. That wrapper is a contributor convenience and is not something a consumer project has or needs.
The _assert.ts compile tripwire
contract/generated/_assert.ts contains:
import type { ResolvedState as Gen } from "./types.ts";
import type { ResolvedState as Runtime } from "../../runtime/src/flags.ts";
const _a: Gen = {} as Runtime;
const _b: Runtime = {} as Gen;
void _a; void _b;
These two assignments enforce structural bi-directional assignability. If ZigBase changes the ResolvedState envelope (e.g. renames experiments to variants) and apigen regenerates types.ts, then Gen gains variants but Runtime still has experiments. Either _a or _b (or both) becomes a type error and tsc --noEmit fails.
How to run the tripwire:
cd contract && mise exec -- bun x tsc --noEmit -p tsconfig.json
contract/tsconfig.json includes only generated/types.ts and generated/_assert.ts so the check is fast and isolated.
Note:
client.tsimports@z/runtime/hostwhich is a host-side import. Its type-correctness is verified in the consumer project’s owntscpass, not by this in-repo tripwire (which coverstypes.ts+_assert.tsonly).
Drift-catch proof
contract/test/drift.sh proves the gate is not vacuous. It runs three cases:
- Schema drift — mutates
contract/zigbase.openapi.jsonwithout regenerating, then asserts the drift gate exits non-zero. - Type divergence — mutates the schema, regenerates, then asserts
tsc --noEmitexits non-zero (generatedResolvedState⊄ runtimeResolvedState). - Clean state — after reverting all mutations, asserts both gates pass.
The script uses a trap on EXIT to restore the tree even on early failure.
ZigBase-native mode (mode: "zigbase")
In zigbase mode there is no hand-authored OpenAPI doc and no apigen.ts emission. The typed client is the backend’s own zig build gen-client output — a single zbase.gen.ts (header // generated by zigbase — do not edit + // schema-hash: <hash>, importing @zigbase/client and its /typed / /realtime subpaths, exporting typed record interfaces + services) — vendored into the app tree as a committed artifact, exactly like openapi mode’s contract/generated/. So the repo still builds and type-checks without the backend checked out.
Config — contract/codegen.config.json
{
"mode": "zigbase",
"zigbase": {
"genClientCmd": ["zig", "build", "gen-client"],
"genClientCwd": "../backend",
"out": "contract/generated/zbase.gen.ts",
"apiPrefix": "/api",
"producedPath": "clients/typescript/zbase.gen.ts"
}
}
| Field | Meaning |
|---|---|
mode | "openapi" (default when the file is absent) or "zigbase" |
zigbase.genClientCmd | argv of the backend’s gen-client command |
zigbase.genClientCwd | where to run it (cwd-relative or absolute) |
zigbase.out | vendored client destination in the app tree |
zigbase.apiPrefix | API prefix passed to / documented for the generator |
zigbase.producedPath | optional — where the command writes its output, relative to genClientCwd. When set, api-gen copies producedPath → out. When omitted, the command is expected to write out directly. |
producedPath exists because a backend’s gen-client step usually hardcodes its own output path (e.g. golfsim writes clients/typescript/zbase.gen.ts inside the backend tree). We run it there and then vendor the result into the app’s out. A backend that accepts an app-tree --out can omit producedPath.
All paths resolve against the process cwd (your project root, where you run the dispatcher); absolute paths pass through.
The dispatcher — runtime/scripts/apiclient.ts
bun runtime/scripts/apiclient.ts <gen|check> [--config <path>]
gen— runsgenClientCmdingenClientCwd, then vendorsproducedPath → out. Requires the backend present (you can’t refresh from an absent backend — that’s a hard error, not the check-time fallback).check— the drift gate. Re-runsgenClientCmdand diffs the fresh output against the committedout. A backend comptime-config change flips theschema-hashand produces a different client, socheckexits non-zero and renders the diff. The check is non-destructive: when the command writesoutdirectly, the committed bytes are saved and restored around the regeneration.
Wiring it up
The dispatcher reads mode from contract/codegen.config.json itself, so the same two commands cover both modes — put them wherever your project keeps its scripts:
bun runtime/scripts/apiclient.ts gen # refresh the vendored client from the backend
bun runtime/scripts/apiclient.ts check # fail if the vendored client drifts
In openapi mode (the default, and what you get when the config file is absent) these are equivalent to the apigen commands earlier in this document.
Trust model
- The config paths are trusted.
genClientCwd,producedPath, andoutare developer-authored and committed (contract/codegen.config.jsonin the app repo) — not attacker-controlled input. The dispatcher spawnsgenClientCmdvia argv (no shell), but it does run a configured command, so treat the config like any other build script. - The gen-client command must be authoritative and deterministic. The drift gate compares the command’s fresh output against the committed client, so it is only as strong as the command actually regenerating. A cached / no-op / skipped gen-client that leaves a stale
producedPathon disk would let the gate false-pass. PointgenClientCmdat a build step that always regenerates from the backend’s current source (ZigBase’sgen-clientre-emits every run).
Backend-absent fallback (cross-repo)
zigbase mode’s full drift gate needs the backend reachable at genClientCwd. Across independently-released repos the backend often isn’t checked out (CI on the frontend repo, a fresh clone, …). When genClientCwd is missing, check does not silently pass — it falls back to a weaker but loud presence/pin gate:
- committed
outmissing or empty → hard FAIL (the vendored client must be committed so the repo builds without the backend); - committed
outpresent + non-empty → PASS with aWARNINGlog that names the absent backend path, records the committedschema-hash, and states that drift was not verified.
So a missing backend degrades coverage visibly, never invisibly.
Import allowlist (composition with the island import config)
The vendored client imports @zigbase/client (+ /typed, /realtime). That is data/fetch logic — no Preact — so it does not go through the island one-Preact bundler. The consumer allowlists the scope via the island import config z-runtime.config.json:
{ "islandImports": { "firstParty": ["@zigbase/client"] } }
(npmCompat is for react-aliased packages; @zigbase/client is first-party data code, so firstParty is the right list.)
Payoff
For a converted consumer this deletes the hand-maintained OpenAPI doc (contract/zigbase.openapi.json) and the emitted contract/generated/{types, client,_assert}.ts (~1,000 lines) — the backend’s own typed client is the single source of truth, and a backend route/field change fails api-check instead of silently lagging a parallel hand-written contract.
Proven against ZigBase golfsim
The gate is validated against ZigBase’s public examples/golfsim (it ships a real zig build gen-client + committed zbase.gen.ts):
api-genvendors golfsim’s 903-linezbase.gen.ts(schema-hasha69ad908…) into a scratch app tree;api-check→ PASS.- Renaming a golfsim collection field (
rating → stars) flips the schema-hash (a69ad908… → a8f0f702…);api-checkre-runs the backend, sees the fresh client differ from the committed one, renders therating: number → stars: numberdiff, and exits non-zero. - With the backend path pointed at a non-existent dir,
api-checklogs theWARNINGpresence gate and passes on a present committed client, and hard- fails when it’s missing.