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