cratestack_core/batch.rs
1//! Batch envelope.
2//!
3//! tRPC-style per-item envelope for batch operations. The HTTP
4//! response is always `200 OK` carrying a [`BatchResponse<T>`];
5//! whole-batch infrastructure failures (bad request shape, size cap
6//! exceeded, DB connection lost) still flow through the outer
7//! `Result<_, CoolError>` and map to their usual status codes via
8//! the standard handler.
9//!
10//! Per-item failures (validation, policy denial, not-found, stale
11//! `if_match`, PK conflict) ride inside [`BatchItemStatus::Error`]
12//! and DO NOT abort the batch — successful items in the same request
13//! still commit. The transactional model used by the server backends
14//! is one outer transaction with a per-item SAVEPOINT, so failed
15//! items leave no audit row, no event outbox entry, and no row
16//! mutation.
17
18use serde::{Deserialize, Serialize};
19
20use crate::error::CoolError;
21
22#[cfg(test)]
23mod tests;
24
25/// Per-item result inside a [`BatchResponse`]. The `index` is the
26/// item's position in the original request, so clients can pair
27/// results with inputs even after server-side reordering (e.g.
28/// parallel `batch_get` fetches in the future).
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30pub struct BatchItemResult<T> {
31 pub index: usize,
32 #[serde(flatten)]
33 pub status: BatchItemStatus<T>,
34}
35
36/// Either a successful per-item outcome (`Ok`) or a per-item failure
37/// (`Error`). Serializes as a tagged enum with the discriminant in
38/// `status`:
39///
40/// ```json
41/// { "status": "ok", "value": { ... } }
42/// { "status": "error", "error": { "code": "POLICY_DENIED", "message": "…" } }
43/// ```
44///
45/// The `code` field maps 1:1 to [`CoolError::code`], so consumers
46/// can share error-code constants across single and batch routes.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(tag = "status", rename_all = "lowercase")]
49pub enum BatchItemStatus<T> {
50 Ok { value: T },
51 Error { error: BatchItemError },
52}
53
54/// Public, safe-to-expose shape of a per-item failure. Mirrors
55/// [`crate::CoolErrorResponse`] without the optional `details` field
56/// — batch callers asking for per-item detail can repeat the
57/// operation singly against the failed item to get the full error
58/// envelope.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct BatchItemError {
61 pub code: String,
62 pub message: String,
63}
64
65impl BatchItemError {
66 /// Project a [`CoolError`] into the public per-item shape, using
67 /// the same `code()` / `public_message()` mapping the standard
68 /// HTTP error handler uses for single-route responses.
69 pub fn from_cool(error: &CoolError) -> Self {
70 Self {
71 code: error.code().to_owned(),
72 message: error.public_message().into_owned(),
73 }
74 }
75}
76
77/// Summary counts attached to every [`BatchResponse`] so callers can
78/// branch on aggregate status without scanning the result list.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80pub struct BatchSummary {
81 pub total: usize,
82 pub ok: usize,
83 pub err: usize,
84}
85
86/// Wire envelope returned by every batch route. Always `200 OK` at
87/// the HTTP layer; inspect `summary.err` (or scan `results`) to
88/// surface per-item failures to the user.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct BatchResponse<T> {
91 pub results: Vec<BatchItemResult<T>>,
92 pub summary: BatchSummary,
93}
94
95impl<T> BatchResponse<T> {
96 /// Build a [`BatchResponse`] from an in-order
97 /// `Vec<Result<T, CoolError>>`. The `index` of each result
98 /// matches its position in the input.
99 pub fn from_results(per_item: Vec<Result<T, CoolError>>) -> Self {
100 let total = per_item.len();
101 let mut ok = 0usize;
102 let mut err = 0usize;
103 let results = per_item
104 .into_iter()
105 .enumerate()
106 .map(|(index, outcome)| match outcome {
107 Ok(value) => {
108 ok += 1;
109 BatchItemResult {
110 index,
111 status: BatchItemStatus::Ok { value },
112 }
113 }
114 Err(error) => {
115 err += 1;
116 BatchItemResult {
117 index,
118 status: BatchItemStatus::Error {
119 error: BatchItemError::from_cool(&error),
120 },
121 }
122 }
123 })
124 .collect();
125 Self {
126 results,
127 summary: BatchSummary { total, ok, err },
128 }
129 }
130}
131
132/// Wire envelope for `POST /<model>/batch-*` request bodies. Holds
133/// the items in a single field so the envelope can grow (e.g. a
134/// future `client_request_id`) without breaking deserialization.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct BatchRequest<I> {
137 pub items: Vec<I>,
138}
139
140/// Default upper bound on items in a single batch request. Server
141/// backends enforce this before any SQL runs and surface
142/// [`CoolError::Validation`] on the outer `Result` when exceeded.
143/// The cap is identical for all five batch operations; deviating
144/// per-op would invite footguns where `batch_get` accepts a list
145/// that `batch_create` of the same length rejects.
146pub const BATCH_MAX_ITEMS: usize = 1000;
147
148/// Detect duplicate keys in a batch input, loud-failing the whole
149/// request when found. Returns the first duplicate (by position) so
150/// the surfaced error can name a specific offending index. Linear-
151/// time, allocation-only in proportion to the input length.
152///
153/// Used by all five batch primitives — `batch_get`, `batch_delete`,
154/// `batch_update`, `batch_create` (when the input carries a client-
155/// supplied PK), and `batch_upsert`. The dedup posture is deliberate:
156/// silently collapsing duplicates would break the per-item `index`
157/// mapping the envelope promises and hide caller bugs.
158pub fn find_duplicate_position<K: Eq + std::hash::Hash>(
159 keys: impl IntoIterator<Item = K>,
160) -> Option<(usize, usize)> {
161 let mut seen: std::collections::HashMap<K, usize> = std::collections::HashMap::new();
162 for (index, key) in keys.into_iter().enumerate() {
163 if let Some(&first) = seen.get(&key) {
164 return Some((first, index));
165 }
166 seen.insert(key, index);
167 }
168 None
169}