systemprompt_database/lifecycle/installation/
undeclared.rs1use std::collections::BTreeSet;
17
18use serde::Serialize;
19use systemprompt_extension::LoaderError;
20use systemprompt_identifiers::ExtensionId;
21
22use crate::services::DatabaseProvider;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct UndeclaredTable {
26 pub schema: String,
27 pub table: String,
28 pub live_rows: i64,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct OrphanMigrationLedger {
33 pub extension_id: ExtensionId,
34 pub rows: i64,
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
38pub struct SchemaResidue {
39 pub undeclared_tables: Vec<UndeclaredTable>,
40 pub orphan_migration_ledgers: Vec<OrphanMigrationLedger>,
41}
42
43impl SchemaResidue {
44 #[must_use]
45 pub const fn is_empty(&self) -> bool {
46 self.undeclared_tables.is_empty() && self.orphan_migration_ledgers.is_empty()
47 }
48}
49
50pub async fn audit_schema_residue(
51 db: &dyn DatabaseProvider,
52 owned: &[String],
53 extension_ids: &[ExtensionId],
54) -> Result<SchemaResidue, LoaderError> {
55 let declared: BTreeSet<String> = owned.iter().map(|t| qualify(t)).collect();
56 let namespaces: BTreeSet<String> = declared
57 .iter()
58 .filter_map(|t| t.split_once('.').map(|(schema, _)| schema.to_owned()))
59 .collect();
60 let mut residue = SchemaResidue::default();
61 for (schema, table, live_rows) in live_tables(db).await? {
62 let qualified = format!("{schema}.{table}");
63 if namespaces.contains(&schema) && !declared.contains(&qualified) {
64 residue.undeclared_tables.push(UndeclaredTable {
65 schema,
66 table,
67 live_rows,
68 });
69 }
70 }
71 let registered: BTreeSet<&ExtensionId> = extension_ids.iter().collect();
72 for (extension_id, rows) in migration_ledgers(db).await? {
73 if !registered.contains(&extension_id) {
74 residue
75 .orphan_migration_ledgers
76 .push(OrphanMigrationLedger { extension_id, rows });
77 }
78 }
79 Ok(residue)
80}
81
82fn as_count(value: &serde_json::Value) -> i64 {
86 value
87 .as_i64()
88 .or_else(|| value.as_str().and_then(|s| s.parse().ok()))
89 .unwrap_or(0)
90}
91
92fn qualify(table: &str) -> String {
93 if table.contains('.') {
94 table.to_owned()
95 } else {
96 format!("public.{table}")
97 }
98}
99
100const LEDGER_TABLES: [&str; 2] = ["extension_migrations", "_sqlx_migrations"];
103
104async fn live_tables(db: &dyn DatabaseProvider) -> Result<Vec<(String, String, i64)>, LoaderError> {
105 let result = db
106 .query_raw_with(
107 &"SELECT n.nspname AS schema, c.relname AS table, \
108 COALESCE(s.n_live_tup, 0)::bigint AS live_rows \
109 FROM pg_class c \
110 JOIN pg_namespace n ON n.oid = c.relnamespace \
111 LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid \
112 WHERE c.relkind = 'r' \
113 AND n.nspname NOT IN ('pg_catalog', 'information_schema') \
114 AND n.nspname NOT LIKE 'pg_toast%' \
115 ORDER BY 1, 2",
116 &[],
117 )
118 .await
119 .map_err(|e| LoaderError::SchemaInstallationStepFailed {
120 extension: ExtensionId::new("schema-residue"),
121 context: "could not list live tables".to_owned(),
122 source: Box::new(e),
123 })?;
124 Ok(result
125 .rows
126 .iter()
127 .filter_map(|row| {
128 let schema = row.get("schema")?.as_str()?.to_owned();
129 let table = row.get("table")?.as_str()?.to_owned();
130 let live_rows = row.get("live_rows").map_or(0, as_count);
131 (!LEDGER_TABLES.contains(&table.as_str())).then_some((schema, table, live_rows))
132 })
133 .collect())
134}
135
136async fn migration_ledgers(
137 db: &dyn DatabaseProvider,
138) -> Result<Vec<(ExtensionId, i64)>, LoaderError> {
139 let result = db
140 .query_raw_with(
141 &"SELECT extension_id, COUNT(*)::bigint AS rows \
142 FROM extension_migrations GROUP BY extension_id ORDER BY extension_id",
143 &[],
144 )
145 .await
146 .map_err(|e| LoaderError::SchemaInstallationStepFailed {
147 extension: ExtensionId::new("schema-residue"),
148 context: "could not read extension_migrations".to_owned(),
149 source: Box::new(e),
150 })?;
151 Ok(result
152 .rows
153 .iter()
154 .filter_map(|row| {
155 let id = ExtensionId::new(row.get("extension_id")?.as_str()?);
156 Some((id, row.get("rows").map_or(0, as_count)))
157 })
158 .collect())
159}