Skip to main content

systemprompt_database/lifecycle/migrations/
stamp.rs

1//! Fresh-install baseline stamping.
2//!
3//! The declarative schema (`schema/*.sql`) is the baseline: a fresh database
4//! reaches target shape from the structural/dependent DDL alone, so its
5//! migrations carry no information and must not execute. [`MigrationService::
6//! assess_freshness`] decides, before any DDL has run, whether an extension is
7//! landing on a fresh database; [`MigrationService::baseline_stamp_rows`] then
8//! yields the `extension_migrations` rows recording every defined migration as
9//! applied, which the installer commits alongside the structural DDL rather
10//! than executing their SQL. Established databases (any tracking history, or
11//! any owned table already present) take the normal incremental path.
12//!
13//! One class of migration is stamped **and** executed: a retirement, whose
14//! every statement is a `DROP … IF EXISTS` or a `DELETE FROM
15//! extension_migrations`. Such a migration retires relations that another,
16//! since-deleted extension left behind, and an extension whose own tables are
17//! all absent says nothing about theirs — a production database kept nineteen
18//! `eval_*` tables and three orphaned ledger rows because the migration that
19//! dropped them belonged to an extension the database was meeting for the
20//! first time. Every statement of a retirement is idempotent, so running it
21//! on a truly fresh database is a no-op.
22//!
23//! Copyright (c) systemprompt.io — Business Source License 1.1.
24//! See <https://systemprompt.io> for licensing details.
25
26use 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/// One `extension_migrations` row recording a migration as applied without
34/// having executed it.
35#[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}