Batches
External producers send rows in groups: a webhook delivers ten transactions, a CSV import fans out a thousand line items, an offline-first device flushes its outbox after reconnecting. CrateStack exposes five batch ORM primitives that take those groups whole, run each item independently, and return a structured envelope per item so the caller can split successes from failures without unwrapping a flat error.Wire envelope
Every batch call returns aBatchResponse<M> with a Vec<BatchItemResult<M>> and a summary count:
- The outer
Result<BatchResponse<M>, CoolError>carries whole-batch infrastructure failures only: request exceeds the 1000-item cap, duplicate keys detected in the input list, database connection lost. Outer failures stop the batch before any per-item work runs. BatchItemStatus::Errorcarries per-item failures: validation failed, policy denied, row not found,if_matchwas stale, primary key already existed. Per-item failures do not abort the rest of the batch — the savepoint rolls back just that item.
index on every BatchItemResult is the item’s position in the original request, preserved across the response. Clients can match results back to inputs by index without depending on ordering.
The five primitives
Transactional model
Two patterns, picked per primitive:| Operation | SQL shape | Why |
|---|---|---|
batch_get | One SELECT … WHERE pk IN (…) | Policy merges into WHERE; missing rows are naturally NOT_FOUND. No mutation, no savepoints needed. |
batch_delete | One DELETE … WHERE pk IN (…) RETURNING … (or soft-delete UPDATE) | Policy merges into WHERE. The returned rows become the audit before-snapshots; missing rows surface as NOT_FOUND. |
batch_create | One outer BEGIN, per-item SAVEPOINT … INSERT … RELEASE | Per-item failures (validation, policy, unique conflict) roll back to their savepoint without taking the rest of the batch down. |
batch_update | Same pattern as batch_create, per-item UPDATE | Each item carries its own optional if_match; per-item version mismatches surface as PRECONDITION_FAILED in their envelope slot. |
batch_upsert | Same pattern, per-item probe + INSERT … ON CONFLICT … DO UPDATE | Inherits the full upsert semantics (see the Upsert guide) on a per-item basis. |
Why the SAVEPOINT pattern
In a flat single-transaction batch, a per-item constraint violation aborts the transaction — Postgres marks the connection’s current transaction as failed and refuses further statements until youROLLBACK. That model doesn’t fit the tRPC-envelope contract: we promised the caller that item N+1 still gets a chance.
Savepoints solve this exactly. ROLLBACK TO SAVEPOINT returns the outer transaction to a usable state without losing earlier work. The outer commit then writes only the successful items, atomically together with their audit and outbox rows.
Size cap and duplicate handling
CoolError::Validation, before any SQL runs. The cap is the same for all five operations; deviating per-op would invite footguns where batch_get accepts a list that batch_create of the same length rejects.
Duplicate input keys are loud-failed, not silently deduplicated:
results[i] corresponds to the input at position i. Silent dedup would break that contract — the caller passes 3 items and gets back 2 results, with no way to tell which positions collapsed. Loud-failing forces the caller to dedupe at the boundary they own.
Detection runs on the natural key per operation:
batch_get/batch_delete: the PK list itselfbatch_update: theidfield of eachBatchUpdateItem<PK, I>batch_upsert:UpsertModelInput::primary_key_value()on each input
batch_create skips the check — CreateModelInput doesn’t expose the primary key generically, and server-generated PKs can’t collide by construction. Duplicate client-supplied PKs in a batch_create will trip the database’s unique constraint and surface as per-item CONFLICT in the envelope, with the rest of the batch committing cleanly via savepoint isolation. If you need explicit boundary dedup on a batch_create, dedupe before the call.
Per-item error codes
Per-itemBatchItemError { code, message } uses the same string codes as the framework’s standard HTTP responses, so client-side error-mapping tables work uniformly across single and batch routes:
| Code | When |
|---|---|
VALIDATION_ERROR | input failed @length, @regex, @email, etc. |
FORBIDDEN | create/update/delete policy denied this item |
NOT_FOUND | batch_get / batch_update / batch_delete saw no row at this PK |
PRECONDITION_FAILED | versioned batch_update with stale if_match |
CONFLICT | batch_create tripped a unique constraint (incl. duplicate client PKs) |
DATABASE_ERROR | unexpected DB failure — usually means escalate via outer error |
CoolError::code() so a single mapping table covers single-route responses and batch envelope entries.
Comparison with IdempotencyLayer and .upsert(...)
Three orthogonal primitives, three different replays:
IdempotencyLayer | .upsert(...) | .batch_*(...) | |
|---|---|---|---|
| Scope | One HTTP request | One row | One transaction with N rows |
| Key | Idempotency-Key header | Model primary key | Per-item PK / input |
| Replay | Returns captured response | Re-executes against current row | Re-runs the batch (envelope shows current state per item) |
| Failure model | All-or-nothing per request | All-or-nothing per row | Per-item independent |
IdempotencyLayerprotects against duplicate handler executions caused by client retries on a flaky network..batch_upsert(...)makes the payload itself idempotent — replays converge to the same row state regardless of how many times the same item appears..batch_*(...)envelope semantics let the caller decide per-item what to do about failures (retry only the failed ones, surface specific items to a human, log and continue).
Embedded backend (cratestack-rusqlite)
All five primitives are available on the embedded ModelDelegate too. The path is sync (.run() instead of .run().await) and noticeably thinner: no policy enforcement, no audit, no event outbox. SAVEPOINT semantics carry over directly — SQLite supports SAVEPOINT … RELEASE … ROLLBACK TO the same way Postgres does, so the per-item isolation contract holds on-device.
BatchItemError { code: "DATABASE_ERROR" } or code: "CONFLICT" (constraint violations); we don’t enumerate VALIDATION_ERROR / FORBIDDEN because the embedded layer doesn’t run validators or policies. The codes still match the server side so cross-platform clients keep a single error-mapping table.
The embedded batch_update doesn’t support per-item if_match — the on-device runtime doesn’t enforce @version for single rows either, so consistency wins over surprise. If a future on-device version-check use case appears, the API knob is non-breaking to add.
HTTP
Auto-generatedPOST /<model>/batch-* routes are deferred to a follow-up release. The wire envelope types — BatchRequest<I> and BatchResponse<T> — are stable in cratestack-core today, so applications can hand-roll a thin axum handler against the ORM:
Worked example: the notes CLI
The embedded-cli example ships three batch-aware subcommands so you can see the envelope in actual terminal output rather than just JSON snippets:
| Subcommand | Primitive | Notes |
|---|---|---|
notes import <file.json> | batch_upsert | Idempotent JSON ingestion. Re-running the same file converges instead of duplicating. |
notes bulk-done <id...> | batch_update | Missing ids surface as per-item NOT_FOUND without aborting the successful ones. |
notes bulk-delete <id...> | batch_delete | Single statement; ids that didn’t match (or were already tombstoned, on soft-delete models) surface as per-item NOT_FOUND. |
print_envelope() helper at the bottom of examples/embedded-cli/src/main.rs is six lines and copy-paste-ready for any sync rusqlite-backed app.