Skip to main content

coven_database/gate/
mod.rs

1//! Row-level sync gating.
2//!
3//! A host declares a boolean **gate** column on a *root* synced table (via
4//! [`SyncedTable::gated_by`](coven_protocol::synced_schema::SyncedTable::gated_by)). A root row
5//! is shared — i.e. it syncs to peers — iff its gate column is true. The gate
6//! flows down *declared foreign keys*: a child row is shared iff the row at the
7//! top of its FK chain (its gated-ancestor root) is shared. A
8//! [`SyncedTable::remote_root`](coven_protocol::synced_schema::SyncedTable::remote_root) is a
9//! root whose rows and FK descendants always sync, and whose blobs are always
10//! Remote. Rows that are not gated and not FK-descendants of a gated or remote root
11//! always sync.
12//!
13//! The gate also flows **up** for declared *ancestors*
14//! ([`SyncedTable::gated_by_descendants`](coven_protocol::synced_schema::SyncedTable::gated_by_descendants)).
15//! An ancestor is an always-shared FK *parent* of gated rows (e.g. an album is
16//! the FK parent of releases). Left alone it would sync even when its whole gated
17//! subtree is cut, landing on peers as an orphan with zero children. A
18//! gated-by-descendants ancestor is shared iff some inferred child table still
19//! holds a kept row referencing it; the keep composes recursively up the FK chain
20//! to the gated roots at the bottom. The keep-children are inferred from the live
21//! FK graph, never declared — except a child the host marks an *asset*
22//! ([`SyncedTable::asset`](coven_protocol::synced_schema::SyncedTable::asset)), a decoration
23//! (cover, artist image) that rides its subject's gate but never grants keep, so
24//! it is excluded from the subject's keep-children. An asset is typically a
25//! host-provided blob; see the replication layer's blob concept tree for the blob-side
26//! vocabulary.
27//!
28//! Keep is what the gate *elects* to share. What those elections *oblige* is a
29//! second relation: a shared row lands on a receiver that rebuilds the Store by
30//! replaying the published commits into an empty database, so every foreign key
31//! the row carries has to resolve there — along *every* FK it declares, not only
32//! the one its gate was inherited through. So the shared set is closed under
33//! FK-parent: a row is shared iff it is kept, or some shared row references it.
34//! [`model::SharedRows`] is that set, and the two relations must not be
35//! conflated — an ancestor's keep-children exclude the join-table back-edge (a
36//! child cannot be a reason to keep its own gate-parent alive), which is right
37//! for keep and wrong for closure, and is exactly how a container row nothing
38//! keeps ends up named by a row everything ships.
39//!
40//! [`gate_outbound`] is the one entry point. Given the changeset a cycle
41//! captured, it returns a new changeset with gated-false rows cut, plus — when a
42//! root's gate flips false→true this cycle — full-state INSERTs for that root's
43//! whole now-visible subtree (peers never saw it while it was private), so the
44//! promotion lands as a complete consistent subtree on every peer.
45//!
46//! Revoke (gate true→false) is a *retract*: when a previously-shared root flips
47//! true→false this cycle, the rows that leave the shared set are emitted as
48//! DELETEs so peers remove them — the exact mirror of the false→true re-emit. The
49//! flipping device keeps its rows locally (now gated-false = local-only); retract
50//! writes only to the outbound changeset, never to the live tables, and fires once
51//! on the flip cycle. A root that was never shared has nothing on peers to retract
52//! and emits nothing.
53//!
54//! ## How it is built
55//!
56//! - **Cut / keep** uses `sqlite3changegroup_add_change`: we walk the captured
57//!   changeset and, at each kept row's iterator position, append the change
58//!   verbatim into a changegroup, then `sqlite3changegroup_output` the result.
59//!   Kept rows keep their exact binary form; nothing is reconstructed.
60//! - **Re-emit on flip** uses `sqlite3session_diff`: we attach an empty,
61//!   schema-identical in-memory database, create a session on it, diff each
62//!   gated table against `main` (empty vs. populated yields a full-state INSERT
63//!   per current row), then scope those INSERTs through the same keep-filter,
64//!   restricted to the roots that flipped this cycle, and merge them into the
65//!   output. The changegroup dedups by primary key, so a row already present
66//!   from the captured changeset is not duplicated.
67//! - **Retract on flip** is the reverse `sqlite3session_diff`: we create the
68//!   session on the *empty* clone and diff `from = "main"` (populated → empty
69//!   yields a full-state DELETE per current row), then scope those DELETEs to the
70//!   rows leaving the shared set — the structural connected component of the roots
71//!   that flipped true→false this cycle, minus the rows still kept by another
72//!   managed root — and merge them in.
73
74use std::ffi::c_int;
75
76use rusqlite::{Connection, OptionalExtension, Params};
77
78use crate::quote_ident;
79
80mod audience;
81mod ffi;
82mod model;
83mod outbound;
84
85pub(crate) use audience::{
86    active_circle_control, align_inbound_scoped_root_audiences, audience_moves,
87    capture_routing_changes, filter_inbound_circle_changeset, filter_inbound_store_rows,
88    filter_snapshot_circle_changeset, live_row_audience, normalize_inbound_store_changeset,
89    partition_outbound, prune_ineligible_scoped_rows, prune_private_routes_without_rows,
90    recorded_host_changeset, retain_projection_rows, retain_snapshot_audience_rows,
91    validate_accepted_foreign_key_closure, validate_scoped_foreign_key_audiences,
92    validate_snapshot_routing_state, PartitionedAudienceWrite,
93};
94pub use audience::{
95    is_routing_table, store_audience_transitions, AudienceMove, AudiencePartition,
96    CirclePartitionControl, CirclePartitionControlError, RoutingChanges, StoreAudienceTransitions,
97};
98pub(crate) use ffi::{for_each_change, update_values, Changegroup};
99pub use model::Gates;
100#[cfg(any(test, feature = "test-utils"))]
101pub use model::{from_tables_call_count, reset_from_tables_call_count};
102pub(crate) use outbound::attach_empty_clone;
103pub(crate) use outbound::query_truth;
104
105/// [`crate::table_columns`] with its `rusqlite::Error` adapted
106/// into the gate's error at the boundary.
107fn gate_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, GateError> {
108    crate::table_columns(conn, table)
109        .map_err(|e| GateError::Sql(format!("read columns of {table}"), e))
110}
111
112/// Every row id in `table`, in id order, for the passes that walk a whole table
113/// row by row.
114fn all_row_ids(conn: &Connection, table: &str) -> Result<Vec<String>, GateError> {
115    let sql = format!(
116        "SELECT {id} FROM {table} ORDER BY {id}",
117        id = quote_ident("id"),
118        table = quote_ident(table),
119    );
120    query_mapped_rows(conn, &sql, [], |row| row.get::<_, String>(0))
121}
122
123fn execute_batch(conn: &Connection, sql: &str) -> Result<(), GateError> {
124    conn.execute_batch(sql)
125        .map_err(|e| GateError::Sql(format!("execute batch: {sql}"), e))
126}
127
128/// The shared row query, with the statement that failed named in the error the
129/// gate reports.
130fn query_mapped_rows<T, P, F>(
131    conn: &Connection,
132    sql: &str,
133    params: P,
134    mapper: F,
135) -> Result<Vec<T>, GateError>
136where
137    P: Params,
138    F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
139{
140    crate::query_mapped_rows(conn, sql, params, mapper)
141        .map_err(|e| GateError::Sql(format!("query: {sql}"), e))
142}
143
144fn query_row_optional<T, P, F>(
145    conn: &Connection,
146    sql: &str,
147    params: P,
148    mapper: F,
149) -> Result<Option<T>, GateError>
150where
151    P: Params,
152    F: FnOnce(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
153{
154    conn.query_row(sql, params, mapper)
155        .optional()
156        .map_err(|e| GateError::Sql(format!("query: {sql}"), e))
157}
158/// Render a row column read against the live db as text, matching what the raw
159/// changeset path produces for the same value, so a gate resolved from a live
160/// row and one resolved from a changeset agree. The single rendering rule —
161/// including SQLite's REAL→text — lives in the database changeset decoder.
162fn row_value_to_string(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<Option<String>> {
163    Ok(crate::value_ref_to_string(row.get_ref(idx)?))
164}
165
166#[derive(Debug)]
167pub enum GateError {
168    Ffi(&'static str, c_int),
169    Session {
170        operation: String,
171        source: rusqlite::Error,
172    },
173    MissingGateColumn(String, String),
174    MissingFkColumn(String, String),
175    ForeignKeySchema(crate::ForeignKeySchemaError),
176    CompositeGateForeignKey {
177        table: String,
178        parent: String,
179    },
180    MissingAudienceParentDeclaration {
181        table: String,
182    },
183    InvalidAudienceParentDeclaration {
184        table: String,
185        column: String,
186        reason: String,
187    },
188    ScopedOutboundRequiresPartitioning {
189        table: String,
190    },
191    InvalidAudience {
192        table: String,
193        value: Option<String>,
194        reason: String,
195    },
196    InvalidAudienceEncoding {
197        table: String,
198        value: Option<String>,
199        source: coven_protocol::circle::CircleIdError,
200    },
201    InvalidInboundAudiencePackage(String),
202    InvalidInboundAudienceEncoding {
203        context: String,
204        source: coven_protocol::circle::CircleIdError,
205    },
206    InvalidInboundRowIdentity {
207        context: String,
208        source: coven_protocol::synced_schema::RowIdentityError,
209    },
210    InvalidMaterializedRouting(String),
211    InvalidMaterializedRoutingId {
212        context: String,
213        source: coven_protocol::circle::RowRoutingIdError,
214    },
215    InvalidMaterializedAudience {
216        context: String,
217        source: coven_protocol::circle::CircleIdError,
218    },
219    InvalidMaterializedRowIdentity {
220        context: String,
221        source: coven_protocol::synced_schema::RowIdentityError,
222    },
223    MissingChangesetPrimaryKey(String),
224    MissingAudienceRow {
225        table: String,
226        row_id: String,
227    },
228    MissingAudienceParent {
229        table: String,
230        row_id: Option<String>,
231        parent: String,
232    },
233    CircleAuthority {
234        circle_id: coven_protocol::circle::CircleId,
235        active_records: usize,
236    },
237    /// A host write named a Circle whose control chain has terminated in a
238    /// deletion. The Circle accepts no further content.
239    CircleDeleted {
240        circle_id: coven_protocol::circle::CircleId,
241    },
242    InvalidCircleControl {
243        circle_id: coven_protocol::circle::CircleId,
244        source: CircleControlFailure,
245    },
246    /// A `gated_by_descendants` ancestor (the table) has no inferred gated
247    /// descendant — no synced table has a foreign key into it after the
248    /// join-table back-edge is excluded. The keep would be vacuously false, so
249    /// the declaration is a host error rather than a silent always-share.
250    NoGatedDescendants(String),
251    /// The gated tables form an FK cycle, so no parent-first apply order exists.
252    FkCycle(Vec<String>),
253    /// A captured write would share a row whose foreign key names a row the gate
254    /// does not share. Every device rebuilds the Store by replaying the published
255    /// commits into an empty database, so publishing this puts a reference on the
256    /// wire that no device can resolve and every replay holds on forever. Boxed:
257    /// it names five strings, and `DbError` travels in every database `Result`.
258    UnsharedForeignKeyParent(Box<UnsharedForeignKeyParent>),
259    CreateTableSchema(crate::CreateTableSchemaError),
260    Sql(String, rusqlite::Error),
261    Cleanup {
262        operation: Box<GateError>,
263        cleanup: Box<GateError>,
264    },
265}
266
267impl std::fmt::Display for GateError {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        match self {
270            GateError::Ffi(func, rc) => write!(f, "{func} failed (rc={rc})"),
271            GateError::Session { operation, source } => {
272                write!(f, "session {operation} failed: {source}")
273            }
274            GateError::MissingGateColumn(tbl, col) => {
275                write!(f, "gated table {tbl} has no gate column {col}")
276            }
277            GateError::MissingFkColumn(tbl, col) => {
278                write!(f, "table {tbl} has no FK column {col}")
279            }
280            GateError::ForeignKeySchema(error) => write!(f, "foreign-key schema: {error}"),
281            GateError::CompositeGateForeignKey { table, parent } => write!(
282                f,
283                "table {table} inherits its gate through a composite foreign key to {parent}, but gate inheritance requires one child column"
284            ),
285            GateError::MissingAudienceParentDeclaration { table } => write!(
286                f,
287                "scoped descendant table {table} must declare its audience-parent foreign key"
288            ),
289            GateError::InvalidAudienceParentDeclaration {
290                table,
291                column,
292                reason,
293            } => write!(
294                f,
295                "table {table} cannot inherit its audience through {column}: {reason}"
296            ),
297            GateError::ScopedOutboundRequiresPartitioning { table } => write!(
298                f,
299                "scoped root {table} must use audience-partitioned outbound capture"
300            ),
301            GateError::InvalidAudience {
302                table,
303                value,
304                reason,
305            } => write!(
306                f,
307                "scoped table {table} has invalid audience {value:?}: {reason}"
308            ),
309            GateError::InvalidAudienceEncoding {
310                table,
311                value,
312                source,
313            } => write!(
314                f,
315                "scoped table {table} has invalid audience {value:?}: {source}"
316            ),
317            GateError::InvalidInboundAudiencePackage(reason) => {
318                write!(f, "invalid inbound audience package: {reason}")
319            }
320            GateError::InvalidInboundAudienceEncoding { context, source } => {
321                write!(f, "invalid inbound audience package: {context}: {source}")
322            }
323            GateError::InvalidInboundRowIdentity { context, source } => {
324                write!(f, "invalid inbound audience package: {context}: {source}")
325            }
326            GateError::InvalidMaterializedRouting(reason) => {
327                write!(f, "invalid materialized routing state: {reason}")
328            }
329            GateError::InvalidMaterializedRoutingId { context, source } => {
330                write!(f, "invalid materialized routing state: {context}: {source}")
331            }
332            GateError::InvalidMaterializedAudience { context, source } => {
333                write!(f, "invalid materialized routing state: {context}: {source}")
334            }
335            GateError::InvalidMaterializedRowIdentity { context, source } => {
336                write!(f, "invalid materialized routing state: {context}: {source}")
337            }
338            GateError::MissingChangesetPrimaryKey(table) => {
339                write!(f, "scoped changeset row in {table} has no primary key")
340            }
341            GateError::MissingAudienceRow { table, row_id } => {
342                write!(
343                    f,
344                    "scoped row {table}.{row_id} is absent while resolving its audience"
345                )
346            }
347            GateError::MissingAudienceParent {
348                table,
349                row_id,
350                parent,
351            } => write!(
352                f,
353                "scoped row {table}.{row_id:?} has no audience parent in {parent}"
354            ),
355            GateError::CircleAuthority {
356                circle_id,
357                active_records,
358            } => write!(
359                f,
360                "circle {circle_id} has {active_records} active local access records; expected exactly one"
361            ),
362            GateError::CircleDeleted { circle_id } => {
363                write!(f, "circle {circle_id} is deleted and accepts no writes")
364            }
365            GateError::InvalidCircleControl { circle_id, source } => {
366                write!(f, "circle {circle_id} has invalid active control: {source}")
367            }
368            GateError::NoGatedDescendants(tbl) => {
369                write!(
370                    f,
371                    "gated_by_descendants ancestor {tbl} has no inferred gated descendant: no \
372                     synced table references it"
373                )
374            }
375            GateError::FkCycle(tables) => {
376                write!(f, "gated tables form an FK cycle: {}", tables.join(", "))
377            }
378            GateError::UnsharedForeignKeyParent(unshared) => match &unshared.parent_id {
379                Some(parent_id) => write!(
380                    f,
381                    "shared row {table}.{row_id} names {parent}.{parent_id} through {column}, \
382                     which the gate does not share",
383                    table = unshared.table,
384                    row_id = unshared.row_id,
385                    parent = unshared.parent,
386                    column = unshared.column,
387                ),
388                None => write!(
389                    f,
390                    "shared row {table}.{row_id} names a {parent} row through {column} that the \
391                     database does not hold",
392                    table = unshared.table,
393                    row_id = unshared.row_id,
394                    parent = unshared.parent,
395                    column = unshared.column,
396                ),
397            },
398            GateError::CreateTableSchema(error) => error.fmt(f),
399            GateError::Sql(op, err) => write!(f, "{op} failed: {err}"),
400            GateError::Cleanup { operation, cleanup } => {
401                write!(
402                    f,
403                    "{operation}; temporary gate cleanup also failed: {cleanup}"
404                )
405            }
406        }
407    }
408}
409
410impl std::error::Error for GateError {
411    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
412        match self {
413            Self::Session { source, .. } | Self::Sql(_, source) => Some(source),
414            Self::ForeignKeySchema(source) => Some(source),
415            Self::CreateTableSchema(source) => Some(source),
416            Self::InvalidCircleControl { source, .. } => Some(source),
417            Self::InvalidAudienceEncoding { source, .. } => Some(source),
418            Self::InvalidInboundAudienceEncoding { source, .. }
419            | Self::InvalidMaterializedAudience { source, .. } => Some(source),
420            Self::InvalidInboundRowIdentity { source, .. }
421            | Self::InvalidMaterializedRowIdentity { source, .. } => Some(source),
422            Self::InvalidMaterializedRoutingId { source, .. } => Some(source),
423            Self::Cleanup { operation, .. } => Some(operation.as_ref()),
424            _ => None,
425        }
426    }
427}
428
429/// The row a captured write would share, and the foreign key on it the gate does
430/// not resolve. Carried behind a `Box` in
431/// [`GateError::UnsharedForeignKeyParent`].
432#[derive(Debug)]
433pub struct UnsharedForeignKeyParent {
434    /// The table and id of the row that would be shared.
435    pub table: String,
436    pub row_id: String,
437    /// The foreign-key column on that row, and the table it points into.
438    pub column: String,
439    pub parent: String,
440    /// The parent row the foreign key names, or `None` when it names a key no
441    /// row in `parent` carries at all — the local database is already
442    /// inconsistent, which is a different fault worth telling apart.
443    pub parent_id: Option<String>,
444}
445
446#[derive(Debug, thiserror::Error)]
447pub enum CircleControlFailure {
448    #[error("parse current state: {0}")]
449    ParseCurrentState(serde_json::Error),
450    #[error("current state failed verification")]
451    Verification,
452    #[error("serialize current control coordinate: {0}")]
453    SerializeCoordinate(serde_json::Error),
454    #[error(transparent)]
455    PartitionControl(#[from] CirclePartitionControlError),
456}
457
458impl From<crate::CreateTableSchemaError> for GateError {
459    fn from(error: crate::CreateTableSchemaError) -> Self {
460        Self::CreateTableSchema(error)
461    }
462}
463
464#[cfg(test)]
465mod retraction_tests;
466#[cfg(test)]
467mod tests;