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//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use super::MigrationService;
17use systemprompt_extension::{Extension, LoaderError};
18use tracing::warn;
19
20/// One `extension_migrations` row recording a migration as applied without
21/// having executed it.
22#[derive(Debug, Clone)]
23pub struct BaselineStamp {
24    pub id: String,
25    pub version: u32,
26    pub name: String,
27    pub checksum: String,
28}
29
30#[derive(Debug, Clone, Copy)]
31pub struct FreshnessCheck {
32    pub no_history: bool,
33    pub tables_present: usize,
34    pub tables_total: usize,
35}
36
37impl FreshnessCheck {
38    #[must_use]
39    pub const fn is_fresh(&self) -> bool {
40        self.no_history && self.tables_present == 0
41    }
42}
43
44impl MigrationService<'_> {
45    pub async fn assess_freshness(
46        &self,
47        extension_id: &str,
48        owned_tables: &[String],
49    ) -> Result<FreshnessCheck, LoaderError> {
50        self.ensure_migrations_table_exists().await?;
51
52        let no_history = self.get_applied_migrations(extension_id).await?.is_empty();
53
54        let mut tables_present = 0usize;
55        for table in owned_tables {
56            let (schema, name) = table.split_once('.').unwrap_or(("public", table.as_str()));
57            let result = self
58                .db
59                .query_raw_with(
60                    &"SELECT 1 AS present FROM information_schema.tables WHERE table_schema = $1 \
61                      AND table_name = $2",
62                    &[&schema, &name],
63                )
64                .await
65                .map_err(|e| LoaderError::MigrationFailed {
66                    extension: extension_id.to_owned(),
67                    message: format!("Failed to check for existing table '{table}': {e}"),
68                })?;
69            if !result.rows.is_empty() {
70                tables_present += 1;
71            }
72        }
73
74        let check = FreshnessCheck {
75            no_history,
76            tables_present,
77            tables_total: owned_tables.len(),
78        };
79
80        if check.no_history && check.tables_present > 0 && check.tables_present < check.tables_total
81        {
82            warn!(
83                extension = %extension_id,
84                tables_present = check.tables_present,
85                tables_total = check.tables_total,
86                "Extension has no migration history but some owned tables already exist; \
87                 treating as an established database and executing migrations normally"
88            );
89        }
90
91        Ok(check)
92    }
93
94    // Why: the rows only, executed by the caller — the installer commits them
95    // in the same transaction as the extension's structural DDL. Stamping in a
96    // transaction of its own left a window in which the tables existed and the
97    // baseline did not, and a database in that state is no longer fresh: the
98    // next install calls it established and executes migration SQL written for
99    // a schema shape the declarative baseline has already moved past.
100    #[must_use]
101    pub fn baseline_stamp_rows(extension: &dyn Extension) -> Vec<BaselineStamp> {
102        let ext_id = extension.metadata().id;
103        extension
104            .migrations()
105            .iter()
106            // Why: a tombstone has no SQL, so stamping it would record a
107            // checksum of the empty string against a slot this database never
108            // used. The slot stays free of tracking rows here and spent in the
109            // tree, which is exactly the truth.
110            .filter(|migration| !migration.tombstone)
111            .map(|migration| BaselineStamp {
112                id: format!("{}_{:03}", ext_id, migration.version),
113                version: migration.version,
114                name: migration.name.clone(),
115                checksum: migration.checksum(),
116            })
117            .collect()
118    }
119}