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