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, procedures, and enums 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 (query ↔ mutation) — 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.
  • (Since 0.17.0.) Removing an enum, or removing a variant from one.
  • (Since 0.17.0.) Adding a variant to an enum that can appear in a response: one reachable from any model, view, procedure result or query result, through fields, types and generic arguments. A client generated from the old schema refuses a value it does not know (the generated Dart decoders throw ArgumentError, the Rust ones fail to deserialize), so ship regenerated clients first. The reachability check is conservative: a @server_only field still counts.

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.
  • (Since 0.17.0.) Adding an enum, or adding a variant to an enum that only appears in requests (procedure arguments and the types they use).

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.
  • (Since 0.17.0.) Reordering an enum’s variants. The wire carries names, so only declaration order changes, and with it the first variant, which is the generated Rust Default.

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 and mixins (as declarations) 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 definitions, aren’t compared. Enums were in this list until 0.17.0; in 0.16.0 and earlier an enum change reports nothing at all.
  • type declarations are not diffed directly either; a field change on a type is invisible.
  • 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.
For signed clients, cratestack contract check is the enforcing gate. diff stays a heuristic report; cratestack contract lock|check|prune (Signed transport) judge each op’s wire contract against the contracts locked for shipped clients with a conservative classifier, and fail when the current schema breaks one. 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 Database — cratestack 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.