Schema Diff

cratestack diff old.cstack new.cstack structurally compares two schema versions and classifies every change by its effect on the generated wire contract — REST/RPC responses and client-constructed payloads — rather than by raw text differences. It exists because that effect is easy to miss by eye. Adding @@paged to a model, for example, looks like a one-line change in the schema but silently turns every generated client’s Model.list() return type from Model[] into Page<Model> — a breaking change for every existing consumer, only visible today by diffing generated TypeScript/Dart output before and after.

Usage

The process exits non-zero whenever at least one breaking change is present, so the same invocation works as a CI gate on schema pull requests. diff takes two file paths, not git refs, so pull the base branch’s copy of the schema out to a temp file first:
A failing step here means the PR introduces a breaking wire-contract change — reviewers see it before generation ever runs, in both languages. Add --json for machine-readable output:

Classification

Models, fields, and procedures are matched by name only — the same rename-agnostic philosophy cratestack migrate diff uses for DB schema diffing. A rename shows up as a removal plus an addition, not as a rename; there is no heuristic rename detection to avoid false positives. Each change lands in one of three buckets:

Breaking

  • Removing a model, field, or procedure.
  • Adding @@paged to a model, or removing it — flips .list()’s response envelope between T[] and Page<T>.
  • Adding a required field or procedure argument with no default — existing client payloads that omit it are rejected.
  • Retyping a field, argument, or return type.
  • Narrowing arity: optional → required, or introducing/removing a list ([]).
  • A procedure changing kind (querymutation) — the generated dispatch/HTTP method differs.
  • Adding @@internal("action") to a model action — the route, the RPC dispatch arm and the generated client method all disappear, so a PR that suppresses an action with live consumers fails the gate.

Additive

  • Adding a model, an optional field, or a procedure.
  • Adding a required field that carries a @default(...) — the server fills it in, so existing payloads still work. This only applies to model fields: a procedure argument can’t carry @default(...) in the current schema model, so adding a required procedure argument is always Breaking, regardless of any default.
  • Widening arity: required → optional.
  • Removing @@internal("action") — restoring a suppressed action adds a route and a client method back. Additive for what a schema-only diff can observe: it cannot see a consumer’s hand-written replacement handler colliding with the regenerated route at the same path.

Internal-only

  • Model-level attributes other than @@paged and @@internal(...)@@soft_delete, @@audit, @@retain(...), @@emit(...), composite @@id([...]) / @@unique([...]). These affect server-side behavior or database constraints, not the shape of the response the client sees. Each declared @@unique([...]) is tracked as its own constraint — a model carrying several reports each add/remove separately rather than collapsing them into one entry. Each @@internal("action") declaration is keyed independently for the same reason.

Known gaps

This is a first pass scoped to the most common, clear-cut categories — not an exhaustive breaking-change taxonomy. Explicitly out of scope for now:
  • Behavioral (non-shape) breaking changes. @@soft_delete changes which rows .list()/.get() return; that’s a real behavior change for callers, but it doesn’t change the response type, so it’s reported as internal-only today.
  • Views, mixins (as declarations), and enums are not diffed directly. Mixin fields are already inlined into Model.fields by the parser before this tool sees them, so a mixin field change is caught as a field change on every model that uses the mixin — but the mixin declaration itself, and view/enum definitions, aren’t compared.
  • Policy (@@allow/@@deny) changes are not diffed — a policy that goes from permissive to restrictive is an authorization-breaking change this tool doesn’t yet see.
  • Field-level attribute changes other than what feeds the type/arity/default checks above (e.g. @pii, @sensitive, @unique) aren’t classified.
Source of truth: issue #134.
  1. Migrations — the DB-facing counterpart: cratestack migrate diff diffs .cstack against a committed snapshot to generate SQL, using the same by-name matching philosophy.
  2. Adopting an Existing Databasecratestack migrate baseline, for the case where the committed snapshot doesn’t exist yet because the database predates cratestack.
  3. Installing the CLI — get cratestack on your machine.