Skip to main content

coven_database/store/store_session/
test_support.rs

1use super::{DbError, StoreDatabase, StoreSession};
2use coven_protocol::store_commit::{
3    ObjectHash, StoreBatchCommitRef, StoreDeviceExclusionRef, StoreDeviceHeadRef,
4};
5use coven_protocol::write::WriteId;
6use rusqlite::OptionalExtension;
7use std::collections::BTreeSet;
8
9#[derive(Clone, Copy, Debug)]
10pub enum AuthorExclusionLocatorTamper {
11    Missing,
12    ExclusionReference,
13    AcceptedCut,
14    ActivationCommit,
15    ActivationHead,
16}
17
18struct PreparedWriteTransfer {
19    write: (String, String, String, String, String, String),
20    partitions: Vec<(String, Option<String>, String)>,
21    packages: Vec<(String, String)>,
22    blobs: Vec<(String, String, String, Option<String>)>,
23    remotes: Vec<(String, String)>,
24    payload_claims: Vec<(String, BTreeSet<ObjectHash>)>,
25    payloads: Vec<(ObjectHash, Vec<u8>)>,
26}
27
28impl StoreSession<'_> {
29    fn seed_prepared_audience_write_for_test(
30        &self,
31        write_id: &WriteId,
32        changeset_hash: ObjectHash,
33    ) -> Result<(), DbError> {
34        let base = serde_json::to_string(&crate::StoreWriteBase {
35            dependencies: std::collections::BTreeMap::new(),
36        })
37        .map_err(|error| DbError::context("serialize test Store write base", error))?;
38        crate::DatabaseTestSql::for_store(self.conn, self.store_dir).transaction(|transaction| {
39            transaction
40                .execute(
41                    "INSERT INTO store_writes
42                     (write_id, status, affected_rows, changeset_hash, base, blob_facts)
43                     VALUES (?1, '\"pending\"', '[]', ?2, ?3, '{\"blobs\":[]}')",
44                    rusqlite::params![write_id.as_str(), changeset_hash.to_string(), base],
45                )
46                .map_err(DbError::from)?;
47            transaction.set_payload_owner_claims(
48                &crate::payload_store::store_write_owner_key(write_id),
49                &BTreeSet::from([changeset_hash]),
50            )
51        })
52    }
53
54    fn persist_prepared_audience_objects_for_test(
55        &self,
56        write_id: &WriteId,
57        remotes: &[coven_protocol::remote_object::RemoteObjectRecord],
58        packages: &[crate::PreparedAudiencePackage],
59        blobs: &[crate::PreparedAudienceBlob],
60    ) -> Result<(), DbError> {
61        crate::DatabaseTestSql::for_store(self.conn, self.store_dir).transaction(|transaction| {
62            for remote in remotes {
63                transaction
64                    .execute(
65                        "INSERT INTO remote_objects (object_id, state) VALUES (?1, ?2)",
66                        rusqlite::params![
67                            remote.object_id().to_string(),
68                            serde_json::to_string(remote).map_err(|error| {
69                                DbError::context("serialize prepared remote object", error)
70                            })?,
71                        ],
72                    )
73                    .map_err(DbError::from)?;
74            }
75            transaction.persist_prepared_audience_objects(self.store_dir, write_id, packages, blobs)
76        })
77    }
78
79    pub(crate) fn capture_test_changeset(&self, statements: &[String]) -> Result<Vec<u8>, DbError> {
80        let tables = self
81            .synced_tables
82            .iter()
83            .map(|table| table.name().to_string())
84            .collect::<Vec<_>>();
85        crate::DatabaseTestSql::for_store(self.conn, self.store_dir)
86            .capture_changeset(&tables, statements)
87    }
88
89    pub(crate) fn apply_test_changeset(&self, bytes: &[u8]) -> Result<crate::ApplyResult, DbError> {
90        crate::resolve_and_apply_changeset(
91            self.conn,
92            self.store_dir,
93            bytes,
94            self.synced_tables,
95            self.hlc.wall_now_ms(),
96        )
97    }
98
99    fn export_prepared_write(&self, write_id: &WriteId) -> Result<PreparedWriteTransfer, DbError> {
100        let connection = self.conn;
101        let write = connection
102            .query_row(
103                "SELECT status, affected_rows, changeset_hash,
104                        base, blob_facts, prepared
105                 FROM store_writes WHERE write_id = ?1",
106                [write_id.as_str()],
107                |row| {
108                    Ok((
109                        row.get(0)?,
110                        row.get(1)?,
111                        row.get(2)?,
112                        row.get(3)?,
113                        row.get(4)?,
114                        row.get(5)?,
115                    ))
116                },
117            )
118            .map_err(DbError::from)?;
119        let partitions = {
120            let mut statement = connection
121                .prepare(
122                    "SELECT audience, control_coord, changeset_hash
123                     FROM store_write_partitions WHERE write_id = ?1 ORDER BY audience",
124                )
125                .map_err(DbError::from)?;
126            let rows = statement
127                .query_map([write_id.as_str()], |row| {
128                    Ok((row.get(0)?, row.get(1)?, row.get(2)?))
129                })
130                .map_err(DbError::from)?
131                .collect::<Result<Vec<_>, _>>()
132                .map_err(DbError::from)?;
133            rows
134        };
135        let packages = {
136            let mut statement = connection
137                .prepare(
138                    "SELECT audience, remote_object_id
139                     FROM store_write_packages WHERE write_id = ?1 ORDER BY audience",
140                )
141                .map_err(DbError::from)?;
142            let rows = statement
143                .query_map([write_id.as_str()], |row| Ok((row.get(0)?, row.get(1)?)))
144                .map_err(DbError::from)?
145                .collect::<Result<Vec<_>, _>>()
146                .map_err(DbError::from)?;
147            rows
148        };
149        let blobs = {
150            let mut statement = connection
151                .prepare(
152                    "SELECT audience, locator_hash, remote_object_id, spool_path
153                     FROM store_write_blobs WHERE write_id = ?1
154                     ORDER BY audience, remote_object_id",
155                )
156                .map_err(DbError::from)?;
157            let rows = statement
158                .query_map([write_id.as_str()], |row| {
159                    Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
160                })
161                .map_err(DbError::from)?
162                .collect::<Result<Vec<_>, _>>()
163                .map_err(DbError::from)?;
164            rows
165        };
166        let remotes = {
167            let mut statement = connection
168                .prepare("SELECT object_id, state FROM remote_objects ORDER BY object_id")
169                .map_err(DbError::from)?;
170            let rows = statement
171                .query_map([], |row| {
172                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
173                })
174                .map_err(DbError::from)?
175                .collect::<Result<Vec<_>, _>>()
176                .map_err(DbError::from)?;
177            rows
178        };
179        let mut owner_keys = vec![crate::payload_store::store_write_owner_key(write_id)];
180        for (object_id, _) in &remotes {
181            let object_id = object_id
182                .parse()
183                .map_err(|error| DbError::context("parse transferred remote object id", error))?;
184            owner_keys.push(crate::payload_store::remote_object_owner_key(object_id));
185        }
186        let mut payload_claims = Vec::new();
187        let mut payload_hashes = BTreeSet::new();
188        for owner_key in owner_keys {
189            let claims = {
190                let mut statement = connection
191                    .prepare(
192                        "SELECT payload_hash FROM payload_owners
193                         WHERE owner_key = ?1 ORDER BY payload_hash",
194                    )
195                    .map_err(DbError::from)?;
196                let claims = statement
197                    .query_map([&owner_key], |row| row.get::<_, String>(0))
198                    .map_err(DbError::from)?
199                    .map(|encoded| {
200                        encoded.map_err(DbError::from)?.parse().map_err(|error| {
201                            DbError::context("parse transferred payload hash", error)
202                        })
203                    })
204                    .collect::<Result<BTreeSet<ObjectHash>, DbError>>()?;
205                claims
206            };
207            payload_hashes.extend(claims.iter().copied());
208            if !claims.is_empty() {
209                payload_claims.push((owner_key, claims));
210            }
211        }
212        let payloads = payload_hashes
213            .into_iter()
214            .map(|hash| {
215                Ok((
216                    hash,
217                    crate::store::store_session::StoreRecords::new(self.conn, self.store_dir)
218                        .payload(hash)?,
219                ))
220            })
221            .collect::<Result<Vec<_>, DbError>>()?;
222        Ok(PreparedWriteTransfer {
223            write,
224            partitions,
225            packages,
226            blobs,
227            remotes,
228            payload_claims,
229            payloads,
230        })
231    }
232
233    fn import_prepared_write(
234        &self,
235        write_id: &WriteId,
236        transfer: PreparedWriteTransfer,
237    ) -> Result<(), DbError> {
238        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
239        for (expected_hash, bytes) in &transfer.payloads {
240            let actual_hash =
241                crate::payload_store::write_payload_blocking(&transaction, self.store_dir, bytes)?;
242            if actual_hash != *expected_hash {
243                return Err(DbError::Message(format!(
244                    "transferred payload expected {expected_hash} but stored as {actual_hash}"
245                )));
246            }
247        }
248        for (object_id, state) in transfer.remotes {
249            let imported = transaction
250                .execute(
251                    "INSERT INTO remote_objects (object_id, state) VALUES (?1, ?2)
252                     ON CONFLICT(object_id) DO UPDATE SET state = excluded.state
253                     WHERE remote_objects.state = excluded.state",
254                    (object_id, state),
255                )
256                .map_err(DbError::from)?;
257            if imported != 1 {
258                return Err(DbError::Message(
259                    "prepared write remote object conflicts with restored state".to_string(),
260                ));
261            }
262        }
263        transaction
264            .execute(
265                "INSERT INTO store_writes
266                 (write_id, status, affected_rows, changeset_hash,
267                  base, blob_facts, prepared)
268                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
269                rusqlite::params![
270                    write_id.as_str(),
271                    transfer.write.0,
272                    transfer.write.1,
273                    transfer.write.2,
274                    transfer.write.3,
275                    transfer.write.4,
276                    transfer.write.5,
277                ],
278            )
279            .map_err(DbError::from)?;
280        for (audience, control, changeset_hash) in transfer.partitions {
281            transaction
282                .execute(
283                    "INSERT INTO store_write_partitions
284                     (write_id, audience, control_coord, changeset_hash)
285                     VALUES (?1, ?2, ?3, ?4)",
286                    rusqlite::params![write_id.as_str(), audience, control, changeset_hash],
287                )
288                .map_err(DbError::from)?;
289        }
290        for (audience, object_id) in transfer.packages {
291            transaction
292                .execute(
293                    "INSERT INTO store_write_packages
294                     (write_id, audience, remote_object_id) VALUES (?1, ?2, ?3)",
295                    rusqlite::params![write_id.as_str(), audience, object_id],
296                )
297                .map_err(DbError::from)?;
298        }
299        for (audience, locator_hash, object_id, spool_path) in transfer.blobs {
300            transaction
301                .execute(
302                    "INSERT INTO store_write_blobs
303                     (write_id, audience, locator_hash, remote_object_id, spool_path)
304                     VALUES (?1, ?2, ?3, ?4, ?5)",
305                    rusqlite::params![
306                        write_id.as_str(),
307                        audience,
308                        locator_hash,
309                        object_id,
310                        spool_path
311                    ],
312                )
313                .map_err(DbError::from)?;
314        }
315        for (owner_key, claims) in transfer.payload_claims {
316            crate::payload_store::set_payload_owner_claims_on(&transaction, &owner_key, &claims)?;
317        }
318        transaction.commit().map_err(DbError::from)
319    }
320
321    fn register_external_blob_for_test(
322        &self,
323        reference: &coven_protocol::blob::RowBlobRef,
324        path: &std::path::Path,
325    ) -> Result<(), DbError> {
326        crate::DatabaseTestSql::new(self.conn).register_external_blob(reference, path)
327    }
328
329    fn enqueue_blob_upload_for_test(
330        &self,
331        root_table: &str,
332        root_id: &str,
333        reference: &coven_protocol::blob::RowBlobRef,
334        source_path: &std::path::Path,
335        created_at: &str,
336    ) -> Result<(), DbError> {
337        crate::DatabaseTestSql::new(self.conn).enqueue_blob_upload(
338            root_table,
339            root_id,
340            &format!("{root_table}/{root_id}"),
341            reference,
342            source_path,
343            false,
344            created_at,
345        )
346    }
347
348    fn cleanup_intent_count_for_test(
349        &self,
350        namespace: &str,
351        blob_id: &str,
352    ) -> Result<i64, DbError> {
353        self.conn
354            .query_row(
355                "SELECT COUNT(*) FROM local_cleanup_intents
356                 WHERE namespace = ?1 AND blob_id = ?2",
357                (namespace, blob_id),
358                |row| row.get(0),
359            )
360            .map_err(DbError::from)
361    }
362
363    fn coven_table_exists_for_test(
364        &self,
365        table: crate::DatabaseTestTable,
366    ) -> Result<bool, DbError> {
367        self.conn
368            .query_row(
369                "SELECT EXISTS(
370                    SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1
371                 )",
372                [table.0],
373                |row| row.get(0),
374            )
375            .map_err(DbError::from)
376    }
377
378    fn install_store_write_failure_trigger_for_test(&self) -> Result<(), DbError> {
379        self.conn
380            .execute_batch(
381                "CREATE TRIGGER fail_store_write_journal
382                 BEFORE INSERT ON store_writes
383                 BEGIN
384                   SELECT RAISE(ABORT, 'injected Store write journal failure');
385                 END;",
386            )
387            .map_err(DbError::from)
388    }
389
390    fn remove_store_write_failure_trigger_for_test(&self) -> Result<(), DbError> {
391        self.conn
392            .execute_batch("DROP TRIGGER fail_store_write_journal")
393            .map_err(DbError::from)
394    }
395
396    fn write_blob_facts_for_test(&self, write_id: &WriteId) -> Result<String, DbError> {
397        self.conn
398            .query_row(
399                "SELECT blob_facts FROM store_writes WHERE write_id = ?1",
400                [write_id.as_str()],
401                |row| row.get(0),
402            )
403            .map_err(DbError::from)
404    }
405
406    fn install_test_active_circle(&self, label: &str) -> coven_protocol::circle::CircleId {
407        self.install_test_active_circle_with_control(label).0
408    }
409
410    fn install_test_active_circle_with_control(
411        &self,
412        label: &str,
413    ) -> (
414        coven_protocol::circle::CircleId,
415        coven_protocol::circle::CircleControlCoord,
416    ) {
417        let database = crate::DatabaseTestSql::new(self.conn);
418        database.install_test_active_circle(label)
419    }
420
421    fn install_test_inactive_circle(&self, label: &str) -> coven_protocol::circle::CircleId {
422        let database = crate::DatabaseTestSql::new(self.conn);
423        let (circle_id, _) = database.install_test_inactive_circle(label);
424        circle_id
425    }
426
427    fn insert_write_status_for_test(
428        &self,
429        write_id: &WriteId,
430        status: &str,
431        base: &str,
432    ) -> Result<(), DbError> {
433        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
434        let changeset_hash =
435            crate::payload_store::write_payload_blocking(&transaction, self.store_dir, b"")?;
436        let owner_key = crate::payload_store::store_write_owner_key(write_id);
437        transaction
438            .execute(
439                r#"INSERT INTO store_writes
440                 (write_id, status, affected_rows, changeset_hash, base, blob_facts)
441                 VALUES (?1, ?2, '[]', ?3, ?4, '{"blobs":[]}')"#,
442                (write_id.as_str(), status, changeset_hash.to_string(), base),
443            )
444            .map_err(DbError::from)?;
445        crate::payload_store::set_payload_owner_claims_on(
446            &transaction,
447            &owner_key,
448            &BTreeSet::from([changeset_hash]),
449        )?;
450        transaction.commit().map_err(DbError::from)
451    }
452
453    fn delete_write_for_test(&self, write_id: &WriteId) -> Result<(), DbError> {
454        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
455        crate::payload_store::release_payload_owner_on(
456            &transaction,
457            &crate::payload_store::store_write_owner_key(write_id),
458        )?;
459        transaction
460            .execute(
461                "DELETE FROM store_writes WHERE write_id = ?1",
462                [write_id.as_str()],
463            )
464            .map_err(DbError::from)?;
465        transaction.commit().map_err(DbError::from)
466    }
467
468    fn store_write_partition_for_test(&self, write_id: &WriteId) -> Result<Vec<u8>, DbError> {
469        let encoded: String = self
470            .conn
471            .query_row(
472                "SELECT changeset_hash FROM store_write_partitions
473                 WHERE write_id = ?1 AND audience = 'store'",
474                [write_id.as_str()],
475                |row| row.get(0),
476            )
477            .map_err(DbError::from)?;
478        let hash = encoded
479            .parse()
480            .map_err(|error| DbError::context("parse captured changeset hash", error))?;
481        Ok(
482            crate::store::store_session::StoreRecords::new(self.conn, self.store_dir)
483                .payload(hash)?,
484        )
485    }
486
487    /// How much of the store the write journal is holding: rows in
488    /// `store_writes` and the payload claims those rows own.
489    fn store_write_journal_counts_for_test(&self) -> Result<(i64, i64), DbError> {
490        let writes: i64 = self
491            .conn
492            .query_row("SELECT COUNT(*) FROM store_writes", [], |row| row.get(0))
493            .map_err(DbError::from)?;
494        let claims: i64 = self
495            .conn
496            .query_row(
497                "SELECT COUNT(*) FROM payload_owners WHERE owner_key LIKE 'store-write:%'",
498                [],
499                |row| row.get(0),
500            )
501            .map_err(DbError::from)?;
502        Ok((writes, claims))
503    }
504
505    fn write_blob_lease_count_for_test(&self, write_id: &WriteId) -> Result<i64, DbError> {
506        self.conn
507            .query_row(
508                "SELECT COUNT(*) FROM store_write_blob_leases WHERE write_id = ?1",
509                [write_id.as_str()],
510                |row| row.get(0),
511            )
512            .map_err(DbError::from)
513    }
514
515    fn latest_materialized_commit_coordinate_for_test(&self) -> Result<(String, u64), DbError> {
516        let (device_id, sequence): (String, i64) = self
517            .conn
518            .query_row(
519                "SELECT device_id, seq
520                 FROM materialized_commits
521                 ORDER BY seq DESC
522                 LIMIT 1",
523                [],
524                |row| Ok((row.get(0)?, row.get(1)?)),
525            )
526            .map_err(DbError::from)?;
527        let sequence = u64::try_from(sequence).map_err(|error| {
528            DbError::context(
529                format!("materialized commit sequence {sequence} is invalid"),
530                error,
531            )
532        })?;
533        Ok((device_id, sequence))
534    }
535
536    /// Replay the whole retained history and count `table`'s rows.
537    ///
538    /// A device that advanced its baseline replays from the new image plus the
539    /// commits it does not cover; this is how a test checks that what comes out
540    /// is still the store the live database holds.
541    /// Answer a read-only test query against the Store's own connection.
542    ///
543    /// A test that drives a real join or restore never opens the database
544    /// itself — the install is what creates it — so its assertions have to go
545    /// through the Store. Handing out the connection instead would put a raw
546    /// database in a caller's hands.
547    fn test_query_optional_text(&mut self, sql: &str) -> Result<Option<String>, DbError> {
548        self.conn
549            .query_row(sql, [], |row| row.get::<_, Option<String>>(0))
550            .optional()
551            .map(Option::flatten)
552            .map_err(DbError::from)
553    }
554
555    fn replay_row_count_for_test(
556        &mut self,
557        root: &coven_protocol::store_commit::StoreRootRef,
558        table: &str,
559    ) -> Result<i64, DbError> {
560        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
561        let replay =
562            crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir)
563                .replay_projection_with_authority(
564                    self.verified_store_authority,
565                    root,
566                    self.blob_decls,
567                    self.gates,
568                    self.synced_tables,
569                    None,
570                    &BTreeSet::new(),
571                    None,
572                    crate::ReplayJournal::Owed,
573                    coven_protocol::membership::LocalStoreMembership::Current,
574                )?;
575        let count = replay.row_count(table)?;
576        transaction.rollback().map_err(DbError::from)?;
577        Ok(count)
578    }
579
580    fn compare_circle_bootstrap_replay_with_missing_coverage_for_test(
581        &mut self,
582        root: &coven_protocol::store_commit::StoreRootRef,
583        routing_key: &coven_protocol::circle::RowRoutingKey,
584        historical_id: &str,
585        late_id: &str,
586    ) -> Result<(i64, i64, i64, i64), DbError> {
587        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
588        let retained =
589            crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir)
590                .replay_projection_with_authority(
591                    self.verified_store_authority,
592                    root,
593                    self.blob_decls,
594                    self.gates,
595                    self.synced_tables,
596                    Some(routing_key),
597                    &BTreeSet::new(),
598                    None,
599                    crate::ReplayJournal::Omit,
600                    coven_protocol::membership::LocalStoreMembership::Current,
601                )?;
602        let retained_count = retained.document_count(historical_id)?;
603        let retained_late_count = retained.document_count(late_id)?;
604        transaction
605            .execute("DELETE FROM circle_bootstrap_coverage", [])
606            .map_err(DbError::from)?;
607        let sabotaged =
608            crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir)
609                .replay_projection_with_authority(
610                    self.verified_store_authority,
611                    root,
612                    self.blob_decls,
613                    self.gates,
614                    self.synced_tables,
615                    Some(routing_key),
616                    &BTreeSet::new(),
617                    None,
618                    crate::ReplayJournal::Omit,
619                    coven_protocol::membership::LocalStoreMembership::Current,
620                )?;
621        let sabotaged_count = sabotaged.document_count(historical_id)?;
622        let sabotaged_late_count = sabotaged.document_count(late_id)?;
623        transaction.rollback().map_err(DbError::from)?;
624        Ok((
625            retained_count,
626            retained_late_count,
627            sabotaged_count,
628            sabotaged_late_count,
629        ))
630    }
631
632    fn circle_bootstrap_coverage_count_for_test(
633        &self,
634        circle_id: coven_protocol::circle::CircleId,
635    ) -> Result<i64, DbError> {
636        self.conn
637            .query_row(
638                "SELECT COUNT(*) FROM circle_bootstrap_coverage WHERE circle_id = ?1",
639                [circle_id.to_string()],
640                |row| row.get(0),
641            )
642            .map_err(DbError::from)
643    }
644
645    fn reject_missing_circle_bootstrap_payload_claim_for_test(
646        &self,
647        circle_id: coven_protocol::circle::CircleId,
648    ) -> Result<String, DbError> {
649        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
650        transaction
651            .execute(
652                "DELETE FROM payload_owners WHERE owner_key = ?1",
653                [crate::payload_store::circle_bootstrap_coverage_owner_key(
654                    circle_id,
655                )],
656            )
657            .map_err(DbError::from)?;
658        let error =
659            crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir)
660                .circle_bootstrap_replay_inputs()
661                .expect_err("Circle bootstrap replay must require its payload claim");
662        transaction.rollback().map_err(DbError::from)?;
663        Ok(error.to_string())
664    }
665
666    fn reject_changed_circle_bootstrap_image_hash_for_test(
667        &mut self,
668        circle_id: coven_protocol::circle::CircleId,
669        root: &coven_protocol::store_commit::StoreRootRef,
670        activation_commit: &StoreBatchCommitRef,
671    ) -> Result<String, DbError> {
672        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
673        transaction
674            .execute(
675                "UPDATE circle_bootstrap_coverage
676                 SET image_hash = ?2
677                 WHERE circle_id = ?1",
678                rusqlite::params![
679                    circle_id.to_string(),
680                    ObjectHash::digest(b"corrupt Circle bootstrap image hash").to_string(),
681                ],
682            )
683            .map_err(DbError::from)?;
684        let retained =
685            crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir)
686                .load_retained_merge_materialization_by_ref(
687                    root,
688                    self.verified_store_authority,
689                    activation_commit,
690                )?;
691        let error =
692            crate::store::store_session::StoreTransaction::new(&transaction, self.store_dir)
693                .record_circle_bootstrap_coverage(
694                    self.verified_store_authority,
695                    root,
696                    activation_commit,
697                    retained.circle_activations(),
698                )
699                .expect_err("changed image hash must conflict with its exact reference");
700        transaction.rollback().map_err(DbError::from)?;
701        Ok(error.to_string())
702    }
703
704    fn author_exclusion_activation_evidence_for_test(
705        &self,
706        exclusion: &str,
707    ) -> Result<(String, String), DbError> {
708        self.conn
709            .query_row(
710                "SELECT accepted_cut, activation_head
711                 FROM store_author_exclusion_activations
712                 WHERE exclusion_ref = ?1",
713                [exclusion],
714                |row| Ok((row.get(0)?, row.get(1)?)),
715            )
716            .map_err(DbError::from)
717    }
718
719    fn sole_author_exclusion_activation_evidence_for_test(
720        &self,
721    ) -> Result<(String, String, String, String), DbError> {
722        crate::test_support::author_exclusion_activation_evidence(self.conn)
723    }
724
725    fn latest_local_write_facts_for_test(&self) -> Result<(String, i64, i64), DbError> {
726        crate::DatabaseTestSql::for_store(self.conn, self.store_dir).latest_local_write_facts()
727    }
728
729    fn install_retracted_device_state_failure_trigger_for_test(&self) -> Result<(), DbError> {
730        crate::DatabaseTestSql::new(self.conn).install_retracted_device_state_failure_trigger()
731    }
732
733    fn prepared_write_count_for_test(&self, write_id: &WriteId) -> Result<i64, DbError> {
734        crate::DatabaseTestSql::new(self.conn).prepared_write_count(write_id)
735    }
736
737    fn install_indexed_shared_blobs_for_test(
738        &self,
739        write_id: &WriteId,
740        records: Vec<coven_protocol::remote_object::RemoteObjectRecord>,
741    ) -> Result<(), DbError> {
742        crate::DatabaseTestSql::for_store(self.conn, self.store_dir)
743            .install_indexed_shared_blobs(write_id, records)
744    }
745
746    /// Run the production candidate-nonactivation transition against one
747    /// indexed remote object, the way a lost merge race, an abandonment, or an
748    /// author exclusion does — so a test can put a write's own object into the
749    /// ownership state those paths produce.
750    fn begin_remote_candidate_nonactivation_for_test(
751        &self,
752        object_id: coven_protocol::store_commit::ObjectHash,
753        nonactivation: coven_protocol::remote_object::CandidateNonactivation,
754    ) -> Result<(), DbError> {
755        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
756        crate::begin_remote_candidate_nonactivation_on(&transaction, object_id, nonactivation)?;
757        transaction.commit().map_err(DbError::from)
758    }
759
760    fn replace_blob_row_stamp_for_test(
761        &self,
762        table: &str,
763        row_id: &str,
764        stamp: &str,
765    ) -> Result<(), DbError> {
766        self.conn
767            .execute(
768                &format!(
769                    "UPDATE {} SET _updated_at = ?2 WHERE id = ?1",
770                    crate::quote_ident(table)
771                ),
772                (row_id, stamp),
773            )
774            .map(|_| ())
775            .map_err(DbError::from)
776    }
777
778    fn replace_blob_row_facts_for_test(
779        &self,
780        table: &str,
781        row_id: &str,
782        size: i64,
783        hash: &str,
784        stamp: &str,
785    ) -> Result<(), DbError> {
786        self.conn
787            .execute(
788                &format!(
789                    "UPDATE {} SET size = ?2, hash = ?3, _updated_at = ?4 WHERE id = ?1",
790                    crate::quote_ident(table)
791                ),
792                rusqlite::params![row_id, size, hash, stamp],
793            )
794            .map(|_| ())
795            .map_err(DbError::from)
796    }
797
798    fn complete_note_blob_transition_to_remote_for_test(
799        &self,
800        reference: &coven_protocol::blob::RowBlobRef,
801        note_id: &str,
802    ) -> Result<(), DbError> {
803        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
804        crate::ExternalBlobRecords::new(&transaction).clear(reference)?;
805        transaction
806            .execute("UPDATE notes SET shared = 1 WHERE id = ?1", [note_id])
807            .map_err(DbError::from)?;
808        transaction.commit().map_err(DbError::from)
809    }
810
811    fn plant_blob_namespace_collision_for_test(
812        &self,
813        id: &str,
814        local_hash: &str,
815        remote_hash: &str,
816    ) -> Result<(), DbError> {
817        let transaction = self.conn.unchecked_transaction().map_err(DbError::from)?;
818        transaction
819            .execute(
820                "INSERT INTO notes (id, title, shared, _updated_at, created_at)
821                 VALUES ('note-local', 'x', 0, '0000000001000-0000-dev1', '2026-01-01'),
822                        ('note-remote', 'x', 1, '0000000001000-0000-dev1', '2026-01-01')",
823                [],
824            )
825            .map_err(DbError::from)?;
826        transaction
827            .execute(
828                "INSERT INTO note_photos
829                 (id, note_id, kind, size, hash, _updated_at, created_at)
830                 VALUES (?1, 'note-local', 'attach', 17, ?2,
831                         '0000000001000-0000-dev1', '2026-01-01')",
832                rusqlite::params![id, local_hash],
833            )
834            .map_err(DbError::from)?;
835        transaction
836            .execute(
837                "INSERT INTO note_covers
838                 (id, note_id, size, hash, _updated_at, created_at)
839                 VALUES (?1, 'note-remote', 18, ?2,
840                         '0000000001000-0000-dev1', '2026-01-01')",
841                rusqlite::params![id, remote_hash],
842            )
843            .map_err(DbError::from)?;
844        transaction.commit().map_err(DbError::from)
845    }
846
847    fn plant_note_cover_blob_row_for_test(
848        &self,
849        id: &str,
850        note_id: &str,
851        size: i64,
852        hash: &str,
853    ) -> Result<(), DbError> {
854        self.conn
855            .execute(
856                "INSERT INTO note_covers
857                 (id, note_id, size, hash, _updated_at, created_at)
858                 VALUES (?1, ?2, ?3, ?4, '0000000001000-0000-dev1', '2026-01-01')",
859                rusqlite::params![id, note_id, size, hash],
860            )
861            .map(|_| ())
862            .map_err(DbError::from)
863    }
864
865    fn circle_bootstrap_failure_state_for_test(
866        &self,
867        blob_id: &str,
868        circle_id: coven_protocol::circle::CircleId,
869        control: &str,
870        remote_object_id: String,
871    ) -> Result<(bool, bool, bool, bool), DbError> {
872        crate::DatabaseTestSql::new(self.conn).circle_bootstrap_failure_state(
873            blob_id,
874            circle_id,
875            control,
876            remote_object_id,
877        )
878    }
879
880    fn circle_bootstrap_replay_for_control_for_test(
881        &self,
882        circle_id: coven_protocol::circle::CircleId,
883        control: &coven_protocol::circle::CircleControlCoord,
884    ) -> Result<Option<coven_protocol::circle_activation::VerifiedCircleImage>, DbError> {
885        Ok(crate::DatabaseTestSql::for_store(self.conn, self.store_dir)
886            .circle_bootstrap_replay_inputs()?
887            .into_iter()
888            .find_map(|(_, bootstrap)| {
889                (bootstrap.circle_id() == circle_id && bootstrap.control() == control)
890                    .then_some(bootstrap)
891            }))
892    }
893
894    fn forge_circle_close_exclusion_for_test(
895        &self,
896        circle_id: coven_protocol::circle::CircleId,
897    ) -> Result<(), DbError> {
898        crate::DatabaseTestSql::new(self.conn).forge_circle_close_exclusion(circle_id)
899    }
900
901    fn tamper_author_exclusion_locator_for_test(
902        &self,
903        exclusion: StoreDeviceExclusionRef,
904        candidate: &StoreBatchCommitRef,
905        tamper: AuthorExclusionLocatorTamper,
906    ) -> Result<(), DbError> {
907        let connection = self.conn;
908        let exact = serde_json::to_string(&exclusion)
909            .map_err(|error| DbError::context("serialize exact exclusion reference", error))?;
910        let affected = match tamper {
911            AuthorExclusionLocatorTamper::Missing => connection.execute(
912                "DELETE FROM store_author_exclusion_activations
913                 WHERE exclusion_ref = ?1",
914                [&exact],
915            ),
916            AuthorExclusionLocatorTamper::ExclusionReference => {
917                let mut wrong = exclusion;
918                wrong.outcome_hash = ObjectHash::digest(b"wrong exclusion reference");
919                let wrong = serde_json::to_string(&wrong).map_err(|error| {
920                    DbError::context("serialize wrong exclusion reference", error)
921                })?;
922                connection.execute(
923                    "UPDATE store_author_exclusion_activations
924                     SET exclusion_ref = ?1 WHERE exclusion_ref = ?2",
925                    (&wrong, &exact),
926                )
927            }
928            AuthorExclusionLocatorTamper::AcceptedCut => {
929                let cut: String = connection
930                    .query_row(
931                        "SELECT accepted_cut
932                         FROM store_author_exclusion_activations
933                         WHERE exclusion_ref = ?1",
934                        [&exact],
935                        |row| row.get(0),
936                    )
937                    .map_err(DbError::from)?;
938                let mut cut: std::collections::BTreeMap<
939                    coven_protocol::causal_grants::AuthorStreamId,
940                    StoreBatchCommitRef,
941                > = serde_json::from_str(&cut)
942                    .map_err(|error| DbError::context("parse exclusion accepted cut", error))?;
943                cut.insert(
944                    coven_protocol::causal_grants::AuthorStreamId::from_digest(ObjectHash::digest(
945                        b"wrong exclusion accepted-cut stream",
946                    )),
947                    candidate.clone(),
948                );
949                let wrong = serde_json::to_string(&cut).map_err(|error| {
950                    DbError::context("serialize wrong exclusion accepted cut", error)
951                })?;
952                connection.execute(
953                    "UPDATE store_author_exclusion_activations
954                     SET accepted_cut = ?1 WHERE exclusion_ref = ?2",
955                    (&wrong, &exact),
956                )
957            }
958            AuthorExclusionLocatorTamper::ActivationCommit => {
959                let wrong = serde_json::to_string(candidate).map_err(|error| {
960                    DbError::context("serialize wrong exclusion activation commit", error)
961                })?;
962                connection.execute(
963                    "UPDATE store_author_exclusion_activations
964                     SET activation_commit = ?1 WHERE exclusion_ref = ?2",
965                    (&wrong, &exact),
966                )
967            }
968            AuthorExclusionLocatorTamper::ActivationHead => {
969                let head: String = connection
970                    .query_row(
971                        "SELECT activation_head
972                         FROM store_author_exclusion_activations
973                         WHERE exclusion_ref = ?1",
974                        [&exact],
975                        |row| row.get(0),
976                    )
977                    .map_err(DbError::from)?;
978                let mut head: StoreDeviceHeadRef = serde_json::from_str(&head)
979                    .map_err(|error| DbError::context("parse exclusion activation head", error))?;
980                head.head_hash = ObjectHash::digest(b"wrong exclusion activation head");
981                let wrong = serde_json::to_string(&head).map_err(|error| {
982                    DbError::context("serialize wrong exclusion activation head", error)
983                })?;
984                connection.execute(
985                    "UPDATE store_author_exclusion_activations
986                     SET activation_head = ?1 WHERE exclusion_ref = ?2",
987                    (&wrong, &exact),
988                )
989            }
990        }
991        .map_err(DbError::from)?;
992        if affected != 1 {
993            return Err(DbError::Message(format!(
994                "locator tamper {tamper:?} changed {affected} rows"
995            )));
996        }
997        Ok(())
998    }
999}
1000
1001mod blob;
1002mod database;
1003mod device_exclusion;