Skip to main content

systemprompt_database/lifecycle/migrations/
mark_applied.rs

1//! Record an extension migration as applied without running its SQL.
2//!
3//! Recovers the partial-state case where a migration's schema changes are
4//! present in the database but no row exists in `extension_migrations` to
5//! track them. Distinct from checksum-drift repair, which reconciles rows
6//! that already exist: here, the operator asserts the migration is already
7//! applied; the service only computes the current checksum and writes the
8//! tracking row.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use super::MigrationService;
14use systemprompt_extension::{Extension, LoaderError};
15
16#[derive(Debug, Clone)]
17pub struct MarkAppliedOutcome {
18    pub extension_id: String,
19    pub version: u32,
20    pub name: String,
21    pub checksum: String,
22}
23
24impl MigrationService<'_> {
25    pub async fn mark_applied(
26        &self,
27        extension: &dyn Extension,
28        version: u32,
29    ) -> Result<MarkAppliedOutcome, LoaderError> {
30        let ext_id = extension.metadata().id;
31
32        let migration = extension
33            .migrations()
34            .into_iter()
35            .find(|m| m.version == version)
36            .ok_or_else(|| LoaderError::MigrationFailed {
37                extension: ext_id.to_owned(),
38                message: format!(
39                    "Migration version {version} is not defined for extension '{ext_id}'"
40                ),
41            })?;
42
43        if migration.tombstone {
44            return Err(LoaderError::MigrationFailed {
45                extension: ext_id.to_owned(),
46                message: format!(
47                    "Migration {version} ('{}') is a tombstone: the slot is recorded as spent and \
48                     has no SQL, so there is nothing to mark applied",
49                    migration.name
50                ),
51            });
52        }
53
54        self.ensure_migrations_table_exists().await?;
55
56        let applied = self.get_applied_migrations(ext_id).await?;
57        if applied.iter().any(|m| m.version == version) {
58            return Err(LoaderError::MigrationFailed {
59                extension: ext_id.to_owned(),
60                message: format!(
61                    "Migration {version} ('{}') is already tracked as applied for extension \
62                     '{ext_id}'; nothing to do",
63                    migration.name
64                ),
65            });
66        }
67
68        let id = format!("{ext_id}_{:03}", migration.version);
69        let checksum = migration.checksum();
70
71        self.db
72            .execute(
73                &"INSERT INTO extension_migrations (id, extension_id, version, name, checksum) \
74                  VALUES ($1, $2, $3, $4, $5)",
75                &[&id, &ext_id, &migration.version, &migration.name, &checksum],
76            )
77            .await
78            .map_err(|e| LoaderError::MigrationFailed {
79                extension: ext_id.to_owned(),
80                message: format!("Failed to record migration as applied: {e}"),
81            })?;
82
83        Ok(MarkAppliedOutcome {
84            extension_id: ext_id.to_owned(),
85            version: migration.version,
86            name: migration.name.clone(),
87            checksum,
88        })
89    }
90}