mkit_server/sql/mod.rs
1//! The shared SQL backend (`sql` feature): the [`NamespaceStore`] contract
2//! implemented once over `SQLite`, on the tiny synchronous [`SqlConn`]
3//! trait.
4//!
5//! `rusqlite` and Durable Object `SQLite` can both implement [`SqlConn`], so
6//! a native backend and every
7//! per-partition Durable Object run the same statements and the same
8//! physical [`schema`] migrations. SQL is an internal detail of this
9//! backend: nothing above [`NamespaceStore`] sees it.
10//!
11//! Every statement stays within Durable Object limits: at most
12//! [`MAX_BOUND_PARAMS`] bound parameters, and no transaction-control
13//! statement ([`SqlConn::transaction`] owns atomicity).
14//!
15//! [`NamespaceStore`]: crate::NamespaceStore
16
17mod capacity;
18mod kv;
19pub mod schema;
20#[cfg(test)]
21mod tests;
22
23pub use capacity::{
24 Capacity, DEFAULT_PAGE_SIZE, RESERVE_TREE_DEPTH, batch_growth_bytes, reserve_floor,
25};
26pub use kv::{GET_MANY_CHUNK, SqlKvStore, TIMER_WINDOW_AFTER, TIMER_WINDOW_START, TimerCursor};
27
28use crate::error::Redacted;
29use crate::rt::{MaybeSend, MaybeSync};
30use crate::store::StoreError;
31
32/// Most bound parameters in one statement: the Durable Object SQL limit
33/// (<https://developers.cloudflare.com/durable-objects/platform/limits/>).
34/// [`SqlKvStore`] never binds more.
35pub const MAX_BOUND_PARAMS: usize = 100;
36
37/// A bound parameter or a result column: the four storage classes both
38/// `rusqlite` and Durable Object SQL bind (no `REAL`).
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum SqlValue {
41 /// `NULL`.
42 Null,
43 /// A 64-bit signed integer.
44 Integer(i64),
45 /// UTF-8 text.
46 Text(String),
47 /// Raw bytes. Blobs compare by `memcmp`, then length: raw byte order.
48 Blob(Vec<u8>),
49}
50
51/// One result row, its columns in select order.
52pub type Row = Vec<SqlValue>;
53
54/// Why a SQL call failed. The backend's own message never reaches a client:
55/// it is kept [`Redacted`] for the server-side log only.
56#[derive(Debug, thiserror::Error)]
57#[non_exhaustive]
58pub enum SqlError {
59 /// The engine failed (I/O, busy past its timeout, a malformed
60 /// statement). The outcome of a failed transaction is a rollback.
61 #[error("sql backend error")]
62 Backend(Redacted),
63 /// A constraint was violated.
64 #[error("sql constraint violated")]
65 Constraint,
66 /// The database is at its size cap (`SQLITE_FULL`): writes that add data
67 /// fail, reads and deletes keep working.
68 #[error("sql database full")]
69 Full,
70 /// A row did not have the expected shape.
71 #[error("corrupt sql row: {0}")]
72 Corrupt(&'static str),
73}
74
75impl From<SqlError> for StoreError {
76 fn from(e: SqlError) -> Self {
77 match e {
78 SqlError::Full => Self::Full,
79 SqlError::Corrupt(what) => Self::Corrupt(what.into()),
80 e @ (SqlError::Backend(_) | SqlError::Constraint) => Self::unavailable(e),
81 }
82 }
83}
84
85/// The transaction body [`SqlConn::transaction`] runs: owned and `'static`.
86pub type TxFn<C, T> = Box<dyn FnOnce(C) -> Result<T, SqlError> + 'static>;
87
88/// A synchronous SQL connection: a cheaply cloneable handle (`rusqlite`:
89/// a shared, locked `Connection`; a Durable Object: its storage handle).
90///
91/// Synchronous on purpose: Durable Object `sql.exec` and `transactionSync`
92/// are synchronous, and so is `rusqlite`. A native host runs a store over
93/// it on a blocking thread.
94pub trait SqlConn: MaybeSend + MaybeSync + Clone + 'static {
95 /// Run one statement that returns no rows; the number of rows changed.
96 fn exec(&self, sql: &str, params: &[SqlValue]) -> Result<u64, SqlError>;
97
98 /// Run one statement and collect its rows.
99 fn query(&self, sql: &str, params: &[SqlValue]) -> Result<Vec<Row>, SqlError>;
100
101 /// Run `f` atomically and synchronously: commit if it returns `Ok`,
102 /// roll back if it returns `Err` or panics. `f` receives a handle for
103 /// its statements.
104 ///
105 /// An implementation never issues transaction-control SQL itself:
106 /// Durable Objects reject those statements and require
107 /// `storage.transactionSync`; `rusqlite` uses its `Transaction` type.
108 /// `f` never awaits, which is what makes `apply` cancellation-safe
109 /// (normative rule 4).
110 ///
111 /// `f` is owned and `'static` (not a borrowed `FnMut`): the Durable
112 /// Object bridge wraps it in a `'static` wasm-bindgen closure, and this
113 /// crate forbids `unsafe`.
114 fn transaction<T: 'static>(&self, f: TxFn<Self, T>) -> Result<T, SqlError>;
115
116 /// The backend's own clock, Unix ms, for [`Precondition::NotAfter`]
117 /// (normative rule 8): the host's system clock natively, `Date.now()` on
118 /// a Durable Object. A reading before the epoch is `u64::MAX`, so every
119 /// deadline fails closed.
120 ///
121 /// [`Precondition::NotAfter`]: crate::Precondition::NotAfter
122 fn now_ms(&self) -> u64;
123
124 /// Bytes the database uses, for the soft cap ([`Capacity`]): pages
125 /// holding data (natively `(page_count - freelist_count) * page_size`;
126 /// free pages are reused before the file grows), `databaseSize` on a
127 /// Durable Object. Called inside a transaction.
128 fn size_bytes(&self) -> Result<u64, SqlError>;
129
130 /// Enforce a hard size limit of about `bytes` at the engine (natively
131 /// `max_page_count`). The default does nothing: a Durable Object has a
132 /// fixed limit of its own, which the [`Capacity`] must not exceed.
133 fn set_size_limit(&self, bytes: u64) -> Result<(), SqlError> {
134 let _ = bytes;
135 Ok(())
136 }
137
138 /// Write a consistent backend-native backup of the whole database to
139 /// `dest` (natively a file path, via `VACUUM INTO`). The default is
140 /// [`StoreError::Unsupported`]: back such a backend up with the
141 /// portable export (`store::export_partition`).
142 fn backup_to(&self, dest: &str) -> Result<(), StoreError> {
143 let _ = dest;
144 Err(StoreError::Unsupported(
145 "this SQL backend has no physical backup; use the portable export".into(),
146 ))
147 }
148}
149
150/// Column `i` of `row` as a blob, moved out of the row.
151pub(crate) fn blob(row: &mut Row, i: usize) -> Result<Vec<u8>, SqlError> {
152 match row.get_mut(i) {
153 Some(SqlValue::Blob(b)) => Ok(core::mem::take(b)),
154 _ => Err(SqlError::Corrupt("expected a blob column")),
155 }
156}
157
158/// Column `i` of `row` as a non-negative integer (`NULL` reads as 0, for
159/// aggregates over no rows).
160pub(crate) fn count(row: &Row, i: usize) -> Result<u64, SqlError> {
161 match row.get(i) {
162 Some(SqlValue::Null) => Ok(0),
163 Some(SqlValue::Integer(n)) => {
164 u64::try_from(*n).map_err(|_| SqlError::Corrupt("negative count"))
165 }
166 _ => Err(SqlError::Corrupt("expected an integer column")),
167 }
168}