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` and `weight_events` record 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    "weight_events",
368    "ann_consumer_watermark",
369    "ann_consumer_pending",
370    "retrieval_snapshots",
371];
372
373/// `note_streams` is read by a move and never written by one. Four triggers in
374/// the stream schema abort an `UPDATE` of a member note's namespace, the delete,
375/// and every write to the ledger rows, so a move reaching a stream member
376/// refuses from a pre-flight read. Its `(namespace, stream, seq)` primary key is
377/// therefore in the census and out of reach, and it is kept apart from
378/// [`TABLES_EXCLUDED_FROM_MOVE`] because the reasons differ: those tables are a
379/// decision about what a move should carry, this one is what the schema allows.
380pub const TABLE_REFUSED_BY_SCHEMA: &str = "note_streams";
381
382/// The constraints a move can actually violate: every one the census finds,
383/// minus those on tables the move never writes.
384pub fn reachable_constraints(census: &NamespaceCensus) -> Vec<&NamespaceConstraint> {
385    let excluded: BTreeSet<&str> = TABLES_EXCLUDED_FROM_MOVE
386        .iter()
387        .copied()
388        .chain(std::iter::once(TABLE_REFUSED_BY_SCHEMA))
389        .collect();
390    census
391        .constraints
392        .iter()
393        .filter(|c| !excluded.contains(c.table.as_str()))
394        .collect()
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::migrations::run_migrations_for_test as run_migrations;
401
402    fn migrated() -> Connection {
403        let mut conn = Connection::open_in_memory().expect("in-memory connection");
404        run_migrations(&mut conn).expect("migrate to the current schema");
405        conn
406    }
407
408    fn constraint_names(census: &NamespaceCensus) -> BTreeSet<(String, String)> {
409        census
410            .constraints
411            .iter()
412            .map(|c| (c.table.clone(), c.index.clone()))
413            .collect()
414    }
415
416    /// The whole point of the module, stated as the list it replaces. Every row
417    /// here was verified at source; five of them are the ones two hand
418    /// enumerations missed, and they are called out so a future edit that drops
419    /// one has to argue with the reason rather than with a name.
420    #[test]
421    fn census_finds_every_namespace_bearing_uniqueness_constraint() {
422        let conn = migrated();
423        let report = super::census(&conn).expect("census");
424        let found = constraint_names(&report);
425
426        let by_index: BTreeSet<&str> = found.iter().map(|(_, i)| i.as_str()).collect();
427        for expected in [
428            "idx_notes_namespace_kind_key",
429            "idx_comm_message_external_id",
430            "idx_graph_edges_unique_triple",
431            "idx_knowledge_atoms_ns_slug",
432            "idx_knowledge_domains_ns_slug",
433            "idx_brain_serve_ledger_unique",
434        ] {
435            assert!(
436                by_index.contains(expected),
437                "census missed the unique index {expected}; found {by_index:?}"
438            );
439        }
440
441        let by_table: BTreeSet<&str> = found.iter().map(|(t, _)| t.as_str()).collect();
442        for expected in [
443            "graph_edges",
444            "brain_implicit_mass",
445            "brain_profile_snapshots",
446            "ann_consumer_watermark",
447            "ann_consumer_pending",
448            "note_streams",
449            "fts_notes_rowids",
450            "fts_entities_rowids",
451        ] {
452            assert!(
453                by_table.contains(expected),
454                "census missed a namespace-bearing primary key on {expected}; found {by_table:?}"
455            );
456        }
457    }
458
459    /// An expression index has no column name for its third key. This is the
460    /// shape that defeated both hand enumerations, so it gets its own assertion
461    /// rather than riding on the count above.
462    #[test]
463    fn an_expression_index_is_found_and_reports_its_expression_as_unnameable() {
464        let conn = migrated();
465        let report = super::census(&conn).expect("census");
466        let external_id = report
467            .constraints
468            .iter()
469            .find(|c| c.index == "idx_comm_message_external_id")
470            .expect("the comm external-id index is a namespace-bearing unique index");
471
472        assert_eq!(external_id.table, "notes");
473        assert_eq!(external_id.origin, ConstraintOrigin::UniqueIndex);
474        assert!(
475            external_id.partial,
476            "the index is filtered, and a collision check ignoring that refuses moves SQLite accepts"
477        );
478        assert!(
479            !external_id.columns_are_nameable(),
480            "the third key is json_extract(...), which has no column name: {:?}",
481            external_id.columns
482        );
483        assert!(
484            external_id
485                .columns
486                .iter()
487                .any(|c| c.as_deref() == Some("namespace")),
488            "namespace is still named inside an expression index: {:?}",
489            external_id.columns
490        );
491    }
492
493    /// The gap the column read alone cannot close: an index over
494    /// `lower(namespace)` names nothing in `index_xinfo`, because an expression
495    /// key column is reported with a null name. The comm external-id index does
496    /// not exercise this - its namespace is a literal column and the expression
497    /// is a different one - so the arm builds the shape the schema does not yet
498    /// have.
499    #[test]
500    fn an_index_whose_namespace_is_inside_an_expression_is_found_from_its_own_ddl() {
501        let conn = migrated();
502        conn.execute_batch(
503            "CREATE UNIQUE INDEX idx_expr_ns ON notes(lower(namespace), kind, name)",
504        )
505        .expect("an index whose namespace sits inside an expression");
506
507        // Control on the instrument, not on the answer: the column read alone
508        // must NOT see it, or this arm is proving nothing about the DDL path.
509        let columns = index_key_columns(&conn, "idx_expr_ns").expect("xinfo");
510        assert!(
511            !columns.iter().any(|c| c.as_deref() == Some("namespace")),
512            "control: index_xinfo must not name namespace here, got {columns:?}"
513        );
514
515        let report = super::census(&conn).expect("census");
516        assert!(
517            report.constraints.iter().any(|c| c.index == "idx_expr_ns"),
518            "an index carrying an expression is read from its own DDL"
519        );
520    }
521
522    /// The DDL read is a text match, so it is bounded to whole words. A column
523    /// merely spelled like the one we care about must not enter the refusal set.
524    #[test]
525    fn a_namespace_shaped_column_name_does_not_match_the_ddl_read() {
526        assert!(mentions_namespace_as_a_word(
527            "CREATE UNIQUE INDEX i ON t(lower(namespace), id)"
528        ));
529        assert!(mentions_namespace_as_a_word("ON t(NAMESPACE)"));
530        assert!(!mentions_namespace_as_a_word(
531            "CREATE UNIQUE INDEX i ON t(lower(namespace_hash), id)"
532        ));
533        assert!(!mentions_namespace_as_a_word("ON t(ns_namespace_key)"));
534    }
535
536    /// A composite primary key reports through the same surface as an index,
537    /// which is the half the index-only enumeration could not see.
538    #[test]
539    fn a_composite_primary_key_reports_as_a_constraint_with_its_columns() {
540        let conn = migrated();
541        let report = super::census(&conn).expect("census");
542        let edges = report
543            .constraints_on("graph_edges")
544            .into_iter()
545            .find(|c| c.origin == ConstraintOrigin::PrimaryKey)
546            .expect("graph_edges is PRIMARY KEY (namespace, id)");
547        assert_eq!(
548            edges.columns,
549            vec![Some("namespace".to_string()), Some("id".to_string())]
550        );
551    }
552
553    /// The arm that catches the failure this module exists for: a constraint
554    /// arriving with a migration is in the census on the next run, with no edit
555    /// to any list.
556    #[test]
557    fn a_constraint_added_after_this_code_was_written_is_found_with_no_code_change() {
558        let conn = migrated();
559        let before = constraint_names(&super::census(&conn).expect("census"));
560        assert!(
561            !before.iter().any(|(_, i)| i == "idx_future_ns_status"),
562            "control: the index under test must not already exist"
563        );
564
565        conn.execute_batch(
566            "CREATE UNIQUE INDEX idx_future_ns_status ON notes(namespace, status, name)",
567        )
568        .expect("a later migration adds a namespace-bearing unique index");
569
570        let after = constraint_names(&super::census(&conn).expect("census"));
571        assert!(
572            after
573                .iter()
574                .any(|(t, i)| t == "notes" && i == "idx_future_ns_status"),
575            "the census has to find a constraint nobody told it about; found {after:?}"
576        );
577        assert_eq!(
578            after.len(),
579            before.len() + 1,
580            "and it must find exactly the one that was added"
581        );
582    }
583
584    /// A non-unique index naming namespace is not a constraint, and counting it
585    /// would refuse moves that are legal.
586    #[test]
587    fn a_non_unique_index_naming_namespace_is_not_a_constraint() {
588        let conn = migrated();
589        let before = super::census(&conn).expect("census").constraints.len();
590        conn.execute_batch("CREATE INDEX idx_plain_ns_salience ON notes(namespace, salience)")
591            .expect("plain index");
592        let after = super::census(&conn).expect("census").constraints.len();
593        assert_eq!(
594            after, before,
595            "a non-unique index is not a uniqueness constraint"
596        );
597    }
598
599    /// The tables a move never writes carry constraints that cannot collide, and
600    /// the reachable set is the census minus exactly those.
601    #[test]
602    fn the_reachable_set_excludes_the_tables_a_move_never_writes() {
603        let conn = migrated();
604        let report = super::census(&conn).expect("census");
605        let reachable: BTreeSet<&str> = reachable_constraints(&report)
606            .into_iter()
607            .map(|c| c.table.as_str())
608            .collect();
609
610        for out_of_reach in [
611            "note_streams",
612            "ann_consumer_watermark",
613            "ann_consumer_pending",
614        ] {
615            assert!(
616                report.constraints.iter().any(|c| c.table == out_of_reach),
617                "control: {out_of_reach} must be IN the census, or this arm proves nothing"
618            );
619            assert!(
620                !reachable.contains(out_of_reach),
621                "{out_of_reach} is out of reach for a move"
622            );
623        }
624        for in_reach in [
625            "notes",
626            "graph_edges",
627            "fts_notes_rowids",
628            "brain_serve_ledger",
629        ] {
630            assert!(reachable.contains(in_reach), "{in_reach} is reachable");
631        }
632    }
633
634    /// A census is per connection, and a store can be three files. The report
635    /// says which one it read.
636    #[test]
637    fn a_census_names_the_database_it_read() {
638        let conn = migrated();
639        let report = super::census(&conn).expect("census");
640        assert_eq!(
641            report.database, "",
642            "an in-memory database has no file, and the empty string is that answer"
643        );
644
645        let dir = tempfile::tempdir().expect("tempdir");
646        let path = dir.path().join("second-backend.db");
647        let mut file_conn = Connection::open(&path).expect("open a file-backed store");
648        run_migrations(&mut file_conn).expect("migrate the second backend");
649        let file_census = super::census(&file_conn).expect("census");
650        // SQLite reports the path it resolved, and macOS hands a temp dir out
651        // through a symlink, so the comparison is between two resolved paths.
652        let resolved = std::fs::canonicalize(&path).expect("resolve the store path");
653        let reported = std::fs::canonicalize(&file_census.database)
654            .expect("the census names a path that exists");
655        assert_eq!(reported, resolved, "a file-backed census names its file");
656    }
657
658    /// An fts5 table reports no indexes, and that is not the same statement as
659    /// "has no constraints". The census says so by name.
660    #[test]
661    fn a_virtual_table_reporting_no_indexes_is_recorded_as_unread_not_as_clean() {
662        let conn = migrated();
663        let report = super::census(&conn).expect("census");
664        assert!(
665            report.unenumerable.iter().any(|t| t == "fts_notes"),
666            "fts_notes reports no index list, so its constraints are unread: {:?}",
667            report.unenumerable
668        );
669        assert!(
670            !report.unenumerable.iter().any(|t| t == "notes"),
671            "control: an ordinary table's constraints ARE readable, so it is not listed"
672        );
673    }
674
675    /// The table half. `notes` is the obvious one; the assertion that matters is
676    /// that the fts5 virtual tables are found and marked, because a virtual
677    /// table is where a namespace write behaves differently from everywhere
678    /// else.
679    #[test]
680    fn the_table_census_finds_the_virtual_tables_and_marks_them() {
681        let conn = migrated();
682        let report = super::census(&conn).expect("census");
683        let names = report.table_names();
684        for expected in [
685            "notes",
686            "entities",
687            "graph_edges",
688            "knowledge_atoms",
689            "events",
690        ] {
691            assert!(
692                names.contains(&expected),
693                "missing {expected} from {names:?}"
694            );
695        }
696        // Pinned as a set on purpose, and this is the one place in the file where
697        // pinning is right. A constraint arriving in a migration must be found
698        // with no edit here, because nothing downstream has to understand it. A
699        // namespace-bearing VIRTUAL TABLE arriving in a migration must break a
700        // test, because a mover has to decide what happens to its rows and no
701        // default is safe.
702        let virtual_tables: Vec<&str> = report
703            .tables
704            .iter()
705            .filter(|t| t.virtual_table)
706            .map(|t| t.name.as_str())
707            .collect();
708        println!("namespace-bearing virtual tables: {virtual_tables:?}");
709        assert_eq!(
710            virtual_tables,
711            ["fts_entities", "fts_knowledge", "fts_notes", "fts_sections"],
712            "the namespace-bearing virtual tables of a freshly migrated store"
713        );
714        let notes = report
715            .tables
716            .iter()
717            .find(|t| t.name == "notes")
718            .expect("notes");
719        assert!(
720            !notes.virtual_table,
721            "control: an ordinary table is not marked virtual"
722        );
723    }
724}