Skip to main content

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}