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