Skip to content

Generate the TypeScript client at build time, not committed (T3) - #135

Merged
stephane-segning merged 1 commit into
mainfrom
claude/dashboard-t3-client
Aug 8, 2026
Merged

Generate the TypeScript client at build time, not committed (T3)#135
stephane-segning merged 1 commit into
mainfrom
claude/dashboard-t3-client

Conversation

@stephane-segning

@stephane-segning stephane-segning commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Generates packages/sms-client from schema/schema.cstack with cratestack generate-typescript (T3), and adds a CI drift gate over it. Reworked after this PR's first commit landed the generated client as tracked files — the owner's standing rule is that auto-generated code is never committed to version control, so this now generates the client at build/CI time instead. The first commit (2562529) is left in history rather than squashed; the second commit (c9ca85f) is the actual shape this PR ships.

Intent

Source of truth: the T3 task brief in the dashboard architecture plan (DECISIONS §2, "Depend on packages/sms-client? — YES, generate it"), M4 issue #47 (client drift-detection gate), and a direct owner correction mid-PR: generated code must never be committed.

T3 was previously blocked on cratestack#455/#456 (the Decimal scalar never emitting a TS type — vsms uses it on three money fields), fixed and released in cratestack 0.7.8. This PR generates with that release (a locally built ~/dev/cratestack binary for local verification; CI installs the published 0.7.8 from crates.io — see below).

