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(|(_, °ree)| 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;