Composite Keys

Two model-level attributes take a bracketed, ordered list of local field names instead of applying to a single field:
  • @@id([...]) — the model’s primary key spans every listed column.
  • @@unique([...]) — a unique constraint spans every listed column. A model may declare several.
Both share the same syntax and the same field-name rules — at least two fields, no repeats, every name must resolve to a real scalar field on the model (not a relation, not a field carrying @readonly / @server_only on @@id’s case) — but they differ in what they unlock today.
Column order is significant for both: it’s the order columns appear in the emitted constraint, and for @@unique it’s also part of the generated index name (applications_tenant_id_name_environment_key, following the same <table>_<column>_key convention as field-level @unique). Reordering the list is a real schema change — cratestack migrate diff emits a drop-and-recreate, not a no-op.

@@id([...]) — composite primary key

cratestack-migrate emits a real multi-column PRIMARY KEY constraint for it, and cratestack check validates the field list at authoring time. Both backends are covered.

Not usable in a running app yet

include_server_schema! and include_embedded_schema! reject any model declaring @@id([...]) with a compile error — query builders, axum/RPC routing, and all three client generators (cratestack-client-rust / -dart / -typescript) still assume exactly one scalar @id column throughout. A schema using @@id([...]) today is authorable and migratable, but not yet loadable by a server or embedded app. Track issue #136 for status.

@@unique([...]) — composite unique constraint

cratestack-migrate emits CREATE UNIQUE INDEX <table>_<col1>_<col2>_key ON <table> (<col1>, <col2>) for each declared @@unique([...]), on both Postgres and SQLite — the same DDL a hand-written unique index would produce. cratestack check validates it the same way as @@id([...]). Unlike @@id, this one compiles through codegen without complaint — there’s no ORM-level feature depending on it yet, so there’s nothing to reject.

What it enables today

A real, enforced database constraint. This matters beyond integrity: Postgres will only accept INSERT ... ON CONFLICT (a, b, c) DO UPDATE when a unique index over exactly that tuple exists, so a hand-written idempotent upsert targeting a composite key now has something to conflict on.

What it doesn’t do yet

  • No query-builder helper for “look up by this tuple” — that’s still a hand-written WHERE clause.
  • .upsert(...) only targets the primary key. Widening it to accept a @@unique([...]) conflict target is a separate, future addition; this attribute only gets the constraint into the database, not into the generated builder.
  1. Migrations — how cratestack migrate diff turns schema changes into the SQL these constraints compile to
  2. Field Attributes — the single-field @id / @unique these compose with
  3. Upsert — today’s primary-key-only conflict target