Scope

  • packages/sms-client/ — generated by cratestack generate-typescript, not committed. .gitignore now reads packages/sms-client/* with two explicit exceptions:
    • package.json — tracked deliberately. pnpm needs a package.json on disk to recognise this as a workspace member and validate pnpm install --frozen-lockfile against the committed pnpm-lock.yaml, and that install has to succeed before generation is even possible to run (generation itself needs no node_modules, but everything downstream of install — typecheck, build — does). Its content is exactly what generation writes (the generator's dependency list is schema-invariant boilerplate, not derived from model content) — confirmed byte-identical across repeated regenerations, not a hand-maintained fork.
    • GENERATING.md — a short, hand-written, tracked note. It's the one thing that exists in packages/sms-client/ in a fresh, ungenerated clone (before just client-gen has ever run), specifically so "run this one command first" is discoverable — the real generated README.md that would normally say that doesn't exist yet at that point.
  • ci/assert-sms-client-matches-schema.mjs — deleted. This was "Gate A" (does the committed client match the schema?) from the PR's first commit. With nothing committed, there's nothing to diff against; it would assert nothing, so it's removed rather than kept as decoration.
  • ci/assert-client-routes-match-server.mjs ("Gate B") — kept, and is now the only gate: does every route the freshly generated client calls exist on the pinned server's real route table (sms-gateway routes)? This is what actually catches a cratestack-CLI-vs-pinned-library (=0.6.7) skew, independent of whether anything's committed.
  • justfileclient-gen unchanged in shape; client-check now depends on client-gen (client-check: client-gen) and only runs Gate B.
  • turbo.json — a package-specific task override, "@vsms/sms-client#build": { "inputs": ["src/**", "package.json", "tsconfig.json"] }. See "the caching bug" below — this is not decorative.
  • .github/workflows/ci.yml — new js job (see CI wiring below).

Untouched, same as before: packages/gateway/src/messages.ts (hand-written previewMessage/sendMessage) — this PR is "the client exists, is generated correctly, and stays provably in sync," not "the gateway now uses it."

The pnpm chicken-and-egg problem, and how it's resolved

With the client untracked, pnpm install --frozen-lockfile still has to succeed on a clean checkout, and it does — because package.json (and only that file) stays tracked. I considered the alternative (generate the whole package, including package.json, before pnpm install runs) and rejected it: it would mean pnpm-lock.yaml's existing importer entry for packages/sms-client has nothing to validate against until generation has already run once per checkout, which is a worse ordering constraint than "one boilerplate file stays tracked." Tracking package.json — confirmed to be exactly the generator's own deterministic output, never hand-edited — was the more honest fix. Full reasoning is inline in .gitignore's comment on this package and in packages/sms-client/GENERATING.md.

Verified from a genuinely clean git clone (not rm -rf node_modules, per the instruction to actually test this path — a local clone into a fresh temp directory, matching what CI itself does):

  1. git clone → only package.json and GENERATING.md exist under packages/sms-client/.
  2. pnpm install --frozen-lockfilesucceeds, resolves @vsms/sms-client@0.0.0 as a real workspace member (pnpm -r list --depth -1 shows it), installs its own devDependencies (typescript@5.9.3 via the workspace override, @tanstack/react-query) into packages/sms-client/node_modules.
  3. just client-gen → generates src/, tsconfig.json, README.md.
  4. just client-check → builds sms-gateway, Gate B passes (102/102 routes match).
  5. SKIP_ENV_VALIDATION=1 pnpm turbo run typecheck build8/8 tasks succeed, all cache misses (correct — nothing was cached yet).
  6. pnpm turbo run test3/3 succeed.
  7. pnpm biome ci . → clean, 84 files.
  8. packages/sms-client/node_modules/.bin/tsc -p tsconfig.json --noEmitclean (the exact check that was broken before the upstream Decimal fix).

The caching bug found while wiring this up

Not assumed — found live. Turborepo's default task-hashing only considers git-tracked (or untracked-but-not-gitignored) files. Since packages/sms-client/src is now gitignored, turbo run build served a stale cache hit after mutating generated source content:

@vsms/sms-client:build: cache miss, executing 1ea1a8d52b02baa7   # first build
@vsms/sms-client:build: cache hit, replaying logs 1ea1a8d52b02baa7   # after editing src/index.ts — WRONG, should rebuild

Fixed with an explicit per-package task override in turbo.json:

"@vsms/sms-client#build": {
  "outputs": ["dist/**"],
  "inputs": ["src/**", "package.json", "tsconfig.json"]
}

which forces turbo to hash those globs regardless of .gitignore. Re-verified with a clean three-step sequence: miss on first build → hit on an identical rebuild (proving caching still works when nothing changed) → miss again after mutating generated content (proving the fix). All three behaved correctly.

CI wiring

.github/workflows/ci.yml gains a js job. PR #133 (which independently adds a job with the same name) had not landed on main as of this branch, so there was nothing existing to attach to. The new job carries a comment for whoever merges both: if #133 lands too, keep one js job and fold this PR's two new steps (installing cratestack, generating the client) into it, in the position noted (after install, before typecheck/build).

Job order, and why it's exactly this order:

  1. actions/checkoutactions/setup-nodecorepack enable
  2. pnpm install --frozen-lockfile — succeeds because package.json is tracked (see above).
  3. pnpm biome ci .
  4. dtolnay/rust-toolchain@stable + Swatinem/rust-cache@v2cargo install cratestack-cli --version 0.7.8 --lockedthe published crate from crates.io, never a locally built binary, so this gate proves something on every CI run, not just one developer's machine. >=0.7.8 is required for the Decimal fix.
  5. Generate packages/sms-client (the same two commands just client-gen wraps).
  6. node ci/assert-client-routes-match-server.mjs (Gate B).
  7. pnpm turbo run typecheck build, then pnpm turbo run test.

Could not verify by actually running GitHub Actions — no network access to trigger a live CI run from this environment. Verified every individual piece of this ordering by hand instead (see the clean-clone steps above), using the locally-built 0.7.8 binary as a stand-in for what cargo install cratestack-cli --version 0.7.8 --locked will produce. Two things about the CI-specific step I could not verify directly and are worth a reviewer's second look: (a) whether cargo install cratestack-cli --version 0.7.8 --locked compiles in acceptable CI time with no prior cache (it's a real compile from source, not a prebuilt binary — cargo-binstall would be faster since cratestack-cli's own Cargo.toml ships [package.metadata.binstall] pointing at GitHub Release assets, but I chose the slower, dependency-free cargo install path because I could verify its mechanism — plain crates.io resolution — with higher confidence than binstall's asset-matching from this sandbox); (b) Swatinem/rust-cache@v2's actual speedup for a bare cargo install of a crate outside this repo's own workspace, since its cache-keying is normally driven by this repo's own Cargo.lock.

Verification

Exact generation command:

cratestack generate-typescript --schema schema/schema.cstack \
  --out packages/sms-client --package-name @vsms/sms-client --base-path ''
  • --base-path '' confirmed load-bearing: sms-gateway routes reports 102 routes served at /. Generated README.md says so explicitly.
  • Zero bare Decimal types; the three money fields are string.
  • just client-check (client-gen + Gate B) passes.
  • Gate B proven to fail, twice across both commits on this branch, by pointing a route at a nonexistent path:
    assert-client-routes-match-server: 1 client call(s) name a route the pinned server (sms-gateway, cratestack =0.6.7) does not serve:
    
      GET    /apps-broken-again    (client.ts:223)
    
    Reverted cleanly both times.
  • cargo check --workspace — clean, unaffected (no Rust/schema files touched).
  • Full genuinely-clean-clone run (install → generate → check → typecheck/build → test → biome) — all green, detailed above.

Screenshots/Evidence

N/A — generated TypeScript package, CI config, and gitignore/turbo config. No UI surface in this PR.

Risk Assessment

Low-to-moderate. The main residual risk is the two things flagged above as unverifiable from this sandbox: the CI job's actual runtime behavior (install speed, action references) has only been verified piece-by-piece by hand, never as one continuous GitHub Actions run. Everything else — the untracked-generation model itself, the turbo caching fix, Gate B — was verified against real command execution, including a genuinely clean git clone, not assumed.

AI Usage Declaration

Written and verified by Claude (Sonnet 5). This PR was reworked mid-flight after the owner corrected the original approach (committing the generated client) — the rework is a new commit, not a squash, so both approaches are visible in history.

  • I ran every verification command in this PR body myself, including a genuine git clone into a fresh temp directory (not rm -rf node_modules) to test the install-before-generation ordering for real.
  • I found the turbo caching bug by deliberately mutating gitignored generated content and observing a stale cache hit — not assumed from documentation.
  • I deliberately broke Gate B and captured the real failure output before reverting, twice (once per commit on this branch).
  • I disclose exactly what I could not verify (live CI execution) rather than presenting the CI job as proven.
  • I take responsibility for this being an accurate account of what was built and verified.

Reviewer Focus

  • Whether cargo install cratestack-cli --version 0.7.8 --locked (correct but slow, no caching benefit demonstrated) is the right call for the js job versus cargo-binstall (faster, but its GitHub-Release-asset-matching mechanism is what I could least verify from this sandbox).
  • The .gitignore/tracked-package.json design: confirm the reasoning (pnpm needs a manifest on disk before install, and before generation is even runnable) matches your own understanding of pnpm workspace resolution.
  • The js-job duplication risk with unmerged Add a CI job for the TypeScript half, and correct a stale security comment #133 — the in-file comment is there, but you'll need to actually reconcile it when both land.
  • The turbo.json inputs override — confirm this is the right fix shape versus, say, disabling turbo caching entirely for this one task.

@changeset-bot

changeset-bot Bot commented Aug 8, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 84a6479

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@stephane-segning stephane-segning changed the title Generate the TypeScript client and its two drift gates (T3) Generate the TypeScript client at build time, not committed (T3) Aug 8, 2026
@stephane-segning
stephane-segning force-pushed the claude/dashboard-t3-client branch from c9ca85f to 004ceb0 Compare August 8, 2026 13:12
Auto-generated code should not be in version control, so
`packages/sms-client` is now produced by `just client-gen` rather than
checked in. Two files stay tracked, each for a concrete reason:

- `package.json`, because pnpm needs a manifest on disk to resolve the
  workspace member at all — `pnpm install --frozen-lockfile` has to succeed
  *before* generation can run. Its content is schema-invariant boilerplate
  and byte-identical to what the generator writes, so it is not a hand-forked
  copy that can drift.
- `GENERATING.md`, hand-written, because in a fresh un-generated clone it is
  the only thing telling you to run `just client-gen` first. The generated
  README that would say so does not exist yet at that point.

Gate A (`generate-typescript --check`) is deleted rather than kept: it existed
to prove the *committed* client matched the schema, and with nothing committed
there is no drift to detect. A check that asserts nothing is worse than no
check. Gate B — `ci/assert-client-routes-match-server.mjs`, comparing every
route the client calls against the pinned `sms-gateway routes` output — is now
the load-bearing one and is verified to fail when a route is broken.

CI generates before typechecking, installing the CLI from crates.io
version-locked to the library pin. A client generated by a locally-built
binary is reproducible on exactly one machine, which would defeat the point.

Two real problems found while doing this, neither predictable from reading:

- **Turborepo's default hashing ignores gitignored files.** Once the
  generated sources became untracked, `turbo run build` happily served a
  stale cache after they changed. Fixed with an explicit `inputs` override on
  `@vsms/sms-client#build`, reverified with a miss/hit/miss sequence.
- **The TypeScript override was declared in two places.** T1 put it in
  `.pnpmrc`; this change needs it in `pnpm-workspace.yaml` because the
  generated `package.json` asks for `typescript@^7.0.2` and Next 15.5
  predates TS 7. Both present produced
  `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` on a clean clone — invisible to any
  existing checkout with a warm `node_modules`. Consolidated on
  `pnpm-workspace.yaml`, pnpm 11's documented home for settings.

Extends the existing `js` CI job rather than adding a second one, since
#133 landed that job while this branch was open.

Verified from a real `git clone` into a temp directory, not a wiped
`node_modules`: `pnpm install --frozen-lockfile` succeeds, `just client-gen`
generates, `just client-check` reports all 102 client calls matching 102
served routes, and `turbo run typecheck build` reports 8/8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stephane-segning
stephane-segning force-pushed the claude/dashboard-t3-client branch from 004ceb0 to 84a6479 Compare August 8, 2026 13:17
@stephane-segning
stephane-segning merged commit be29a12 into main Aug 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant