Upsert
External integrators replay the same payload — webhook redeliveries, file imports, retry loops after a network drop. The right primitive for “make the row look like this, whether or not it already exists” is upsert, keyed on a stable identifier the producer owns. CrateStack exposes it as.upsert(input)
on every model whose primary key is client-supplied.
When to use it
- Idempotent ingestion — an external producer (payment processor, CSV import, message-queue consumer) sends events with stable IDs that you want to converge to, not duplicate
- Cache rehydration — re-deriving a projection from a source-of-truth stream where each event already carries the resulting row state
- CRDT-style materializations — when the input fully describes the desired state and you don’t care whether the row was new
.create(...) when you want a duplicate-key error to surface a bug.
Use .update(...) when “row must already exist” is a precondition.
Eligibility
.upsert(...) is generated only on models whose @id field is
client-supplied — i.e. has no @default(...). Calling .upsert(...)
on a model with a server-generated PK (@id @default(cuid()),
@id @default(uuid_v7()), etc.) is a compile error, not a runtime
“not supported.”
@unique
columns is a future, non-breaking addition.
Programmatic use
The input shape is the sameCreate<Model>Input struct you already use
for .create(...). The runtime decides at call time whether the call
becomes an INSERT or an UPDATE.
Server semantics
The server (cratestack-sqlx) path is always transactional and follows a
deliberate, banking-friendly sequence:
- Validate input — schema-derived validators (
@length,@regex, …) run before any SQL - Apply create defaults —
@default(auth().*)and@default(...)columns are filled in - Evaluate create policies —
@@allow(create, …)and@@deny(create, …)must permit the call, against the input values plus defaults - Begin transaction, ensure outbox / audit tables exist
- Probe with
SELECT … FOR UPDATEon the primary key — this both discriminates insert vs. update and serializes concurrent upserts on the same key - If the probe found a row → evaluate the update policy against the
live row. Denial is indistinguishable from a missing row, matching
ordinary
.update(...)semantics. - Execute
INSERT … ON CONFLICT (<pk>) DO UPDATE SET …and read the resulting row back viaRETURNING - Enqueue the appropriate event —
Createdif the probe saw no row,Updatedotherwise — into the event outbox - Enqueue the audit event with the
beforesnapshot from the probe (Noneon the insert branch) and theaftersnapshot fromRETURNING - Commit, then drain the outbox
SELECT … FOR UPDATE is the price of clean
event / audit semantics without leaning on Postgres xmax — keeping the
rusqlite mirror trivial. Upsert is not a hot read path; callers who need
raw insert/update throughput should use .create(...) / .update(...)
directly.
Policies: both must allow
Upsert evaluates both create and update policies at call time, before the runtime knows which branch will actually fire. This is stricter than “evaluate the path that runs,” but it’s the only choice we can make without leaking row existence to the caller (pre-flighting a read just to pick the policy slot would tell denied callers whether the row exists). In practice this means:- write
@@allow(create, …)and@@allow(update, …)so the intersection of permitted callers is exactly the set you want to be able to upsert - don’t reach for
.upsert(...)on models where create and update audiences are deliberately disjoint — that’s a sign the operation wants to be split into separate create / update routes
@version is bumped, but if_match isn’t honored
Models with @version get the same monotonic guarantee as .update(...):
the update branch emits version = <table>.version + 1 in the same
statement, so concurrent upserts converge to a coherent version number.
if_match is not supported on upsert. The semantics — “update only if
version = N, otherwise insert” — is rarely what callers actually want; if
you really need that conditional, the right shape is an explicit
transaction with find_unique → update.if_match(N). Adding if_match
to the upsert builder is on the deferred list and will require a clear
use case.
Soft-deleted rows are not silently revived
Models with@@soft_delete treat tombstoned rows as “not present” for
the probe step. The INSERT branch then trips the primary-key uniqueness
constraint and the upsert fails — the framework refuses to silently
un-tombstone a row that an operator deleted. Callers who genuinely need
revive-on-upsert semantics should issue an explicit update that sets
deleted_at = NULL; we may add a .revive_soft_deleted() opt-in later
if a real use case appears.
Auth-derived defaults are insert-only
Columns marked@default(auth().*) (e.g. ownership_id derived from the
caller’s principal) are excluded from the DO UPDATE clause. They’re
identity bindings, not column values; clobbering them on an update would
turn upsert into “take ownership of any row I name,” which is exactly the
attack we’re not interested in shipping.
The descriptor exposes the exact set of columns the update branch is
allowed to overwrite as ModelDescriptor::upsert_update_columns. Today
the rule is scalar columns − {primary key, @version, @readonly, @server_only, @default(...) }.
Embedded semantics
The on-device (cratestack-rusqlite) path is deliberately thinner:
- no policy enforcement (the embedded backend is single-user and trusts its caller)
- no transactional probe — the upsert is a single statement
- no event outbox or audit log to discriminate
INSERT … ON CONFLICT (<pk>) DO UPDATE SET …
with the same upsert_update_columns rule, and @version is bumped via
<table>.<col> + 1 so concurrent on-device writers converge. Use this
path when you’re processing inbound sync messages from a server-of-truth
and want each message to be a self-describing convergence step.
HTTP
Upsert is ORM-only at v1. There is noPUT /<model>/<id> route
generated today; that’s deferred until the precondition story (If-Match,
If-None-Match: *) is wired through the upsert builder. The route shape
when it lands will be canonical REST:
If-* header → either branch is allowed, matching the current ORM
behavior. There is no POST /<model>/upsert and no verb-in-path
alternative; the conflict target lives in the URL.
Comparison with idempotency
IdempotencyLayer and .upsert(...) solve complementary
problems and compose cleanly:
IdempotencyLayer | .upsert(...) | |
|---|---|---|
| Layer | HTTP middleware | ORM primitive |
| Key | Idempotency-Key header | Model primary key |
| Replay | Returns captured response bytes | Re-executes against current row |
| Scope | One request, regardless of side effects | One row, regardless of request shape |
| Cost | Token reservation + response capture | One extra SELECT FOR UPDATE |