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}