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
diff takes two file paths, not git refs, so pull the base
branch’s copy of the schema out to a temp file first:
--json for machine-readable output:
Classification
Models, fields, procedures, and enums are matched by name only — the same rename-agnostic philosophycratestack 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
@@pagedto a model, or removing it — flips.list()’s response envelope betweenT[]andPage<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 throwArgumentError, the Rust ones fail to deserialize), so ship regenerated clients first. The reachability check is conservative: a@server_onlyfield 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
@@pagedand@@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_deletechanges 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.fieldsby 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. typedeclarations are not diffed directly either; a field change on atypeis 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.
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.
Read Next
- Migrations — the DB-facing counterpart:
cratestack migrate diffdiffs.cstackagainst a committed snapshot to generate SQL, using the same by-name matching philosophy. - Adopting an Existing Database —
cratestack migrate baseline, for the case where the committed snapshot doesn’t exist yet because the database predatescratestack. - Installing the CLI — get
cratestackon your machine.