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 private_rows(
628        &self,
629        conn: &Connection,
630    ) -> Result<std::collections::BTreeSet<(String, String)>, GateError> {
631        let shared = self.shared_rows(conn)?;
632        let mut private = std::collections::BTreeSet::new();
633        for table in self.tables.keys() {
634            for row_id in super::all_row_ids(conn, table)? {
635                if !shared.contains(table, &row_id)? {
636                    private.insert((table.clone(), row_id));
637                }
638            }
639        }
640        Ok(private)
641    }
642
643    pub fn has_scoped_graph(&self) -> bool {
644        self.tables
645            .values()
646            .any(|gate| matches!(gate, TableGate::ScopedRoot { .. }))
647    }
648
649    pub fn table_is_scoped(&self, table: &str) -> bool {
650        gate_reaches_scoped_root(&self.tables, table)
651    }
652
653    /// Every scoped table, sorted, so a pass over the scoped graph visits them
654    /// in one order whatever the gate map's iteration order is.
655    pub fn scoped_table_names(&self) -> Vec<String> {
656        let mut tables = self
657            .tables
658            .keys()
659            .filter(|table| self.table_is_scoped(table))
660            .cloned()
661            .collect::<Vec<_>>();
662        tables.sort();
663        tables
664    }
665
666    /// Every synced table, sorted.
667    pub fn sorted_synced_table_names(&self) -> Vec<String> {
668        let mut tables = self
669            .synced_table_names()
670            .map(str::to_string)
671            .collect::<Vec<_>>();
672        tables.sort();
673        tables
674    }
675
676    pub fn is_synced_table(&self, table: &str) -> bool {
677        self.synced_tables.contains(table)
678    }
679
680    pub fn synced_table_names(&self) -> impl Iterator<Item = &str> {
681        self.synced_tables.iter().map(String::as_str)
682    }
683
684    pub fn row_identity(&self, table: &str) -> Option<RowIdentity> {
685        self.row_identities.get(table).copied()
686    }
687
688    /// Build the gate model from the declared [`SyncedTable`]s and the live
689    /// schema (`PRAGMA table_info` for gate-column indices, `PRAGMA
690    /// foreign_key_list` for FK edges).
691    ///
692    pub(crate) fn from_tables(
693        conn: &Connection,
694        tables: &[SyncedTable],
695    ) -> Result<Self, GateError> {
696        #[cfg(any(test, feature = "test-utils"))]
697        FROM_TABLES_CALLS.with(|calls| calls.set(calls.get() + 1));
698
699        GateModelConstruction::new(conn, tables).build()
700    }
701
702    /// Every table governed by the gate, in FK-topological order: a table comes
703    /// after every gated table it has a foreign key to (e.g. artists, albums,
704    /// album_artists, releases, tracks).
705    ///
706    /// [`delete_gated_false`](Self::delete_gated_false) needs this so it can
707    /// delete *child-first* — its reverse — without an FK rejecting the deletion
708    /// of a parent a child still references under `foreign_keys=ON`. The re-emit
709    /// changeset uses the same order for a deterministic, FK-sensible layout; the
710    /// changeset *apply* itself tolerates any order, since
711    /// `sqlite3changeset_apply` defers FK enforcement to the end of its savepoint.
712    ///
713    /// A chain-depth sort does not suffice: the gate graph spans both directions
714    /// — an ancestor (album) is the FK *parent* of gated rows (releases) yet is
715    /// itself kept *by* them — so only a real topological sort over the FK edges
716    /// among the gated tables produces a valid order.
717    ///
718    pub(crate) fn gated_tables_parent_first(
719        &self,
720        conn: &Connection,
721    ) -> Result<Vec<String>, GateError> {
722        // Edge parent -> child means "parent must precede child". A table's FK to a
723        // gated table makes that table its prerequisite (it points at the parent's
724        // id), so the FK target is the parent of the edge and the referrer the child.
725        // Only edges between two gated tables matter.
726        let names: Vec<String> = self.tables.keys().cloned().collect();
727        let mut indegree: HashMap<String, usize> =
728            names.iter().map(|name| (name.clone(), 0)).collect();
729        let mut edges: HashMap<String, Vec<String>> = names
730            .iter()
731            .map(|name| (name.clone(), Vec::new()))
732            .collect();
733
734        let child_edges = gated_fk_child_edges(conn, &self.tables)?;
735        for (parent, children) in &child_edges {
736            for child in children {
737                edges
738                    .get_mut(parent)
739                    .expect("gated parent has a topological node")
740                    .push(child.child_table.clone());
741                *indegree
742                    .get_mut(&child.child_table)
743                    .expect("gated child has a topological node") += 1;
744            }
745        }
746
747        // Kahn with a deterministic tie-break: a min-heap of the ready
748        // (zero-indegree) tables, so equal-rank tables always emit smallest-first.
749        let mut ready: BinaryHeap<Reverse<String>> = indegree
750            .iter()
751            .filter(|(_, &degree)| degree == 0)
752            .map(|(name, _)| Reverse(name.clone()))
753            .collect();
754
755        let mut order = Vec::with_capacity(names.len());
756        while let Some(Reverse(next)) = ready.pop() {
757            for child in &edges[&next] {
758                let degree = indegree
759                    .get_mut(child)
760                    .expect("gated child has a topological degree");
761                *degree -= 1;
762                if *degree == 0 {
763                    ready.push(Reverse(child.clone()));
764                }
765            }
766            order.push(next);
767        }
768
769        if order.len() != names.len() {
770            let mut remaining: Vec<String> = names
771                .iter()
772                .filter(|name| !order.contains(name))
773                .cloned()
774                .collect();
775            remaining.sort();
776            return Err(GateError::FkCycle(remaining));
777        }
778        Ok(order)
779    }
780
781    /// Delete from `db` every row the gate excludes: each gated root row whose
782    /// gate is false, plus its FK-descendants. This is the same exclusion the
783    /// outbound changeset gate applies (a root shares iff its gate is true; a
784    /// descendant shares iff its gated-ancestor root does), expressed as SQL
785    /// `DELETE`s over the live tables rather than as a changeset filter.
786    ///
787    /// Both channels a row can use to cross devices — the changeset
788    /// (`gate_store_outbound`) and the snapshot — must honor the same gate, so the
789    /// snapshot calls this on its VACUUM'd copy to strip gated-false subtrees
790    /// before the bytes leave the device. Sharing this method (not a parallel
791    /// FK model) keeps a single definition of what the gate excludes.
792    ///
793    /// Each gated table — root, descendant, or ancestor — resolves its own keep
794    /// by a fully-inlined clause that bottoms out at root truthy columns, and
795    /// then survives anyway if a row that already survived still references it
796    /// (the closure half of the shared set — see [`SharedRows`]).
797    ///
798    /// That closure test is why the deletion order is load-bearing, not merely
799    /// FK-safe. Walking strictly child-first means every table holding a foreign
800    /// key into `tbl` has already been pruned by the time `tbl` is, so a
801    /// *surviving* referrer is exactly a *shared* referrer and the test needs no
802    /// recursion. Running the tables in any other order would consult rows that
803    /// have not yet been decided.
804    ///
805    pub(crate) fn delete_gated_false(&self, conn: &Connection) -> Result<(), GateError> {
806        self.delete_gated_false_conn(conn)
807    }
808
809    fn delete_gated_false_conn(&self, conn: &Connection) -> Result<(), GateError> {
810        // Child-first — the reverse of the FK-topological apply order. It is what
811        // makes the surviving-referrer test below exact (above), and it also keeps
812        // the prune correct under `foreign_keys=ON`: a parent FK without
813        // `ON DELETE CASCADE` would otherwise reject deleting a parent a child
814        // still references. The only caller is the snapshot scope, whose copy
815        // connection opens with `foreign_keys` OFF, so neither depends on the
816        // other.
817        let mut order = self.gated_tables_parent_first(conn)?;
818        order.reverse();
819        let referrers = gated_fk_child_edges(conn, &self.tables)?;
820        for tbl in order {
821            let keep = self.keep_clause(&tbl)?;
822            let predicate = match self.referenced_by_surviving_clause(&referrers, &tbl) {
823                Some(referenced) => format!("({keep}) OR ({referenced})"),
824                None => keep,
825            };
826            let sql = format!("DELETE FROM {} WHERE NOT ({predicate})", quote_ident(&tbl));
827            execute_batch(conn, &sql)?;
828        }
829        Ok(())
830    }
831
832    /// A SQL boolean that is true for rows of `tbl` some already-pruned table
833    /// still references — the closure half of the shared set, expressed against
834    /// the child-first prune order that has already settled every referrer.
835    ///
836    /// `None` when nothing references `tbl`, or when `tbl` is a root: a root's
837    /// gate (or audience) column is the host's own decision about whether the row
838    /// leaves the device, and a reference from elsewhere never overrides it.
839    fn referenced_by_surviving_clause(
840        &self,
841        referrers: &HashMap<String, Vec<GatedChildEdge>>,
842        tbl: &str,
843    ) -> Option<String> {
844        if !gate_is_derived(self.tables.get(tbl)) {
845            return None;
846        }
847        let edges = referrers.get(tbl)?;
848        if edges.is_empty() {
849            return None;
850        }
851        Some(
852            edges
853                .iter()
854                .map(|edge| {
855                    format!(
856                        "EXISTS (SELECT 1 FROM {child} WHERE {child}.{fk} = {tbl}.{parent})",
857                        child = quote_ident(&edge.child_table),
858                        fk = quote_ident(&edge.child_column),
859                        tbl = quote_ident(tbl),
860                        parent = quote_ident(&edge.parent_column),
861                    )
862                })
863                .collect::<Vec<_>>()
864                .join(" OR "),
865        )
866    }
867
868    /// The gate's shared row set over the live database in `conn`.
869    ///
870    /// Building one scans the FK graph once, so a pass that asks about many rows
871    /// builds a single set and queries it rather than re-deriving the graph per
872    /// row.
873    pub(crate) fn shared_rows<'a>(
874        &'a self,
875        conn: &'a Connection,
876    ) -> Result<SharedRows<'a>, GateError> {
877        Ok(SharedRows {
878            gates: self,
879            conn,
880            referrers: gated_fk_child_edges(conn, &self.tables)?,
881        })
882    }
883
884    /// A SQL boolean that is true for rows of `tbl` the gate keeps. The shape
885    /// depends on how `tbl` relates to the gate:
886    ///
887    /// - **Root**: the root's own gate column, tested truthy.
888    /// - **Child**: a correlated `EXISTS` joining up the FK to the parent's
889    ///   keep-clause, so the gate flows *down* the chain to the root truthy test.
890    /// - **Parent** (ancestor): a disjunction of correlated `EXISTS`, one per
891    ///   inferred child, so the keep flows *up* — the ancestor is kept iff some
892    ///   child has a kept row referencing it.
893    ///
894    /// Built inside-out and fully inlined down to the root truthy columns. A
895    /// dangling FK anywhere makes its `EXISTS` false (not shared), matching
896    /// `resolve_root`'s treatment of a missing ancestor. The recursion is
897    /// cycle-guarded by `visiting`: a `Parent` references its children and a
898    /// `Child` references its parent, so a malformed declaration could otherwise
899    /// loop. Revisiting a table in the current path yields `FALSE` rather than
900    /// recursing again.
901    ///
902    fn keep_clause(&self, tbl: &str) -> Result<String, GateError> {
903        self.keep_clause_guarded(tbl, &mut HashSet::new(), false)
904    }
905
906    fn keep_clause_guarded(
907        &self,
908        tbl: &str,
909        visiting: &mut HashSet<String>,
910        keeps_ancestor: bool,
911    ) -> Result<String, GateError> {
912        if !visiting.insert(tbl.to_string()) {
913            // Already on the current recursion path: refuse to loop. A row kept
914            // only via a cycle is treated as not kept.
915            return Ok("FALSE".to_string());
916        }
917        let clause = match self.tables.get(tbl) {
918            Some(TableGate::Root { gate_col }) => truthy_sql(&format!(
919                "{}.{}",
920                quote_ident(tbl),
921                quote_ident(&gate_col.name)
922            )),
923            Some(TableGate::ScopedRoot { audience_col }) if keeps_ancestor => format!(
924                "({table}.{column} IS NULL OR {table}.{column} <> 'local')",
925                table = quote_ident(tbl),
926                column = quote_ident(&audience_col.name),
927            ),
928            Some(TableGate::ScopedRoot { audience_col }) => format!(
929                "{}.{} IS NULL",
930                quote_ident(tbl),
931                quote_ident(&audience_col.name)
932            ),
933            Some(TableGate::RemoteRoot) => "TRUE".to_string(),
934            Some(TableGate::Child {
935                fk_col,
936                parent,
937                parent_col,
938            }) => {
939                let inner = self.keep_clause_guarded(parent, visiting, keeps_ancestor)?;
940                fk_exists_clause(parent, &parent_col.name, tbl, &fk_col.name, &inner)
941            }
942            Some(TableGate::Parent { children }) => {
943                if children.is_empty() {
944                    // `from_tables` rejects an ancestor with no inferred children
945                    // at construction, so a `Parent` reaching here always has at
946                    // least one.
947                    unreachable!("Parent {tbl} has empty children, rejected by from_tables");
948                }
949                let mut disjuncts = Vec::with_capacity(children.len());
950                for (child, fk_col, parent_col) in children {
951                    let inner = self.keep_clause_guarded(child, visiting, true)?;
952                    disjuncts.push(fk_exists_clause(
953                        child,
954                        &fk_col.name,
955                        tbl,
956                        &parent_col.name,
957                        &inner,
958                    ));
959                }
960                format!("({})", disjuncts.join(" OR "))
961            }
962            // Unreachable: callers pass table names straight from `self.tables`,
963            // and the recursion descends only to parents/children that
964            // `from_tables` proved are in the map. A table outside the map never
965            // reaches this match.
966            None => unreachable!("keep_clause called for {tbl}, absent from the gate map"),
967        };
968        visiting.remove(tbl);
969        Ok(clause)
970    }
971
972    /// Whether the live row (`tbl`, `id`) is currently kept by the gate, by
973    /// evaluating `tbl`'s keep-clause against the live db for that one row. Used
974    /// to resolve an ancestor's share decision (an album is kept iff it has a
975    /// kept child) — a property of the live child tables, not of the ancestor
976    /// row's own columns.
977    ///
978    pub(crate) fn row_kept(
979        &self,
980        conn: &Connection,
981        tbl: &str,
982        id: &str,
983    ) -> Result<bool, GateError> {
984        let keep = self.keep_clause(tbl)?;
985        let sql = format!(
986            "SELECT 1 FROM {t} WHERE {t}.{id_col} = ? AND ({keep})",
987            t = quote_ident(tbl),
988            id_col = quote_ident("id"),
989        );
990        let present = query_row_optional(conn, &sql, [id], |_| Ok(()))?.is_some();
991        Ok(present)
992    }
993
994    /// The locality terminus the live row `(table, id)` resolves to by walking up
995    /// its declared-FK chain — the gated root, remote root, or inheriting ancestor at
996    /// the top — as `(terminus_table, terminus_id)`, regardless of whether a gated
997    /// terminus currently keeps it. `None` if the row is ungated/unrooted, or a row
998    /// along the chain is absent from the live db.
999    ///
1000    /// The blob-transition drain uses this to map a just-uploaded blob's row to the
1001    /// gated root a make_remote tracks: a `release_files` row resolves up to its
1002    /// `releases` root, whose `blob_make_remote_intents` row the completion check reads.
1003    pub(crate) fn resolve_root_of(
1004        &self,
1005        conn: &Connection,
1006        table: &str,
1007        id: &str,
1008    ) -> Result<Option<(String, String)>, GateError> {
1009        Ok(resolve_root(conn, self, table, id)?.map(|r| (r.terminus_table, r.terminus_id)))
1010    }
1011
1012    /// Whether the blob-bearing row `(table, id)` resolves to Remote locality:
1013    /// `Some(true)` is Remote (shared, bytes in the cloud), `Some(false)` is Local
1014    /// (bytes on-device). The same FK up-walk as
1015    /// [`resolve_root_of`](Self::resolve_root_of), returning the locality truth that
1016    /// walk already reads (a gated root's own column, a remote root's declared Remote
1017    /// state, or a `gated_by_descendants` ancestor's keep), so the read path dispatches
1018    /// on this rather than probing every store. `None` when the chain reaches no
1019    /// locality terminus (the row is ungated/unrooted) or a row along it is missing —
1020    /// an unresolvable locality the read path fails loud on rather than guessing a
1021    /// source.
1022    pub(crate) fn root_kept_of(
1023        &self,
1024        conn: &Connection,
1025        table: &str,
1026        id: &str,
1027    ) -> Result<Option<bool>, GateError> {
1028        Ok(resolve_root(conn, self, table, id)?.map(|r| r.kept))
1029    }
1030
1031    /// Every row in the gated subtree rooted at `(root_table, root_id)`: the root
1032    /// itself plus the transitive closure of its gated FK-*descendants*, as
1033    /// `(table, primary key)` pairs. A pure down-walk over the gated FK edges — it
1034    /// does NOT climb to ancestors or cross to sibling roots, so a release's subtree
1035    /// is exactly that release and its own files, never another release sharing an
1036    /// album. Structural (no kept-filter): a managed or managing root's whole
1037    /// subtree is returned whatever its gate currently reads.
1038    ///
1039    /// `row_blob_refs_for_root_on` maps these rows to the blobs a transition
1040    /// uploads (make_remote) or materializes (make_local).
1041    pub(crate) fn subtree_rows(
1042        &self,
1043        conn: &Connection,
1044        root_table: &str,
1045        root_id: &str,
1046    ) -> Result<HashSet<(String, String)>, GateError> {
1047        self.subtree_rows_conn(conn, root_table, root_id)
1048    }
1049
1050    fn subtree_rows_conn(
1051        &self,
1052        conn: &Connection,
1053        root_table: &str,
1054        root_id: &str,
1055    ) -> Result<HashSet<(String, String)>, GateError> {
1056        // The down-edges (parent table -> its gated children + FK column), the same
1057        // map the re-emit/retract closure walks; here we follow only this map (down,
1058        // never up) from the single root so the result is one subtree.
1059        let down_edges = gated_fk_child_edges(conn, &self.tables)?;
1060        let mut out: HashSet<(String, String)> = HashSet::new();
1061        let mut work = vec![(root_table.to_string(), root_id.to_string())];
1062        while let Some((table, id)) = work.pop() {
1063            if !out.insert((table.clone(), id.clone())) {
1064                continue; // already visited: cycle-guard and dedup.
1065            }
1066            if let Some(edges) = down_edges.get(table.as_str()) {
1067                work.extend(child_rows(conn, edges, &table, &id)?);
1068            }
1069        }
1070        Ok(out)
1071    }
1072}
1073
1074/// The SQL form of [`truthy`]: a predicate that is true for `expr` exactly when
1075/// [`truthy`] would return true for the same value. [`truthy`] owns the single
1076/// definition of gate-truth; this realizes it in SQL — the `CAST` collapses to 0
1077/// for NULL and non-numeric text, so only a genuine nonzero integer passes.
1078/// Keep the two in lockstep: a change to the gate-truth rule changes both.
1079fn truthy_sql(expr: &str) -> String {
1080    format!("({expr} IS NOT NULL AND CAST({expr} AS INTEGER) <> 0)")
1081}
1082
1083/// A correlated `EXISTS` that follows one FK edge to a related table's keep:
1084/// true for a row of `self_t` when some row of `other_t` joins to it on
1085/// `other_t.other_col = self_t.self_col` and itself satisfies `inner`. The Child
1086/// keep (join *up* to the parent) and the Parent keep (join *down* to a child)
1087/// are the same named-column relation with the join direction swapped.
1088fn fk_exists_clause(
1089    other_t: &str,
1090    other_col: &str,
1091    self_t: &str,
1092    self_col: &str,
1093    inner: &str,
1094) -> String {
1095    format!(
1096        "EXISTS (SELECT 1 FROM {other} \
1097           WHERE {other}.{other_col} = {this}.{self_col} AND ({inner}))",
1098        other = quote_ident(other_t),
1099        other_col = quote_ident(other_col),
1100        this = quote_ident(self_t),
1101        self_col = quote_ident(self_col),
1102    )
1103}
1104
1105/// Whether walking `gate_map` up the declared-FK chain from `table` reaches a
1106/// gate `accept` recognizes. Only `Child` links are followed upward, so the walk
1107/// ends at the first table `accept` refuses and cannot climb from (a `Parent`'s
1108/// upward keep over its own children is a separate relation, not part of this
1109/// downward chain). Cycle-guarded: a chain that loops reaches nothing.
1110fn chain_reaches(
1111    gate_map: &HashMap<String, TableGate>,
1112    table: &str,
1113    accept: impl Fn(&TableGate) -> bool,
1114) -> bool {
1115    let mut current = table;
1116    let mut seen = HashSet::new();
1117    loop {
1118        if !seen.insert(current.to_string()) {
1119            return false;
1120        }
1121        match gate_map.get(current) {
1122            Some(gate) if accept(gate) => return true,
1123            Some(TableGate::Child { parent, .. }) => current = parent.as_str(),
1124            _ => return false,
1125        }
1126    }
1127}
1128
1129/// Whether `name`'s chain reaches a gate terminus: a gated root, scoped root,
1130/// remote root, or ancestor — every gate but an inheriting `Child`.
1131fn reaches_gate_terminus(gate_map: &HashMap<String, TableGate>, name: &str) -> bool {
1132    chain_reaches(gate_map, name, |gate| {
1133        !matches!(gate, TableGate::Child { .. })
1134    })
1135}
1136
1137/// The gated FK edges of the schema, as `parent table -> [(child table, child's
1138/// FK column name)]`: for every gated table, each of its FKs that points at
1139/// another gated table contributes an edge under the *target* (the parent). The
1140/// fixpoint walk in `connected_component` (the outbound pass) follows these down-edges
1141/// directly; [`Gates::gated_tables_parent_first`] uses the same edges (discarding the FK
1142/// column) so the parent-first order is derived from one definition, not a second
1143/// parallel FK scan.
1144///
1145pub(crate) struct GatedChildEdge {
1146    pub child_table: String,
1147    pub child_column: String,
1148    pub parent_column: String,
1149}
1150
1151pub(crate) fn gated_fk_child_edges(
1152    conn: &Connection,
1153    gate_map: &HashMap<String, TableGate>,
1154) -> Result<HashMap<String, Vec<GatedChildEdge>>, GateError> {
1155    let mut edges: HashMap<String, Vec<GatedChildEdge>> = HashMap::new();
1156    for referrer in gate_map.keys() {
1157        for (fk_col, target, parent_col) in foreign_keys(conn, referrer)? {
1158            // Self-FKs and FKs to ungated tables are not cross-table gate edges.
1159            if target == *referrer {
1160                continue;
1161            }
1162            if gate_map.contains_key(&target) {
1163                edges.entry(target).or_default().push(GatedChildEdge {
1164                    child_table: referrer.clone(),
1165                    child_column: fk_col,
1166                    parent_column: parent_col,
1167                });
1168            }
1169        }
1170    }
1171    Ok(edges)
1172}
1173
1174/// Every row that references the live row `(table, id)` through `edges` — the
1175/// gated children of that row, as `(child table, child row id)`. One step of the
1176/// down-walk both the subtree closure and the outbound connected component take;
1177/// they differ in what they do with the children, not in how they find them.
1178pub(crate) fn child_rows(
1179    conn: &Connection,
1180    edges: &[GatedChildEdge],
1181    table: &str,
1182    id: &str,
1183) -> Result<Vec<(String, String)>, GateError> {
1184    let mut rows = Vec::new();
1185    for edge in edges {
1186        let Some(parent_key) = query_column_text(conn, table, &edge.parent_column, id)? else {
1187            // The row does not carry the key its children reference, so no child
1188            // can join to it through this edge.
1189            debug!(
1190                table,
1191                id,
1192                column = %edge.parent_column,
1193                "gate: row has no value for the column its gated children reference; skipping the edge"
1194            );
1195            continue;
1196        };
1197        for child_id in rows_referencing(conn, &edge.child_table, &edge.child_column, &parent_key)?
1198        {
1199            rows.push((edge.child_table.clone(), child_id));
1200        }
1201    }
1202    Ok(rows)
1203}
1204
1205/// The ids of rows in `table` whose `fk` column equals `value`.
1206pub(crate) fn rows_referencing(
1207    conn: &Connection,
1208    table: &str,
1209    fk: &str,
1210    value: &str,
1211) -> Result<Vec<String>, GateError> {
1212    let sql = format!(
1213        "SELECT {id} FROM {t} WHERE {fk} = ?",
1214        id = quote_ident("id"),
1215        t = quote_ident(table),
1216        fk = quote_ident(fk),
1217    );
1218    let mut ids = Vec::new();
1219    for id in query_mapped_rows(conn, &sql, [value], |row| row_value_to_string(row, 0))? {
1220        let Some(id) = id else {
1221            // `id` is a NOT NULL primary key, so a NULL here is a genuine schema
1222            // anomaly, not a row we may quietly drop from the kept component.
1223            warn!(
1224                "gate: row in {table} referencing {fk}={value} has a NULL id; skipping it from the kept component"
1225            );
1226            continue;
1227        };
1228        ids.push(id);
1229    }
1230    Ok(ids)
1231}
1232/// The single definition of gate-truth, evaluated in Rust over a gate value read
1233/// as text: a nonzero integer is true; `0`/empty/non-integer is false.
1234/// [`truthy_sql`] is the SQL realization of this same rule for the snapshot path;
1235/// changing the rule here means changing it there too.
1236pub(crate) fn truthy(s: &str) -> bool {
1237    s.trim().parse::<i64>().map(|n| n != 0).unwrap_or(false)
1238}
1239
1240// ---- small schema/query helpers -------------------------------------------
1241
1242fn gate_column(cols: &[String], table: &str, name: &str) -> Result<GateColumn, GateError> {
1243    column_ref_or(cols, table, name, GateError::MissingGateColumn)
1244}
1245
1246fn fk_column(cols: &[String], table: &str, name: &str) -> Result<GateColumn, GateError> {
1247    column_ref_or(cols, table, name, GateError::MissingFkColumn)
1248}
1249
1250/// The foreign-key column `name` of `table` as a [`GateColumn`], reading its
1251/// changeset position from the live schema. For callers holding a column name
1252/// from an FK scan that need to read the same column out of a changeset row.
1253pub(crate) fn fk_column_ref(
1254    conn: &Connection,
1255    table: &str,
1256    name: &str,
1257) -> Result<GateColumn, GateError> {
1258    let columns = super::gate_table_columns(conn, table)?;
1259    fk_column(&columns, table, name)
1260}
1261
1262fn column_ref_or(
1263    cols: &[String],
1264    table: &str,
1265    name: &str,
1266    err: impl FnOnce(String, String) -> GateError,
1267) -> Result<GateColumn, GateError> {
1268    cols.iter()
1269        .position(|c| c == name)
1270        .map(|index| GateColumn {
1271            index,
1272            name: name.to_string(),
1273        })
1274        .ok_or_else(|| err(table.to_string(), name.to_string()))
1275}
1276
1277/// Every single-column foreign key on `table`, including both named columns.
1278pub(crate) fn foreign_keys(
1279    conn: &Connection,
1280    table: &str,
1281) -> Result<Vec<(String, String, String)>, GateError> {
1282    foreign_key_edges(conn, table)
1283        .map_err(GateError::ForeignKeySchema)?
1284        .into_iter()
1285        .map(|edge| {
1286            let [column] = edge.columns.as_slice() else {
1287                return Err(GateError::CompositeGateForeignKey {
1288                    table: table.to_string(),
1289                    parent: edge.parent_table,
1290                });
1291            };
1292            Ok((
1293                column.child.clone(),
1294                edge.parent_table,
1295                column.parent.clone(),
1296            ))
1297        })
1298        .collect()
1299}
1300
1301/// Whether `table`'s chain ends at an audience root, so its rows inherit an
1302/// audience rather than the boolean gate.
1303fn gate_reaches_scoped_root(gates: &HashMap<String, TableGate>, table: &str) -> bool {
1304    chain_reaches(gates, table, |gate| {
1305        matches!(gate, TableGate::ScopedRoot { .. })
1306    })
1307}
1308
1309#[cfg(test)]
1310#[path = "model_tests.rs"]
1311mod tests;