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