Skip to main content

coven_database/
test_utils.rs

1//! Schema fixtures and crash checkpoints for tests of the production database.
2
3use crate::{Migration, RowIdentity, SyncedTable};
4
5#[path = "followed_tables.rs"]
6mod followed_tables;
7pub use followed_tables::followed_tables_sql;
8
9/// What `coven_path_text` returns for its argument, for an app's test that
10/// registers the function on a connection it opened itself, outside any
11/// store: the body every store registers.
12pub fn path_text_sql(
13    value: rusqlite::types::ValueRef<'_>,
14) -> rusqlite::Result<rusqlite::types::Value> {
15    crate::coven_functions::path_text_sql(value)
16}
17
18#[path = "work_counts.rs"]
19mod work_counts;
20pub use work_counts::{WorkCounter, WorkCounts};
21
22/// One measured number, as a line `scripts/perf.sh` reads:
23/// `COST metric=<name> value=<n> unit=<ms|ratio|count>`. Lower is better in
24/// every unit. A character in `metric` outside letters, digits and `_./-`
25/// becomes `_`, so a label can be passed as it reads.
26pub fn report_cost(metric: &str, value: f64, unit: &str) {
27    let metric: String = metric
28        .chars()
29        .map(|c| if c.is_ascii_alphanumeric() || "_./-".contains(c) { c } else { '_' })
30        .collect();
31    eprintln!("COST metric={metric} value={value} unit={unit}");
32}
33
34/// [`report_cost`] for a time, in milliseconds.
35pub fn report_time(metric: &str, time: std::time::Duration) {
36    report_cost(metric, time.as_secs_f64() * 1000.0, "ms");
37}
38
39pub(crate) type WriteCheckpointObserver = Box<dyn Fn(WriteCheckpoint) + Send>;
40
41/// Boundaries after a local write's durable commit, for process-crash tests.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum WriteCheckpoint {
44    /// Live queries have judged the write, before SQLite commits it.
45    Judged,
46    /// SQLite has committed, before notifying queries and sync.
47    Committed,
48    /// Commit observers have been notified.
49    Observed,
50    /// The write has released its staged-file reservations.
51    StagingReleased,
52    /// An obsolete file's bytes have been removed, after the writer that
53    /// released them; its pending-removal row is about to be deleted.
54    FileRemoved,
55    /// The obsolete file's pending-removal row has been deleted.
56    FileRemovalRecorded,
57    /// Post-commit file cleanup has committed the deletion of those rows.
58    Finished,
59}
60
61/// A live query's results as a test follows them: each in the order the
62/// query reports it, kept as the latest one seen.
63pub struct Followed<T, E> {
64    query: crate::LiveQuery<T, E>,
65    latest: Option<T>,
66}
67
68impl<T, E> Followed<T, E>
69where
70    T: Clone + PartialEq + std::fmt::Debug + Send + 'static,
71    E: From<crate::DbError> + coven_foundation::task::CarriesBug + std::fmt::Debug + Send + 'static,
72{
73    /// Follow `query` from its first result.
74    pub fn new(query: crate::LiveQuery<T, E>) -> Self {
75        Self { query, latest: None }
76    }
77
78    /// The next result the query reports. Panics on an error result or
79    /// after the query ends, and when none ever comes.
80    pub async fn next(&mut self) -> T {
81        let next = coven_foundation::clock::within(self.query.next())
82            .await
83            .unwrap_or_else(|| panic!("no result after {:?}", self.latest))
84            .expect("the live query ended")
85            .expect("the live query failed");
86        self.latest = Some(next.clone());
87        next
88    }
89
90    /// The latest result seen, waiting for the first one.
91    pub async fn latest(&mut self) -> T {
92        match self.latest.clone() {
93            Some(latest) => latest,
94            None => self.next().await,
95        }
96    }
97
98    /// The first result `matches` accepts, from the latest one seen on.
99    pub async fn wait_for(&mut self, mut matches: impl FnMut(&T) -> bool) -> T {
100        let mut result = self.latest().await;
101        while !matches(&result) {
102            result = self.next().await;
103        }
104        result
105    }
106}
107
108/// Writers for a test not about forgetting kept records: one other device
109/// that can still write but never lands a write, so every kept record stays
110/// (§16.7).
111pub fn idle_writers() -> crate::Writers {
112    crate::Writers::new(std::collections::BTreeMap::from([(
113        coven_foundation::id_source::DeviceId(u64::MAX),
114        None,
115    )]))
116}
117
118/// Fixed member key used to authenticate fixture writes.
119pub fn signer() -> coven_crypto::SigningKey {
120    coven_format::test_utils::keys_from_seed(0xA5).public_keys().signing
121}
122
123/// Stage a snapshot's whole plaintext from `input`, one chunk at a time, as
124/// sync stages an opened body (§16.2).
125pub async fn stage_plaintext(
126    database: &crate::Database,
127    id: coven_format::admin::SnapshotId,
128    prefix: coven_format::sealed_snapshot::SnapshotObjectPrefix,
129    mut input: impl std::io::Read,
130) -> Result<crate::SnapshotStaging, crate::DbError> {
131    let mut staging = database.stage_snapshot(id, prefix).await?;
132    loop {
133        let mut chunk = vec![0; coven_format::chunks::CHUNK_SIZE];
134        let length = match input.read(&mut chunk) {
135            Ok(0) => break,
136            Ok(length) => length,
137            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
138            Err(error) => return Err(crate::SnapshotError::Read(error).into()),
139        };
140        chunk.truncate(length);
141        staging.stage(chunk).await?;
142    }
143    staging.finish().await?;
144    Ok(staging)
145}
146
147/// Declarations for a UUID-keyed notes table in the store audience.
148pub fn notes_tables() -> Vec<SyncedTable> {
149    vec![SyncedTable::new("notes", RowIdentity::IndependentUuid)]
150}
151
152/// The corresponding app migration sequence.
153pub fn notes_migrations() -> Vec<Migration> {
154    vec![Migration::addition(1, "notes", "CREATE TABLE notes (id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '')")]
155}
156
157/// Authorship of test writes, sealed and hashed with one fixed test key for
158/// every audience. It opens headers it sealed itself and checks no signature:
159/// the database checks roots and openings; sync checks signatures (D9).
160#[derive(Clone, Copy, Debug, Default)]
161pub struct TestAuthorship;
162
163impl TestAuthorship {
164    const STORE: coven_foundation::id_source::StoreId =
165        coven_foundation::id_source::StoreId(uuid::Uuid::from_bytes([0x10; 16]));
166
167    /// The test key sealing headers and keying every part's leaves.
168    pub fn key() -> coven_crypto::StoreKey {
169        coven_crypto::StoreKey::from_bytes(
170            coven_foundation::id_source::KeyId(uuid::Uuid::from_bytes([1; 16])),
171            [17; 32],
172        )
173    }
174
175    fn path(write: coven_merge::WriteId) -> String {
176        coven_format::path::ObjectPath::slot(
177            Self::STORE,
178            std::num::NonZeroU64::new(write.number).expect("a positive write number"),
179        )
180        .into()
181    }
182
183    /// The evidence and hashers a stream of this record carries.
184    pub fn write(record: &coven_format::write::WriteRecord) -> crate::WriteAuthorship {
185        let encoder = coven_format::write_stream::WriteEncoder::new(record).unwrap();
186        Self::for_header(
187            encoder.header(),
188            record.parts.iter().map(coven_format::authorship::part_leaves).collect(),
189        )
190    }
191
192    /// Seal an explicit stream header, including unopened parts.
193    pub(crate) fn for_header(
194        frame: &coven_format::write_stream::WriteHeaderFrame,
195        leaves: Vec<Vec<coven_format::authorship::Leaf>>,
196    ) -> crate::WriteAuthorship {
197        let key = Self::key();
198        let object = coven_format::sealed_write::WriteObjectPrefix {
199            format: coven_format::FormatVersion::CURRENT,
200            store_key: key.id(),
201            part_keys: vec![key.id(); frame.parts.len()],
202        };
203        let prefix = object.encode().unwrap();
204        let plain = frame.encode().unwrap();
205        let sealed = key
206            .derive()
207            .seal_object_chunk(&Self::path(frame.header.position), &prefix, 0, 0, &plain)
208            .unwrap();
209        let header = coven_format::sealed_write::WriteObjectLayout::new(
210            object,
211            &plain,
212            frame.parts.iter().map(|part| part.plaintext_length).collect(),
213        )
214        .unwrap()
215        .encode_chunk(&sealed)
216        .unwrap();
217        let hasher = key.derive().authorship_hasher();
218        crate::WriteAuthorship {
219            evidence: crate::HeaderEvidence {
220                prefix,
221                header,
222                roots: leaves
223                    .iter()
224                    .map(|leaves| coven_format::authorship::root(&hasher, leaves).unwrap())
225                    .collect(),
226                signature: [0; 64],
227            },
228            hashers: frame.parts.iter().map(|_| Some(hasher.clone())).collect(),
229        }
230    }
231}
232
233impl crate::Authorship for TestAuthorship {
234    #[expect(
235        clippy::map_err_ignore,
236        reason = "a test fixture whose header does not open is the fixture refusal this names"
237    )]
238    fn header(
239        &self,
240        evidence: &coven_format::authorship::WriteEvidence,
241    ) -> Result<coven_format::write_stream::WriteHeaderFrame, crate::DbError> {
242        let sealed = coven_format::sealed_write::WriteObjectPrefix::header_chunk(&evidence.header)?;
243        let plain = Self::key()
244            .derive()
245            .open_object_chunk(&Self::path(evidence.write), &evidence.prefix, 0, 0, sealed)
246            .map_err(|_| crate::snapshot_error::invalid("test evidence header does not open"))?;
247        Ok(coven_format::write_stream::WriteHeaderFrame::decode(&plain)?)
248    }
249
250    fn hasher(
251        &self,
252        _audience: &coven_merge::Audience,
253        _key: coven_foundation::id_source::KeyId,
254    ) -> Result<coven_crypto::AuthorshipHasher, crate::DbError> {
255        Ok(Self::key().derive().authorship_hasher())
256    }
257}
258
259#[cfg(test)]
260#[path = "test_utils_tests.rs"]
261pub(crate) mod tests;