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] = &[
366 "events",
367 "ann_consumer_watermark",
368 "ann_consumer_pending",
369 "retrieval_snapshots",
370];
371
372pub const TABLE_REFUSED_BY_SCHEMA: &str = "note_streams";
380
381pub 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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}