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 systemprompt_identifiers::ExtensionId;
32use tracing::{debug, warn};
33
34/// One `extension_migrations` row recording a migration as applied without
35/// having executed it.
36#[derive(Debug, Clone)]
37pub struct BaselineStamp {
38    pub id: String,
39    pub version: u32,
40    pub name: String,
41    pub checksum: String,
42}
43
44// Why: the ledger is owned by the `database` extension and is created by
45// `ensure_migrations_table_exists` before any extension's freshness is read,
46// so counting it would make `database` an established extension on every
47// empty database and run its migrations instead of stamping them.
48const 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}