systemprompt_database/lifecycle/migrations/
stamp.rs1use super::MigrationService;
27use super::exec::execute_statements_transactional;
28use crate::services::SqlExecutor;
29use pg_query::NodeEnum;
30use systemprompt_extension::{Extension, LoaderError, Migration};
31use systemprompt_identifiers::ExtensionId;
32use tracing::{debug, warn};
33
34#[derive(Debug, Clone)]
37pub struct BaselineStamp {
38 pub id: String,
39 pub version: u32,
40 pub name: String,
41 pub checksum: String,
42}
43
44const LEDGER_TABLE: &str = "extension_migrations";
49
50#[derive(Debug, Clone, Copy)]
51pub struct FreshnessCheck {
52 pub no_history: bool,
53 pub tables_present: usize,
54 pub tables_total: usize,
55}
56
57impl FreshnessCheck {
58 #[must_use]
59 pub const fn is_fresh(&self) -> bool {
60 self.no_history && self.tables_present == 0
61 }
62}
63
64impl MigrationService<'_> {
65 pub async fn assess_freshness(
66 &self,
67 extension_id: &ExtensionId,
68 owned_tables: &[String],
69 ) -> Result<FreshnessCheck, LoaderError> {
70 self.ensure_migrations_table_exists().await?;
71
72 let no_history = self.get_applied_migrations(extension_id).await?.is_empty();
73
74 let mut tables_present = 0usize;
75 let mut tables_total = 0usize;
76 for table in owned_tables {
77 let (schema, name) = table.split_once('.').unwrap_or(("public", table.as_str()));
78 if schema == "public" && name == LEDGER_TABLE {
79 continue;
80 }
81 tables_total += 1;
82 let result = self
83 .db
84 .query_raw_with(
85 &"SELECT 1 AS present FROM information_schema.tables WHERE table_schema = $1 \
86 AND table_name = $2",
87 &[&schema, &name],
88 )
89 .await
90 .map_err(|e| LoaderError::MigrationStepFailed {
91 extension: extension_id.clone(),
92 context: format!("Failed to check for existing table '{table}'"),
93 source: Box::new(e),
94 })?;
95 if !result.rows.is_empty() {
96 tables_present += 1;
97 }
98 }
99
100 let check = FreshnessCheck {
101 no_history,
102 tables_present,
103 tables_total,
104 };
105
106 if check.no_history && check.tables_present > 0 && check.tables_present < check.tables_total
107 {
108 warn!(
109 extension = %extension_id,
110 tables_present = check.tables_present,
111 tables_total = check.tables_total,
112 "Extension has no migration history but some owned tables already exist; \
113 treating as an established database and executing migrations normally"
114 );
115 }
116
117 Ok(check)
118 }
119
120 pub async fn run_stamped_retirements(
121 &self,
122 extension: &dyn Extension,
123 ) -> Result<usize, LoaderError> {
124 let ext_id = &ExtensionId::new(extension.metadata().id);
125 let mut ran = 0usize;
126 for migration in extension
127 .migrations()
128 .iter()
129 .filter(|migration| !migration.tombstone && is_retirement(migration))
130 {
131 let statements = SqlExecutor::parse_sql_statements(migration.sql).map_err(|e| {
132 LoaderError::MigrationStepFailed {
133 extension: ext_id.clone(),
134 context: format!(
135 "Failed to parse retirement migration {} ({})",
136 migration.version, migration.name
137 ),
138 source: Box::new(e),
139 }
140 })?;
141 debug!(
142 extension = %ext_id,
143 version = migration.version,
144 name = %migration.name,
145 "Fresh install: executing stamped retirement migration"
146 );
147 execute_statements_transactional(self.db, &statements, ext_id, migration, None).await?;
148 ran += 1;
149 }
150 Ok(ran)
151 }
152
153 #[must_use]
154 pub fn baseline_stamp_rows(extension: &dyn Extension) -> Vec<BaselineStamp> {
155 let ext_id = &ExtensionId::new(extension.metadata().id);
156 extension
157 .migrations()
158 .iter()
159 .filter(|migration| !migration.tombstone)
160 .map(|migration| BaselineStamp {
161 id: format!("{}_{:03}", ext_id, migration.version),
162 version: migration.version,
163 name: migration.name.clone(),
164 checksum: migration.checksum(),
165 })
166 .collect()
167 }
168}
169
170#[must_use]
171pub fn is_retirement(migration: &Migration) -> bool {
172 let Ok(parsed) = pg_query::parse(migration.sql) else {
173 return false;
174 };
175 let mut statements = 0usize;
176 for raw in parsed.protobuf.stmts {
177 let Some(node) = raw.stmt.and_then(|s| s.node) else {
178 continue;
179 };
180 statements += 1;
181 let retires = match &node {
182 NodeEnum::DropStmt(drop) => drop.missing_ok,
183 NodeEnum::DeleteStmt(delete) => delete
184 .relation
185 .as_ref()
186 .is_some_and(|relation| relation.relname == "extension_migrations"),
187 _ => false,
188 };
189 if !retires {
190 return false;
191 }
192 }
193 statements > 0
194}