Pinned Inputs
Commit two small JSON files as a lockfile for your API contract. Gitignore everything generated. Let CI do the updating.
On a full-stack team, the worst place for committed types to come from is whichever backend a dev happened to be running. Pinned inputs (v3) makes that structurally impossible: the repo commits the inputs — openapi.json and chowbea.bus.json — and gitignores every generated file. Think package-lock.json, but for your API contract.
The model
| Path | In git? | Written by |
|---|---|---|
openapi.json (pinned spec) | ✅ | sync only |
chowbea.bus.json (pinned manifest) | ✅ | sync only |
api.config.toml | ✅ | you |
api.config.local.toml | ❌ gitignored | each dev, optional |
src/api/_generated/** | ❌ gitignored | generate / fetch |
src/api/_internal/** | ❌ gitignored | fetch |
Your hand-editable client files (api.client.ts, instance, error helpers) stay committed exactly as before.
Every build — dev machine or CI — runs generate offline from the two pins. Checking out any commit reproduces the exact types it was reviewed against. No endpoint needs to be up; no PR build can be skewed by a mid-deploy backend.
Turning it on
Pinned mode is the presence of two config keys, not a flag:
api_endpoint = "https://staging.example.com/openapi.json" # stable endpoint — sync's source
spec_file = "openapi.json" # pinned spec, committed
[bus]
endpoint = "https://staging.example.com/.well-known/chowbea.json"
file = "chowbea.bus.json" # pinned manifest, committedNew project? init --pinned scaffolds all of it — config, gitignore entries, both CI workflows, and a first sync. Existing project? See Migration.
Three commands, three jobs
generate— offline. Pins →_generated/. What CI runs on every PR. Never touches the network.fetch [--endpoint URL]— the dev loop. Pulls from a live backend (localhost, a tunnel, anywhere) into gitignored paths only. Never writes the pins, so nothing you generate locally can reach a commit.sync— the only pin writer. Fetches from the committed stable endpoint, validates everything, writes the pins only when their content changed, regenerates. CI runs it when the backend deploys.
After a localhost fetch, a bare generate snaps _generated/ back to the pinned types. That's deliberate — pins always win for generate, no freshness guessing. Want live types again? Run fetch again.
Pointing your workspace anywhere
Ports differ. Tunnels differ per dev. Don't put your URL in the committed config — overlay it:
api_endpoint = "https://my-tunnel.ngrok.app/openapi.json"
[bus]
endpoint = "https://my-tunnel.ngrok.app/.well-known/chowbea.json"Field-level merge, local wins, nested tables merge per key — your [bus].endpoint override keeps the committed [bus].file. fetch, generate, and watch apply it (and log which keys are overridden). sync deliberately ignores it — pins come from the committed truth only, so a dev running sync by hand can never pin their tunnel's contract into git.
[fetch.headers] and [fetch.auth] overlay the same way, so a tunnel that needs its own auth works too. One rule: Basic Auth is refused over plain http:// to anything that isn't loopback — use an https tunnel.
A day in the life
- Backend + frontend running locally →
chowbea-axios fetch(with your local overlay, zero flags). Editor sees your in-progress types. - Push your branch → the commit contains app code only. PR CI regenerates from the pins and typechecks — offline, reproducible.
- Backend merges and deploys → its pipeline rings the doorbell → your repo gets a
chowbea/syncPR with the contract diff (bus: + Grade,~ Plan), typechecked against your whole app before anyone merges it. - No backend change? No PR. The pins are byte-stable, so a no-op sync produces literally nothing.
Never hand-edit the pins. The manifest is hash-validated end to end — a tampered chowbea.bus.json fails generate on the spot, which is exactly what you want from a trust boundary.
Next steps
- CI/CD setup — the two workflows, the doorbell, the tokens.
- Migration — move an existing repo over in one commit.
syncreference.