1use std::collections::BTreeSet;
31
32use rusqlite::Connection;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
36pub enum ConstraintOrigin {
37 PrimaryKey,
39 UniqueConstraint,
41 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
58pub struct NamespaceTable {
59 pub name: String,
60 pub virtual_table: bool,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
68pub struct NamespaceConstraint {
69 pub table: String,
70 pub index: String,
73 pub origin: ConstraintOrigin,
74 pub columns: Vec<Option<String>>,
77 pub partial: bool,
81}
82
83impl NamespaceConstraint {
84 pub fn columns_are_nameable(&self) -> bool {
87 self.columns.iter().all(Option::is_some)
88 }
89}
90
91#[derive(Debug, Clone, Default)]
99pub struct NamespaceCensus {
100 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 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
136pub(crate) fn quote_ident(name: &str) -> String {
141 format!("\"{}\"", name.replace('"', "\"\""))
142}
143
144pub 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 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
191pub 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 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
224fn 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 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
294fn 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
312fn 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
336fn 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
357pub const TABLES_EXCLUDED_FROM_MOVE: &[&str] =
365 &["events", "ann_consumer_watermark", "ann_consumer_pending"];
366
367pub const TABLE_REFUSED_BY_SCHEMA: &str = "note_streams";
375
376pub 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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}