Skip to main content

coven_database/gate/
model.rs

1//! Gate-model construction: classify each synced table against the gate (root,
2//! remote root, inheriting child, or kept-by-descendants ancestor), infer the
3//! keep-children from the live FK graph, and answer share/keep/subtree queries
4//! against the live database.
5
6use std::cmp::Reverse;
7use std::collections::{BinaryHeap, HashMap, HashSet};
8
9use rusqlite::Connection;
10use tracing::{debug, warn};
11
12use super::outbound::{query_column_text, resolve_root};
13use super::{execute_batch, query_mapped_rows, query_row_optional, row_value_to_string, GateError};
14use crate::{foreign_key_edges, quote_ident, ForeignKeyEdge};
15use coven_protocol::synced_schema::{RowIdentity, SyncedTable};
16
17/// How a synced table relates to the gate.
18pub enum TableGate {
19    /// A gated root: the boolean gate lives at this column.
20    Root { gate_col: GateColumn },
21    /// A scoped root: this column names Store (`NULL`), one circle, or the
22    /// local device (`local`). Descendants inherit the same audience through
23    /// their selected foreign-key parent.
24    ScopedRoot { audience_col: GateColumn },
25    /// A root whose rows sync unconditionally and whose blob subtree is always
26    /// Remote.
27    RemoteRoot,
28    /// A child whose gate is inherited from `parent` via the FK column at
29    /// `fk_col` (in *this* table), holding the parent's id.
30    Child {
31        fk_col: GateColumn,
32        parent: String,
33        parent_col: GateColumn,
34    },
35    /// An always-shared ancestor kept alive by its gated subtree: shared iff
36    /// some inferred child still has a kept row referencing it. Each entry is a
37    /// `(child table, FK column in that child)` pair, where the FK column holds
38    /// this table's id. The children are inferred from the live FK graph, never
39    /// declared.
40    Parent {
41        children: Vec<(String, GateColumn, GateColumn)>,
42    },
43}
44
45/// A gate column as both a changeset position and a SQL column name.
46#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
47pub struct GateColumn {
48    pub index: usize,
49    pub name: String,
50}
51
52/// The gate model for a database handle, computed from the live schema at open.
53///
54/// Maps each gated-or-inheriting synced table to how it resolves its gate. A
55/// synced table absent from this map is ungated and unconditionally shared.
56pub struct Gates {
57    pub tables: HashMap<String, TableGate>,
58    synced_tables: HashSet<String>,
59    row_identities: HashMap<String, RowIdentity>,
60}
61
62struct GateModelConstruction<'schema> {
63    conn: &'schema Connection,
64    tables: &'schema [SyncedTable],
65    ancestors: HashSet<&'schema str>,
66    assets: HashSet<&'schema str>,
67}
68
69impl<'schema> GateModelConstruction<'schema> {
70    fn new(conn: &'schema Connection, tables: &'schema [SyncedTable]) -> Self {
71        Self {
72            conn,
73            tables,
74            ancestors: tables
75                .iter()
76                .filter(|table| table.is_gated_by_descendants())
77                .map(|table| table.name())
78                .collect(),
79            assets: tables
80                .iter()
81                .filter(|table| table.is_asset())
82                .map(|table| table.name())
83                .collect(),
84        }
85    }
86
87    fn build(&self) -> Result<Gates, GateError> {
88        // Asset tables ride their FK subject's gate as inherited children but are
89        // never keep-reasons: excluded from every ancestor's keep-children below.
90        let mut gate_map = HashMap::new();
91
92        // Classify each table's downward gate-parent. Roots and ancestors are
93        // termini; a plain table inherits from the FK parent picked by
94        // `select_parent_fk` (which considers ALL its synced-parent FKs, prefers
95        // a parent that reaches a gated root, then the most-specific ancestor,
96        // then lexicographic). Ancestors are deferred: their upward keep-children
97        // are built below, once every plain table's downward parent is known, so
98        // an ancestor is inserted already complete — never empty-then-filled.
99        for t in self.tables {
100            let has_scoped_ancestor = self.reaches_scoped_root(t.name(), &mut HashSet::new())?;
101            if let Some(column) = t.audience_parent_column().filter(|_| {
102                t.is_gated_by_descendants()
103                    || t.is_remote_root()
104                    || t.gate_column().is_some()
105                    || t.audience_column().is_some()
106            }) {
107                return Err(GateError::InvalidAudienceParentDeclaration {
108                    table: t.name().to_string(),
109                    column: column.to_string(),
110                    reason: "only a plain descendant table may select an audience parent"
111                        .to_string(),
112                });
113            }
114            if has_scoped_ancestor
115                && t.audience_column().is_none()
116                && t.audience_parent_column().is_none()
117            {
118                return Err(GateError::MissingAudienceParentDeclaration {
119                    table: t.name().to_string(),
120                });
121            }
122            if t.is_gated_by_descendants() {
123                continue;
124            }
125
126            let cols = super::gate_table_columns(self.conn, t.name())?;
127
128            if t.is_remote_root() {
129                gate_map.insert(t.name().to_string(), TableGate::RemoteRoot);
130                continue;
131            }
132
133            if let Some(gate) = t.gate_column() {
134                let gate_col = gate_column(&cols, t.name(), gate)?;
135                gate_map.insert(t.name().to_string(), TableGate::Root { gate_col });
136                continue;
137            }
138
139            if let Some(audience) = t.audience_column() {
140                let audience_col = gate_column(&cols, t.name(), audience)?;
141                gate_map.insert(t.name().to_string(), TableGate::ScopedRoot { audience_col });
142                continue;
143            }
144
145            // A plain table inherits the gate downward from its selected FK
146            // parent. Inheritance flows ONLY through declared FKs, toward synced
147            // parents, and (for a multi-FK join row) toward the gated side, never
148            // up an ancestor back-edge.
149            let selected_parent = if let Some(column) = t.audience_parent_column() {
150                Some(self.select_audience_parent_fk(t.name(), column)?)
151            } else {
152                self.select_parent_fk(t.name(), &mut HashSet::new())?
153            };
154            if let Some((fk_name, parent, parent_name)) = selected_parent {
155                let fk_col = fk_column(&cols, t.name(), &fk_name)?;
156                let parent_cols = super::gate_table_columns(self.conn, &parent)?;
157                let parent_col = fk_column(&parent_cols, &parent, &parent_name)?;
158                gate_map.insert(
159                    t.name().to_string(),
160                    TableGate::Child {
161                        fk_col,
162                        parent,
163                        parent_col,
164                    },
165                );
166            }
167            // else: ungated, unconditionally shared — not in the map.
168        }
169
170        for (table, column) in self
171            .tables
172            .iter()
173            .filter_map(|table| table.audience_parent_column().map(|column| (table, column)))
174        {
175            if !gate_reaches_scoped_root(&gate_map, table.name()) {
176                return Err(GateError::InvalidAudienceParentDeclaration {
177                    table: table.name().to_string(),
178                    column: column.to_string(),
179                    reason: "the selected foreign-key chain does not end at an audience root"
180                        .to_string(),
181                });
182            }
183        }
184
185        // Children are filled once all downward parents are known. A keep-child
186        // of ancestor P is any synced table with an FK referencing P, MINUS two
187        // kinds: an *asset* (a host-declared decoration that rides P's gate but
188        // never keeps it alive — e.g. an artist image keeping its artist), and a
189        // table whose chosen downward gate-parent IS P (the join-table back-edge:
190        // a child cannot keep its own parent alive — that is the circular fixpoint
191        // that would keep an empty album alive forever). An ancestor that infers
192        // no children is a host error (the keep would be vacuously false). The
193        // children are computed first, so the `Parent` is inserted fully formed.
194        for &ancestor in &self.ancestors {
195            let mut children = Vec::new();
196            for t in self.tables {
197                if t.name() == ancestor {
198                    continue;
199                }
200                // Skip an asset: it inherits the gate downward as a child but is
201                // never a keep-reason. Excluding it also keeps the asset-rides-gate
202                // vs. ancestor-kept-by-children relation acyclic.
203                if self.assets.contains(t.name()) {
204                    continue;
205                }
206                // Skip the back-edge: a table whose downward gate-parent is this
207                // ancestor is NOT a keep-child of it.
208                if let Some(TableGate::Child { parent, .. }) = gate_map.get(t.name()) {
209                    if parent == ancestor {
210                        continue;
211                    }
212                }
213                // Otherwise, if this table has an FK referencing the ancestor, it
214                // is a keep-child: record the FK column in that child.
215                if let Some((fk_name, parent_name)) = self.fk_col_referencing(t.name(), ancestor)? {
216                    let cols = super::gate_table_columns(self.conn, t.name())?;
217                    let fk_col = fk_column(&cols, t.name(), &fk_name)?;
218                    let parent_cols = super::gate_table_columns(self.conn, ancestor)?;
219                    let parent_col = fk_column(&parent_cols, ancestor, &parent_name)?;
220                    children.push((t.name().to_string(), fk_col, parent_col));
221                }
222            }
223            if children.is_empty() {
224                return Err(GateError::NoGatedDescendants(ancestor.to_string()));
225            }
226            children.sort();
227            gate_map.insert(ancestor.to_string(), TableGate::Parent { children });
228        }
229
230        // Prune children whose FK chain never reaches a gate terminus (a
231        // gated root or an ancestor): they are effectively ungated. Roots and
232        // ancestors are themselves termini and are always retained.
233        let reaches_gate: HashSet<String> = gate_map
234            .keys()
235            .filter(|name| reaches_gate_terminus(&gate_map, name))
236            .cloned()
237            .collect();
238        gate_map.retain(|name, tg| match tg {
239            TableGate::Root { .. }
240            | TableGate::ScopedRoot { .. }
241            | TableGate::RemoteRoot
242            | TableGate::Parent { .. } => true,
243            TableGate::Child { .. } => reaches_gate.contains(name),
244        });
245
246        Ok(Gates {
247            tables: gate_map,
248            synced_tables: self
249                .tables
250                .iter()
251                .map(|table| table.name().to_string())
252                .collect(),
253            row_identities: self
254                .tables
255                .iter()
256                .map(|table| (table.name().to_string(), table.row_identity()))
257                .collect(),
258        })
259    }
260
261    /// The FK column in `child` that references `parent`, or `None` if `child` has
262    /// no FK to `parent`. Used to wire an ancestor to a keep-child: the inference
263    /// names the child *table*, and this resolves which of its columns holds the
264    /// ancestor's id.
265    fn fk_col_referencing(
266        &self,
267        child: &str,
268        parent: &str,
269    ) -> Result<Option<(String, String)>, GateError> {
270        Ok(foreign_keys(self.conn, child)?
271            .into_iter()
272            .find(|(_, p, _)| p == parent)
273            .map(|(from, _, to)| (from, to)))
274    }
275
276    /// Pick `table`'s single DOWNWARD gate-parent among ALL its synced-parent FKs —
277    /// not just the first PRAGMA row, whose order is non-deterministic w.r.t.
278    /// declaration (SQLite numbers FKs in reverse). Returns `(child FK column name,
279    /// parent table)`, or `None` if no synced-parent FK exists.
280    ///
281    /// A join row (e.g. `album_artists` → albums, artists) must inherit downward from
282    /// the right parent, so the choice follows a deterministic preference:
283    ///
284    /// 1. **Prefer a parent that reaches a gated root downward** — a Root, or a plain
285    ///    table whose own chosen FK chain reaches a Root. So `release_files` →
286    ///    releases (a Root), not `release_files` → audio_formats (a lookup ancestor).
287    /// 2. **Else, among ancestor parents, pick the most-specific** — the candidate
288    ///    that is itself an FK-descendant of the other candidates (deepest in the
289    ///    containment DAG). So `album_artists` → albums, since albums is a descendant
290    ///    of artists (albums.artist_id → artists).
291    /// 3. **Else break ties lexicographically** by parent name.
292    fn select_parent_fk(
293        &self,
294        table: &str,
295        visiting: &mut HashSet<String>,
296    ) -> Result<Option<(String, String, String)>, GateError> {
297        let synced: HashSet<&str> = self.tables.iter().map(|t| t.name()).collect();
298        let candidates: Vec<ForeignKeyEdge> = foreign_key_edges(self.conn, table)
299            .map_err(GateError::ForeignKeySchema)?
300            .into_iter()
301            .filter(|edge| synced.contains(edge.parent_table.as_str()))
302            .collect();
303        if candidates.is_empty() {
304            return Ok(None);
305        }
306
307        // Rank each candidate `(fk, parent)` by the preference and pick the smallest:
308        //   tier 0  parent reaches a gated root downward (a Root, or a plain chain to
309        //           one) — the gated side of a join row;
310        //   tier 1  parent is an ancestor, ranked most-specific first (a deeper
311        //           ancestor sorts before a shallower one, so albums beats artists);
312        //   tier 2  some other synced parent (neither).
313        // The lexicographic parent name is the final tie-break. A stable key makes
314        // the choice deterministic regardless of PRAGMA row order. The ranking probes
315        // the FK graph (fallible), so build each key before sorting rather than inside
316        // the sort comparator.
317        //
318        // `ParentRank`'s field order is its comparison order (derived `Ord`): tier,
319        // then specificity, then name.
320        #[derive(PartialEq, Eq, PartialOrd, Ord)]
321        struct ParentRank {
322            tier: u8,
323            specificity: isize,
324            name: String,
325            columns: Vec<(String, String)>,
326            on_update: String,
327            on_delete: String,
328            match_clause: String,
329        }
330        let mut keyed = Vec::with_capacity(candidates.len());
331        for edge in candidates {
332            let parent = &edge.parent_table;
333            let tier = if self.parent_reaches_root(parent, visiting)? {
334                0u8
335            } else if self.ancestors.contains(parent.as_str()) {
336                1
337            } else {
338                2
339            };
340            let specificity = if tier == 1 {
341                -(self.ancestor_depth(parent, &mut HashSet::new())? as isize)
342            } else {
343                0
344            };
345            let rank = ParentRank {
346                tier,
347                specificity,
348                name: parent.clone(),
349                columns: edge
350                    .columns
351                    .iter()
352                    .map(|column| (column.child.clone(), column.parent.clone()))
353                    .collect(),
354                on_update: edge.on_update.clone(),
355                on_delete: edge.on_delete.clone(),
356                match_clause: edge.match_clause.clone(),
357            };
358            keyed.push((rank, edge));
359        }
360        keyed.sort_by(|a, b| a.0.cmp(&b.0));
361        let Some((_, edge)) = keyed.into_iter().next() else {
362            return Ok(None);
363        };
364        let [column] = edge.columns.as_slice() else {
365            return Err(GateError::CompositeGateForeignKey {
366                table: table.to_string(),
367                parent: edge.parent_table,
368            });
369        };
370        Ok(Some((
371            column.child.clone(),
372            edge.parent_table,
373            column.parent.clone(),
374        )))
375    }
376
377    fn select_audience_parent_fk(
378        &self,
379        table: &str,
380        column: &str,
381    ) -> Result<(String, String, String), GateError> {
382        let synced: HashSet<&str> = self.tables.iter().map(|table| table.name()).collect();
383        let mut matches = foreign_key_edges(self.conn, table)
384            .map_err(GateError::ForeignKeySchema)?
385            .into_iter()
386            .filter(|edge| {
387                edge.columns
388                    .iter()
389                    .any(|candidate| candidate.child == column)
390            })
391            .collect::<Vec<_>>();
392        if matches.len() != 1 {
393            return Err(GateError::InvalidAudienceParentDeclaration {
394                table: table.to_string(),
395                column: column.to_string(),
396                reason: match matches.len() {
397                    0 => "no foreign key uses that child column".to_string(),
398                    count => format!("{count} foreign keys use that child column"),
399                },
400            });
401        }
402        let edge = matches.remove(0);
403        if !synced.contains(edge.parent_table.as_str()) {
404            return Err(GateError::InvalidAudienceParentDeclaration {
405                table: table.to_string(),
406                column: column.to_string(),
407                reason: format!(
408                    "its foreign key targets undeclared table {}",
409                    edge.parent_table
410                ),
411            });
412        }
413        let [foreign_key_column] = edge.columns.as_slice() else {
414            return Err(GateError::CompositeGateForeignKey {
415                table: table.to_string(),
416                parent: edge.parent_table,
417            });
418        };
419        Ok((
420            foreign_key_column.child.clone(),
421            edge.parent_table,
422            foreign_key_column.parent.clone(),
423        ))
424    }
425
426    fn reaches_scoped_root(
427        &self,
428        table: &str,
429        visiting: &mut HashSet<String>,
430    ) -> Result<bool, GateError> {
431        if !visiting.insert(table.to_string()) {
432            return Ok(false);
433        }
434        let synced = self
435            .tables
436            .iter()
437            .map(|declaration| (declaration.name(), declaration))
438            .collect::<HashMap<_, _>>();
439        let mut reaches = false;
440        for edge in foreign_key_edges(self.conn, table).map_err(GateError::ForeignKeySchema)? {
441            let Some(parent) = synced.get(edge.parent_table.as_str()) else {
442                continue;
443            };
444            if parent.audience_column().is_some()
445                || self.reaches_scoped_root(parent.name(), visiting)?
446            {
447                reaches = true;
448                break;
449            }
450        }
451        visiting.remove(table);
452        Ok(reaches)
453    }
454
455    /// Whether `parent`'s own gate eventually reaches a locality root downward, so a
456    /// child inheriting from it lands on a real root rather than on an ancestor or
457    /// nothing. A gated root or remote root is the terminus; a plain table reaches one
458    /// iff its own selected parent FK does; an ancestor is NOT a downward root path (its
459    /// keep is the separate upward relation). Cycle-guarded by `visiting`.
460    fn parent_reaches_root(
461        &self,
462        parent: &str,
463        visiting: &mut HashSet<String>,
464    ) -> Result<bool, GateError> {
465        if !visiting.insert(parent.to_string()) {
466            return Ok(false); // a cycle is not a path to a real root.
467        }
468        let decl = self.tables.iter().find(|t| t.name() == parent);
469        let reaches = match decl {
470            Some(t)
471                if t.gate_column().is_some()
472                    || t.audience_column().is_some()
473                    || t.is_remote_root() =>
474            {
475                true
476            }
477            // An ancestor is not a downward root path.
478            Some(t) if t.is_gated_by_descendants() => false,
479            // A plain (or unknown) parent reaches a root iff its own chain does.
480            _ => match self.select_parent_fk(parent, visiting)? {
481                Some((_, grandparent, _)) => self.parent_reaches_root(&grandparent, visiting)?,
482                // No synced-parent FK: the chain ends here without a root.
483                None => false,
484            },
485        };
486        visiting.remove(parent);
487        Ok(reaches)
488    }
489
490    /// How deep `ancestor` sits in the containment DAG of ancestor tables: 0 if it
491    /// references no other ancestor, else 1 + the max depth of the ancestors it has
492    /// an FK to. A deeper ancestor is more specific (e.g. albums references artists,
493    /// so albums is depth 1 and artists depth 0). Cycle-guarded by `visiting`.
494    fn ancestor_depth(
495        &self,
496        ancestor: &str,
497        visiting: &mut HashSet<String>,
498    ) -> Result<usize, GateError> {
499        if !visiting.insert(ancestor.to_string()) {
500            return Ok(0); // defensive against a malformed ancestor cycle.
501        }
502        let mut depth = 0;
503        for (_, parent, _) in foreign_keys(self.conn, ancestor)? {
504            if parent != ancestor && self.ancestors.contains(parent.as_str()) {
505                depth = depth.max(1 + self.ancestor_depth(&parent, visiting)?);
506            }
507        }
508        visiting.remove(ancestor);
509        Ok(depth)
510    }
511}
512
513/// Whether a table's gate is *derived* from other rows rather than declared on
514/// the row itself. A root (gated, scoped, or remote) carries its own decision
515/// about whether its rows leave the device; a descendant and an ancestor both
516/// read theirs off the FK graph. Only a derived gate is extended by closure.
517pub(super) fn gate_is_derived(gate: Option<&TableGate>) -> bool {
518    matches!(
519        gate,
520        Some(TableGate::Child { .. } | TableGate::Parent { .. })
521    )
522}
523
524/// The rows the gate shares, as a set you can ask about, over one live database.
525///
526/// Two relations decide membership, and they answer different questions.
527///
528/// **Keep** ([`Gates::row_kept`]) decides what the gate *elects* to share: a root
529/// row iff its gate column is true, a descendant iff its selected gate-parent is
530/// kept, an ancestor iff some inferred keep-child still references it.
531///
532/// **Closure** decides what those elections *oblige*. A shared row lands on a
533/// receiver that replays the published commits into an empty database, so every
534/// foreign key the row carries has to resolve there — and a row's foreign keys
535/// run along every FK it declares, not only the one its gate was inherited
536/// through. So a row is shared iff it is kept, or some shared row references it.
537///
538/// The gap between the two is not hypothetical. An ancestor's keep-children
539/// exclude the join-table back-edge, because a child that inherits its gate from
540/// an ancestor cannot also be a reason to keep that ancestor alive — that is the
541/// circular fixpoint [`Gates::from_tables`] refuses. But excluding the back-edge
542/// from *keep* is not license to exclude it from *closure*: such a child's other
543/// foreign keys can name ancestor rows nothing keeps. bae's `work_parts` names
544/// two `works` rows, inherits its gate from one, and the other — a container work
545/// with no recording and no credit of its own — is kept by nothing. Sharing the
546/// join row without it puts a foreign key on the wire no receiver can resolve,
547/// and the receiver's replay holds on it forever.
548///
549/// Closure never crosses into a **root**. A root's gate (or audience) column is
550/// the host's own decision about whether the row leaves the device, and a
551/// reference from elsewhere must not overturn it. A shared row that names a
552/// gate-false root is a gate inconsistency, refused where the write is captured
553/// rather than quietly published.
554pub(crate) struct SharedRows<'a> {
555    gates: &'a Gates,
556    conn: &'a Connection,
557    referrers: HashMap<String, Vec<GatedChildEdge>>,
558}
559
560impl SharedRows<'_> {
561    /// Whether the live row `(table, id)` is shared: kept outright, or reached by
562    /// closure from a shared row that references it.
563    pub(crate) fn contains(&self, table: &str, id: &str) -> Result<bool, GateError> {
564        self.contains_guarded(table, id, &mut HashSet::new())
565    }
566
567    /// The closure walk descends from a row to the rows referencing it, stopping
568    /// at the first kept one. Kept rows are the common case and short-circuit
569    /// immediately, so the descent only ever runs over rows the gate did not
570    /// elect. `visiting` guards a reference cycle, which resolves to not-shared
571    /// for the same reason [`Gates::keep_clause`] resolves one to `FALSE`: a row
572    /// shared only by way of itself is shared by nothing.
573    fn contains_guarded(
574        &self,
575        table: &str,
576        id: &str,
577        visiting: &mut HashSet<(String, String)>,
578    ) -> Result<bool, GateError> {
579        if !visiting.insert((table.to_string(), id.to_string())) {
580            return Ok(false);
581        }
582        if self.gates.row_kept(self.conn, table, id)? {
583            return Ok(true);
584        }
585        if !gate_is_derived(self.gates.tables.get(table)) {
586            return Ok(false);
587        }
588        let Some(edges) = self.referrers.get(table) else {
589            return Ok(false);
590        };
591        for (referrer, referrer_id) in child_rows(self.conn, edges, table, id)? {
592            if self.contains_guarded(&referrer, &referrer_id, visiting)? {
593                return Ok(true);
594            }
595        }
596        Ok(false)
597    }
598}
599
600#[cfg(any(test, feature = "test-utils"))]
601thread_local! {
602    static FROM_TABLES_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
603}
604
605#[cfg(any(test, feature = "test-utils"))]
606pub fn reset_from_tables_call_count() {
607    FROM_TABLES_CALLS.with(|calls| calls.set(0));
608}
609
610#[cfg(any(test, feature = "test-utils"))]
611pub fn from_tables_call_count() -> usize {
612    FROM_TABLES_CALLS.with(std::cell::Cell::get)
613}
614
615impl Gates {
616    pub(crate) fn locality_column_index(&self, table: &str) -> Option<usize> {
617        match self.tables.get(table) {
618            Some(TableGate::Root { gate_col }) => Some(gate_col.index),
619            Some(TableGate::ScopedRoot { audience_col }) => Some(audience_col.index),
620            None
621            | Some(TableGate::RemoteRoot)
622            | Some(TableGate::Child { .. })
623            | Some(TableGate::Parent { .. }) => None,
624        }
625    }
626
627    pub(crate) fn row_can_be_private(&self, table: &str) -> bool {
628        self.tables.contains_key(table)
629    }
630
631    pub(crate) fn private_rows(
632        &self,
633        conn: &Connection,
634    ) -> Result<std::collections::BTreeSet<(String, String)>, GateError> {
635        let shared = self.shared_rows(conn)?;
636        let mut private = std::collections::BTreeSet::new();
637        for table in self.tables.keys() {
638            for row_id in super::all_row_ids(conn, table)? {
639                if !shared.contains(table, &row_id)? {
640                    private.insert((table.clone(), row_id));
641                }
642            }
643        }
644        Ok(private)
645    }
646
647    pub fn has_scoped_graph(&self) -> bool {
648        self.tables
649            .values()
650            .any(|gate| matches!(gate, TableGate::ScopedRoot { .. }))
651    }
652
653    pub fn table_is_scoped(&self, table: &str) -> bool {
654        gate_reaches_scoped_root(&self.tables, table)
655    }
656
657    /// Every scoped table, sorted, so a pass over the scoped graph visits them
658    /// in one order whatever the gate map's iteration order is.
659    pub fn scoped_table_names(&self) -> Vec<String> {
660        let mut tables = self
661            .tables
662            .keys()
663            .filter(|table| self.table_is_scoped(table))
664            .cloned()
665            .collect::<Vec<_>>();
666        tables.sort();
667        tables
668    }
669
670    /// Every synced table, sorted.
671    pub fn sorted_synced_table_names(&self) -> Vec<String> {
672        let mut tables = self
673            .synced_table_names()
674            .map(str::to_string)
675            .collect::<Vec<_>>();
676        tables.sort();
677        tables
678    }
679
680    pub fn is_synced_table(&self, table: &str) -> bool {
681        self.synced_tables.contains(table)
682    }
683
684    pub fn synced_table_names(&self) -> impl Iterator<Item = &str> {
685        self.synced_tables.iter().map(String::as_str)
686    }
687
688    pub fn row_identity(&self, table: &str) -> Option<RowIdentity> {
689        self.row_identities.get(table).copied()
690    }
691
692    /// Build the gate model from the declared [`SyncedTable`]s and the live
693    /// schema (`PRAGMA table_info` for gate-column indices, `PRAGMA
694    /// foreign_key_list` for FK edges).
695    ///
696    pub(crate) fn from_tables(
697        conn: &Connection,
698        tables: &[SyncedTable],
699    ) -> Result<Self, GateError> {
700        #[cfg(any(test, feature = "test-utils"))]
701        FROM_TABLES_CALLS.with(|calls| calls.set(calls.get() + 1));
702
703        GateModelConstruction::new(conn, tables).build()
704    }
705
706    /// Every table governed by the gate, in FK-topological order: a table comes
707    /// after every gated table it has a foreign key to (e.g. artists, albums,
708    /// album_artists, releases, tracks).
709    ///
710    /// [`delete_gated_false`](Self::delete_gated_false) needs this so it can
711    /// delete *child-first* — its reverse — without an FK rejecting the deletion
712    /// of a parent a child still references under `foreign_keys=ON`. The re-emit
713    /// changeset uses the same order for a deterministic, FK-sensible layout; the
714    /// changeset *apply* itself tolerates any order, since
715    /// `sqlite3changeset_apply` defers FK enforcement to the end of its savepoint.
716    ///
717    /// A chain-depth sort does not suffice: the gate graph spans both directions
718    /// — an ancestor (album) is the FK *parent* of gated rows (releases) yet is
719    /// itself kept *by* them — so only a real topological sort over the FK edges
720    /// among the gated tables produces a valid order.
721    ///
722    pub(crate) fn gated_tables_parent_first(
723        &self,
724        conn: &Connection,
725    ) -> Result<Vec<String>, GateError> {
726        // Edge parent -> child means "parent must precede child". A table's FK to a
727        // gated table makes that table its prerequisite (it points at the parent's
728        // id), so the FK target is the parent of the edge and the referrer the child.
729        // Only edges between two gated tables matter.
730        let names: Vec<String> = self.tables.keys().cloned().collect();
731        let mut indegree: HashMap<String, usize> =
732            names.iter().map(|name| (name.clone(), 0)).collect();
733        let mut edges: HashMap<String, Vec<String>> = names
734            .iter()
735            .map(|name| (name.clone(), Vec::new()))
736            .collect();
737
738        let child_edges = gated_fk_child_edges(conn, &self.tables)?;
739        for (parent, children) in &child_edges {
740            for child in children {
741                edges
742                    .get_mut(parent)
743                    .expect("gated parent has a topological node")
744                    .push(child.child_table.clone());
745                *indegree
746                    .get_mut(&child.child_table)
747                    .expect("gated child has a topological node") += 1;
748            }
749        }
750
751        // Kahn with a deterministic tie-break: a min-heap of the ready
752        // (zero-indegree) tables, so equal-rank tables always emit smallest-first.
753        let mut ready: BinaryHeap<Reverse<String>> = indegree
754            .iter()
755            .filter(|(_, &degree)| degree == 0)
756            .map(|(name, _)| Reverse(name.clone()))
757            .collect();
758
759        let mut order = Vec::with_capacity(names.len());
760        while let Some(Reverse(next)) = ready.pop() {
761            for child in &edges[&next] {
762                let degree = indegree
763                    .get_mut(child)
764                    .expect("gated child has a topological degree");
765                *degree -= 1;
766                if *degree == 0 {
767                    ready.push(Reverse(child.clone()));
768                }
769            }
770            order.push(next);
771        }
772
773        if order.len() != names.len() {
774            let mut remaining: Vec<String> = names
775                .iter()
776                .filter(|name| !order.contains(name))
777                .cloned()
778                .collect();
779            remaining.sort();
780            return Err(GateError::FkCycle(remaining));
781        }
782        Ok(order)
783    }
784
785    /// Delete from `db` every row the gate excludes: each gated root row whose
786    /// gate is false, plus its FK-descendants. This is the same exclusion the
787    /// outbound changeset gate applies (a root shares iff its gate is true; a
788    /// descendant shares iff its gated-ancestor root does), expressed as SQL
789    /// `DELETE`s over the live tables rather than as a changeset filter.
790    ///
791    /// Both channels a row can use to cross devices — the changeset
792    /// (`gate_store_outbound`) and the snapshot — must honor the same gate, so the
793    /// snapshot calls this on its VACUUM'd copy to strip gated-false subtrees
794    /// before the bytes leave the device. Sharing this method (not a parallel
795    /// FK model) keeps a single definition of what the gate excludes.
796    ///
797    /// Each gated table — root, descendant, or ancestor — resolves its own keep
798    /// by a fully-inlined clause that bottoms out at root truthy columns, and
799    /// then survives anyway if a row that already survived still references it
800    /// (the closure half of the shared set — see [`SharedRows`]).
801    ///
802    /// That closure test is why the deletion order is load-bearing, not merely
803    /// FK-safe. Walking strictly child-first means every table holding a foreign
804    /// key into `tbl` has already been pruned by the time `tbl` is, so a
805    /// *surviving* referrer is exactly a *shared* referrer and the test needs no
806    /// recursion. Running the tables in any other order would consult rows that
807    /// have not yet been decided.
808    ///
809    pub(crate) fn delete_gated_false(&self, conn: &Connection) -> Result<(), GateError> {
810        self.delete_gated_false_conn(conn)
811    }
812
813    fn delete_gated_false_conn(&self, conn: &Connection) -> Result<(), GateError> {
814        // Child-first — the reverse of the FK-topological apply order. It is what
815        // makes the surviving-referrer test below exact (above), and it also keeps
816        // the prune correct under `foreign_keys=ON`: a parent FK without
817        // `ON DELETE CASCADE` would otherwise reject deleting a parent a child
818        // still references. The only caller is the snapshot scope, whose copy
819        // connection opens with `foreign_keys` OFF, so neither depends on the
820        // other.
821        let mut order = self.gated_tables_parent_first(conn)?;
822        order.reverse();
823        let referrers = gated_fk_child_edges(conn, &self.tables)?;
824        for tbl in order {
825            let keep = self.keep_clause(&tbl)?;
826            let predicate = match self.referenced_by_surviving_clause(&referrers, &tbl) {
827                Some(referenced) => format!("({keep}) OR ({referenced})"),
828                None => keep,
829            };
830            let sql = format!("DELETE FROM {} WHERE NOT ({predicate})", quote_ident(&tbl));
831            execute_batch(conn, &sql)?;
832        }
833        Ok(())
834    }
835
836    /// A SQL boolean that is true for rows of `tbl` some already-pruned table
837    /// still references — the closure half of the shared set, expressed against
838    /// the child-first prune order that has already settled every referrer.
839    ///
840    /// `None` when nothing references `tbl`, or when `tbl` is a root: a root's
841    /// gate (or audience) column is the host's own decision about whether the row
842    /// leaves the device, and a reference from elsewhere never overrides it.
843    fn referenced_by_surviving_clause(
844        &self,
845        referrers: &HashMap<String, Vec<GatedChildEdge>>,
846        tbl: &str,
847    ) -> Option<String> {
848        if !gate_is_derived(self.tables.get(tbl)) {
849            return None;
850        }
851        let edges = referrers.get(tbl)?;
852        if edges.is_empty() {
853            return None;
854        }
855        Some(
856            edges
857                .iter()
858                .map(|edge| {
859                    format!(
860                        "EXISTS (SELECT 1 FROM {child} WHERE {child}.{fk} = {tbl}.{parent})",
861                        child = quote_ident(&edge.child_table),
862                        fk = quote_ident(&edge.child_column),
863                        tbl = quote_ident(tbl),
864                        parent = quote_ident(&edge.parent_column),
865                    )
866                })
867                .collect::<Vec<_>>()
868                .join(" OR "),
869        )
870    }
871
872    /// The gate's shared row set over the live database in `conn`.
873    ///
874    /// Building one scans the FK graph once, so a pass that asks about many rows
875    /// builds a single set and queries it rather than re-deriving the graph per
876    /// row.
877    pub(crate) fn shared_rows<'a>(
878        &'a self,
879        conn: &'a Connection,
880    ) -> Result<SharedRows<'a>, GateError> {
881        Ok(SharedRows {
882            gates: self,
883            conn,
884            referrers: gated_fk_child_edges(conn, &self.tables)?,
885        })
886    }
887
888    /// A SQL boolean that is true for rows of `tbl` the gate keeps. The shape
889    /// depends on how `tbl` relates to the gate:
890    ///
891    /// - **Root**: the root's own gate column, tested truthy.
892    /// - **Child**: a correlated `EXISTS` joining up the FK to the parent's
893    ///   keep-clause, so the gate flows *down* the chain to the root truthy test.
894    /// - **Parent** (ancestor): a disjunction of correlated `EXISTS`, one per
895    ///   inferred child, so the keep flows *up* — the ancestor is kept iff some
896    ///   child has a kept row referencing it.
897    ///
898    /// Built inside-out and fully inlined down to the root truthy columns. A
899    /// dangling FK anywhere makes its `EXISTS` false (not shared), matching
900    /// `resolve_root`'s treatment of a missing ancestor. The recursion is
901    /// cycle-guarded by `visiting`: a `Parent` references its children and a
902    /// `Child` references its parent, so a malformed declaration could otherwise
903    /// loop. Revisiting a table in the current path yields `FALSE` rather than
904    /// recursing again.
905    ///
906    fn keep_clause(&self, tbl: &str) -> Result<String, GateError> {
907        self.keep_clause_guarded(tbl, &mut HashSet::new(), false)
908    }
909
910    fn keep_clause_guarded(
911        &self,
912        tbl: &str,
913        visiting: &mut HashSet<String>,
914        keeps_ancestor: bool,
915    ) -> Result<String, GateError> {
916        if !visiting.insert(tbl.to_string()) {
917            // Already on the current recursion path: refuse to loop. A row kept
918            // only via a cycle is treated as not kept.
919            return Ok("FALSE".to_string());
920        }
921        let clause = match self.tables.get(tbl) {
922            Some(TableGate::Root { gate_col }) => truthy_sql(&format!(
923                "{}.{}",
924                quote_ident(tbl),
925                quote_ident(&gate_col.name)
926            )),
927            Some(TableGate::ScopedRoot { audience_col }) if keeps_ancestor => format!(
928                "({table}.{column} IS NULL OR {table}.{column} <> 'local')",
929                table = quote_ident(tbl),
930                column = quote_ident(&audience_col.name),
931            ),
932            Some(TableGate::ScopedRoot { audience_col }) => format!(
933                "{}.{} IS NULL",
934                quote_ident(tbl),
935                quote_ident(&audience_col.name)
936            ),
937            Some(TableGate::RemoteRoot) => "TRUE".to_string(),
938            Some(TableGate::Child {
939                fk_col,
940                parent,
941                parent_col,
942            }) => {
943                let inner = self.keep_clause_guarded(parent, visiting, keeps_ancestor)?;
944                fk_exists_clause(parent, &parent_col.name, tbl, &fk_col.name, &inner)
945            }
946            Some(TableGate::Parent { children }) => {
947                if children.is_empty() {
948                    // `from_tables` rejects an ancestor with no inferred children
949                    // at construction, so a `Parent` reaching here always has at
950                    // least one.
951                    unreachable!("Parent {tbl} has empty children, rejected by from_tables");
952                }
953                let mut disjuncts = Vec::with_capacity(children.len());
954                for (child, fk_col, parent_col) in children {
955                    let inner = self.keep_clause_guarded(child, visiting, true)?;
956                    disjuncts.push(fk_exists_clause(
957                        child,
958                        &fk_col.name,
959                        tbl,
960                        &parent_col.name,
961                        &inner,
962                    ));
963                }
964                format!("({})", disjuncts.join(" OR "))
965            }
966            // Unreachable: callers pass table names straight from `self.tables`,
967            // and the recursion descends only to parents/children that
968            // `from_tables` proved are in the map. A table outside the map never
969            // reaches this match.
970            None => unreachable!("keep_clause called for {tbl}, absent from the gate map"),
971        };
972        visiting.remove(tbl);
973        Ok(clause)
974    }
975
976    /// Whether the live row (`tbl`, `id`) is currently kept by the gate, by
977    /// evaluating `tbl`'s keep-clause against the live db for that one row. Used
978    /// to resolve an ancestor's share decision (an album is kept iff it has a
979    /// kept child) — a property of the live child tables, not of the ancestor
980    /// row's own columns.
981    ///
982    pub(crate) fn row_kept(
983        &self,
984        conn: &Connection,
985        tbl: &str,
986        id: &str,
987    ) -> Result<bool, GateError> {
988        let keep = self.keep_clause(tbl)?;
989        let sql = format!(
990            "SELECT 1 FROM {t} WHERE {t}.{id_col} = ? AND ({keep})",
991            t = quote_ident(tbl),
992            id_col = quote_ident("id"),
993        );
994        let present = query_row_optional(conn, &sql, [id], |_| Ok(()))?.is_some();
995        Ok(present)
996    }
997
998    /// The locality terminus the live row `(table, id)` resolves to by walking up
999    /// its declared-FK chain — the gated root, remote root, or inheriting ancestor at
1000    /// the top — as `(terminus_table, terminus_id)`, regardless of whether a gated
1001    /// terminus currently keeps it. `None` if the row is ungated/unrooted, or a row
1002    /// along the chain is absent from the live db.
1003    ///
1004    /// The blob-transition drain uses this to map a just-uploaded blob's row to the
1005    /// gated root a make_remote tracks: a `release_files` row resolves up to its
1006    /// `releases` root, whose `blob_make_remote_intents` row the completion check reads.
1007    pub(crate) fn resolve_root_of(
1008        &self,
1009        conn: &Connection,
1010        table: &str,
1011        id: &str,
1012    ) -> Result<Option<(String, String)>, GateError> {
1013        Ok(resolve_root(conn, self, table, id)?.map(|r| (r.terminus_table, r.terminus_id)))
1014    }
1015
1016    /// Whether the blob-bearing row `(table, id)` resolves to Remote locality:
1017    /// `Some(true)` is Remote (shared, bytes in the cloud), `Some(false)` is Local
1018    /// (bytes on-device). The same FK up-walk as
1019    /// [`resolve_root_of`](Self::resolve_root_of), returning the locality truth that
1020    /// walk already reads (a gated root's own column, a remote root's declared Remote
1021    /// state, or a `gated_by_descendants` ancestor's keep), so the read path dispatches
1022    /// on this rather than probing every store. `None` when the chain reaches no
1023    /// locality terminus (the row is ungated/unrooted) or a row along it is missing —
1024    /// an unresolvable locality the read path fails loud on rather than guessing a
1025    /// source.
1026    pub(crate) fn root_kept_of(
1027        &self,
1028        conn: &Connection,
1029        table: &str,
1030        id: &str,
1031    ) -> Result<Option<bool>, GateError> {
1032        Ok(resolve_root(conn, self, table, id)?.map(|r| r.kept))
1033    }
1034
1035    /// Every row in the gated subtree rooted at `(root_table, root_id)`: the root
1036    /// itself plus the transitive closure of its gated FK-*descendants*, as
1037    /// `(table, primary key)` pairs. A pure down-walk over the gated FK edges — it
1038    /// does NOT climb to ancestors or cross to sibling roots, so a release's subtree
1039    /// is exactly that release and its own files, never another release sharing an
1040    /// album. Structural (no kept-filter): a managed or managing root's whole
1041    /// subtree is returned whatever its gate currently reads.
1042    ///
1043    /// `row_blob_refs_for_root_on` maps these rows to the blobs a transition
1044    /// uploads (make_remote) or materializes (make_local).
1045    pub(crate) fn subtree_rows(
1046        &self,
1047        conn: &Connection,
1048        root_table: &str,
1049        root_id: &str,
1050    ) -> Result<HashSet<(String, String)>, GateError> {
1051        self.subtree_rows_conn(conn, root_table, root_id)
1052    }
1053
1054    fn subtree_rows_conn(
1055        &self,
1056        conn: &Connection,
1057        root_table: &str,
1058        root_id: &str,
1059    ) -> Result<HashSet<(String, String)>, GateError> {
1060        // The down-edges (parent table -> its gated children + FK column), the same
1061        // map the re-emit/retract closure walks; here we follow only this map (down,
1062        // never up) from the single root so the result is one subtree.
1063        let down_edges = gated_fk_child_edges(conn, &self.tables)?;
1064        let mut out: HashSet<(String, String)> = HashSet::new();
1065        let mut work = vec![(root_table.to_string(), root_id.to_string())];
1066        while let Some((table, id)) = work.pop() {
1067            if !out.insert((table.clone(), id.clone())) {
1068                continue; // already visited: cycle-guard and dedup.
1069            }
1070            if let Some(edges) = down_edges.get(table.as_str()) {
1071                work.extend(child_rows(conn, edges, &table, &id)?);
1072            }
1073        }
1074        Ok(out)
1075    }
1076}
1077
1078/// The SQL form of [`truthy`]: a predicate that is true for `expr` exactly when
1079/// [`truthy`] would return true for the same value. [`truthy`] owns the single
1080/// definition of gate-truth; this realizes it in SQL — the `CAST` collapses to 0
1081/// for NULL and non-numeric text, so only a genuine nonzero integer passes.
1082/// Keep the two in lockstep: a change to the gate-truth rule changes both.
1083fn truthy_sql(expr: &str) -> String {
1084    format!("({expr} IS NOT NULL AND CAST({expr} AS INTEGER) <> 0)")
1085}
1086
1087/// A correlated `EXISTS` that follows one FK edge to a related table's keep:
1088/// true for a row of `self_t` when some row of `other_t` joins to it on
1089/// `other_t.other_col = self_t.self_col` and itself satisfies `inner`. The Child
1090/// keep (join *up* to the parent) and the Parent keep (join *down* to a child)
1091/// are the same named-column relation with the join direction swapped.
1092fn fk_exists_clause(
1093    other_t: &str,
1094    other_col: &str,
1095    self_t: &str,
1096    self_col: &str,
1097    inner: &str,
1098) -> String {
1099    format!(
1100        "EXISTS (SELECT 1 FROM {other} \
1101           WHERE {other}.{other_col} = {this}.{self_col} AND ({inner}))",
1102        other = quote_ident(other_t),
1103        other_col = quote_ident(other_col),
1104        this = quote_ident(self_t),
1105        self_col = quote_ident(self_col),
1106    )
1107}
1108
1109/// Whether walking `gate_map` up the declared-FK chain from `table` reaches a
1110/// gate `accept` recognizes. Only `Child` links are followed upward, so the walk
1111/// ends at the first table `accept` refuses and cannot climb from (a `Parent`'s
1112/// upward keep over its own children is a separate relation, not part of this
1113/// downward chain). Cycle-guarded: a chain that loops reaches nothing.
1114fn chain_reaches(
1115    gate_map: &HashMap<String, TableGate>,
1116    table: &str,
1117    accept: impl Fn(&TableGate) -> bool,
1118) -> bool {
1119    let mut current = table;
1120    let mut seen = HashSet::new();
1121    loop {
1122        if !seen.insert(current.to_string()) {
1123            return false;
1124        }
1125        match gate_map.get(current) {
1126            Some(gate) if accept(gate) => return true,
1127            Some(TableGate::Child { parent, .. }) => current = parent.as_str(),
1128            _ => return false,
1129        }
1130    }
1131}
1132
1133/// Whether `name`'s chain reaches a gate terminus: a gated root, scoped root,
1134/// remote root, or ancestor — every gate but an inheriting `Child`.
1135fn reaches_gate_terminus(gate_map: &HashMap<String, TableGate>, name: &str) -> bool {
1136    chain_reaches(gate_map, name, |gate| {
1137        !matches!(gate, TableGate::Child { .. })
1138    })
1139}
1140
1141/// The gated FK edges of the schema, as `parent table -> [(child table, child's
1142/// FK column name)]`: for every gated table, each of its FKs that points at
1143/// another gated table contributes an edge under the *target* (the parent). The
1144/// fixpoint walk in `connected_component` (the outbound pass) follows these down-edges
1145/// directly; [`Gates::gated_tables_parent_first`] uses the same edges (discarding the FK
1146/// column) so the parent-first order is derived from one definition, not a second
1147/// parallel FK scan.
1148///
1149pub(crate) struct GatedChildEdge {
1150    pub child_table: String,
1151    pub child_column: String,
1152    pub parent_column: String,
1153}
1154
1155pub(crate) fn gated_fk_child_edges(
1156    conn: &Connection,
1157    gate_map: &HashMap<String, TableGate>,
1158) -> Result<HashMap<String, Vec<GatedChildEdge>>, GateError> {
1159    let mut edges: HashMap<String, Vec<GatedChildEdge>> = HashMap::new();
1160    for referrer in gate_map.keys() {
1161        for (fk_col, target, parent_col) in foreign_keys(conn, referrer)? {
1162            // Self-FKs and FKs to ungated tables are not cross-table gate edges.
1163            if target == *referrer {
1164                continue;
1165            }
1166            if gate_map.contains_key(&target) {
1167                edges.entry(target).or_default().push(GatedChildEdge {
1168                    child_table: referrer.clone(),
1169                    child_column: fk_col,
1170                    parent_column: parent_col,
1171                });
1172            }
1173        }
1174    }
1175    Ok(edges)
1176}
1177
1178/// Every row that references the live row `(table, id)` through `edges` — the
1179/// gated children of that row, as `(child table, child row id)`. One step of the
1180/// down-walk both the subtree closure and the outbound connected component take;
1181/// they differ in what they do with the children, not in how they find them.
1182pub(crate) fn child_rows(
1183    conn: &Connection,
1184    edges: &[GatedChildEdge],
1185    table: &str,
1186    id: &str,
1187) -> Result<Vec<(String, String)>, GateError> {
1188    let mut rows = Vec::new();
1189    for edge in edges {
1190        let Some(parent_key) = query_column_text(conn, table, &edge.parent_column, id)? else {
1191            // The row does not carry the key its children reference, so no child
1192            // can join to it through this edge.
1193            debug!(
1194                table,
1195                id,
1196                column = %edge.parent_column,
1197                "gate: row has no value for the column its gated children reference; skipping the edge"
1198            );
1199            continue;
1200        };
1201        for child_id in rows_referencing(conn, &edge.child_table, &edge.child_column, &parent_key)?
1202        {
1203            rows.push((edge.child_table.clone(), child_id));
1204        }
1205    }
1206    Ok(rows)
1207}
1208
1209/// The ids of rows in `table` whose `fk` column equals `value`.
1210pub(crate) fn rows_referencing(
1211    conn: &Connection,
1212    table: &str,
1213    fk: &str,
1214    value: &str,
1215) -> Result<Vec<String>, GateError> {
1216    let sql = format!(
1217        "SELECT {id} FROM {t} WHERE {fk} = ?",
1218        id = quote_ident("id"),
1219        t = quote_ident(table),
1220        fk = quote_ident(fk),
1221    );
1222    let mut ids = Vec::new();
1223    for id in query_mapped_rows(conn, &sql, [value], |row| row_value_to_string(row, 0))? {
1224        let Some(id) = id else {
1225            // `id` is a NOT NULL primary key, so a NULL here is a genuine schema
1226            // anomaly, not a row we may quietly drop from the kept component.
1227            warn!("gate: row in {table} referencing {fk}={value} has a NULL id; skipping it from the kept component");
1228            continue;
1229        };
1230        ids.push(id);
1231    }
1232    Ok(ids)
1233}
1234/// The single definition of gate-truth, evaluated in Rust over a gate value read
1235/// as text: a nonzero integer is true; `0`/empty/non-integer is false.
1236/// [`truthy_sql`] is the SQL realization of this same rule for the snapshot path;
1237/// changing the rule here means changing it there too.
1238pub(crate) fn truthy(s: &str) -> bool {
1239    s.trim().parse::<i64>().map(|n| n != 0).unwrap_or(false)
1240}
1241
1242// ---- small schema/query helpers -------------------------------------------
1243
1244fn gate_column(cols: &[String], table: &str, name: &str) -> Result<GateColumn, GateError> {
1245    column_ref_or(cols, table, name, GateError::MissingGateColumn)
1246}
1247
1248fn fk_column(cols: &[String], table: &str, name: &str) -> Result<GateColumn, GateError> {
1249    column_ref_or(cols, table, name, GateError::MissingFkColumn)
1250}
1251
1252/// The foreign-key column `name` of `table` as a [`GateColumn`], reading its
1253/// changeset position from the live schema. For callers holding a column name
1254/// from an FK scan that need to read the same column out of a changeset row.
1255pub(crate) fn fk_column_ref(
1256    conn: &Connection,
1257    table: &str,
1258    name: &str,
1259) -> Result<GateColumn, GateError> {
1260    let columns = super::gate_table_columns(conn, table)?;
1261    fk_column(&columns, table, name)
1262}
1263
1264fn column_ref_or(
1265    cols: &[String],
1266    table: &str,
1267    name: &str,
1268    err: impl FnOnce(String, String) -> GateError,
1269) -> Result<GateColumn, GateError> {
1270    cols.iter()
1271        .position(|c| c == name)
1272        .map(|index| GateColumn {
1273            index,
1274            name: name.to_string(),
1275        })
1276        .ok_or_else(|| err(table.to_string(), name.to_string()))
1277}
1278
1279/// Every single-column foreign key on `table`, including both named columns.
1280pub(crate) fn foreign_keys(
1281    conn: &Connection,
1282    table: &str,
1283) -> Result<Vec<(String, String, String)>, GateError> {
1284    foreign_key_edges(conn, table)
1285        .map_err(GateError::ForeignKeySchema)?
1286        .into_iter()
1287        .map(|edge| {
1288            let [column] = edge.columns.as_slice() else {
1289                return Err(GateError::CompositeGateForeignKey {
1290                    table: table.to_string(),
1291                    parent: edge.parent_table,
1292                });
1293            };
1294            Ok((
1295                column.child.clone(),
1296                edge.parent_table,
1297                column.parent.clone(),
1298            ))
1299        })
1300        .collect()
1301}
1302
1303/// Whether `table`'s chain ends at an audience root, so its rows inherit an
1304/// audience rather than the boolean gate.
1305fn gate_reaches_scoped_root(gates: &HashMap<String, TableGate>, table: &str) -> bool {
1306    chain_reaches(gates, table, |gate| {
1307        matches!(gate, TableGate::ScopedRoot { .. })
1308    })
1309}
1310
1311#[cfg(test)]
1312#[path = "model_tests.rs"]
1313mod tests;