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    live_row_audience, normalize_inbound_store_changeset, partition_outbound,
89    prune_ineligible_scoped_rows, prune_private_routes_without_rows, retain_snapshot_audience_rows,
90    validate_accepted_foreign_key_closure, validate_scoped_foreign_key_audiences,
91    validate_snapshot_routing_state,
92};
93pub use audience::{
94    is_routing_table, store_audience_transitions, AudienceMove, AudiencePartition,
95    CirclePartitionControl, CirclePartitionControlError, RoutingChanges, StoreAudienceTransitions,
96};
97pub use model::Gates;
98#[cfg(any(test, feature = "test-utils"))]
99pub use model::{from_tables_call_count, reset_from_tables_call_count};
100pub(crate) use outbound::attach_empty_clone;
101pub(crate) use outbound::query_truth;
102
103/// [`crate::table_columns`] with its `rusqlite::Error` adapted
104/// into the gate's error at the boundary.
105fn gate_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, GateError> {
106    crate::table_columns(conn, table)
107        .map_err(|e| GateError::Sql(format!("read columns of {table}"), e))
108}
109
110/// Every row id in `table`, in id order, for the passes that walk a whole table
111/// row by row.
112fn all_row_ids(conn: &Connection, table: &str) -> Result<Vec<String>, GateError> {
113    let sql = format!(
114        "SELECT {id} FROM {table} ORDER BY {id}",
115        id = quote_ident("id"),
116        table = quote_ident(table),
117    );
118    query_mapped_rows(conn, &sql, [], |row| row.get::<_, String>(0))
119}
120
121fn execute_batch(conn: &Connection, sql: &str) -> Result<(), GateError> {
122    conn.execute_batch(sql)
123        .map_err(|e| GateError::Sql(format!("execute batch: {sql}"), e))
124}
125
126/// The shared row query, with the statement that failed named in the error the
127/// gate reports.
128fn query_mapped_rows<T, P, F>(
129    conn: &Connection,
130    sql: &str,
131    params: P,
132    mapper: F,
133) -> Result<Vec<T>, GateError>
134where
135    P: Params,
136    F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
137{
138    crate::query_mapped_rows(conn, sql, params, mapper)
139        .map_err(|e| GateError::Sql(format!("query: {sql}"), e))
140}
141
142fn query_row_optional<T, P, F>(
143    conn: &Connection,
144    sql: &str,
145    params: P,
146    mapper: F,
147) -> Result<Option<T>, GateError>
148where
149    P: Params,
150    F: FnOnce(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
151{
152    conn.query_row(sql, params, mapper)
153        .optional()
154        .map_err(|e| GateError::Sql(format!("query: {sql}"), e))
155}
156/// Render a row column read against the live db as text, matching what the raw
157/// changeset path produces for the same value, so a gate resolved from a live
158/// row and one resolved from a changeset agree. The single rendering rule —
159/// including SQLite's REAL→text — lives in the database changeset decoder.
160fn row_value_to_string(row: &rusqlite::Row<'_>, idx: usize) -> rusqlite::Result<Option<String>> {
161    Ok(crate::value_ref_to_string(row.get_ref(idx)?))
162}
163
164#[derive(Debug)]
165pub enum GateError {
166    Ffi(&'static str, c_int),
167    Session {
168        operation: String,
169        source: rusqlite::Error,
170    },
171    MissingGateColumn(String, String),
172    MissingFkColumn(String, String),
173    ForeignKeySchema(crate::ForeignKeySchemaError),
174    CompositeGateForeignKey {
175        table: String,
176        parent: String,
177    },
178    MissingAudienceParentDeclaration {
179        table: String,
180    },
181    InvalidAudienceParentDeclaration {
182        table: String,
183        column: String,
184        reason: String,
185    },
186    ScopedOutboundRequiresPartitioning {
187        table: String,
188    },
189    InvalidAudience {
190        table: String,
191        value: Option<String>,
192        reason: String,
193    },
194    InvalidAudienceEncoding {
195        table: String,
196        value: Option<String>,
197        source: coven_protocol::circle::CircleIdError,
198    },
199    InvalidInboundAudiencePackage(String),
200    InvalidInboundAudienceEncoding {
201        context: String,
202        source: coven_protocol::circle::CircleIdError,
203    },
204    InvalidInboundRowIdentity {
205        context: String,
206        source: coven_protocol::synced_schema::RowIdentityError,
207    },
208    InvalidMaterializedRouting(String),
209    InvalidMaterializedRoutingId {
210        context: String,
211        source: coven_protocol::circle::RowRoutingIdError,
212    },
213    InvalidMaterializedAudience {
214        context: String,
215        source: coven_protocol::circle::CircleIdError,
216    },
217    InvalidMaterializedRowIdentity {
218        context: String,
219        source: coven_protocol::synced_schema::RowIdentityError,
220    },
221    MissingChangesetPrimaryKey(String),
222    MissingAudienceRow {
223        table: String,
224        row_id: String,
225    },
226    MissingAudienceParent {
227        table: String,
228        row_id: Option<String>,
229        parent: String,
230    },
231    CircleAuthority {
232        circle_id: coven_protocol::circle::CircleId,
233        active_records: usize,
234    },
235    /// A host write named a Circle whose control chain has terminated in a
236    /// deletion. The Circle accepts no further content.
237    CircleDeleted {
238        circle_id: coven_protocol::circle::CircleId,
239    },
240    InvalidCircleControl {
241        circle_id: coven_protocol::circle::CircleId,
242        source: CircleControlFailure,
243    },
244    /// A `gated_by_descendants` ancestor (the table) has no inferred gated
245    /// descendant — no synced table has a foreign key into it after the
246    /// join-table back-edge is excluded. The keep would be vacuously false, so
247    /// the declaration is a host error rather than a silent always-share.
248    NoGatedDescendants(String),
249    /// The gated tables form an FK cycle, so no parent-first apply order exists.
250    FkCycle(Vec<String>),
251    /// A captured write would share a row whose foreign key names a row the gate
252    /// does not share. Every device rebuilds the Store by replaying the published
253    /// commits into an empty database, so publishing this puts a reference on the
254    /// wire that no device can resolve and every replay holds on forever. Boxed:
255    /// it names five strings, and `DbError` travels in every database `Result`.
256    UnsharedForeignKeyParent(Box<UnsharedForeignKeyParent>),
257    CreateTableSchema(crate::CreateTableSchemaError),
258    Sql(String, rusqlite::Error),
259    Cleanup {
260        operation: Box<GateError>,
261        cleanup: Box<GateError>,
262    },
263}
264
265impl std::fmt::Display for GateError {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        match self {
268            GateError::Ffi(func, rc) => write!(f, "{func} failed (rc={rc})"),
269            GateError::Session { operation, source } => {
270                write!(f, "session {operation} failed: {source}")
271            }
272            GateError::MissingGateColumn(tbl, col) => {
273                write!(f, "gated table {tbl} has no gate column {col}")
274            }
275            GateError::MissingFkColumn(tbl, col) => {
276                write!(f, "table {tbl} has no FK column {col}")
277            }
278            GateError::ForeignKeySchema(error) => write!(f, "foreign-key schema: {error}"),
279            GateError::CompositeGateForeignKey { table, parent } => write!(
280                f,
281                "table {table} inherits its gate through a composite foreign key to {parent}, but gate inheritance requires one child column"
282            ),
283            GateError::MissingAudienceParentDeclaration { table } => write!(
284                f,
285                "scoped descendant table {table} must declare its audience-parent foreign key"
286            ),
287            GateError::InvalidAudienceParentDeclaration {
288                table,
289                column,
290                reason,
291            } => write!(
292                f,
293                "table {table} cannot inherit its audience through {column}: {reason}"
294            ),
295            GateError::ScopedOutboundRequiresPartitioning { table } => write!(
296                f,
297                "scoped root {table} must use audience-partitioned outbound capture"
298            ),
299            GateError::InvalidAudience {
300                table,
301                value,
302                reason,
303            } => write!(f, "scoped table {table} has invalid audience {value:?}: {reason}"),
304            GateError::InvalidAudienceEncoding {
305                table,
306                value,
307                source,
308            } => write!(f, "scoped table {table} has invalid audience {value:?}: {source}"),
309            GateError::InvalidInboundAudiencePackage(reason) => {
310                write!(f, "invalid inbound audience package: {reason}")
311            }
312            GateError::InvalidInboundAudienceEncoding { context, source } => {
313                write!(f, "invalid inbound audience package: {context}: {source}")
314            }
315            GateError::InvalidInboundRowIdentity { context, source } => {
316                write!(f, "invalid inbound audience package: {context}: {source}")
317            }
318            GateError::InvalidMaterializedRouting(reason) => {
319                write!(f, "invalid materialized routing state: {reason}")
320            }
321            GateError::InvalidMaterializedRoutingId { context, source } => {
322                write!(f, "invalid materialized routing state: {context}: {source}")
323            }
324            GateError::InvalidMaterializedAudience { context, source } => {
325                write!(f, "invalid materialized routing state: {context}: {source}")
326            }
327            GateError::InvalidMaterializedRowIdentity { context, source } => {
328                write!(f, "invalid materialized routing state: {context}: {source}")
329            }
330            GateError::MissingChangesetPrimaryKey(table) => {
331                write!(f, "scoped changeset row in {table} has no primary key")
332            }
333            GateError::MissingAudienceRow { table, row_id } => {
334                write!(f, "scoped row {table}.{row_id} is absent while resolving its audience")
335            }
336            GateError::MissingAudienceParent {
337                table,
338                row_id,
339                parent,
340            } => write!(
341                f,
342                "scoped row {table}.{row_id:?} has no audience parent in {parent}"
343            ),
344            GateError::CircleAuthority {
345                circle_id,
346                active_records,
347            } => write!(
348                f,
349                "circle {circle_id} has {active_records} active local access records; expected exactly one"
350            ),
351            GateError::CircleDeleted { circle_id } => {
352                write!(f, "circle {circle_id} is deleted and accepts no writes")
353            }
354            GateError::InvalidCircleControl { circle_id, source } => {
355                write!(f, "circle {circle_id} has invalid active control: {source}")
356            }
357            GateError::NoGatedDescendants(tbl) => {
358                write!(
359                    f,
360                    "gated_by_descendants ancestor {tbl} has no inferred gated descendant: no \
361                     synced table references it"
362                )
363            }
364            GateError::FkCycle(tables) => {
365                write!(f, "gated tables form an FK cycle: {}", tables.join(", "))
366            }
367            GateError::UnsharedForeignKeyParent(unshared) => match &unshared.parent_id {
368                Some(parent_id) => write!(
369                    f,
370                    "shared row {table}.{row_id} names {parent}.{parent_id} through {column}, \
371                     which the gate does not share",
372                    table = unshared.table,
373                    row_id = unshared.row_id,
374                    parent = unshared.parent,
375                    column = unshared.column,
376                ),
377                None => write!(
378                    f,
379                    "shared row {table}.{row_id} names a {parent} row through {column} that the \
380                     database does not hold",
381                    table = unshared.table,
382                    row_id = unshared.row_id,
383                    parent = unshared.parent,
384                    column = unshared.column,
385                ),
386            },
387            GateError::CreateTableSchema(error) => error.fmt(f),
388            GateError::Sql(op, err) => write!(f, "{op} failed: {err}"),
389            GateError::Cleanup { operation, cleanup } => {
390                write!(
391                    f,
392                    "{operation}; temporary gate cleanup also failed: {cleanup}"
393                )
394            }
395        }
396    }
397}
398
399impl std::error::Error for GateError {
400    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
401        match self {
402            Self::Session { source, .. } | Self::Sql(_, source) => Some(source),
403            Self::ForeignKeySchema(source) => Some(source),
404            Self::CreateTableSchema(source) => Some(source),
405            Self::InvalidCircleControl { source, .. } => Some(source),
406            Self::InvalidAudienceEncoding { source, .. } => Some(source),
407            Self::InvalidInboundAudienceEncoding { source, .. }
408            | Self::InvalidMaterializedAudience { source, .. } => Some(source),
409            Self::InvalidInboundRowIdentity { source, .. }
410            | Self::InvalidMaterializedRowIdentity { source, .. } => Some(source),
411            Self::InvalidMaterializedRoutingId { source, .. } => Some(source),
412            Self::Cleanup { operation, .. } => Some(operation.as_ref()),
413            _ => None,
414        }
415    }
416}
417
418/// The row a captured write would share, and the foreign key on it the gate does
419/// not resolve. Carried behind a `Box` in
420/// [`GateError::UnsharedForeignKeyParent`].
421#[derive(Debug)]
422pub struct UnsharedForeignKeyParent {
423    /// The table and id of the row that would be shared.
424    pub table: String,
425    pub row_id: String,
426    /// The foreign-key column on that row, and the table it points into.
427    pub column: String,
428    pub parent: String,
429    /// The parent row the foreign key names, or `None` when it names a key no
430    /// row in `parent` carries at all — the local database is already
431    /// inconsistent, which is a different fault worth telling apart.
432    pub parent_id: Option<String>,
433}
434
435#[derive(Debug, thiserror::Error)]
436pub enum CircleControlFailure {
437    #[error("parse current state: {0}")]
438    ParseCurrentState(serde_json::Error),
439    #[error("current state failed verification")]
440    Verification,
441    #[error("serialize current control coordinate: {0}")]
442    SerializeCoordinate(serde_json::Error),
443    #[error(transparent)]
444    PartitionControl(#[from] CirclePartitionControlError),
445}
446
447impl From<crate::CreateTableSchemaError> for GateError {
448    fn from(error: crate::CreateTableSchemaError) -> Self {
449        Self::CreateTableSchema(error)
450    }
451}
452
453#[cfg(test)]
454mod retraction_tests;
455#[cfg(test)]
456mod tests;