cratestack-sqlx
SQLx-backed Postgres runtime and delegate primitives for CrateStack models.
Overview
cratestack-sqlx is the server-side database layer. include_server_schema! generates one delegate per model, plus migration helpers, audit DDL, idempotency-store DDL, optimistic-locking support, transaction-isolation helpers, and — since 0.4 — a ViewDelegate per view block. All of it is backed by SQLx + PostgreSQL.
Installation
[]
= "0.4"
= { = "0.8", = false, = [
"runtime-tokio-rustls", "postgres", "chrono", "uuid", "json", "macros",
] }
Most users depend on the cratestack-pg facade instead (renamed to cratestack via Cargo's package = field), which re-exports the entire surface.
View delegates
view blocks (ADR-0003) get a ViewDelegate<'_, V, PK> exposed at runtime.views().<view_snake>(). The delegate is read-only at the type level — ViewDescriptor doesn't implement the WriteSource trait that powers CreateRecord / UpdateRecord / DeleteRecord, so the bound on those builders simply doesn't hold.
let db = builder.build;
// Same FindMany / FindUnique builders models use — `ReadSource` makes
// them polymorphic across `ModelDescriptor` and `ViewDescriptor`.
let rows = db.views.active_customer.find_many.run.await?;
// Materialized views also expose `refresh()` →
// `REFRESH MATERIALIZED VIEW CONCURRENTLY <name>`.
db.views.account_balance.refresh.await?;
Views declared @@no_unique get a separate ViewDelegateNoUnique<V> that omits find_unique and refresh() at the type level.
Delegate Usage
Generated by include_server_schema!. The unscoped delegate takes &ctx on .run; the scoped variant (db.bind_context(ctx).user()) captures the context once and drops the trailing argument.
use ;
use ;
include_server_schema!;
let pool = connect.await?;
let db = builder.build;
let ctx = anonymous;
// find_unique → Option<M>
let user = db.user.find_unique.run.await?;
// find_many with filters and ordering
let posts = db
.post
.find_many
.where_expr
.order_by
.limit
.run
.await?;
// Create
let created = db.user.create.run.await?;
// Update (with optimistic locking via `if_match`)
let updated = db
.user
.update
.set
.if_match
.run
.await?;
// Delete
db.user.delete.run.await?;
Transactions Under an Isolation Level
The crate exposes run_in_isolated_tx and run_in_isolated_tx_with_retries for procedures that need explicit isolation. Both transparently retry on PostgreSQL SQLSTATE 40001 (serialization_failure) and 40P01 (deadlock_detected), including failures detected at COMMIT time. Only database errors are retried: an error the body builds itself is returned on the first attempt, even when its text mentions 40001. A database error that carries a SQLSTATE is retried by that SQLSTATE alone, never by its text, so a RAISE EXCEPTION or a failed cast that echoes 40001 from request data is not retried either.
use ;
let result = run_in_isolated_tx.await?;
These are the hand-rolled form, for code that owns a pool. A procedure that declares @isolation("serializable" | "repeatable_read" | "read_committed") in the schema does not need them: its generated dispatch — REST, RPC (including /rpc/batch), MCP tools/call and <procedure>::invoke_with_db — runs the procedure's authorization and body in one transaction begun at that level, retries on 40001/40P01 (3 retries by default, CratestackBuilder::with_isolation_max_retries(n)), and answers 409 with code TRANSACTION_ABORTED (RPC aborted) when the retries run out; nothing was committed, and an idempotency layer does not record that response. One that a caller propagates out of its own body is answered as 500 INTERNAL_ERROR instead, and recorded as usual: that caller may have written something of its own first. An @isolation procedure invoked from inside another's transaction joins it as a savepoint instead of committing on its own, one at a time, and is refused if it declares a stricter level. The procedure's ProcedureRegistry method receives an IsolatedCratestack whose every operation runs in that transaction; it has no pool(). The body must be safe to run more than once. AuditSink fan-out and the @@emit outbox drain happen once, after the committed attempt. See docs/design/procedure-isolation.md.
In 0.14.0 and earlier (GHSA-r67q-4qqq-g9gm) the attribute was validated and then ignored: declared-serializable procedures ran at the server default, READ COMMITTED.
Audit Log
Models with @@audit write before/after snapshots into a cratestack_audit table inside the same transaction as the mutation. AUDIT_TABLE_DDL is exported for migration tooling. @pii and @sensitive columns are redacted in the persisted snapshots.
Idempotency
SqlxIdempotencyStore::new(pool) implements the IdempotencyStore trait from cratestack-axum::idempotency. Use it with IdempotencyLayer. The expiry_from(created_at, ttl) helper computes the deadline a record should be evicted at.
Migrations
The crate exports Migration, MigrationState, MigrationStatus, MIGRATIONS_TABLE_DDL, apply_pending, ensure_migrations_table, and status for working with a cratestack_migrations table.
Decimal Backend
cratestack-sqlx follows the workspace decimal-rust-decimal / decimal-bigdecimal feature flags; generated columns of type Decimal use the selected backend.
See Also
- Transaction Isolation guide
- Audit Log guide
- Optimistic Locking guide
- Migrations guide
cratestack-sql— shared SQL primitivescratestack-rusqlite— SQLite backend (sync, on-device)
License
MIT