Skip to main content

stackless_core/state/
instance.rs

1//! Instance records: one name, one truth (invariant 1).
2
3use std::collections::BTreeMap;
4
5use super::error::StateError;
6use super::row::Row;
7use super::store::Store;
8use crate::types::DnsName;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum InstanceStatus {
12    Active,
13    Tombstoned,
14}
15
16impl InstanceStatus {
17    pub(super) fn from_sql(s: &str) -> Result<Self, StateError> {
18        match s {
19            "active" => Ok(Self::Active),
20            "tombstoned" => Ok(Self::Tombstoned),
21            other => Err(StateError::RowDecode {
22                column: 2,
23                detail: format!("unknown instance status {other:?}"),
24            }),
25        }
26    }
27}
28
29#[derive(Debug, Clone)]
30pub struct InstanceRecord {
31    pub name: DnsName,
32    pub substrate: DnsName,
33    pub status: InstanceStatus,
34    /// The definition snapshot taken at creation (raw stackless.toml).
35    pub definition: String,
36    /// Recorded per-invocation `--source` pins (service โ†’ path).
37    pub source_overrides: BTreeMap<String, String>,
38    /// Whether `--dirty` was passed: snapshot source pins instead of using
39    /// them in place.
40    pub dirty: bool,
41    /// The directory the definition file came from at creation; the
42    /// sibling secrets env file resolves from here on resume.
43    pub definition_dir: String,
44    pub created_at: i64,
45    pub tombstoned_at: Option<i64>,
46}
47
48const SELECT_COLUMNS: &str = "name, substrate, status, definition, source_overrides, created_at, tombstoned_at, definition_dir, dirty";
49
50impl TryFrom<&Row> for InstanceRecord {
51    type Error = StateError;
52
53    fn try_from(row: &Row) -> Result<Self, Self::Error> {
54        let status = row.get_string(2)?;
55        let overrides_json = row.get_string(4)?;
56        let name = row.get_string(0)?;
57        let substrate = row.get_string(1)?;
58        Ok(Self {
59            name: DnsName::try_new(&name).map_err(|err| StateError::RowDecode {
60                column: 0,
61                detail: err.to_string(),
62            })?,
63            substrate: DnsName::try_new(&substrate).map_err(|err| StateError::RowDecode {
64                column: 1,
65                detail: err.to_string(),
66            })?,
67            status: InstanceStatus::from_sql(&status)?,
68            definition: row.get_string(3)?,
69            source_overrides: serde_json::from_str(&overrides_json).unwrap_or_default(),
70            created_at: row.get_i64(5)?,
71            tombstoned_at: row.get_opt_i64(6)?,
72            definition_dir: row.get_string(7)?,
73            dirty: row.get_i64(8)? != 0,
74        })
75    }
76}
77
78impl Store {
79    /// Create an instance record. Names are unique across substrates:
80    /// a clash is an error naming the existing substrate, not a sibling.
81    /// The UNIQUE PRIMARY KEY enforces this on both backends โ€” fleet-wide
82    /// when the remote plane is configured.
83    pub fn create_instance(
84        &self,
85        name: &str,
86        substrate: &str,
87        definition: &str,
88        source_overrides: &BTreeMap<String, String>,
89        definition_dir: &str,
90        dirty: bool,
91    ) -> Result<InstanceRecord, StateError> {
92        let overrides_json =
93            serde_json::to_string(source_overrides).unwrap_or_else(|_| "{}".into());
94        let result = self.execute(
95            "INSERT INTO instances (name, substrate, status, definition, source_overrides, created_at, definition_dir, dirty)
96             VALUES (?1, ?2, 'active', ?3, ?4, ?5, ?6, ?7)",
97            &[
98                name.into(),
99                substrate.into(),
100                definition.into(),
101                overrides_json.into(),
102                Self::now().into(),
103                definition_dir.into(),
104                i64::from(dirty).into(),
105            ],
106        );
107        match result {
108            Ok(_) => self
109                .instance(name)?
110                .ok_or_else(|| StateError::InstanceNotFound { name: name.into() }),
111            Err(err) => {
112                // A failed insert under an existing name is the
113                // uniqueness conflict (driver-agnostic: the PRIMARY KEY
114                // rejected it on either backend). Distinguish it from a
115                // real error by re-reading.
116                if let Some(existing) = self.instance(name)? {
117                    Err(StateError::InstanceExists {
118                        name: name.into(),
119                        existing_substrate: existing.substrate.as_str().to_owned(),
120                    })
121                } else {
122                    Err(err)
123                }
124            }
125        }
126    }
127
128    pub fn instance(&self, name: &str) -> Result<Option<InstanceRecord>, StateError> {
129        self.query_row(
130            &format!("SELECT {SELECT_COLUMNS} FROM instances WHERE name = ?1"),
131            &[name.into()],
132            |row| InstanceRecord::try_from(row),
133        )
134    }
135
136    pub fn instances(&self) -> Result<Vec<InstanceRecord>, StateError> {
137        self.query_map(
138            &format!("SELECT {SELECT_COLUMNS} FROM instances ORDER BY name"),
139            &[],
140            |row| InstanceRecord::try_from(row),
141        )
142    }
143
144    /// Teardown leaves a tombstone, not amnesia (ยง2).
145    pub fn tombstone_instance(&self, name: &str) -> Result<(), StateError> {
146        let changed = self.execute(
147            "UPDATE instances SET status = 'tombstoned', tombstoned_at = ?2 WHERE name = ?1",
148            &[name.into(), Self::now().into()],
149        )?;
150        if changed == 0 {
151            return Err(StateError::InstanceNotFound { name: name.into() });
152        }
153        Ok(())
154    }
155
156    /// `up` on a tombstone is a fresh birth under the old name: new
157    /// definition snapshot, active status, empty journal.
158    pub fn revive_instance(
159        &self,
160        name: &str,
161        definition: &str,
162        source_overrides: &BTreeMap<String, String>,
163        dirty: bool,
164    ) -> Result<(), StateError> {
165        let overrides_json =
166            serde_json::to_string(source_overrides).unwrap_or_else(|_| "{}".into());
167        let changed = self.execute(
168            "UPDATE instances SET status = 'active', definition = ?2, source_overrides = ?3,
169             created_at = ?4, tombstoned_at = NULL, dirty = ?5 WHERE name = ?1",
170            &[
171                name.into(),
172                definition.into(),
173                overrides_json.into(),
174                Self::now().into(),
175                i64::from(dirty).into(),
176            ],
177        )?;
178        if changed == 0 {
179            return Err(StateError::InstanceNotFound { name: name.into() });
180        }
181        self.execute(
182            "DELETE FROM checkpoints WHERE instance = ?1",
183            &[name.into()],
184        )?;
185        Ok(())
186    }
187
188    /// Record per-invocation overrides on resume (an explicit, recorded
189    /// choice โ€” never ambient discovery).
190    pub fn update_source_overrides(
191        &self,
192        name: &str,
193        source_overrides: &BTreeMap<String, String>,
194    ) -> Result<(), StateError> {
195        let overrides_json =
196            serde_json::to_string(source_overrides).unwrap_or_else(|_| "{}".into());
197        self.execute(
198            "UPDATE instances SET source_overrides = ?2 WHERE name = ?1",
199            &[name.into(), overrides_json.into()],
200        )?;
201        Ok(())
202    }
203
204    /// Record per-invocation dirty mode on resume.
205    pub fn update_dirty(&self, name: &str, dirty: bool) -> Result<(), StateError> {
206        self.execute(
207            "UPDATE instances SET dirty = ?2 WHERE name = ?1",
208            &[name.into(), i64::from(dirty).into()],
209        )?;
210        Ok(())
211    }
212}