Skip to main content

khive_db/
namespace_census.rs

1//! What a live store's schema says about `namespace` (ADR-189).
2//!
3//! Two questions this answers, both of which used to be answered by reading the
4//! schema sources by hand:
5//!
6//! 1. which tables carry a `namespace` column, and
7//! 2. which uniqueness constraints that column participates in.
8//!
9//! Neither is answerable from the sources. The `vec_{model_key}` vector tables
10//! are created at runtime, one per embedding model (`backend.rs`), appear in no
11//! `.sql` file and in no migration, and their membership is a property of a
12//! given store rather than of the code.
13//!
14//! The constraint half is the one that bites. Two independent hand enumerations
15//! of the namespace-bearing uniqueness constraints agreed on nine; there are
16//! fourteen. One asked which `CREATE UNIQUE INDEX` bodies name `namespace` and
17//! could not see a composite `PRIMARY KEY`; the other asked which composite
18//! primary keys name it and could not see an index; neither could see an index
19//! whose third key is an expression (`json_extract(properties, '$.external_id')`
20//! on `notes`) rather than a column name. Every miss is a shape the question
21//! could not express, which is why the answer here is a derivation and not a
22//! longer list.
23//!
24//! `PRAGMA index_list` reports primary keys (`origin = "pk"`), `UNIQUE` table
25//! constraints (`"u"`) and `CREATE UNIQUE INDEX` (`"c"`) through one surface,
26//! and `PRAGMA index_xinfo` names the `namespace` column inside an expression
27//! index like any other. So a constraint a future migration adds is in the
28//! census on the next run, with no edit here.
29
30use std::collections::BTreeSet;
31
32use rusqlite::Connection;
33
34/// Where a uniqueness constraint came from, as SQLite reports it.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
36pub enum ConstraintOrigin {
37    /// `PRIMARY KEY` on the table (`PRAGMA index_list` origin `pk`).
38    PrimaryKey,
39    /// A `UNIQUE` constraint in the table body (origin `u`).
40    UniqueConstraint,
41    /// A standalone `CREATE UNIQUE INDEX` (origin `c`).
42    UniqueIndex,
43}
44
45impl ConstraintOrigin {
46    fn parse(origin: &str) -> Option<Self> {
47        match origin {
48            "pk" => Some(Self::PrimaryKey),
49            "u" => Some(Self::UniqueConstraint),
50            "c" => Some(Self::UniqueIndex),
51            _ => None,
52        }
53    }
54}
55
56/// A table carrying a `namespace` column.
57#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
58pub struct NamespaceTable {
59    pub name: String,
60    /// `CREATE VIRTUAL TABLE` — fts5 and vec0. These accept no `UPDATE` of an
61    /// indexed column and expose no index list, so they are moved by delete and
62    /// re-insert and contribute no constraints.
63    pub virtual_table: bool,
64}
65
66/// One uniqueness constraint in which `namespace` participates.
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
68pub struct NamespaceConstraint {
69    pub table: String,
70    /// The index backing it. Implicit primary-key indexes are named by SQLite
71    /// (`sqlite_autoindex_<table>_<n>`).
72    pub index: String,
73    pub origin: ConstraintOrigin,
74    /// Key columns in index order. `None` is an expression, which has no column
75    /// name — the shape both hand enumerations were blind to.
76    pub columns: Vec<Option<String>>,
77    /// A partial index (`... WHERE ...`). Its constraint binds only the rows its
78    /// predicate admits, so a collision check that ignores this refuses moves
79    /// SQLite would have accepted.
80    pub partial: bool,
81}
82
83impl NamespaceConstraint {
84    /// True when every key column is a plain column name, so a caller can build
85    /// a collision query from this constraint alone.
86    pub fn columns_are_nameable(&self) -> bool {
87        self.columns.iter().all(Option::is_some)
88    }
89}
90
91/// The census of one live store.
92///
93/// `unenumerable` is not decoration. A virtual table answers `PRAGMA index_list`
94/// with nothing or with an error depending on the module, and an empty answer
95/// there is indistinguishable from "no constraints" — so the tables whose
96/// constraints were never readable are listed by name instead of being folded
97/// into the clean case.
98#[derive(Debug, Clone, Default)]
99pub struct NamespaceCensus {
100    /// The file this census describes, from `PRAGMA database_list`, empty for
101    /// an in-memory database.
102    ///
103    /// A census is per connection and a pack can be assigned its own backend,
104    /// so one store's records can live in three SQLite files. A report that
105    /// does not say which file it read is unusable the moment there is a second
106    /// one.
107    pub database: String,
108    pub tables: Vec<NamespaceTable>,
109    pub constraints: Vec<NamespaceConstraint>,
110    pub unenumerable: Vec<String>,
111}
112
113impl NamespaceCensus {
114    pub fn table_names(&self) -> Vec<&str> {
115        self.tables.iter().map(|t| t.name.as_str()).collect()
116    }
117
118    /// The vector tables, which exist only in a store that has embedded with a
119    /// model. Named by prefix because their suffix is the model key.
120    pub fn vector_tables(&self) -> Vec<&str> {
121        self.tables
122            .iter()
123            .filter(|t| t.name.starts_with("vec_"))
124            .map(|t| t.name.as_str())
125            .collect()
126    }
127
128    pub fn constraints_on(&self, table: &str) -> Vec<&NamespaceConstraint> {
129        self.constraints
130            .iter()
131            .filter(|c| c.table == table)
132            .collect()
133    }
134}
135
136/// Double an identifier's embedded quotes so it can be interpolated into a
137/// `PRAGMA`. Pragmas take no bound parameters for their argument, so the name
138/// has to be inlined; every name here comes from `sqlite_master`, and quoting it
139/// anyway keeps that a property of this function rather than of its callers.
140pub(crate) fn quote_ident(name: &str) -> String {
141    format!("\"{}\"", name.replace('"', "\"\""))
142}
143
144/// Every table in the main schema carrying a `namespace` column.
145pub fn namespace_tables(conn: &Connection) -> rusqlite::Result<Vec<NamespaceTable>> {
146    let mut stmt = conn.prepare(
147        "SELECT name, COALESCE(sql, '') FROM sqlite_master \
148         WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
149    )?;
150    let candidates: Vec<(String, String)> = stmt
151        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
152        .collect::<rusqlite::Result<_>>()?;
153
154    let mut tables = Vec::new();
155    for (name, sql) in candidates {
156        if !table_has_namespace_column(conn, &name)? {
157            continue;
158        }
159        // Tokenized rather than sliced: the stored text is whatever the
160        // migration wrote, and a length-prefix comparison is off by one the
161        // moment anyone reformats it.
162        let mut head = sql.split_whitespace();
163        let virtual_table = matches!(
164            (head.next(), head.next(), head.next()),
165            (Some(create), Some(virt), Some(table))
166                if create.eq_ignore_ascii_case("CREATE")
167                    && virt.eq_ignore_ascii_case("VIRTUAL")
168                    && table.eq_ignore_ascii_case("TABLE")
169        );
170        tables.push(NamespaceTable {
171            name,
172            virtual_table,
173        });
174    }
175    Ok(tables)
176}
177
178fn table_has_namespace_column(conn: &Connection, table: &str) -> rusqlite::Result<bool> {
179    let sql = format!("PRAGMA table_info({})", quote_ident(table));
180    let mut stmt = conn.prepare(&sql)?;
181    let mut rows = stmt.query([])?;
182    while let Some(row) = rows.next()? {
183        let column: String = row.get(1)?;
184        if column.eq_ignore_ascii_case("namespace") {
185            return Ok(true);
186        }
187    }
188    Ok(false)
189}
190
191/// The full census: namespace-carrying tables, and every uniqueness constraint
192/// `namespace` participates in.
193pub fn census(conn: &Connection) -> rusqlite::Result<NamespaceCensus> {
194    let tables = namespace_tables(conn)?;
195    let mut constraints = Vec::new();
196    let mut unenumerable = Vec::new();
197
198    for table in &tables {
199        match unique_constraints_naming_namespace(conn, &table.name) {
200            // A virtual table that reports NO indexes has not said it has none:
201            // `PRAGMA index_list` describes the indexes SQLite keeps for a
202            // table, and a module keeps its own. vec0 declares
203            // `subject_id TEXT PRIMARY KEY` in its own DDL and reports nothing
204            // here. So an empty answer from a virtual table is recorded as
205            // unread rather than as clean; a module that does report rows is
206            // read like any other table.
207            Ok(found) if found.is_empty() && table.virtual_table => {
208                unenumerable.push(table.name.clone())
209            }
210            Ok(found) => constraints.extend(found),
211            Err(_) if table.virtual_table => unenumerable.push(table.name.clone()),
212            Err(error) => return Err(error),
213        }
214    }
215    constraints.sort();
216    Ok(NamespaceCensus {
217        database: main_database_file(conn)?,
218        tables,
219        constraints,
220        unenumerable,
221    })
222}
223
224/// The file backing `main`, or an empty string for an in-memory database.
225fn main_database_file(conn: &Connection) -> rusqlite::Result<String> {
226    let mut stmt = conn.prepare("PRAGMA database_list")?;
227    let mut rows = stmt.query([])?;
228    while let Some(row) = rows.next()? {
229        let name: String = row.get(1)?;
230        if name == "main" {
231            return Ok(row.get::<_, Option<String>>(2)?.unwrap_or_default());
232        }
233    }
234    Ok(String::new())
235}
236
237fn unique_constraints_naming_namespace(
238    conn: &Connection,
239    table: &str,
240) -> rusqlite::Result<Vec<NamespaceConstraint>> {
241    let list_sql = format!("PRAGMA index_list({})", quote_ident(table));
242    let mut list = conn.prepare(&list_sql)?;
243    let indexes: Vec<(String, i64, String, i64)> = list
244        .query_map([], |row| {
245            Ok((
246                row.get::<_, String>(1)?,
247                row.get::<_, i64>(2)?,
248                row.get::<_, String>(3)?,
249                row.get::<_, i64>(4)?,
250            ))
251        })?
252        .collect::<rusqlite::Result<_>>()?;
253
254    let mut found = Vec::new();
255    for (index, unique, origin, partial) in indexes {
256        if unique == 0 {
257            continue;
258        }
259        let Some(origin) = ConstraintOrigin::parse(&origin) else {
260            continue;
261        };
262        let columns = index_key_columns(conn, &index)?;
263        let named = columns.iter().any(|c| {
264            c.as_deref()
265                .is_some_and(|c| c.eq_ignore_ascii_case("namespace"))
266        });
267        // An expression key column is reported as a null name, so an index
268        // over `lower(namespace)` names nothing here at all. Measured:
269        // `CREATE UNIQUE INDEX i ON t(lower(namespace), id)` gives xinfo rows
270        // `-2|NULL|key=1` and `0|id|key=1`. The comm external-id index survives
271        // the column read only because its namespace is a literal first column
272        // and the expression sits on a different one.
273        //
274        // So an index carrying an expression is additionally read from its own
275        // DDL. The two instruments cover each other exactly: only an autoindex
276        // has a null `sql`, and an autoindex is a table constraint over a
277        // column list, which cannot carry an expression.
278        let named = named
279            || (columns.iter().any(Option::is_none) && index_ddl_names_namespace(conn, &index)?);
280        if !named {
281            continue;
282        }
283        found.push(NamespaceConstraint {
284            table: table.to_string(),
285            index,
286            origin,
287            columns,
288            partial: partial != 0,
289        });
290    }
291    Ok(found)
292}
293
294/// Whether an index's own `CREATE INDEX` text mentions `namespace` as a word.
295///
296/// Only consulted for an index that has an expression key column, where the
297/// column read cannot answer. A text match is coarser than a parse, and it errs
298/// in the safe direction on purpose: a false positive puts one more constraint
299/// in the refusal set, which refuses a move SQLite would have allowed and is
300/// visible to whoever reads the refusal. A miss would corrupt rows silently.
301fn index_ddl_names_namespace(conn: &Connection, index: &str) -> rusqlite::Result<bool> {
302    let ddl: Option<String> = conn
303        .query_row(
304            "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?1",
305            [index],
306            |row| row.get(0),
307        )
308        .unwrap_or(None);
309    Ok(ddl.is_some_and(|ddl| mentions_namespace_as_a_word(&ddl)))
310}
311
312/// `namespace` bounded by non-identifier characters, so `namespace_hash` and
313/// `ns_namespace` do not match.
314fn mentions_namespace_as_a_word(text: &str) -> bool {
315    const NEEDLE: &str = "namespace";
316    let lower = text.to_ascii_lowercase();
317    let bytes = lower.as_bytes();
318    let mut from = 0;
319    while let Some(offset) = lower[from..].find(NEEDLE) {
320        let start = from + offset;
321        let end = start + NEEDLE.len();
322        let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
323        let after_ok = end == bytes.len() || !is_ident_byte(bytes[end]);
324        if before_ok && after_ok {
325            return true;
326        }
327        from = start + 1;
328    }
329    false
330}
331
332fn is_ident_byte(byte: u8) -> bool {
333    byte.is_ascii_alphanumeric() || byte == b'_'
334}
335
336/// The key columns of an index, in index order, `None` for an expression.
337///
338/// `index_xinfo` rather than `index_info`: the former marks which entries are
339/// key columns (`key = 1`) versus the auxiliary columns SQLite appends, and
340/// reports expressions with `cid = -2` and a null name instead of omitting them.
341fn index_key_columns(conn: &Connection, index: &str) -> rusqlite::Result<Vec<Option<String>>> {
342    let sql = format!("PRAGMA index_xinfo({})", quote_ident(index));
343    let mut stmt = conn.prepare(&sql)?;
344    let rows = stmt.query_map([], |row| {
345        Ok((row.get::<_, Option<String>>(2)?, row.get::<_, i64>(5)?))
346    })?;
347    let mut columns = Vec::new();
348    for row in rows {
349        let (name, key) = row?;
350        if key == 1 {
351            columns.push(name);
352        }
353    }
354    Ok(columns)
355}
356
357/// The tables a move must not write, whatever the census finds.
358///
359/// `events` is the record of what happened under the namespace it happened
360/// under. The two `ann_consumer_*` tables are excluded by the write-log
361/// decision: the log is appended to at a fresh `seq` and no watermark is edited,
362/// so moving a watermark would claim a consumer is caught up on entries it has
363/// never seen. `retrieval_snapshots` is only deleted from, never written for a
364/// target, so its `(namespace, index_type)` key has nothing to collide with.
365pub const TABLES_EXCLUDED_FROM_MOVE: &[&str] = &[
366    "events",
367    "ann_consumer_watermark",
368    "ann_consumer_pending",
369    "retrieval_snapshots",
370];
371
372/// `note_streams` is read by a move and never written by one. Four triggers in
373/// the stream schema abort an `UPDATE` of a member note's namespace, the delete,
374/// and every write to the ledger rows, so a move reaching a stream member
375/// refuses from a pre-flight read. Its `(namespace, stream, seq)` primary key is
376/// therefore in the census and out of reach, and it is kept apart from
377/// [`TABLES_EXCLUDED_FROM_MOVE`] because the reasons differ: those tables are a
378/// decision about what a move should carry, this one is what the schema allows.
379pub const TABLE_REFUSED_BY_SCHEMA: &str = "note_streams";
380
381/// The constraints a move can actually violate: every one the census finds,
382/// minus those on tables the move never writes.
383pub fn reachable_constraints(census: &NamespaceCensus) -> Vec<&NamespaceConstraint> {
384    let excluded: BTreeSet<&str> = TABLES_EXCLUDED_FROM_MOVE
385        .iter()
386        .copied()
387        .chain(std::iter::once(TABLE_REFUSED_BY_SCHEMA))
388        .collect();
389    census
390        .constraints
391        .iter()
392        .filter(|c| !excluded.contains(c.table.as_str()))
393        .collect()
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::migrations::run_migrations_for_test as run_migrations;
400
401    fn migrated() -> Connection {
402        let mut conn = Connection::open_in_memory().expect("in-memory connection");
403        run_migrations(&mut conn).expect("migrate to the current schema");
404        conn
405    }
406
407    fn constraint_names(census: &NamespaceCensus) -> BTreeSet<(String, String)> {
408        census
409            .constraints
410            .iter()
411            .map(|c| (c.table.clone(), c.index.clone()))
412            .collect()
413    }
414
415    /// The whole point of the module, stated as the list it replaces. Every row
416    /// here was verified at source; five of them are the ones two hand
417    /// enumerations missed, and they are called out so a future edit that drops
418    /// one has to argue with the reason rather than with a name.
419    #[test]
420    fn census_finds_every_namespace_bearing_uniqueness_constraint() {
421        let conn = migrated();
422        let report = super::census(&conn).expect("census");
423        let found = constraint_names(&report);
424
425        let by_index: BTreeSet<&str> = found.iter().map(|(_, i)| i.as_str()).collect();
426        for expected in [
427            "idx_notes_namespace_kind_key",
428            "idx_comm_message_external_id",
429            "idx_graph_edges_unique_triple",
430            "idx_knowledge_atoms_ns_slug",
431            "idx_knowledge_domains_ns_slug",
432            "idx_brain_serve_ledger_unique",
433        ] {
434            assert!(
435                by_index.contains(expected),
436                "census missed the unique index {expected}; found {by_index:?}"
437            );
438        }
439
440        let by_table: BTreeSet<&str> = found.iter().map(|(t, _)| t.as_str()).collect();
441        for expected in [
442            "graph_edges",
443            "brain_implicit_mass",
444            "brain_profile_snapshots",
445            "ann_consumer_watermark",
446            "ann_consumer_pending",
447            "note_streams",
448            "fts_notes_rowids",
449            "fts_entities_rowids",
450        ] {
451            assert!(
452                by_table.contains(expected),
453                "census missed a namespace-bearing primary key on {expected}; found {by_table:?}"
454            );
455        }
456    }
457
458    /// An expression index has no column name for its third key. This is the
459    /// shape that defeated both hand enumerations, so it gets its own assertion
460    /// rather than riding on the count above.
461    #[test]
462    fn an_expression_index_is_found_and_reports_its_expression_as_unnameable() {
463        let conn = migrated();
464        let report = super::census(&conn).expect("census");
465        let external_id = report
466            .constraints
467            .iter()
468            .find(|c| c.index == "idx_comm_message_external_id")
469            .expect("the comm external-id index is a namespace-bearing unique index");
470
471        assert_eq!(external_id.table, "notes");
472        assert_eq!(external_id.origin, ConstraintOrigin::UniqueIndex);
473        assert!(
474            external_id.partial,
475            "the index is filtered, and a collision check ignoring that refuses moves SQLite accepts"
476        );
477        assert!(
478            !external_id.columns_are_nameable(),
479            "the third key is json_extract(...), which has no column name: {:?}",
480            external_id.columns
481        );
482        assert!(
483            external_id
484                .columns
485                .iter()
486                .any(|c| c.as_deref() == Some("namespace")),
487            "namespace is still named inside an expression index: {:?}",
488            external_id.columns
489        );
490    }
491
492    /// The gap the column read alone cannot close: an index over
493    /// `lower(namespace)` names nothing in `index_xinfo`, because an expression
494    /// key column is reported with a null name. The comm external-id index does
495    /// not exercise this - its namespace is a literal column and the expression
496    /// is a different one - so the arm builds the shape the schema does not yet
497    /// have.
498    #[test]
499    fn an_index_whose_namespace_is_inside_an_expression_is_found_from_its_own_ddl() {
500        let conn = migrated();
501        conn.execute_batch(
502            "CREATE UNIQUE INDEX idx_expr_ns ON notes(lower(namespace), kind, name)",
503        )
504        .expect("an index whose namespace sits inside an expression");
505
506        // Control on the instrument, not on the answer: the column read alone
507        // must NOT see it, or this arm is proving nothing about the DDL path.
508        let columns = index_key_columns(&conn, "idx_expr_ns").expect("xinfo");
509        assert!(
510            !columns.iter().any(|c| c.as_deref() == Some("namespace")),
511            "control: index_xinfo must not name namespace here, got {columns:?}"
512        );
513
514        let report = super::census(&conn).expect("census");
515        assert!(
516            report.constraints.iter().any(|c| c.index == "idx_expr_ns"),
517            "an index carrying an expression is read from its own DDL"
518        );
519    }
520
521    /// The DDL read is a text match, so it is bounded to whole words. A column
522    /// merely spelled like the one we care about must not enter the refusal set.
523    #[test]
524    fn a_namespace_shaped_column_name_does_not_match_the_ddl_read() {
525        assert!(mentions_namespace_as_a_word(
526            "CREATE UNIQUE INDEX i ON t(lower(namespace), id)"
527        ));
528        assert!(mentions_namespace_as_a_word("ON t(NAMESPACE)"));
529        assert!(!mentions_namespace_as_a_word(
530            "CREATE UNIQUE INDEX i ON t(lower(namespace_hash), id)"
531        ));
532        assert!(!mentions_namespace_as_a_word("ON t(ns_namespace_key)"));
533    }
534
535    /// A composite primary key reports through the same surface as an index,
536    /// which is the half the index-only enumeration could not see.
537    #[test]
538    fn a_composite_primary_key_reports_as_a_constraint_with_its_columns() {
539        let conn = migrated();
540        let report = super::census(&conn).expect("census");
541        let edges = report
542            .constraints_on("graph_edges")
543            .into_iter()
544            .find(|c| c.origin == ConstraintOrigin::PrimaryKey)
545            .expect("graph_edges is PRIMARY KEY (namespace, id)");
546        assert_eq!(
547            edges.columns,
548            vec![Some("namespace".to_string()), Some("id".to_string())]
549        );
550    }
551
552    /// The arm that catches the failure this module exists for: a constraint
553    /// arriving with a migration is in the census on the next run, with no edit
554    /// to any list.
555    #[test]
556    fn a_constraint_added_after_this_code_was_written_is_found_with_no_code_change() {
557        let conn = migrated();
558        let before = constraint_names(&super::census(&conn).expect("census"));
559        assert!(
560            !before.iter().any(|(_, i)| i == "idx_future_ns_status"),
561            "control: the index under test must not already exist"
562        );
563
564        conn.execute_batch(
565            "CREATE UNIQUE INDEX idx_future_ns_status ON notes(namespace, status, name)",
566        )
567        .expect("a later migration adds a namespace-bearing unique index");
568
569        let after = constraint_names(&super::census(&conn).expect("census"));
570        assert!(
571            after
572                .iter()
573                .any(|(t, i)| t == "notes" && i == "idx_future_ns_status"),
574            "the census has to find a constraint nobody told it about; found {after:?}"
575        );
576        assert_eq!(
577            after.len(),
578            before.len() + 1,
579            "and it must find exactly the one that was added"
580        );
581    }
582
583    /// A non-unique index naming namespace is not a constraint, and counting it
584    /// would refuse moves that are legal.
585    #[test]
586    fn a_non_unique_index_naming_namespace_is_not_a_constraint() {
587        let conn = migrated();
588        let before = super::census(&conn).expect("census").constraints.len();
589        conn.execute_batch("CREATE INDEX idx_plain_ns_salience ON notes(namespace, salience)")
590            .expect("plain index");
591        let after = super::census(&conn).expect("census").constraints.len();
592        assert_eq!(
593            after, before,
594            "a non-unique index is not a uniqueness constraint"
595        );
596    }
597
598    /// The tables a move never writes carry constraints that cannot collide, and
599    /// the reachable set is the census minus exactly those.
600    #[test]
601    fn the_reachable_set_excludes_the_tables_a_move_never_writes() {
602        let conn = migrated();
603        let report = super::census(&conn).expect("census");
604        let reachable: BTreeSet<&str> = reachable_constraints(&report)
605            .into_iter()
606            .map(|c| c.table.as_str())
607            .collect();
608
609        for out_of_reach in [
610            "note_streams",
611            "ann_consumer_watermark",
612            "ann_consumer_pending",
613        ] {
614            assert!(
615                report.constraints.iter().any(|c| c.table == out_of_reach),
616                "control: {out_of_reach} must be IN the census, or this arm proves nothing"
617            );
618            assert!(
619                !reachable.contains(out_of_reach),
620                "{out_of_reach} is out of reach for a move"
621            );
622        }
623        for in_reach in [
624            "notes",
625            "graph_edges",
626            "fts_notes_rowids",
627            "brain_serve_ledger",
628        ] {
629            assert!(reachable.contains(in_reach), "{in_reach} is reachable");
630        }
631    }
632
633    /// A census is per connection, and a store can be three files. The report
634    /// says which one it read.
635    #[test]
636    fn a_census_names_the_database_it_read() {
637        let conn = migrated();
638        let report = super::census(&conn).expect("census");
639        assert_eq!(
640            report.database, "",
641            "an in-memory database has no file, and the empty string is that answer"
642        );
643
644        let dir = tempfile::tempdir().expect("tempdir");
645        let path = dir.path().join("second-backend.db");
646        let mut file_conn = Connection::open(&path).expect("open a file-backed store");
647        run_migrations(&mut file_conn).expect("migrate the second backend");
648        let file_census = super::census(&file_conn).expect("census");
649        // SQLite reports the path it resolved, and macOS hands a temp dir out
650        // through a symlink, so the comparison is between two resolved paths.
651        let resolved = std::fs::canonicalize(&path).expect("resolve the store path");
652        let reported = std::fs::canonicalize(&file_census.database)
653            .expect("the census names a path that exists");
654        assert_eq!(reported, resolved, "a file-backed census names its file");
655    }
656
657    /// An fts5 table reports no indexes, and that is not the same statement as
658    /// "has no constraints". The census says so by name.
659    #[test]
660    fn a_virtual_table_reporting_no_indexes_is_recorded_as_unread_not_as_clean() {
661        let conn = migrated();
662        let report = super::census(&conn).expect("census");
663        assert!(
664            report.unenumerable.iter().any(|t| t == "fts_notes"),
665            "fts_notes reports no index list, so its constraints are unread: {:?}",
666            report.unenumerable
667        );
668        assert!(
669            !report.unenumerable.iter().any(|t| t == "notes"),
670            "control: an ordinary table's constraints ARE readable, so it is not listed"
671        );
672    }
673
674    /// The table half. `notes` is the obvious one; the assertion that matters is
675    /// that the fts5 virtual tables are found and marked, because a virtual
676    /// table is where a namespace write behaves differently from everywhere
677    /// else.
678    #[test]
679    fn the_table_census_finds_the_virtual_tables_and_marks_them() {
680        let conn = migrated();
681        let report = super::census(&conn).expect("census");
682        let names = report.table_names();
683        for expected in [
684            "notes",
685            "entities",
686            "graph_edges",
687            "knowledge_atoms",
688            "events",
689        ] {
690            assert!(
691                names.contains(&expected),
692                "missing {expected} from {names:?}"
693            );
694        }
695        // Pinned as a set on purpose, and this is the one place in the file where
696        // pinning is right. A constraint arriving in a migration must be found
697        // with no edit here, because nothing downstream has to understand it. A
698        // namespace-bearing VIRTUAL TABLE arriving in a migration must break a
699        // test, because a mover has to decide what happens to its rows and no
700        // default is safe.
701        let virtual_tables: Vec<&str> = report
702            .tables
703            .iter()
704            .filter(|t| t.virtual_table)
705            .map(|t| t.name.as_str())
706            .collect();
707        println!("namespace-bearing virtual tables: {virtual_tables:?}");
708        assert_eq!(
709            virtual_tables,
710            ["fts_entities", "fts_knowledge", "fts_notes", "fts_sections"],
711            "the namespace-bearing virtual tables of a freshly migrated store"
712        );
713        let notes = report
714            .tables
715            .iter()
716            .find(|t| t.name == "notes")
717            .expect("notes");
718        assert!(
719            !notes.virtual_table,
720            "control: an ordinary table is not marked virtual"
721        );
722    }
723}