Skip to main content

coven_database/
lib.rs

1//! Owned SQLite writes and concurrent application reads.
2//!
3//! [`Database`] serializes writes, sync bookkeeping, changeset capture and
4//! apply on its owned connection. [`store::StoreReads`] owns a bounded pool of
5//! read-only connections for application queries, with one transaction per
6//! operation and separate bounded workers for processing owned query results.
7//!
8//! Hosts open coven with `Coven::builder` and run app SQL through
9//! `CovenHandle::write` or `CovenHandle::read`.
10
11pub(crate) use crate::blob_records::load_activated_registration_on;
12pub use crate::blob_records::remote_audience_to_db;
13pub(crate) use crate::circle_snapshot_records::load_outbound_circle_snapshot_on;
14pub(crate) use crate::circle_snapshot_records::load_published_circle_snapshot_on;
15pub use crate::cloud_outbox_records::{
16    outbox_identity, row_to_outbox_entry, CloudOutboxRecords, OutboxIdentity,
17};
18use crate::connection_io::scan_max_updated_at;
19use crate::connection_io::seed_from;
20pub(crate) use crate::local_state::{
21    delete_protocol_state_on, get_protocol_state_on, required_protocol_state_on,
22    set_protocol_state_on,
23};
24use crate::local_store_identity::pin_host_device_id_on;
25use crate::local_store_identity::validate_host_device_id_on;
26pub(crate) use crate::remote_object_records::begin_remote_candidate_nonactivation_on;
27pub use crate::remote_object_records::candidate_graph_exact_objects;
28pub(crate) use crate::remote_object_records::index_retained_replay_owner_on;
29#[cfg(any(test, feature = "test-utils"))]
30pub(crate) use crate::remote_object_records::load_protocol_inert_object_on;
31pub(crate) use crate::remote_object_records::load_remote_object_on;
32pub(crate) use crate::remote_object_records::mark_remote_object_uploaded_on;
33pub(crate) use crate::remote_object_records::mark_reusable_retained_authority_uploaded_on;
34pub(crate) use crate::remote_object_records::persist_exact_remote_object_on;
35pub(crate) use crate::remote_object_records::persist_prepared_remote_object_on;
36pub(crate) use crate::remote_object_records::record_reclaimed_store_package_on;
37pub(crate) use crate::remote_object_records::reopen_remote_object_on;
38pub(crate) use crate::remote_object_records::update_remote_object_on;
39pub(crate) use crate::remote_object_records::{
40    validate_prepared_blob_on, validate_prepared_package_on, validate_remote_object_on,
41};
42use crate::snapshot_objects::validate_snapshot_object_owners_on;
43pub(crate) use crate::snapshot_objects::{
44    install_snapshot_blob_plan_on, install_snapshot_blob_plans_on, persist_snapshot_image_on,
45    validate_snapshot_blob_plans_on,
46};
47pub use crate::snapshot_objects::{
48    snapshot_generation_as_i64, validate_snapshot_author, validate_snapshot_image,
49};
50pub(crate) use crate::snapshot_records::load_outbound_store_snapshot_on;
51pub(crate) use crate::store_ack_records::{
52    finish_outbound_store_ack_on, load_published_store_ack_on, verify_next_local_store_ack_on,
53};
54pub(crate) use crate::store_authority_records::install_store_founder_state_on;
55pub(crate) use crate::store_reclaim_records::{
56    clear_store_reclaim_operation_stuck_on, insert_store_reclaim_operation_on,
57    load_store_reclaim_operation_on, mark_store_reclaim_operation_stuck_on,
58    update_store_reclaim_operation_on,
59};
60pub use crate::store_reclaim_records::{
61    parse_store_reclaim_operation, store_reclaim_journal_error,
62};
63use coven_protocol::store_commit::CommitFrontier;
64use std::collections::{BTreeMap, BTreeSet};
65use std::path::{Path, PathBuf};
66use std::sync::Arc;
67
68use coven_keys::encryption::EncryptionService;
69use coven_protocol::audience_package::{AudiencePackage, RowBlobLocatorBinding};
70use coven_protocol::blob::locator::{BlobLocator, RemoteAudience, StoredBlobRef};
71use coven_protocol::blob::{BlobRef, RowBlobAuthority, RowBlobRef};
72use coven_protocol::circle::Audience;
73use coven_protocol::hlc::{Hlc, Timestamp, HIGHWATER_STATE_KEY, MAX_FUTURE_SKEW_MS};
74use coven_protocol::membership::{
75    AuthorHead, MembershipEntry, MembershipEntryRef, MembershipHeadRef,
76};
77use coven_protocol::objects::{ExactObjectRef, PreparedExactObject};
78use coven_protocol::remote_object::{
79    remote_object_id, CandidateExclusiveObjectDomain, RemoteObjectRecord, RetainedReplayOwner,
80    SharedLiveSetObjectDomain,
81};
82use coven_protocol::store_commit::{
83    ack_slot_prefix, ObjectHash, ResolvedStoreDeviceState, SnapshotImageRef, SnapshotMeta,
84    StoreAck, StoreAckRef, StoreBatchCommit, StoreBatchCommitRef, StoreCommitCoord,
85    StoreDeviceRegistration, StoreDeviceRegistrationRef, StoreProtocolRoot, StoreSnapshotRef,
86};
87use coven_protocol::synced_schema::SyncedTable;
88use coven_protocol::write::{WriteId, WriteStatus};
89use rusqlite::{Connection, OptionalExtension};
90
91pub use rusqlite;
92
93mod blob_bindings;
94pub(crate) use blob_bindings::{
95    install_pulled_merge_membership_activations_on, install_pulled_package_activation_on,
96};
97mod blob_declarations;
98mod blob_records;
99mod changeset;
100mod changeset_identity;
101mod circle_operation_records;
102mod cloud_outbox_records;
103mod connection_io;
104mod coven_migration;
105mod coven_schema;
106mod coven_schema_definitions;
107mod database_connection;
108pub(crate) use connection_io::capture_changeset;
109#[cfg(any(test, feature = "test-utils"))]
110pub(crate) use coven_migration::COVEN_SCHEMA_VERSION_STATE_KEY;
111pub(crate) use coven_migration::{
112    initialize_coven_schema_version, run_coven_migrations_in_transaction,
113    run_uninitialized_snapshot_coven_migrations_in_transaction, validate_coven_schema_for_reader,
114};
115pub use coven_migration::{CovenMigrationError, CovenMigrationPolicy};
116#[cfg(test)]
117pub(crate) use coven_schema::all_table_names;
118pub(crate) use coven_schema::{
119    apply_coven_routing_schema, apply_coven_schema, live_coven_schema_manifest, user_table_names,
120};
121pub use coven_schema::{
122    expected_coven_schema_manifest, is_reserved_table_name, CovenSchemaManifest,
123};
124mod circle_snapshot_records;
125mod database_open;
126mod database_runtime;
127mod database_session;
128mod external_blob_records;
129mod gate;
130mod live_query;
131mod local_state;
132mod local_store_identity;
133mod make_remote;
134mod migration;
135mod operation_models;
136pub use operation_models::{
137    ActiveStorePublication, ActiveStorePublicationAttempt, ActiveStorePublicationOwner,
138    RetiredStoreCandidate, RetiredStoreCandidateInputs,
139};
140mod prepared_audience_objects;
141mod prepared_external_blob;
142mod remote_object_records;
143mod routing_contract;
144mod schema_contract;
145mod schema_introspection;
146mod snapshot_objects;
147mod snapshot_records;
148pub mod store;
149mod store_ack_records;
150mod store_authority_records;
151mod store_coordinates;
152mod store_reclaim_records;
153#[cfg(any(test, feature = "test-utils"))]
154mod test_sql;
155#[cfg(any(test, feature = "test-utils"))]
156pub mod test_support;
157#[cfg(any(test, feature = "test-utils"))]
158mod test_transaction;
159#[cfg(any(test, feature = "test-utils"))]
160pub use coven_schema::DatabaseTestTable;
161#[cfg(any(test, feature = "test-utils"))]
162pub(crate) use test_sql::DatabaseTestSql;
163#[cfg(any(test, feature = "test-utils"))]
164pub use test_support::synthetic_store;
165#[cfg(any(test, feature = "test-utils"))]
166pub use test_support::{
167    DatabaseImageTest, OutboxAttempt, RetainedRegistrationTamper, ScopedRoutingStateForTest,
168};
169#[cfg(any(test, feature = "test-utils"))]
170pub(crate) use test_transaction::DatabaseTestTransaction;
171mod write_lifecycle;
172mod write_models;
173
174#[cfg(any(test, feature = "test-utils"))]
175pub use blob_declarations::{from_tables_call_count, reset_from_tables_call_count};
176pub use blob_declarations::{BlobDeclError, BlobDecls, PublicationBlob};
177pub(crate) use blob_records::{load_prepared_audience_objects_on, previous_row_blob_for_write_on};
178pub use changeset::{
179    value_ref_to_string, walk as walk_changeset, walk_old as walk_old_changeset, ChangesetError,
180};
181pub use changeset_identity::ChangesetIdentityError;
182pub(crate) use circle_operation_records::{
183    circle_operation_ids_in_phase_on, circle_operation_phase_json,
184};
185pub(crate) use circle_operation_records::{
186    circle_operation_uploaded_steps_on, load_circle_operation_on,
187};
188pub use circle_operation_records::{parse_circle_operation_row, PreparedCircleOperationRow};
189pub use coven_protocol::objects::{ExactProtocolObject, PreparedProtocolObject};
190pub use database_connection::PreparedStoreSnapshot;
191pub(crate) use database_connection::{DatabaseConnection, DatabaseCore};
192use database_open::CovenMetadataOpen;
193pub use database_runtime::Database;
194pub use external_blob_records::ExternalBlob;
195use external_blob_records::ExternalBlobRecords;
196pub(crate) use gate::query_truth;
197pub(crate) use gate::{
198    active_circle_control, align_inbound_scoped_root_audiences, audience_moves,
199    capture_routing_changes, filter_inbound_circle_changeset, filter_inbound_store_rows,
200    live_row_audience, normalize_inbound_store_changeset, partition_outbound,
201    prune_ineligible_scoped_rows, prune_private_routes_without_rows, retain_snapshot_audience_rows,
202    validate_scoped_foreign_key_audiences, validate_snapshot_routing_state,
203};
204#[cfg(any(test, feature = "test-utils"))]
205pub use gate::{
206    from_tables_call_count as gate_from_tables_call_count,
207    reset_from_tables_call_count as reset_gate_from_tables_call_count,
208};
209pub use gate::{
210    is_routing_table, store_audience_transitions, AudienceMove, AudiencePartition,
211    CircleControlFailure, CirclePartitionControl, CirclePartitionControlError, GateError, Gates,
212    RoutingChanges, StoreAudienceTransitions,
213};
214pub use live_query::{CommittedChanges, QueryDependencies};
215pub(crate) use local_store_identity::local_activated_registration_ref_on;
216pub use migration::supported_version;
217pub(crate) use migration::{ensure_schema_supported, run_migrations_in_transaction};
218pub use migration::{Migration, MigrationContext, MigrationError, MigrationStep};
219pub use operation_models::{
220    DurableCircleSnapshotPublication, DurableDeviceRegistration, DurableMembershipMutation,
221    DurableSnapshotPublication, LocalDeviceRegistrationJournalRow, LocalDeviceRegistrationState,
222    OwnerRecoveryPublication, PreparedLocalDeviceRegistrationRow, PreparedSnapshotBlob,
223    PublishedCircleSnapshot, PublishedStoreSnapshot, StoreSnapshotPublicationStage,
224};
225pub use prepared_audience_objects::{
226    validate_prepared_audience_blob_graph, BlobActivation, MakeRemoteIntentState,
227    PreparedAudienceBlob, PreparedAudienceObjects, PreparedAudiencePackage, PreparedRemoteObject,
228    StoredBlobReferenceState,
229};
230pub use prepared_external_blob::{prepare_external_blob, PreparedExternalBlob};
231pub use routing_contract::SyncRoutingContract;
232pub use routing_contract::SyncRoutingContractError;
233use schema_contract::validate_host_synced_tables;
234pub use schema_contract::DurablePreparedProtocolObject;
235pub use schema_contract::{StoreBatchCompletion, StoreBatchLocalCleanup};
236
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum MaterializationHold {
239    ForeignKeyDependency,
240    ConstraintConflict(Vec<String>),
241    PrivateSharedConflict {
242        table: String,
243        row_id: String,
244        commit: coven_protocol::store_commit::StoreBatchCommitRef,
245    },
246}
247
248pub type MaterializationOutcome = coven_protocol::membership::ApplyOutcome<MaterializationHold>;
249
250pub(crate) use schema_introspection::{create_table_sql, foreign_key_edges, table_columns};
251pub use schema_introspection::{
252    quote_ident, rewrite_create_into_schema, CreateTableSchemaError, ForeignKeyEdge,
253    ForeignKeySchemaError,
254};
255pub use store::device_join_journal;
256pub use store::device_join_journal::DeviceJoinJournalError;
257pub(crate) use store::payload_store;
258pub use store::PayloadStoreError;
259pub use store::{
260    activated_merge_membership_remote_objects, DeviceJoinBootstrapCommit, DeviceJoinBootstrapPlan,
261    DeviceJoinBootstrapRowData, MembershipAuthorityBytes, PreparedMergeMaterialization,
262    PreparedMergeMaterializationPackage, ResolvedDeviceJoinBootstrap,
263    VerifiedStoreSnapshotAuthority,
264};
265pub use store::{
266    audience_moves_by_row, local_blob_cleanup_intents, AudienceBlobMoveStaging, PostUpload,
267    StagedAudienceBlobRollback,
268};
269pub(crate) use store::{
270    copy_table_with_conflicts, install_circle_bootstrap_image_on,
271    install_circle_bootstrap_remote_objects_on,
272};
273pub use store::{
274    projection_table_names, AcceptedStoreCommitEvidence, AcceptedStoreCommitPublication,
275    AcceptedStorePublicationInterval, AdvancedReplayBaseline, BlobTransitionRoot,
276    BlobUploadDrainPermit, BlockedWriteDiscard, CandidateCleanupObject, CircleAckPublicationInput,
277    CreatedSnapshot, DeviceJoinJournalStore, DurableStoreReclaimObject,
278    DurableStoreReclaimOperation, HostWriteBlobTransaction, HostWriteError, HostWriteOperation,
279    IncomingTimestampPolicy, InstalledReplayBaseline, LocalBlobCleanup, MakeRemoteAdmission,
280    MaterializedLocalBlob, ObservedStorePublication, OutboxEntry, OutboxFailure, OutboxFailureKind,
281    OutboxOperation, OutboxUploadState, OwnStreamAuthorship, OwnedVerifiedMergeMaterialization,
282    PreparedCircleObjects, ReclaimedStorePackage, RetainedAudiencePackage,
283    RetainedMergeHistoryCheckpoint, RetainedMergeMaterializationKey, RetainedPackageApplication,
284    RetainedReplayAuthority, RetainedReplayBaseline, RetainedReplayGenesisAuthority,
285    SnapshotBlobFact, SnapshotDatabaseImage, SnapshotImageError, SnapshotImageOperationError,
286    SnapshotPublicationPermit, StoreCommitPublicationOutcome, StoreDatabase,
287    StorePublicationBoundary, StorePublicationPreparation, StoreReclaimJournalError,
288    StoreRowWrites, StoreWritePreparation, StuckReclaimOperation, TableSchema, ValidatedChangeset,
289    VerifiedMergeMaterialization, VerifiedMergeMembershipObjects, WinningRow,
290};
291#[cfg(any(test, feature = "test-utils"))]
292pub use store::{resolve_and_apply_changeset, ApplyResult};
293pub use store::{BlobFileFailure, BlobFileFailures, SqlContext, SqlReadContext, WriteBatch};
294pub use store::{
295    CloudOutboxSnapshot, MakeRemoteProgress, QueuedDelete, QueuedMakeRemote, QueuedUpload,
296    QueuedUploadPhase,
297};
298pub use store_authority_records::DurableFounderMembershipJournal;
299pub(crate) use store_authority_records::{
300    founder_graph_identity, install_store_root_authority_on, load_local_store_founder_graph_on,
301    load_store_root_authority_on,
302};
303pub use store_authority_records::{
304    DurableFounderGraph, DurableFounderMembership, FounderMembershipRefs, StoreOwnerAnchor,
305};
306pub use write_models::{
307    ActivatedStoreAck, InitialStoreMembershipAuthority, OutboundStoreAck,
308    OutboundStoreAckActivation, PreparedStoreWrite, PreparedStoreWriteCommit,
309    PreparedStoreWritePartitions, PublishedStoreAck, StoreWriteBase, StoreWriteBlobFact,
310    StoreWriteBlobFacts, StoreWriteBlobMoveMaterialization, StoreWriteRemoteBlob,
311    StoreWriteRouting,
312};
313pub(crate) use write_models::{
314    MergeReplayWrite, MergeReplayWriteEffect, ReplayJournal, SettledStoreWrite,
315};
316
317pub const LOCAL_DEVICE_ID_STATE_KEY: &str = "local_device_id";
318const HOST_DEVICE_ID_STATE_KEY: &str = "host_device_id";
319pub const SYNC_ROUTING_CONTRACT_STATE_KEY: &str = "sync_routing_contract";
320pub const SYNC_ROUTING_HASH_STATE_KEY: &str = "sync_routing_hash";
321pub const COVEN_SCHEMA_MANIFEST_STATE_KEY: &str = "coven_schema_manifest";
322pub const COVEN_INITIALIZED_STATE_KEY: &str = "coven_initialized";
323pub const COVEN_INITIALIZED_STATE_VALUE: &str = "1";
324pub const STORE_DEVICE_GENESIS_STATE_KEY: &str = "store_device_genesis_state";
325const GATE_BASELINE_SCHEMA: &str = "coven_gate_empty";
326const COVEN_CLEANUP_GUARD_PREFIX: &str = "coven_cleanup_guard_";
327
328fn is_coven_cleanup_guard_name(name: &str) -> bool {
329    name.get(..COVEN_CLEANUP_GUARD_PREFIX.len())
330        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(COVEN_CLEANUP_GUARD_PREFIX))
331}
332
333thread_local! {
334    /// How many Coven-owned write operations are on the current call stack.
335    ///
336    /// The host-SQL authorizer denies statements that access Coven's reserved
337    /// tables, but Coven's own entry points are documented to run inside the
338    /// host's write closure (`register_external_blob`, `enqueue_blob_delete`,
339    /// `clear_external_blob` all bind to the row version the same write
340    /// produced). Those operations announce themselves through this depth so
341    /// the authorizer can tell "Coven writing its own bookkeeping" apart from
342    /// "host SQL reaching into it" — the statement text is identical; the
343    /// caller is not. Thread-local is sound because a write closure and every
344    /// statement it executes run synchronously on one thread.
345    static COVEN_SQL_AUTHORITY_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
346    static HOST_SQL_WRITE_SEEN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
347}
348
349pub(crate) fn reset_host_sql_write_observation() {
350    HOST_SQL_WRITE_SEEN.with(|seen| seen.set(false));
351}
352
353pub(crate) fn host_sql_write_was_observed() -> bool {
354    HOST_SQL_WRITE_SEEN.with(std::cell::Cell::get)
355}
356
357pub(crate) fn observe_host_sql_write() {
358    HOST_SQL_WRITE_SEEN.with(|seen| seen.set(true));
359}
360
361/// Run `f` with Coven's own SQL authority, so the host-SQL authorizer permits
362/// the reserved-table writes it performs. Panic-safe: the depth restores when
363/// the guard drops.
364pub(crate) fn with_coven_sql_authority<R>(f: impl FnOnce() -> R) -> R {
365    struct DepthGuard;
366    impl Drop for DepthGuard {
367        fn drop(&mut self) {
368            COVEN_SQL_AUTHORITY_DEPTH.with(|depth| depth.set(depth.get() - 1));
369        }
370    }
371    COVEN_SQL_AUTHORITY_DEPTH.with(|depth| depth.set(depth.get() + 1));
372    let _guard = DepthGuard;
373    f()
374}
375
376pub(crate) fn authorize_host_sql(
377    context: rusqlite::hooks::AuthContext<'_>,
378) -> rusqlite::hooks::Authorization {
379    use rusqlite::hooks::{AuthAction, Authorization};
380
381    let coven_owned = COVEN_SQL_AUTHORITY_DEPTH.with(|depth| depth.get()) > 0;
382    if !coven_owned
383        && matches!(
384            context.action,
385            AuthAction::Delete { .. } | AuthAction::Insert { .. } | AuthAction::Update { .. }
386        )
387    {
388        HOST_SQL_WRITE_SEEN.with(|seen| seen.set(true));
389    }
390    if coven_owned {
391        return Authorization::Allow;
392    }
393
394    let runs_from_coven_cleanup_guard = context.accessor.is_some_and(is_coven_cleanup_guard_name);
395    let mut accesses_coven_table = match context.action {
396        AuthAction::Delete { table_name }
397        | AuthAction::Insert { table_name }
398        | AuthAction::CreateTable { table_name }
399        | AuthAction::DropTable { table_name }
400        | AuthAction::CreateVtable { table_name, .. }
401        | AuthAction::DropVtable { table_name, .. } => is_reserved_table_name(table_name),
402        AuthAction::Update { table_name, .. }
403        | AuthAction::Read { table_name, .. }
404        | AuthAction::CreateIndex { table_name, .. }
405        | AuthAction::DropIndex { table_name, .. }
406        | AuthAction::CreateTrigger { table_name, .. }
407        | AuthAction::DropTrigger { table_name, .. }
408        | AuthAction::AlterTable { table_name, .. } => is_reserved_table_name(table_name),
409        _ => false,
410    };
411    if runs_from_coven_cleanup_guard {
412        accesses_coven_table = false;
413    }
414    let changes_coven_cleanup_guard = match context.action {
415        AuthAction::CreateTempTrigger { trigger_name, .. }
416        | AuthAction::CreateTrigger { trigger_name, .. }
417        | AuthAction::DropTempTrigger { trigger_name, .. }
418        | AuthAction::DropTrigger { trigger_name, .. } => is_coven_cleanup_guard_name(trigger_name),
419        _ => false,
420    };
421    if accesses_coven_table
422        || changes_coven_cleanup_guard
423        || matches!(
424            context.action,
425            AuthAction::Transaction { .. } | AuthAction::Savepoint { .. }
426        )
427        || context
428            .database_name
429            .is_some_and(|name| name.eq_ignore_ascii_case(GATE_BASELINE_SCHEMA))
430        || matches!(
431            context.action,
432            AuthAction::Detach { database_name }
433                if database_name.eq_ignore_ascii_case(GATE_BASELINE_SCHEMA)
434        )
435        || matches!(
436            context.action,
437            AuthAction::Pragma { pragma_name, .. }
438                if pragma_name.eq_ignore_ascii_case("database_list")
439        )
440    {
441        Authorization::Deny
442    } else {
443        Authorization::Allow
444    }
445}
446
447/// A staged audience-move blob file that could not be rolled back, and why.
448/// Names the file so a host learns which staged bytes are left on disk.
449#[derive(Debug)]
450pub struct StagedBlobRollbackFailure {
451    pub path: PathBuf,
452    pub reason: StagedBlobRollbackReason,
453}
454
455#[derive(Debug, thiserror::Error)]
456pub enum StagedBlobRollbackReason {
457    #[error("staged audience blob disappeared before rollback")]
458    Missing,
459    #[error("{0}")]
460    File(#[from] coven_foundation::atomic_file::FileError),
461}
462
463impl std::fmt::Display for StagedBlobRollbackFailure {
464    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465        write!(formatter, "{}: {}", self.path.display(), self.reason)
466    }
467}
468
469/// Every staged file that could not be rolled back, in the order attempted.
470#[derive(Debug)]
471pub struct StagedBlobRollbackFailures(pub Vec<StagedBlobRollbackFailure>);
472
473impl std::fmt::Display for StagedBlobRollbackFailures {
474    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475        for (index, failure) in self.0.iter().enumerate() {
476            if index > 0 {
477                formatter.write_str("; ")?;
478            }
479            write!(formatter, "{failure}")?;
480        }
481        Ok(())
482    }
483}
484
485/// An error from the owned database.
486#[derive(Debug, thiserror::Error)]
487pub enum DbError {
488    #[error("database error: {0}")]
489    Message(String),
490    #[error("Store writes depend on state being removed: {writes:?}")]
491    WriteDependencyConflict { writes: Vec<WriteId> },
492    #[error("{0}")]
493    WriteRebaseConflict(#[source] Box<coven_protocol::write::WriteRebaseConflict>),
494    #[error("replay retirement cut is not a canonical application prefix")]
495    ReplayRetirementCutNotPrefix,
496    /// The prepared pull no longer starts at the installed publication.
497    /// Its transaction made no changes; the initiator must prepare it again.
498    #[error("Store publication changed while the pull was being prepared")]
499    StorePublicationChanged,
500    #[error(
501        "write callback prepared no INSERT, UPDATE, or DELETE statement; pure reads belong on read"
502    )]
503    ReadOnlyWriteTransaction,
504    #[error("{0}")]
505    Sqlite(#[from] rusqlite::Error),
506    #[error("failed to construct the expected Coven schema manifest: {0}")]
507    ExpectedSchema(#[source] &'static rusqlite::Error),
508    /// A JSON column's bytes did not read back as the value they encode, or a
509    /// value would not encode. Every synced-protocol column is stored as JSON,
510    /// so this is the shape of every column-level decode failure.
511    #[error("{0}")]
512    Serde(#[from] serde_json::Error),
513    /// Durable bytes failed the Store protocol's own validation — a hash that
514    /// does not match, a signature that does not verify, an object in the wrong
515    /// slot. The database read them back intact; the protocol refused them.
516    #[error("{0}")]
517    Protocol(#[source] Box<coven_protocol::store_commit::StoreProtocolError>),
518    #[error("{0}")]
519    RemoteObject(#[source] Box<coven_protocol::remote_object::RemoteObjectRecordError>),
520    #[error("{0}")]
521    AudiencePackage(#[source] Box<coven_protocol::audience_package::AudiencePackageError>),
522    #[error("{0}")]
523    ObjectHash(#[from] coven_foundation::object_hash::InvalidObjectHash),
524    #[error("{0}")]
525    BlobLocator(#[from] coven_protocol::blob::locator::BlobLocatorError),
526    #[error("{0}")]
527    CircleId(#[from] coven_protocol::circle::CircleIdError),
528    #[error("{0}")]
529    RowRoutingKey(#[from] coven_protocol::circle::RowRoutingKeyError),
530    #[error("{0}")]
531    CirclePartitionControl(#[from] crate::CirclePartitionControlError),
532    #[error("{0}")]
533    Gate(#[from] crate::gate::GateError),
534    #[error("{0}")]
535    Storage(#[from] coven_protocol::objects::StorageError),
536    #[error("unsafe blob path: {0}")]
537    BlobPath(#[from] coven_foundation::store_dir::PathTokenError),
538    #[error("{0}")]
539    Io(#[from] std::io::Error),
540    #[error("{0}")]
541    File(#[from] coven_foundation::atomic_file::FileError),
542    #[error("{0}")]
543    LocalBlobRemoval(#[from] coven_foundation::store_dir::LocalBlobRemovalError),
544    #[error("{0}")]
545    CachedLocatorRemoval(#[from] coven_foundation::store_dir::CachedLocatorRemovalError),
546    /// A stored integer column did not fit the type the schema says it holds.
547    #[error("stored value is out of range: {0}")]
548    IntRange(#[from] std::num::TryFromIntError),
549    #[error("stored value is not an integer: {0}")]
550    ParseInt(#[from] std::num::ParseIntError),
551    #[error("stored value is not UTF-8: {0}")]
552    Utf8(#[from] std::string::FromUtf8Error),
553    #[error("{0}")]
554    Utf8Slice(#[from] std::str::Utf8Error),
555    #[error("{0}")]
556    BlobDecl(#[from] crate::BlobDeclError),
557    #[error("{0}")]
558    ChangesetIdentity(#[from] crate::ChangesetIdentityError),
559    #[error("{0}")]
560    Changeset(#[from] crate::ChangesetError),
561    #[error("{0}")]
562    AuthorStreamId(#[from] coven_protocol::causal_grants::AuthorStreamIdParseError),
563    #[error("{0}")]
564    RowBlobRef(#[from] coven_protocol::blob::RowBlobRefError),
565    #[error("{0}")]
566    RotationGate(#[from] coven_protocol::objects::RotationGateError),
567    #[error("{0}")]
568    Encryption(#[from] coven_keys::encryption::EncryptionError),
569    #[error("{0}")]
570    SnapshotImage(#[source] Box<crate::store::SnapshotImageError>),
571    #[error("{0}")]
572    PayloadStore(#[source] Box<PayloadStoreError>),
573    #[error("{operation}; payload cleanup failed: {cleanup}")]
574    PayloadCleanupFailed {
575        operation: Box<DbError>,
576        cleanup: Box<DbError>,
577    },
578    #[error("{operation}; committed-change capture failed: {capture}")]
579    ChangeCaptureFailed {
580        operation: Box<DbError>,
581        capture: Box<DbError>,
582    },
583    #[error("{0}")]
584    FromSql(#[from] rusqlite::types::FromSqlError),
585    #[error("{0}")]
586    BlobOpeningAuthority(#[from] coven_protocol::blob::BlobOpeningAuthorityError),
587    #[error("{0}")]
588    OwnerPromotionJournal(
589        #[source] Box<coven_protocol::owner_promotion_journal::OwnerPromotionJournalError>,
590    ),
591    #[error("{0}")]
592    CircleJournal(#[source] Box<coven_protocol::circle_journal::CircleJournalError>),
593    #[error("{0}")]
594    SyncRoutingContract(#[from] SyncRoutingContractError),
595    #[error("{0}")]
596    RowIdentity(#[from] coven_protocol::synced_schema::RowIdentityError),
597    #[error("{0}")]
598    PreparedCommit(#[source] Box<coven_protocol::prepared_commit::PreparedCommitError>),
599    #[error("{0}")]
600    CircleState(#[source] Box<coven_protocol::circle_activation::CircleStateError>),
601    #[error("{0}")]
602    DeviceExclusionJournal(
603        #[source] Box<coven_protocol::device_exclusion_journal::StoreDeviceExclusionJournalError>,
604    ),
605    #[error("{0}")]
606    StoreReclaimJournal(#[source] Box<StoreReclaimJournalError>),
607    #[error("{0}")]
608    CommitNewFile(#[from] coven_foundation::local_file::CommitNewFileError),
609    /// Staging a write's audience-move blobs failed. The implementation of
610    /// `AudienceBlobMoveStaging` is injected from above, so its failure is
611    /// carried as an opaque source rather than named here.
612    #[error("audience blob staging: {0}")]
613    AudienceBlobStaging(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
614    /// Staging a write's audience-move blobs failed AND rolling the staged
615    /// files back failed, so those files are left on disk. Carries both
616    /// failures rather than reporting one and describing the other.
617    #[error("{operation}; audience blob rollback failed: {rollback}")]
618    AudienceBlobRollbackFailed {
619        operation: Box<DbError>,
620        rollback: StagedBlobRollbackFailures,
621    },
622    #[error("staged audience blob rollback failed: {0}")]
623    StagedBlobRollback(StagedBlobRollbackFailures),
624    /// An audience move needs its blob materialized locally and it is not —
625    /// the row's bytes are absent, stale, or refuse their declared identity.
626    /// Names the row so the caller can act on it.
627    #[error(
628        "blob move requires materialization for {table}/{row_id}/{column} at {row_stamp}: {reason}"
629    )]
630    BlobMoveRequiresMaterialization {
631        table: String,
632        row_id: String,
633        column: String,
634        row_stamp: String,
635        reason: Box<DbError>,
636    },
637    /// A queued outbox entry's `last_attempt_at` is not an RFC 3339 timestamp,
638    /// so whether the entry is still inside its retry backoff cannot be
639    /// decided. Names the entry so the caller can act on that row.
640    #[error("outbox entry {entry_id} has unparseable last_attempt_at {value:?}: {source}")]
641    UnparseableOutboxAttemptTime {
642        entry_id: i64,
643        value: String,
644        source: chrono::ParseError,
645    },
646    /// A [`DbError`] with the operation that produced it named in front of it.
647    /// Carries the cause as a [`DbError`] so callers keep matching on it after
648    /// it crosses the layer that added the description.
649    #[error("{context}: {source}")]
650    Context {
651        context: String,
652        source: Box<DbError>,
653    },
654    #[error("database error: Store protocol root hash is absent")]
655    StoreRootHashMissing,
656    /// The local device was excluded from a Circle epoch close and has not yet
657    /// reset its projection from the successor bootstrap, so it cannot publish
658    /// into the Circle. Stays matchable at the publication boundary rather than
659    /// flattening into a message.
660    #[error(
661        "device excluded from circle {circle_id} close {close_id} must reset before publishing"
662    )]
663    ExcludedDeviceMustReset {
664        circle_id: coven_protocol::circle::CircleId,
665        close_id: coven_protocol::circle::CircleEpochCloseId,
666    },
667}
668
669impl DbError {
670    pub fn write_rebase_conflict(&self) -> Option<&coven_protocol::write::WriteRebaseConflict> {
671        match self {
672            Self::WriteRebaseConflict(conflict) => Some(conflict),
673            Self::Context { source, .. } => source.write_rebase_conflict(),
674            Self::PayloadCleanupFailed { operation, .. }
675            | Self::ChangeCaptureFailed { operation, .. }
676            | Self::AudienceBlobRollbackFailed { operation, .. } => {
677                operation.write_rebase_conflict()
678            }
679            _ => None,
680        }
681    }
682
683    /// Name the operation `source` failed in without flattening it: the cause
684    /// stays a [`DbError`] the caller can still match on.
685    pub fn context(context: impl Into<String>, source: impl Into<DbError>) -> DbError {
686        DbError::Context {
687            context: context.into(),
688            source: Box::new(source.into()),
689        }
690    }
691}
692
693macro_rules! boxed_db_error_from {
694    ($source:path, $variant:ident) => {
695        impl From<$source> for DbError {
696            fn from(source: $source) -> Self {
697                Self::$variant(Box::new(source))
698            }
699        }
700    };
701}
702
703boxed_db_error_from!(
704    coven_protocol::owner_promotion_journal::OwnerPromotionJournalError,
705    OwnerPromotionJournal
706);
707boxed_db_error_from!(
708    coven_protocol::device_exclusion_journal::StoreDeviceExclusionJournalError,
709    DeviceExclusionJournal
710);
711boxed_db_error_from!(StoreReclaimJournalError, StoreReclaimJournal);
712boxed_db_error_from!(
713    coven_protocol::remote_object::RemoteObjectRecordError,
714    RemoteObject
715);
716boxed_db_error_from!(
717    coven_protocol::write::WriteRebaseConflict,
718    WriteRebaseConflict
719);
720boxed_db_error_from!(crate::store::SnapshotImageError, SnapshotImage);
721boxed_db_error_from!(coven_protocol::store_commit::StoreProtocolError, Protocol);
722boxed_db_error_from!(
723    coven_protocol::audience_package::AudiencePackageError,
724    AudiencePackage
725);
726boxed_db_error_from!(PayloadStoreError, PayloadStore);
727boxed_db_error_from!(
728    coven_protocol::circle_activation::CircleStateError,
729    CircleState
730);
731boxed_db_error_from!(
732    coven_protocol::circle_journal::CircleJournalError,
733    CircleJournal
734);
735boxed_db_error_from!(
736    coven_protocol::prepared_commit::PreparedCommitError,
737    PreparedCommit
738);
739
740#[cfg(test)]
741mod db_error_tests;
742
743/// Run `sql`, map every row through `mapper`, and collect the results.
744///
745/// It returns the SQLite failure as it happened, so every caller's `?` converts
746/// it into whatever error that caller already returns — one helper, no error
747/// vocabulary of its own.
748pub(crate) fn query_mapped_rows<T, P, F>(
749    conn: &Connection,
750    sql: &str,
751    params: P,
752    mut mapper: F,
753) -> Result<Vec<T>, rusqlite::Error>
754where
755    P: rusqlite::Params,
756    F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
757{
758    let mut statement = conn.prepare_cached(sql)?;
759    let rows = statement.query_map(params, |row| mapper(row))?;
760    let mut mapped = Vec::new();
761    for row in rows {
762        mapped.push(row?);
763    }
764    Ok(mapped)
765}
766
767/// Why opening the database failed. Splits a migration-ladder failure from every
768/// other open-time database error so the [`MigrationError`] a host acts on —
769/// [`MigrationError::SchemaTooNew`], whose remedy is "update the app" — stays
770/// matchable at the open boundary instead of being flattened into a
771/// [`DbError`] string.
772#[derive(Debug, thiserror::Error)]
773pub enum OpenError {
774    #[error(transparent)]
775    CovenMigration(#[from] CovenMigrationError),
776    #[error(transparent)]
777    Migration(#[from] MigrationError),
778    #[error(transparent)]
779    Db(#[from] DbError),
780    #[error("{operation}; snapshot preparation cleanup failed: {cleanup}")]
781    PreparationCleanup {
782        #[source]
783        operation: Box<OpenError>,
784        cleanup: Box<DbError>,
785    },
786}
787
788/// Test-only checkpoints reached by database operations whose ordering matters.
789#[cfg(any(test, feature = "test-utils"))]
790#[doc(hidden)]
791#[derive(Clone, Debug, PartialEq, Eq)]
792pub enum DatabaseTestPoint {
793    LocalBlobCleanupRequested,
794    LocalBlobCleanupAcquired,
795    LocalBlobCleanupBeforeFilesystem {
796        namespace: String,
797        blob_id: String,
798    },
799    LocalBlobCleanupFinished,
800    PullAfterRemoteCommit {
801        device_id: String,
802        seq: u64,
803    },
804    StoreWriteCommitUploaded {
805        write_id: WriteId,
806    },
807    StoreWritePublicationAccepted {
808        write_id: WriteId,
809    },
810    CoveredWriteCleanupPrepared,
811    ReceivedSnapshotInstallRequested,
812    StoreDeviceExclusionCandidateStaged,
813    OwnerPromotionCandidatePrepared,
814    CircleCandidatePrepared,
815    /// The owner's device-join acceptance has read the position its attempt
816    /// will be bound to and holds the turn to author it, but has not yet
817    /// published the head that takes it.
818    DeviceJoinAttemptPositionHeld,
819}
820
821#[cfg(any(test, feature = "test-utils"))]
822#[doc(hidden)]
823#[derive(Clone, Copy, Debug, PartialEq, Eq)]
824pub enum MergeMaterializationFailurePoint {
825    SummaryMaterialization,
826    ProjectionReplacement,
827}
828
829#[cfg(any(test, feature = "test-utils"))]
830struct ArmedTestPause<K> {
831    point: K,
832    reached: Arc<tokio::sync::Notify>,
833    resume: Arc<tokio::sync::Notify>,
834}
835
836#[cfg(any(test, feature = "test-utils"))]
837struct TestPauseState<K> {
838    armed: Option<ArmedTestPause<K>>,
839    observers: Vec<tokio::sync::mpsc::UnboundedSender<K>>,
840}
841
842#[cfg(any(test, feature = "test-utils"))]
843struct TestPausePoints<K> {
844    state: std::sync::Mutex<TestPauseState<K>>,
845}
846
847#[cfg(any(test, feature = "test-utils"))]
848impl<K> Default for TestPausePoints<K> {
849    fn default() -> Self {
850        Self {
851            state: std::sync::Mutex::new(TestPauseState {
852                armed: None,
853                observers: Vec::new(),
854            }),
855        }
856    }
857}
858
859#[cfg(any(test, feature = "test-utils"))]
860impl<K: Clone + PartialEq> TestPausePoints<K> {
861    fn arm(&self, point: K) -> (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>) {
862        let reached = Arc::new(tokio::sync::Notify::new());
863        let resume = Arc::new(tokio::sync::Notify::new());
864        let prior = self
865            .state
866            .lock()
867            .expect("database test pause mutex poisoned")
868            .armed
869            .replace(ArmedTestPause {
870                point,
871                reached: reached.clone(),
872                resume: resume.clone(),
873            });
874        assert!(prior.is_none(), "database test pause already armed");
875        (reached, resume)
876    }
877
878    fn observe(&self) -> tokio::sync::mpsc::UnboundedReceiver<K> {
879        let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
880        self.state
881            .lock()
882            .expect("database test pause mutex poisoned")
883            .observers
884            .push(sender);
885        receiver
886    }
887
888    async fn reach(&self, point: K) {
889        let pause = {
890            let mut state = self
891                .state
892                .lock()
893                .expect("database test pause mutex poisoned");
894            state
895                .observers
896                .retain(|observer| observer.send(point.clone()).is_ok());
897            if state
898                .armed
899                .as_ref()
900                .is_some_and(|pause| pause.point == point)
901            {
902                state.armed.take()
903            } else {
904                None
905            }
906        };
907        if let Some(pause) = pause {
908            pause.reached.notify_one();
909            pause.resume.notified().await;
910        }
911    }
912}
913
914/// One Circle image selected against the restoring identity's re-resolved
915/// access. Coverage references imported from the Store snapshot are removed as
916/// a set before these locally verified images are installed.
917pub struct StagedCircleInstall {
918    pub activation_commit: StoreBatchCommitRef,
919    pub image: coven_protocol::circle_activation::VerifiedCircleImage,
920}
921
922/// Recipient-specific access resolved from an exact retained Circle activation.
923/// Its leaf bootstrap remains bound to the leaf even if a newer image is selected.
924pub struct StagedCircleAccess {
925    pub activating_commit: StoreBatchCommitRef,
926    pub activation: coven_protocol::circle_activation::VerifiedCircleReference,
927    pub leaf_bootstrap: Option<coven_protocol::circle_activation::VerifiedCircleImage>,
928    pub local_exclusion: Option<coven_protocol::circle_activation::LocalCircleExclusion>,
929}
930
931/// The verified starting state for one accessible Circle.
932pub enum StagedCircleBase {
933    Founder {
934        circle_id: coven_protocol::circle::CircleId,
935        control: coven_protocol::circle::CircleControlCoord,
936    },
937    Image(StagedCircleInstall),
938}
939
940pub struct StagedCircleRestore {
941    pub access: Vec<StagedCircleAccess>,
942    pub bases: Vec<StagedCircleBase>,
943    pub packages: Option<StagedCirclePackageRestore>,
944}
945
946impl StagedCircleRestore {
947    /// Package replay starts at the selected image, or at the authenticated
948    /// founding control when the recipient held active access from creation.
949    pub fn coverage_cuts(
950        &self,
951    ) -> Result<BTreeMap<coven_protocol::circle::CircleId, CommitFrontier>, DbError> {
952        let mut cuts = BTreeMap::new();
953        for base in &self.bases {
954            let (circle_id, cut) = match base {
955                StagedCircleBase::Image(image) => (
956                    image.image.circle_id(),
957                    image.image.reference().coverage.clone(),
958                ),
959                StagedCircleBase::Founder { circle_id, control } => {
960                    let access = self
961                        .access
962                        .iter()
963                        .find(|access| {
964                            access.activation.circle_id == *circle_id
965                                && access.activation.control.coord == *control
966                        })
967                        .ok_or_else(|| {
968                            DbError::Message(
969                                "Circle founder restore base has no exact recipient access".into(),
970                            )
971                        })?;
972                    if !access.activation.control.value.is_founder()
973                        || !matches!(
974                            access.activation.control.value.active_common().origin,
975                            coven_protocol::circle::CircleEpochOrigin::Founder
976                        )
977                        || !access
978                            .activation
979                            .local_access
980                            .as_ref()
981                            .is_some_and(|local| {
982                                local.active.is_some()
983                                    && matches!(
984                                        local.leaf.value.disposition,
985                                        coven_protocol::circle::CircleAccessDisposition::Active {
986                                            bootstrap: None,
987                                            ..
988                                        }
989                                    )
990                            })
991                        || access.leaf_bootstrap.is_some()
992                    {
993                        return Err(DbError::Message(
994                            "Circle restore requires its recipient bootstrap image".into(),
995                        ));
996                    }
997                    (*circle_id, CommitFrontier(BTreeMap::new()))
998                }
999            };
1000            if cuts.insert(circle_id, cut).is_some() {
1001                return Err(DbError::Message("Circle restoration repeats a base".into()));
1002            }
1003        }
1004        Ok(cuts)
1005    }
1006}
1007
1008pub struct StagedCirclePackageRestore {
1009    pub routing_key: coven_protocol::circle::RowRoutingKey,
1010    pub packages: BTreeMap<StoreBatchCommitRef, Vec<AudiencePackage>>,
1011}
1012
1013enum CircleRestoreSelection {
1014    Pending,
1015    Selected(StagedCircleRestore),
1016}
1017
1018pub struct VerifiedSnapshotBootstrapInstall {
1019    snapshot: PublishedStoreSnapshot,
1020    store_root: coven_protocol::objects::VerifiedObject<StoreProtocolRoot>,
1021    founder: coven_protocol::objects::VerifiedObject<StoreDeviceRegistration>,
1022    authority: coven_protocol::store_commit::RetainedReplaySnapshotAuthority,
1023    membership: InitialStoreMembershipAuthority,
1024    routing_key: Option<coven_protocol::circle::RowRoutingKey>,
1025    circle_selection: CircleRestoreSelection,
1026    /// Fail the Circle-install step of the install transaction, after the Store
1027    /// image has been installed within it — a test's stand-in for a crash between
1028    /// the Store and Circle installs, exercising the single-transaction rollback.
1029    #[cfg(any(test, feature = "test-utils"))]
1030    fail_circle_install: bool,
1031}
1032
1033impl VerifiedSnapshotBootstrapInstall {
1034    pub fn new(
1035        snapshot: PublishedStoreSnapshot,
1036        store_root: coven_protocol::objects::VerifiedObject<StoreProtocolRoot>,
1037        founder: coven_protocol::objects::VerifiedObject<StoreDeviceRegistration>,
1038        authority: crate::VerifiedStoreSnapshotAuthority,
1039        membership: InitialStoreMembershipAuthority,
1040        routing_encryption: Option<&EncryptionService>,
1041    ) -> Result<Self, DbError> {
1042        if store_root.value.to_bytes() != store_root.bytes
1043            || store_root.value.object_hash() != store_root.semantic_hash
1044        {
1045            return Err(DbError::Message(
1046                "bootstrap Store root differs from its verified object".to_string(),
1047            ));
1048        }
1049        let root = coven_protocol::store_commit::StoreRootRef {
1050            store_root_id: store_root.value.descriptor.store_root_id(),
1051            store_root_hash: store_root.semantic_hash,
1052            object: store_root.object.clone(),
1053        };
1054        let founder_reference =
1055            StoreDeviceRegistrationRef::from_registration(&founder.value, founder.object.clone());
1056        if founder.semantic_hash != founder_reference.registration_hash {
1057            return Err(DbError::Message(
1058                "bootstrap founder semantic hash differs from its exact registration".to_string(),
1059            ));
1060        }
1061        let authority = authority.into_authority();
1062        authority.validate()?;
1063        if authority.store_root != root
1064            || authority.founder_registration != founder_reference
1065            || authority.snapshot != snapshot.reference
1066            || authority.metadata != snapshot.meta
1067        {
1068            return Err(DbError::Message(
1069                "bootstrap snapshot differs from its verified authority authority".to_string(),
1070            ));
1071        }
1072        let routing_key = routing_encryption
1073            .map(|encryption| {
1074                coven_protocol::circle::derive_row_routing_key(encryption, root.store_root_hash)
1075                    .map_err(|error| DbError::context("derive bootstrap row-routing key", error))
1076            })
1077            .transpose()?;
1078        Ok(Self {
1079            snapshot,
1080            store_root,
1081            founder,
1082            authority,
1083            membership,
1084            routing_key,
1085            circle_selection: CircleRestoreSelection::Pending,
1086            #[cfg(any(test, feature = "test-utils"))]
1087            fail_circle_install: false,
1088        })
1089    }
1090
1091    /// Attach the Circle images selected against a throwaway query copy opened
1092    /// through this same authority. Kept separate from `new` so one verified
1093    /// install can first query and then install for real without re-verifying the
1094    /// Store authority.
1095    pub fn with_circle_installs(mut self, circle_installs: StagedCircleRestore) -> Self {
1096        self.circle_selection = CircleRestoreSelection::Selected(circle_installs);
1097        self
1098    }
1099
1100    /// Arm the Circle-install failure injection: the install transaction rolls
1101    /// back after the Store image is installed but before any Circle image
1102    /// commits, standing in for a crash between the two installs.
1103    #[cfg(any(test, feature = "test-utils"))]
1104    pub fn fail_circle_install_for_test(mut self) -> Self {
1105        self.fail_circle_install = true;
1106        self
1107    }
1108}
1109
1110#[cfg(test)]
1111mod tests;