Skip to main content

ubiquisync_sql/db/
batch.rs

1use async_trait::async_trait;
2
3use super::{DbError, DbRow, DbValue};
4use crate::dialect::SqlDialect;
5
6/// Identifies one statement queued into a [`DbBatch`].
7///
8/// Returned by [`DbBatch::add_statement`] and used to locate that statement's
9/// [`DbStatementResult`] in the `Vec` returned by [`DbBatch::commit`]: the id
10/// is the result's index, so `results[id.0]` is always this statement's
11/// outcome. Holding the id means callers never have to track insertion order
12/// by hand to find their own `RETURNING` rows (e.g. for emitting change
13/// events after the batch commits).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct StmtId(pub usize);
16
17/// The outcome of a single statement once its batch has committed.
18///
19/// One of these is produced per [`DbBatch::add_statement`] call, in add order.
20/// `rows` carries the statement's `RETURNING` output (empty when it had no
21/// `RETURNING` clause); `rows_affected` is the INSERT/UPDATE/DELETE row count,
22/// which some callers need to decide whether an LWW write actually changed
23/// anything.
24#[derive(Debug)]
25pub struct DbStatementResult {
26    /// The INSERT/UPDATE/DELETE row count for the statement.
27    pub rows_affected: usize,
28    /// The statement's `RETURNING` rows; empty when it had no `RETURNING` clause.
29    pub rows: Vec<DbRow>,
30}
31
32/// An atomic, all-or-nothing unit of writes.
33///
34/// Statements are *collected* with [`add_statement`](DbBatch::add_statement)
35/// and then run together by [`commit`](DbBatch::commit) inside a single
36/// transaction. Either every statement commits or none does; on any error the
37/// whole batch rolls back.
38///
39/// There is deliberately **no read method** on a batch. A queued statement may
40/// not depend on the result of an earlier one in the same batch — all reads
41/// must happen on [`Db`](super::Db) *before* the batch is assembled. This keeps
42/// a batch expressible as a flat, declarative statement list.
43///
44/// That declarative shape is the portable lowest common denominator across
45/// SQLite backends. Any backend where compute is *network-distant* from the
46/// database can't safely expose an interactive `BEGIN`/`COMMIT`: a client could
47/// open a write transaction and then crash or stall, stranding SQLite's single
48/// write slot. So the whole category of HTTP-fronted edge SQLite exposes only a
49/// batch/pipeline primitive that opens and closes the transaction database-side
50/// in one round trip — Cloudflare D1's `batch()`, Turso/libSQL's remote client
51/// (its stateless HTTP requests share no transaction context), and Bunny (also
52/// libSQL-over-HTTP). The same flat list also maps cleanly onto backends that
53/// *do* offer a real interactive transaction because compute is colocated with
54/// the data — rusqlite `BEGIN/COMMIT`, Durable Object `transactionSync`. One
55/// batch abstraction therefore runs identically everywhere.
56#[async_trait]
57pub trait DbBatch: Send {
58    /// The SQL dialect this batch speaks. Available here because callers build
59    /// statements while holding only the batch.
60    fn dialect(&self) -> SqlDialect;
61
62    /// Queue a write statement and return its [`StmtId`]. Infallible: this only
63    /// buffers `sql` and `params`; any SQL error surfaces at
64    /// [`commit`](DbBatch::commit).
65    fn add_statement(&mut self, sql: &str, params: &[DbValue]) -> StmtId;
66
67    /// Commit all queued statements atomically, consuming the batch. Returns
68    /// one [`DbStatementResult`] per queued statement, in add order (so a
69    /// [`StmtId`] indexes straight into it). On any failure the transaction is
70    /// rolled back and nothing is persisted.
71    async fn commit(self: Box<Self>) -> Result<Vec<DbStatementResult>, DbError>;
72}