ADR 0003: SQL Views as Projections of Models

Status

Accepted

Date

  • Proposed: 2026-05-14
  • Accepted: 2026-05-18

Implementation

Shipped end-to-end across eight PRs on cratestack/cratestack: Two notes on the shipped implementation that differ from the original proposal:
  • Body changes emit Drop + Create, not CREATE OR REPLACE VIEW. Codex flagged that a ReplaceView op at the tail of the migration would leave the old view alive when the same migration also dropped a column the old body referenced (Postgres rejects the column drop in that case). The diff engine now models body changes as two ops — drop in the pre-column-drops bucket, create in the post-column-adds bucket — losing the atomicity of Postgres CREATE OR REPLACE VIEW but gaining ordering correctness when column ops overlap with view body changes. Within a Postgres migration transaction other connections never observe the transient missing-view state, so the atomicity loss has no externally visible effect. The ReplaceView IR variant is preserved for hand-constructed callers.
  • @@no_unique produces a separate ViewDelegateNoUnique<V> type rather than just omitting find_unique from a single ViewDelegate<V, PK>. This enforces the gate at the type level — runtime.views().<v>().find_unique(()) on a no-unique view is a compile error rather than a runtime “WHERE = $1” footgun.

Context

CrateStack’s primary developer surface is .cstack schema files that declare model blocks. Models map 1:1 to underlying SQL tables, get typed structs, and get a ModelDelegate with find_many / find_unique / insert / update / delete. A recurring need is to expose denormalized or computed read shapes built from one or more models — account balances aggregated from transfers, customer summaries joining orders, dashboard panels filtered by status. Today these have to be hand-rolled outside the schema: raw sqlx::query_as!, manual struct definitions, manual policy enforcement. That loses every property CrateStack exists to provide: typed deserialization driven by schema, declarative authorization, generated clients, audit hooks. The framework needs a first-class view concept that is a projection of existing models — read-only, SQL-defined, typed end-to-end, governed by the same @@allow policy machinery as models. Three constraints shape the design:
  1. The macro split is strict (ADR 0001 0.3.0 update) — server emit must reference sqlx only, embedded emit must reference rusqlite only, no cross-backend code.
  2. SQL dialect portability is a lie. Postgres and SQLite agree on CREATE VIEW … AS SELECT syntax but diverge on aggregate casts, window function support, JSON functions, materialized views, and concurrent refresh. A single SQL string cannot drive both backends in general.
  3. Banking-grade auditability (banking readiness) — view definitions must be reviewable as SQL, materialization behavior must not silently degrade between environments.

Decision

CrateStack will add a view block to .cstack that declares a read-only, SQL-defined projection over one or more existing model blocks. Views are emitted per-backend with explicitly distinct SQL bodies, and a server-only @@materialized variant is supported with manual refresh semantics.

Schema surface

Rules:
  • view <Name> from <Model>, <Model>, … declares the source-model dependency list. It is not parsed from the SQL — it is declared explicitly so the parser can validate model names, build a migration ordering graph, and so LSP cross-references work without an SQL parser.
  • Every field must either carry @from(Model.field) (to bind the column to its typed source) or be computed, in which case the user declares the Rust type and the macro trusts the SQL.
  • @id is required on exactly one field. Views without a natural unique key opt out with @@no_unique, which drops find_unique from the generated delegate.
  • @@server_sql("…") and @@embedded_sql("…") are the per-backend SQL bodies. At least one must be present. If only one is declared, the view is backend-specific — building the other target emits a clear error pointing at the missing attribute span.
  • @@sql("…") is a shorthand that applies to both backends. The macro emits a cargo warning that single-string portability is the developer’s responsibility.
  • @@allow is supported, but only with action "read". Any other action is a parse error.

Delegate split

A new ViewDelegate is added to both cratestack-sqlx and cratestack-rusqlite with read-only methods:
Write methods (insert, update, delete) are not present on ViewDelegate. Read-only-ness is enforced at the type level, not by runtime check. The existing FindMany / FindUnique query builders are reused via a shared ReadSource trait:
Both Model and View descriptors implement ReadSource. Only Model descriptors implement the additional WriteSource trait that powers insert / update / delete. Views literally cannot be passed to write builders.

Materialized views

@@materialized is server-only. Building a schema that contains a @@materialized view with the embedded backend enabled is a hard compile error that points at the attribute span and references this ADR:
No silent fallback to a regular view. The read latency and consistency contracts of a materialized view differ enough from a regular view that degrading behavior on one backend would be a footgun, especially for banking workloads. Materialization is opt-in and additional, not a replacement. A @@materialized view still requires @@server_sql; the @@materialized attribute changes the DDL emitted (CREATE MATERIALIZED VIEW + CREATE UNIQUE INDEX on the @id column) and adds a refresh() method to the generated delegate. Refresh is manual. No scheduler, no event-driven refresh, no time-based refresh in this ADR. The developer calls:
This emits REFRESH MATERIALIZED VIEW CONCURRENTLY <name>. Concurrent refresh requires a unique index, which is why @@materialized + @@no_unique is a parse-time error: without a unique index, the only available refresh is a non-concurrent one that takes ACCESS EXCLUSIVE and blocks all readers for the duration of the rebuild. CrateStack will not generate that on the developer’s behalf. See the Materialized views guide for refresh trigger patterns the developer is expected to implement.

Migration emission

The macro emits, alongside the model table DDL:
  • CREATE VIEW <name> AS <server_sql> for sqlx builds
  • CREATE VIEW <name> AS <embedded_sql> for rusqlite builds
  • For @@materialized: CREATE MATERIALIZED VIEW <name> AS <server_sql> and CREATE UNIQUE INDEX <name>_pkey ON <name> (<id_column>)
View DDL is ordered after its source-model DDL via the from M, N dependency list. The diff engine described in ADR 0004 treats views as their own IR ops (CreateView, ReplaceView, CreateMaterializedView).

Consequences

Positive

  • Read-shape definitions live next to model definitions, governed by the same review process.
  • @@allow policies apply uniformly to views — no parallel authorization story.
  • Type safety end-to-end: declared columns match Rust struct fields, source-field references catch typos at parse time.
  • Macro split stays disjoint — server emit never references rusqlite, embedded emit never references sqlx.
  • Materialized views remain explicit and opt-in; their consistency contract is honest about the dialect divide.

Negative

  • Two SQL bodies for cross-backend views is more typing than a single string. The shorthand @@sql("…") exists for cases where the developer is genuinely confident, but it ships with a warning.
  • The view’s SQL body is not statically validated against the source models’ columns. A typo in the SQL is a runtime error at view-creation time (CI verification — see ADR 0004 — catches this before production).
  • @@materialized schemas cannot be shared between embedded and server contexts unmodified. This is intentional, but it does fragment some otherwise-portable schemas.

Deferred

  • Time-based refresh (@@materialized(refresh = "5m")) — defer until CrateStack has a scheduler primitive. Spawning tokio::task from the macro is the wrong layer.
  • Event-driven refresh — the existing ModelEvent stream could drive it, but “refresh on every write” is wrong (thrash) and “refresh on burst end” needs debouncing config the schema can’t easily express. Defer until there’s a real use case.
  • Materialized views on embedded via table+trigger emulation — out of scope. The semantics are different enough that a unified surface would mislead developers.
  1. Views reference — full attribute and syntax reference
  2. Materialized views guide — refresh trigger patterns
  3. ADR 0004: Schema diff and migration generation — how view changes flow through migrations