Skip to main content

coven_database/test_support/
synthetic_store.rs

1//! The synthetic store every test database opens over: its synced schema, its
2//! migration ladder, and the `Database` constructors that combine the two.
3//!
4//! Domain-free on purpose — three tables exercising the engine's generic
5//! mechanics rather than any host's real shape.
6
7use crate::Migration;
8use crate::{CovenMigrationPolicy, Database, DbError};
9use coven_protocol::synced_schema::{BlobDecl, SyncedTable};
10
11impl Database {
12    pub fn open_synthetic_for_test(
13        path: &std::path::Path,
14        store_dir: coven_foundation::store_dir::StoreDir,
15        tables: Vec<SyncedTable>,
16        grace: chrono::Duration,
17        transfer_limits: coven_protocol::blob::TransferLimits,
18        device_id: String,
19        clock: coven_foundation::clock::ClockRef,
20        migrations: &[Migration],
21    ) -> Result<Self, crate::OpenError> {
22        Database::open_in_store_dir_for_test(
23            path,
24            store_dir,
25            tables,
26            grace,
27            transfer_limits,
28            device_id,
29            clock,
30            CovenMigrationPolicy::ApplyPending,
31            migrations,
32        )
33    }
34
35    pub fn open_synthetic_with_hlc_for_test(
36        path: &std::path::Path,
37        store_dir: coven_foundation::store_dir::StoreDir,
38        tables: Vec<SyncedTable>,
39        grace: chrono::Duration,
40        transfer_limits: coven_protocol::blob::TransferLimits,
41        hlc: std::sync::Arc<coven_protocol::hlc::Hlc>,
42        migrations: &[Migration],
43    ) -> Result<Self, crate::OpenError> {
44        Database::open_with_hlc_in_store_dir_for_test(
45            path,
46            store_dir,
47            tables,
48            grace,
49            transfer_limits,
50            hlc,
51            CovenMigrationPolicy::ApplyPending,
52            migrations,
53        )
54    }
55}
56
57pub fn store_dir_for_test_database(
58    path: &std::path::Path,
59) -> coven_foundation::store_dir::StoreDir {
60    let store_dir = if path == std::path::Path::new(":memory:") {
61        coven_foundation::store_dir::StoreDir::new_ephemeral(
62            std::env::temp_dir().join(format!("coven-test-store-{}", uuid::Uuid::new_v4())),
63        )
64    } else {
65        coven_foundation::store_dir::StoreDir::new(
66            path.parent()
67                .filter(|parent| !parent.as_os_str().is_empty())
68                .unwrap_or_else(|| std::path::Path::new(".")),
69        )
70    };
71    store_dir
72        .ensure_created()
73        .expect("create test database payload directory");
74    store_dir
75}
76
77/// The synthetic, domain-free schema the sync tests run against. Three synced
78/// tables exercising the engine's generic mechanics: a *gated root* (`notes`,
79/// gated by its `shared` boolean), a child with a foreign key (`note_tags`,
80/// which inherits the gate and exercises FK-violation retry), and a child that
81/// CAN carry a blob (`note_photos`, also FK-to-`notes`, so it inherits the gate).
82/// `note_photos` carries no blob here; blob tests declare one with
83/// [`test_synced_tables_with_blob`].
84pub fn test_synced_tables() -> Vec<SyncedTable> {
85    vec![
86        SyncedTable::new(
87            "notes",
88            coven_protocol::synced_schema::RowIdentity::SharedKey,
89        )
90        .gated_by("shared"),
91        SyncedTable::new(
92            "note_tags",
93            coven_protocol::synced_schema::RowIdentity::SharedKey,
94        ),
95        SyncedTable::new(
96            "note_photos",
97            coven_protocol::synced_schema::RowIdentity::SharedKey,
98        ),
99    ]
100}
101
102/// [`test_synced_tables`] with `note_photos` declared blob-bearing per `decl`, for
103/// tests exercising the blob push/pull/backfill paths. The blob id defaults to the
104/// `note_photos` primary key; `note_photos.cloud_path` holds a readable key for
105/// plain-scheme tests, and `note_photos.blob_id` is there for a decl that names a
106/// blob id apart from the PK — the shape a row repointed at a new blob needs, since
107/// the row keeps its primary key.
108pub fn test_synced_tables_with_blob(decl: BlobDecl) -> Vec<SyncedTable> {
109    vec![
110        SyncedTable::new(
111            "notes",
112            coven_protocol::synced_schema::RowIdentity::SharedKey,
113        )
114        .gated_by("shared"),
115        SyncedTable::new(
116            "note_tags",
117            coven_protocol::synced_schema::RowIdentity::SharedKey,
118        ),
119        SyncedTable::new(
120            "note_photos",
121            coven_protocol::synced_schema::RowIdentity::SharedKey,
122        )
123        .carries_blob(decl),
124    ]
125}
126
127/// [`test_synced_tables_with_blob`] with an ungated `notes` root: the rows are
128/// remote from the start rather than waiting on a gate, which is what a snapshot
129/// test needs to have something to publish before any gate is opened.
130pub fn test_synced_tables_remote_root_with_blob(decl: BlobDecl) -> Vec<SyncedTable> {
131    vec![
132        SyncedTable::new(
133            "notes",
134            coven_protocol::synced_schema::RowIdentity::SharedKey,
135        )
136        .remote_root(),
137        SyncedTable::new(
138            "note_tags",
139            coven_protocol::synced_schema::RowIdentity::SharedKey,
140        ),
141        SyncedTable::new(
142            "note_photos",
143            coven_protocol::synced_schema::RowIdentity::SharedKey,
144        )
145        .carries_blob(decl),
146    ]
147}
148
149/// [`test_synced_tables`] with TWO blob-bearing children of the gated `notes` root:
150/// `note_photos` per `photo_decl` (a release file, user-provided) and `note_covers`
151/// per `cover_decl` (a host-provided asset). Both inherit the `notes` gate, so a
152/// make_remote of a note carries both — the user-provided file through the durable
153/// outbox and the host-provided cover through the inline push — exercising the
154/// per-provenance split in one subtree.
155pub fn test_synced_tables_with_user_and_host_blobs(
156    photo_decl: BlobDecl,
157    cover_decl: BlobDecl,
158) -> Vec<SyncedTable> {
159    vec![
160        SyncedTable::new(
161            "notes",
162            coven_protocol::synced_schema::RowIdentity::SharedKey,
163        )
164        .gated_by("shared"),
165        SyncedTable::new(
166            "note_tags",
167            coven_protocol::synced_schema::RowIdentity::SharedKey,
168        ),
169        SyncedTable::new(
170            "note_photos",
171            coven_protocol::synced_schema::RowIdentity::SharedKey,
172        )
173        .carries_blob(photo_decl),
174        SyncedTable::new(
175            "note_covers",
176            coven_protocol::synced_schema::RowIdentity::SharedKey,
177        )
178        .carries_blob(cover_decl),
179    ]
180}
181
182/// Open a test [`Database`] over the synthetic schema with `note_photos` declared
183/// blob-bearing per `decl`.
184pub fn open_test_db_with_blob(
185    store_dir: coven_foundation::store_dir::StoreDir,
186    decl: BlobDecl,
187) -> Database {
188    open_test_db_schema(
189        store_dir,
190        test_synced_tables_with_blob(decl),
191        test_migrations(),
192    )
193}
194
195/// Open a read-test [`Database`] whose `note_photos` child carries a blob in
196/// `namespace`, so `read_blob`'s locality dispatch can resolve a
197/// blob in that namespace up to its gated `notes` root. The decl's namespace MUST
198/// match the blobs the test reads (the read path resolves the carrying table from the
199/// blob's namespace); its provenance/fill don't matter to that resolution (the read
200/// reads the row → root → gate, and takes provenance off the `BlobRef`), so this fixes
201/// them. Pair with [`Database::plant_blob_row_for_test`].
202pub fn read_test_db(store_dir: coven_foundation::store_dir::StoreDir, namespace: &str) -> Database {
203    open_test_db_with_blob(
204        store_dir,
205        BlobDecl::new(
206            namespace,
207            coven_protocol::blob::Provenance::UserProvided,
208            coven_protocol::blob::CacheFill::CacheLazy,
209        ),
210    )
211}
212
213/// Like [`read_test_db`] but with a chosen `max_concurrent_downloads`, so a pin test
214/// can drive the download loop concurrently. Uploads run one at a time (not exercised here).
215pub fn read_test_db_with_download_limit(
216    store_dir: coven_foundation::store_dir::StoreDir,
217    namespace: &str,
218    downloads: usize,
219) -> Database {
220    let tables = test_synced_tables_with_blob(BlobDecl::new(
221        namespace,
222        coven_protocol::blob::Provenance::UserProvided,
223        coven_protocol::blob::CacheFill::CacheLazy,
224    ));
225    let limits = coven_protocol::blob::TransferLimits {
226        uploads: std::num::NonZeroUsize::MIN,
227        downloads: std::num::NonZeroUsize::new(downloads).expect("downloads limit is nonzero"),
228    };
229    open_synthetic_database(
230        store_dir,
231        tables,
232        coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
233        limits,
234        std::sync::Arc::new(
235            coven_protocol::hlc::Hlc::try_new(
236                "test-device".to_string(),
237                std::sync::Arc::new(coven_foundation::clock::SystemClock),
238            )
239            .expect("create test register clock"),
240        ),
241        test_migrations(),
242    )
243}
244
245/// Open a test [`Database`] with both `note_photos` (per `photo_decl`) and
246/// `note_covers` (per `cover_decl`) declared blob-bearing — the schema for the
247/// per-provenance transition tests.
248pub fn open_test_db_with_user_and_host_blobs(
249    store_dir: coven_foundation::store_dir::StoreDir,
250    photo_decl: BlobDecl,
251    cover_decl: BlobDecl,
252) -> Database {
253    open_test_db_schema(
254        store_dir,
255        test_synced_tables_with_user_and_host_blobs(photo_decl, cover_decl),
256        test_migrations(),
257    )
258}
259
260/// The synthetic test schema as a single-migration ladder, so a test db opens at
261/// `schema_version() == 1`. The host-schema ladder for every `open_test_db*`
262/// helper.
263pub fn test_migrations() -> Vec<Migration> {
264    vec![Migration::run(1, "test-schema", create_synced_schema)]
265}
266
267/// Create the synthetic test schema on a connection. Run as the host migration
268/// step for [`open_test_db`] (see [`test_migrations`]).
269pub fn create_synced_schema(conn: &crate::MigrationContext<'_>) -> Result<(), DbError> {
270    conn.execute_batch(
271        "CREATE TABLE notes (
272            id TEXT PRIMARY KEY,
273            title TEXT NOT NULL,
274            body TEXT,
275            shared INTEGER NOT NULL DEFAULT 0,
276            _updated_at TEXT NOT NULL,
277            created_at TEXT NOT NULL
278        ) STRICT;
279        CREATE TABLE note_tags (
280            id TEXT PRIMARY KEY,
281            note_id TEXT NOT NULL,
282            tag TEXT NOT NULL,
283            _updated_at TEXT NOT NULL,
284            created_at TEXT NOT NULL,
285            FOREIGN KEY (note_id) REFERENCES notes (id) ON DELETE CASCADE
286        ) STRICT;
287        CREATE TABLE note_photos (
288            id TEXT PRIMARY KEY,
289            note_id TEXT NOT NULL,
290            kind TEXT NOT NULL,
291            size INTEGER NOT NULL DEFAULT 0,
292            hash TEXT,
293            _updated_at TEXT NOT NULL,
294            created_at TEXT NOT NULL,
295            cloud_path TEXT,
296            blob_id TEXT,
297            FOREIGN KEY (note_id) REFERENCES notes (id) ON DELETE CASCADE
298        ) STRICT;
299        CREATE TABLE note_covers (
300            id TEXT PRIMARY KEY,
301            note_id TEXT NOT NULL,
302            size INTEGER NOT NULL DEFAULT 0,
303            hash TEXT,
304            _updated_at TEXT NOT NULL,
305            created_at TEXT NOT NULL,
306            cloud_path TEXT,
307            FOREIGN KEY (note_id) REFERENCES notes (id) ON DELETE CASCADE
308        ) STRICT;",
309    )
310    .map_err(DbError::from)
311}
312
313/// Open a [`Database`] over a fresh in-memory connection with the synthetic test
314/// schema and the [`test_synced_tables`] synced set.
315pub fn open_test_db(store_dir: coven_foundation::store_dir::StoreDir) -> Database {
316    open_test_db_schema(store_dir, test_synced_tables(), test_migrations())
317}
318
319/// Construct an isolated Store directory for a test composition root.
320pub fn test_store_dir() -> coven_foundation::store_dir::StoreDir {
321    let store_dir = coven_foundation::store_dir::StoreDir::new_ephemeral(
322        std::env::temp_dir().join(format!("coven-test-store-{}", uuid::Uuid::new_v4())),
323    );
324    store_dir
325        .ensure_created()
326        .expect("create isolated test Store directory");
327    store_dir
328}
329
330pub fn open_test_db_with_tombstone_grace(
331    store_dir: coven_foundation::store_dir::StoreDir,
332    grace: chrono::Duration,
333) -> Database {
334    open_test_db_schema_with_store_dir_and_tombstone_grace(
335        store_dir,
336        test_synced_tables(),
337        test_migrations(),
338        grace,
339    )
340}
341
342/// Like [`open_test_db`] but with an explicit synced set and migration ladder, for
343/// tests that exercise a different schema (gate tests).
344pub fn open_test_db_schema(
345    store_dir: coven_foundation::store_dir::StoreDir,
346    tables: Vec<SyncedTable>,
347    migrations: Vec<Migration>,
348) -> Database {
349    open_test_db_schema_with_store_dir_and_tombstone_grace(
350        store_dir,
351        tables,
352        migrations,
353        coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
354    )
355}
356
357fn open_test_db_schema_with_store_dir_and_tombstone_grace(
358    store_dir: coven_foundation::store_dir::StoreDir,
359    tables: Vec<SyncedTable>,
360    migrations: Vec<Migration>,
361    grace: chrono::Duration,
362) -> Database {
363    let hlc = std::sync::Arc::new(
364        coven_protocol::hlc::Hlc::try_new(
365            "test-device".to_string(),
366            std::sync::Arc::new(coven_foundation::clock::SystemClock),
367        )
368        .expect("create test register clock"),
369    );
370    open_synthetic_database(
371        store_dir,
372        tables,
373        grace,
374        coven_protocol::blob::TransferLimits::one_at_a_time(),
375        hlc,
376        migrations,
377    )
378}
379
380fn open_synthetic_database(
381    store_dir: coven_foundation::store_dir::StoreDir,
382    tables: Vec<SyncedTable>,
383    grace: chrono::Duration,
384    transfer_limits: coven_protocol::blob::TransferLimits,
385    hlc: std::sync::Arc<coven_protocol::hlc::Hlc>,
386    migrations: Vec<Migration>,
387) -> Database {
388    let database = Database::open_with_hlc_in_store_dir_for_test(
389        std::path::Path::new(":memory:"),
390        store_dir.clone(),
391        tables,
392        grace,
393        transfer_limits,
394        hlc,
395        CovenMigrationPolicy::ApplyPending,
396        &migrations,
397    )
398    .expect("open test database");
399    database
400}
401
402/// Open a test [`Database`] over the synthetic schema with a caller-supplied
403/// register clock (so a test can control the wall clock), plus an extra `seed`
404/// step run after the host schema is created to plant host rows before
405/// `Database::open` reads its floor.
406///
407/// Used only by the register-clock tests (`hlc_register_tests`).
408pub fn open_test_db_with_hlc(
409    store_dir: coven_foundation::store_dir::StoreDir,
410    hlc: std::sync::Arc<coven_protocol::hlc::Hlc>,
411    seed: impl for<'connection> Fn(&crate::MigrationContext<'connection>) -> Result<(), DbError>
412        + Send
413        + Sync
414        + 'static,
415) -> Database {
416    let migrations = vec![Migration::run(1, "test-schema", move |conn| {
417        create_synced_schema(conn)?;
418        seed(conn)
419    })];
420    open_synthetic_database(
421        store_dir,
422        test_synced_tables(),
423        coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
424        coven_protocol::blob::TransferLimits::one_at_a_time(),
425        hlc,
426        migrations,
427    )
428}
429/// The Store view of a test database. Every sync test builds one; naming it here
430/// keeps the three test modules that used to declare it from drifting apart.
431pub fn store_database(db: &Database) -> crate::StoreDatabase {
432    crate::StoreDatabase::new(db)
433}
434
435/// The host-provided, eagerly-cached photo blob declaration most blob tests use.
436pub fn photo_decl() -> BlobDecl {
437    BlobDecl::new(
438        "photos",
439        coven_protocol::blob::Provenance::HostProvided,
440        coven_protocol::blob::CacheFill::CacheEager,
441    )
442}
443
444/// The notes schema with a remote-root parent, carrying `decl` on `note_photos`.
445pub fn remote_root_db(
446    store_dir: coven_foundation::store_dir::StoreDir,
447    decl: BlobDecl,
448) -> Database {
449    open_test_db_schema(
450        store_dir,
451        vec![
452            SyncedTable::new(
453                "notes",
454                coven_protocol::synced_schema::RowIdentity::SharedKey,
455            )
456            .remote_root(),
457            SyncedTable::new(
458                "note_tags",
459                coven_protocol::synced_schema::RowIdentity::SharedKey,
460            ),
461            SyncedTable::new(
462                "note_photos",
463                coven_protocol::synced_schema::RowIdentity::SharedKey,
464            )
465            .carries_blob(decl),
466        ],
467        test_migrations(),
468    )
469}