Skip to main content

olai_stack_state/
store.rs

1//! [`Store`]: the SQLite database the CLI and the desktop app share.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, MutexGuard};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use olai_stack_topology::workspace::is_valid_name;
9use olai_stack_topology::{EnvManifest, MAX_SLOT};
10use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
11
12use crate::error::{Error, Result};
13use crate::model::{
14    Desired, Draft, Environment, EnvironmentId, Instance, InstanceId, Revision, SlotLease,
15    SourceBinding, VarScope, VarSet, Workspace, WorkspaceId, digest,
16};
17
18/// The environment variable naming the trestle state directory.
19pub const TRESTLE_HOME_VAR: &str = "TRESTLE_HOME";
20
21/// The database file inside the state directory.
22pub const DB_FILE: &str = "state.db";
23
24/// The migrations, in order: migration `n` (1-based) takes the schema from version `n - 1` to
25/// `n`.
26const MIGRATIONS: &[&str] = &[
27    include_str!("../migrations/0001_init.sql"),
28    include_str!("../migrations/0002_revision_message.sql"),
29];
30
31/// The schema version this crate writes and reads.
32pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32;
33
34/// How long a writer waits for another process's write to finish.
35const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
36
37/// The trestle state directory: `$TRESTLE_HOME` when set, else `trestle` in the platform's data
38/// directory (e.g. `~/Library/Application Support/trestle`, `~/.local/share/trestle`).
39pub fn trestle_home() -> Option<PathBuf> {
40    match std::env::var_os(TRESTLE_HOME_VAR) {
41        Some(home) if !home.is_empty() => Some(PathBuf::from(home)),
42        _ => dirs::data_dir().map(|d| d.join("trestle")),
43    }
44}
45
46fn now() -> i64 {
47    SystemTime::now()
48        .duration_since(UNIX_EPOCH)
49        .map(|d| d.as_secs() as i64)
50        .unwrap_or_default()
51}
52
53/// A `created_at` column (seconds since the epoch) as a time.
54fn time_of(secs: i64) -> SystemTime {
55    UNIX_EPOCH + Duration::from_secs(secs.max(0) as u64)
56}
57
58fn json<T: serde::Serialize>(value: &T) -> Result<String> {
59    Ok(serde_json::to_string(value)?)
60}
61
62fn from_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
63    Ok(serde_json::from_str(text)?)
64}
65
66fn check_name(name: &str) -> Result<()> {
67    if is_valid_name(name) {
68        Ok(())
69    } else {
70        Err(Error::InvalidName(name.to_owned()))
71    }
72}
73
74fn is_unique_violation(e: &rusqlite::Error) -> bool {
75    matches!(
76        e,
77        rusqlite::Error::SqliteFailure(f, _)
78            if f.code == rusqlite::ErrorCode::ConstraintViolation
79    )
80}
81
82/// `e`, or [`Error::Duplicate`] when it is a unique-constraint violation on `name`.
83fn duplicate_or(e: rusqlite::Error, kind: &'static str, name: &str) -> Error {
84    if is_unique_violation(&e) {
85        Error::Duplicate {
86            kind,
87            name: name.to_owned(),
88        }
89    } else {
90        e.into()
91    }
92}
93
94/// Refuse with [`Error::HasInstances`] when `count` (a `SELECT COUNT(*)` over the instances
95/// named after `name`, taking `id`) finds any.
96fn refuse_instances(
97    tx: &Transaction<'_>,
98    count: &str,
99    id: &str,
100    kind: &'static str,
101    name: &str,
102) -> Result<()> {
103    let instances: usize = tx.query_row(count, [id], |r| r.get(0))?;
104    if instances == 0 {
105        Ok(())
106    } else {
107        Err(Error::HasInstances {
108            kind,
109            name: name.to_owned(),
110            instances,
111        })
112    }
113}
114
115const WORKSPACE_INSTANCES: &str = "SELECT COUNT(*) FROM instance i \
116     JOIN environment e ON e.id = i.environment_id WHERE e.workspace_id = ?1";
117
118const ENVIRONMENT_INSTANCES: &str = "SELECT COUNT(*) FROM instance WHERE environment_id = ?1";
119
120/// The trestle state: workspaces, their environments (each a draft and numbered, immutable
121/// revisions), instances and the host-wide slot leases, in one SQLite database (WAL mode, so
122/// readers never block the writer).
123///
124/// Several processes open the same file: the CLI and the desktop app see each other's writes.
125/// A write waits for another process's to finish. A [`Watcher`](crate::Watcher) notices commits
126/// made through other connections.
127pub struct Store {
128    conn: Mutex<Connection>,
129    path: PathBuf,
130}
131
132impl std::fmt::Debug for Store {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("Store").field("path", &self.path).finish()
135    }
136}
137
138/// Open a connection to `path` with the pragmas every connection needs.
139pub(crate) fn connect(path: &Path) -> Result<Connection> {
140    let conn = Connection::open(path)?;
141    conn.busy_timeout(BUSY_TIMEOUT)?;
142    conn.pragma_update(None, "journal_mode", "WAL")?;
143    conn.pragma_update(None, "foreign_keys", true)?;
144    conn.pragma_update(None, "synchronous", "NORMAL")?;
145    Ok(conn)
146}
147
148impl Store {
149    /// Open (creating it if needed) the store in the [state directory](trestle_home).
150    pub fn open_default() -> Result<Self> {
151        let home = trestle_home().ok_or_else(|| Error::NotFound {
152            kind: "data directory",
153            id: format!("(set {TRESTLE_HOME_VAR})"),
154        })?;
155        std::fs::create_dir_all(&home).map_err(|e| Error::io(&home, e))?;
156        Self::open(&home.join(DB_FILE))
157    }
158
159    /// Open (creating it if needed) the store at `path`, and bring its schema up to
160    /// [`SCHEMA_VERSION`]. Errors with [`Error::UnsupportedVersion`] for a newer schema rather
161    /// than read it half-way.
162    pub fn open(path: &Path) -> Result<Self> {
163        let mut conn = connect(path)?;
164        migrate(&mut conn)?;
165        Ok(Store {
166            conn: Mutex::new(conn),
167            path: path.to_owned(),
168        })
169    }
170
171    /// The database file.
172    pub fn path(&self) -> &Path {
173        &self.path
174    }
175
176    fn lock(&self) -> MutexGuard<'_, Connection> {
177        self.conn.lock().unwrap_or_else(|e| e.into_inner())
178    }
179
180    /// Run `f` in a write transaction, taken up front so two processes never deadlock on
181    /// lock upgrades.
182    fn write<T>(&self, f: impl FnOnce(&Transaction<'_>) -> Result<T>) -> Result<T> {
183        let mut conn = self.lock();
184        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
185        let out = f(&tx)?;
186        tx.commit()?;
187        Ok(out)
188    }
189
190    /// Run `f` against a read snapshot.
191    fn read<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
192        f(&self.lock())
193    }
194
195    // --- Workspaces --------------------------------------------------------------------------
196
197    /// Create a workspace called `name`.
198    pub fn create_workspace(&self, name: &str, mesh: bool) -> Result<Workspace> {
199        check_name(name)?;
200        let ws = Workspace {
201            id: WorkspaceId::generate(),
202            name: name.to_owned(),
203            mesh,
204            vars: VarSet::new(),
205            sources: BTreeMap::new(),
206        };
207        self.write(|tx| {
208            tx.execute(
209                "INSERT INTO workspace (id, name, mesh, vars, sources, created_at) \
210                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
211                params![
212                    ws.id.0,
213                    ws.name,
214                    ws.mesh,
215                    json(&ws.vars)?,
216                    json(&ws.sources)?,
217                    now()
218                ],
219            )
220            .map_err(|e| duplicate_or(e, "workspace", name))?;
221            Ok(())
222        })?;
223        Ok(ws)
224    }
225
226    /// Every workspace, by name.
227    pub fn workspaces(&self) -> Result<Vec<Workspace>> {
228        self.read(|conn| {
229            let mut stmt = conn.prepare(&format!("{WS_COLUMNS} ORDER BY name"))?;
230            let rows = stmt.query_map([], workspace_row)?;
231            rows.map(|r| r?).collect()
232        })
233    }
234
235    /// The workspace called `name`.
236    pub fn workspace(&self, name: &str) -> Result<Option<Workspace>> {
237        self.read(|conn| {
238            conn.query_row(
239                &format!("{WS_COLUMNS} WHERE name = ?1"),
240                [name],
241                workspace_row,
242            )
243            .optional()?
244            .transpose()
245        })
246    }
247
248    /// The workspace `id`.
249    pub fn workspace_by_id(&self, id: &WorkspaceId) -> Result<Workspace> {
250        self.read(|conn| {
251            conn.query_row(
252                &format!("{WS_COLUMNS} WHERE id = ?1"),
253                [&id.0],
254                workspace_row,
255            )
256            .optional()?
257            .transpose()?
258            .ok_or_else(|| Error::NotFound {
259                kind: "workspace",
260                id: id.0.clone(),
261            })
262        })
263    }
264
265    /// The name and mesh flag of workspace `id`, read inside `tx`.
266    fn workspace_head(tx: &Transaction<'_>, id: &WorkspaceId) -> Result<(String, bool)> {
267        tx.query_row(
268            "SELECT name, mesh FROM workspace WHERE id = ?1",
269            [&id.0],
270            |r| Ok((r.get(0)?, r.get(1)?)),
271        )
272        .optional()?
273        .ok_or_else(|| Error::NotFound {
274            kind: "workspace",
275            id: id.0.clone(),
276        })
277    }
278
279    /// Rename the workspace to `name`. Its id, environments, revisions, vars and source config
280    /// stay as they are. Renaming it to its own name does nothing.
281    ///
282    /// Refused with [`Error::HasInstances`] while any of its environments has an instance,
283    /// stopped or not: an instance's compose project, volumes and hostnames are named after the
284    /// workspace, as is the mesh network, so they would be left behind under the old name.
285    pub fn rename_workspace(&self, id: &WorkspaceId, name: &str) -> Result<()> {
286        check_name(name)?;
287        self.write(|tx| {
288            let (old, _) = Self::workspace_head(tx, id)?;
289            if old == name {
290                return Ok(());
291            }
292            refuse_instances(tx, WORKSPACE_INSTANCES, &id.0, "workspace", &old)?;
293            tx.execute(
294                "UPDATE workspace SET name = ?2 WHERE id = ?1",
295                params![id.0, name],
296            )
297            .map_err(|e| duplicate_or(e, "workspace", name))?;
298            Ok(())
299        })
300    }
301
302    /// Set whether the workspace's gateways join a shared mesh network. Setting it to what it
303    /// is does nothing.
304    ///
305    /// Refused with [`Error::HasInstances`] while any of its environments has an instance:
306    /// an instance's gateway joins the mesh when it is created, so changing it under existing
307    /// instances would leave them on (or off) a network the rest no longer share.
308    pub fn set_mesh(&self, id: &WorkspaceId, mesh: bool) -> Result<()> {
309        self.write(|tx| {
310            let (name, current) = Self::workspace_head(tx, id)?;
311            if current == mesh {
312                return Ok(());
313            }
314            refuse_instances(tx, WORKSPACE_INSTANCES, &id.0, "workspace", &name)?;
315            tx.execute(
316                "UPDATE workspace SET mesh = ?2 WHERE id = ?1",
317                params![id.0, mesh],
318            )?;
319            Ok(())
320        })
321    }
322
323    /// Replace the workspace's vars.
324    pub fn set_workspace_vars(&self, id: &WorkspaceId, vars: &VarSet) -> Result<()> {
325        self.write(|tx| {
326            let n = tx.execute(
327                "UPDATE workspace SET vars = ?2 WHERE id = ?1",
328                params![id.0, json(vars)?],
329            )?;
330            found(n, "workspace", &id.0)
331        })
332    }
333
334    /// Set the config every instance of the workspace binds the source `name` with, or clear it
335    /// (`None`).
336    pub fn set_workspace_source(
337        &self,
338        id: &WorkspaceId,
339        name: &str,
340        config: Option<serde_json::Value>,
341    ) -> Result<()> {
342        self.write(|tx| {
343            let text: String = tx
344                .query_row(
345                    "SELECT sources FROM workspace WHERE id = ?1",
346                    [&id.0],
347                    |r| r.get(0),
348                )
349                .optional()?
350                .ok_or_else(|| Error::NotFound {
351                    kind: "workspace",
352                    id: id.0.clone(),
353                })?;
354            let mut sources: BTreeMap<String, serde_json::Value> = from_json(&text)?;
355            match config {
356                Some(config) => sources.insert(name.to_owned(), config),
357                None => sources.remove(name),
358            };
359            tx.execute(
360                "UPDATE workspace SET sources = ?2 WHERE id = ?1",
361                params![id.0, json(&sources)?],
362            )?;
363            Ok(())
364        })
365    }
366
367    /// Change the vars `scope` names with `f`, in one write transaction, so a concurrent writer's
368    /// change to other vars of the same set is kept. Returns the vars as written.
369    pub fn modify_vars<E: From<Error>>(
370        &self,
371        scope: &VarScope,
372        f: impl FnOnce(&mut VarSet) -> std::result::Result<(), E>,
373    ) -> std::result::Result<VarSet, E> {
374        let (table, column, kind, id) = match scope {
375            VarScope::Workspace(id) => ("workspace", "vars", "workspace", &id.0),
376            VarScope::Environment(id) => ("environment", "vars", "environment", &id.0),
377            VarScope::Draft(id) => ("environment", "draft_vars", "environment", &id.0),
378        };
379        self.modify(
380            &format!("SELECT {column} FROM {table} WHERE id = ?1"),
381            &format!("UPDATE {table} SET {column} = ?2 WHERE id = ?1"),
382            kind,
383            id,
384            |text| {
385                let mut vars: VarSet = from_json(text)?;
386                f(&mut vars)?;
387                Ok((json(&vars)?, vars))
388            },
389        )
390    }
391
392    /// Read the text `select` returns for `id`, let `f` turn it into a new text and a result,
393    /// and write the text with `update`: all in one write transaction.
394    fn modify<T, E: From<Error>>(
395        &self,
396        select: &str,
397        update: &str,
398        kind: &'static str,
399        id: &str,
400        f: impl FnOnce(&str) -> std::result::Result<(String, T), E>,
401    ) -> std::result::Result<T, E> {
402        let mut conn = self.lock();
403        let tx = conn
404            .transaction_with_behavior(TransactionBehavior::Immediate)
405            .map_err(Error::from)?;
406        let text: String = tx
407            .query_row(select, [id], |r| r.get(0))
408            .optional()
409            .map_err(Error::from)?
410            .ok_or_else(|| Error::NotFound {
411                kind,
412                id: id.to_owned(),
413            })?;
414        let (text, out) = f(&text)?;
415        tx.execute(update, params![id, text]).map_err(Error::from)?;
416        tx.commit().map_err(Error::from)?;
417        Ok(out)
418    }
419
420    /// Delete the workspace with all its environments, revisions, instances and leases.
421    pub fn delete_workspace(&self, id: &WorkspaceId) -> Result<()> {
422        self.write(|tx| {
423            let n = tx.execute("DELETE FROM workspace WHERE id = ?1", [&id.0])?;
424            found(n, "workspace", &id.0)
425        })
426    }
427
428    // --- Environments ------------------------------------------------------------------------
429
430    /// Create the environment `name` in `workspace`, with `draft` as its draft and no
431    /// revision yet. The draft's `env_name` is set to `name`.
432    pub fn create_environment(
433        &self,
434        workspace: &WorkspaceId,
435        name: &str,
436        mut draft: Draft,
437    ) -> Result<Environment> {
438        check_name(name)?;
439        draft.manifest.context.env_name = name.to_owned();
440        let env = Environment {
441            id: EnvironmentId::generate(),
442            workspace: workspace.clone(),
443            name: name.to_owned(),
444            draft,
445            vars: VarSet::new(),
446            head: None,
447        };
448        self.write(|tx| {
449            tx.execute(
450                "INSERT INTO environment (id, workspace_id, name, draft, draft_modules, draft_vars, \
451                 vars, head, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)",
452                params![
453                    env.id.0,
454                    workspace.0,
455                    env.name,
456                    env.draft.manifest.to_toml()?,
457                    json(&env.draft.modules)?,
458                    json(&env.draft.vars)?,
459                    json(&env.vars)?,
460                    now()
461                ],
462            )
463            .map_err(|e| {
464                if matches!(&e, rusqlite::Error::SqliteFailure(f, _)
465                    if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY)
466                {
467                    Error::NotFound {
468                        kind: "workspace",
469                        id: workspace.0.clone(),
470                    }
471                } else {
472                    duplicate_or(e, "environment", name)
473                }
474            })?;
475            Ok(())
476        })?;
477        Ok(env)
478    }
479
480    /// The environments of `workspace`, by name.
481    pub fn environments(&self, workspace: &WorkspaceId) -> Result<Vec<Environment>> {
482        self.read(|conn| {
483            let mut stmt = conn.prepare(&format!(
484                "{ENV_COLUMNS} WHERE workspace_id = ?1 ORDER BY name"
485            ))?;
486            let rows = stmt.query_map([&workspace.0], environment_row)?;
487            rows.map(|r| r?).collect()
488        })
489    }
490
491    /// The environment `id`.
492    pub fn environment(&self, id: &EnvironmentId) -> Result<Environment> {
493        self.read(|conn| {
494            conn.query_row(
495                &format!("{ENV_COLUMNS} WHERE id = ?1"),
496                [&id.0],
497                environment_row,
498            )
499            .optional()?
500            .transpose()?
501            .ok_or_else(|| Error::NotFound {
502                kind: "environment",
503                id: id.0.clone(),
504            })
505        })
506    }
507
508    /// Rename the environment to `name`, within its workspace. Its id, revisions and vars stay
509    /// as they are, and its draft's `env_name` follows the new name. The revisions keep the
510    /// `env_name` they were committed with; an instance is always planned under the
511    /// environment's current name. Renaming it to its own name does nothing.
512    ///
513    /// Refused with [`Error::HasInstances`] while it has an instance, stopped or not: an
514    /// instance's compose project, volumes and hostnames are named after the environment, so
515    /// they would be left behind under the old name.
516    pub fn rename_environment(&self, id: &EnvironmentId, name: &str) -> Result<()> {
517        check_name(name)?;
518        self.write(|tx| {
519            let env = tx
520                .query_row(
521                    &format!("{ENV_COLUMNS} WHERE id = ?1"),
522                    [&id.0],
523                    environment_row,
524                )
525                .optional()?
526                .transpose()?
527                .ok_or_else(|| Error::NotFound {
528                    kind: "environment",
529                    id: id.0.clone(),
530                })?;
531            if env.name == name {
532                return Ok(());
533            }
534            refuse_instances(tx, ENVIRONMENT_INSTANCES, &id.0, "environment", &env.name)?;
535            let mut manifest = env.draft.manifest;
536            manifest.context.env_name = name.to_owned();
537            tx.execute(
538                "UPDATE environment SET name = ?2, draft = ?3 WHERE id = ?1",
539                params![id.0, name, manifest.to_toml()?],
540            )
541            .map_err(|e| duplicate_or(e, "environment", name))?;
542            Ok(())
543        })
544    }
545
546    /// Replace the environment's draft. Its committed revisions are untouched. The draft's
547    /// `env_name` is kept as the environment's name.
548    pub fn update_draft(&self, id: &EnvironmentId, draft: &Draft) -> Result<()> {
549        self.modify_draft(id, |d| {
550            *d = draft.clone();
551            Ok::<_, Error>(())
552        })?;
553        Ok(())
554    }
555
556    /// Change the environment's draft with `f`, in one write transaction, so two writers (the
557    /// CLI and the app, say) never lose each other's edits. The draft's `env_name` is kept as
558    /// the environment's name. Returns the draft as written.
559    pub fn modify_draft<E: From<Error>>(
560        &self,
561        id: &EnvironmentId,
562        f: impl FnOnce(&mut Draft) -> std::result::Result<(), E>,
563    ) -> std::result::Result<Draft, E> {
564        let mut conn = self.lock();
565        let tx = conn
566            .transaction_with_behavior(TransactionBehavior::Immediate)
567            .map_err(Error::from)?;
568        let env = tx
569            .query_row(
570                &format!("{ENV_COLUMNS} WHERE id = ?1"),
571                [&id.0],
572                environment_row,
573            )
574            .optional()
575            .map_err(Error::from)?
576            .transpose()?
577            .ok_or_else(|| Error::NotFound {
578                kind: "environment",
579                id: id.0.clone(),
580            })?;
581        let mut draft = env.draft;
582        f(&mut draft)?;
583        draft.manifest.context.env_name = env.name;
584        tx.execute(
585            "UPDATE environment SET draft = ?2, draft_modules = ?3, draft_vars = ?4 WHERE id = ?1",
586            params![
587                id.0,
588                draft.manifest.to_toml().map_err(Error::from)?,
589                json(&draft.modules)?,
590                json(&draft.vars)?
591            ],
592        )
593        .map_err(Error::from)?;
594        tx.commit().map_err(Error::from)?;
595        Ok(draft)
596    }
597
598    /// Replace the vars every revision of the environment sees.
599    pub fn set_environment_vars(&self, id: &EnvironmentId, vars: &VarSet) -> Result<()> {
600        self.write(|tx| {
601            let n = tx.execute(
602                "UPDATE environment SET vars = ?2 WHERE id = ?1",
603                params![id.0, json(vars)?],
604            )?;
605            found(n, "environment", &id.0)
606        })
607    }
608
609    /// Delete the environment with its revisions, instances and their leases.
610    pub fn delete_environment(&self, id: &EnvironmentId) -> Result<()> {
611        self.write(|tx| {
612            let n = tx.execute("DELETE FROM environment WHERE id = ?1", [&id.0])?;
613            found(n, "environment", &id.0)
614        })
615    }
616
617    /// Commit the environment's draft as its next revision (numbered one past its head) and
618    /// make it the head. The same as [`commit_draft_with`](Self::commit_draft_with) without a
619    /// message.
620    pub fn commit_draft(&self, id: &EnvironmentId) -> Result<Revision> {
621        self.commit_draft_with(id, None)
622    }
623
624    /// Commit the environment's draft as its next revision, with `message` (trimmed; a blank
625    /// one counts as none). The message isn't part of the revision's digest.
626    pub fn commit_draft_with(&self, id: &EnvironmentId, message: Option<&str>) -> Result<Revision> {
627        let message = message
628            .map(str::trim)
629            .filter(|m| !m.is_empty())
630            .map(str::to_owned);
631        self.write(|tx| {
632            let (draft, modules, vars, head): (String, String, String, Option<u32>) = tx
633                .query_row(
634                    "SELECT draft, draft_modules, draft_vars, head FROM environment WHERE id = ?1",
635                    [&id.0],
636                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
637                )
638                .optional()?
639                .ok_or_else(|| Error::NotFound {
640                    kind: "environment",
641                    id: id.0.clone(),
642                })?;
643            let number = head.unwrap_or(0) + 1;
644            let modules: BTreeMap<String, String> = from_json(&modules)?;
645            let vars: VarSet = from_json(&vars)?;
646            let digest = digest(&draft, &modules, &vars)?;
647            let created_at = now();
648            tx.execute(
649                "INSERT INTO revision (environment_id, number, manifest, modules, vars, digest, \
650                 message, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
651                params![
652                    id.0,
653                    number,
654                    draft,
655                    json(&modules)?,
656                    json(&vars)?,
657                    digest,
658                    message,
659                    created_at
660                ],
661            )?;
662            tx.execute(
663                "UPDATE environment SET head = ?2 WHERE id = ?1",
664                params![id.0, number],
665            )?;
666            Ok(Revision {
667                environment: id.clone(),
668                number,
669                manifest: EnvManifest::from_toml(&draft)?,
670                manifest_toml: draft,
671                modules,
672                vars,
673                digest,
674                message,
675                created_at: time_of(created_at),
676            })
677        })
678    }
679
680    /// Revision `number` of the environment.
681    pub fn revision(&self, env: &EnvironmentId, number: u32) -> Result<Revision> {
682        self.read(|conn| {
683            conn.query_row(
684                &format!("{REV_COLUMNS} WHERE environment_id = ?1 AND number = ?2"),
685                params![env.0, number],
686                revision_row,
687            )
688            .optional()?
689            .transpose()?
690            .ok_or_else(|| Error::NotFound {
691                kind: "revision",
692                id: format!("{env}#{number}"),
693            })
694        })
695    }
696
697    /// Every revision of the environment, oldest first.
698    pub fn revisions(&self, env: &EnvironmentId) -> Result<Vec<Revision>> {
699        self.read(|conn| {
700            let mut stmt = conn.prepare(&format!(
701                "{REV_COLUMNS} WHERE environment_id = ?1 ORDER BY number"
702            ))?;
703            let rows = stmt.query_map([&env.0], revision_row)?;
704            rows.map(|r| r?).collect()
705        })
706    }
707
708    // --- Instances ---------------------------------------------------------------------------
709
710    /// Create an instance of revision `revision` of the environment, stopped and without a
711    /// slot.
712    pub fn create_instance(&self, env: &EnvironmentId, revision: u32) -> Result<Instance> {
713        let instance = Instance {
714            id: InstanceId::generate(),
715            environment: env.clone(),
716            revision,
717            desired: Desired::Stopped,
718            overrides: VarSet::new(),
719            sources: Vec::new(),
720            slot: None,
721        };
722        self.write(|tx| {
723            tx.execute(
724                "INSERT INTO instance (id, environment_id, revision, desired, overrides, sources, \
725                 created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
726                params![
727                    instance.id.0,
728                    env.0,
729                    revision,
730                    instance.desired.as_str(),
731                    json(&instance.overrides)?,
732                    json(&instance.sources)?,
733                    now()
734                ],
735            )
736            .map_err(|e| match &e {
737                rusqlite::Error::SqliteFailure(f, _)
738                    if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY =>
739                {
740                    Error::NotFound {
741                        kind: "revision",
742                        id: format!("{env}#{revision}"),
743                    }
744                }
745                _ => e.into(),
746            })?;
747            Ok(())
748        })?;
749        Ok(instance)
750    }
751
752    /// The instance `id`.
753    pub fn instance(&self, id: &InstanceId) -> Result<Instance> {
754        self.read(|conn| {
755            conn.query_row(
756                &format!("{INST_COLUMNS} WHERE i.id = ?1"),
757                [&id.0],
758                instance_row,
759            )
760            .optional()?
761            .transpose()?
762            .ok_or_else(|| Error::NotFound {
763                kind: "instance",
764                id: id.0.clone(),
765            })
766        })
767    }
768
769    /// Every instance on the host, oldest first.
770    pub fn instances(&self) -> Result<Vec<Instance>> {
771        self.read(|conn| {
772            let mut stmt = conn.prepare(&format!("{INST_COLUMNS} ORDER BY i.created_at, i.id"))?;
773            let rows = stmt.query_map([], instance_row)?;
774            rows.map(|r| r?).collect()
775        })
776    }
777
778    /// The instances of the environment, oldest first.
779    pub fn instances_of(&self, env: &EnvironmentId) -> Result<Vec<Instance>> {
780        self.read(|conn| {
781            let mut stmt = conn.prepare(&format!(
782                "{INST_COLUMNS} WHERE i.environment_id = ?1 ORDER BY i.created_at, i.id"
783            ))?;
784            let rows = stmt.query_map([&env.0], instance_row)?;
785            rows.map(|r| r?).collect()
786        })
787    }
788
789    /// Set whether the instance should be running.
790    pub fn set_desired(&self, id: &InstanceId, desired: Desired) -> Result<()> {
791        self.write(|tx| {
792            let n = tx.execute(
793                "UPDATE instance SET desired = ?2 WHERE id = ?1",
794                params![id.0, desired.as_str()],
795            )?;
796            found(n, "instance", &id.0)
797        })
798    }
799
800    /// Replace the instance's overrides.
801    pub fn set_overrides(&self, id: &InstanceId, overrides: &VarSet) -> Result<()> {
802        self.write(|tx| {
803            let n = tx.execute(
804                "UPDATE instance SET overrides = ?2 WHERE id = ?1",
805                params![id.0, json(overrides)?],
806            )?;
807            found(n, "instance", &id.0)
808        })
809    }
810
811    /// Replace the overlay sources the instance takes.
812    pub fn set_sources(&self, id: &InstanceId, sources: &[SourceBinding]) -> Result<()> {
813        self.write(|tx| {
814            let n = tx.execute(
815                "UPDATE instance SET sources = ?2 WHERE id = ?1",
816                params![id.0, json(&sources)?],
817            )?;
818            found(n, "instance", &id.0)
819        })
820    }
821
822    /// Delete the instance, releasing its slot.
823    pub fn delete_instance(&self, id: &InstanceId) -> Result<()> {
824        self.write(|tx| {
825            let n = tx.execute("DELETE FROM instance WHERE id = ?1", [&id.0])?;
826            found(n, "instance", &id.0)
827        })
828    }
829
830    // --- Slot leases -------------------------------------------------------------------------
831
832    /// Lease the instance the lowest slot no instance on the host holds, or return the one it
833    /// already holds. Atomic across processes.
834    pub fn lease_slot(&self, instance: &InstanceId) -> Result<SlotLease> {
835        self.write(|tx| {
836            let held: Option<u16> = tx
837                .query_row(
838                    "SELECT slot FROM slot_lease WHERE instance_id = ?1",
839                    [&instance.0],
840                    |r| r.get(0),
841                )
842                .optional()?;
843            if let Some(slot) = held {
844                return Ok(SlotLease {
845                    slot,
846                    instance: instance.clone(),
847                });
848            }
849            let taken: Vec<u16> = tx
850                .prepare("SELECT slot FROM slot_lease ORDER BY slot")?
851                .query_map([], |r| r.get(0))?
852                .collect::<rusqlite::Result<_>>()?;
853            let slot = (0..=MAX_SLOT)
854                .find(|s| taken.binary_search(s).is_err())
855                .ok_or(Error::SlotsExhausted)?;
856            tx.execute(
857                "INSERT INTO slot_lease (slot, instance_id, leased_at) VALUES (?1, ?2, ?3)",
858                params![slot, instance.0, now()],
859            )
860            .map_err(|e| match &e {
861                rusqlite::Error::SqliteFailure(f, _)
862                    if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY =>
863                {
864                    Error::NotFound {
865                        kind: "instance",
866                        id: instance.0.clone(),
867                    }
868                }
869                _ => e.into(),
870            })?;
871            Ok(SlotLease {
872                slot,
873                instance: instance.clone(),
874            })
875        })
876    }
877
878    /// Release the instance's slot, if it holds one.
879    pub fn release_slot(&self, instance: &InstanceId) -> Result<()> {
880        self.write(|tx| {
881            tx.execute(
882                "DELETE FROM slot_lease WHERE instance_id = ?1",
883                [&instance.0],
884            )?;
885            Ok(())
886        })
887    }
888
889    /// Every lease on the host, by slot.
890    pub fn leases(&self) -> Result<Vec<SlotLease>> {
891        self.read(|conn| {
892            let mut stmt =
893                conn.prepare("SELECT slot, instance_id FROM slot_lease ORDER BY slot")?;
894            let rows = stmt.query_map([], |r| {
895                Ok(SlotLease {
896                    slot: r.get(0)?,
897                    instance: InstanceId(r.get(1)?),
898                })
899            })?;
900            Ok(rows.collect::<rusqlite::Result<_>>()?)
901        })
902    }
903}
904
905fn found(rows: usize, kind: &'static str, id: &str) -> Result<()> {
906    if rows == 0 {
907        Err(Error::NotFound {
908            kind,
909            id: id.to_owned(),
910        })
911    } else {
912        Ok(())
913    }
914}
915
916/// Bring `conn`'s schema up to [`SCHEMA_VERSION`], in one write transaction so two processes
917/// opening a new store at once don't both migrate it.
918fn migrate(conn: &mut Connection) -> Result<()> {
919    let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
920    let found: u32 = tx.query_row("PRAGMA user_version", [], |r| r.get(0))?;
921    if found > SCHEMA_VERSION {
922        return Err(Error::UnsupportedVersion {
923            found,
924            supported: SCHEMA_VERSION,
925        });
926    }
927    for sql in &MIGRATIONS[found as usize..] {
928        tx.execute_batch(sql)?;
929    }
930    tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
931    tx.commit()?;
932    Ok(())
933}
934
935const WS_COLUMNS: &str = "SELECT id, name, mesh, vars, sources FROM workspace";
936
937fn workspace_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Workspace>> {
938    let (vars, sources): (String, String) = (r.get(3)?, r.get(4)?);
939    let (id, name, mesh) = (r.get(0)?, r.get(1)?, r.get(2)?);
940    Ok((|| {
941        Ok(Workspace {
942            id: WorkspaceId(id),
943            name,
944            mesh,
945            vars: from_json(&vars)?,
946            sources: from_json(&sources)?,
947        })
948    })())
949}
950
951const ENV_COLUMNS: &str =
952    "SELECT id, workspace_id, name, draft, draft_modules, draft_vars, vars, head FROM environment";
953
954fn environment_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Environment>> {
955    let (id, workspace, name): (String, String, String) = (r.get(0)?, r.get(1)?, r.get(2)?);
956    let (draft, modules, draft_vars, vars): (String, String, String, String) =
957        (r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?);
958    let head: Option<u32> = r.get(7)?;
959    Ok((|| {
960        Ok(Environment {
961            id: EnvironmentId(id),
962            workspace: WorkspaceId(workspace),
963            name,
964            draft: Draft {
965                manifest: EnvManifest::from_toml(&draft)?,
966                modules: from_json(&modules)?,
967                vars: from_json(&draft_vars)?,
968            },
969            vars: from_json(&vars)?,
970            head,
971        })
972    })())
973}
974
975const REV_COLUMNS: &str = "SELECT environment_id, number, manifest, modules, vars, digest, \
976     message, created_at FROM revision";
977
978fn revision_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Revision>> {
979    let (env, number, manifest_toml): (String, u32, String) = (r.get(0)?, r.get(1)?, r.get(2)?);
980    let (modules, vars, digest): (String, String, String) = (r.get(3)?, r.get(4)?, r.get(5)?);
981    let (message, created_at): (Option<String>, i64) = (r.get(6)?, r.get(7)?);
982    Ok((|| {
983        Ok(Revision {
984            environment: EnvironmentId(env),
985            number,
986            manifest: EnvManifest::from_toml(&manifest_toml)?,
987            manifest_toml,
988            modules: from_json(&modules)?,
989            vars: from_json(&vars)?,
990            digest,
991            message,
992            created_at: time_of(created_at),
993        })
994    })())
995}
996
997const INST_COLUMNS: &str = "SELECT i.id, i.environment_id, i.revision, i.desired, i.overrides, \
998     i.sources, l.slot FROM instance i LEFT JOIN slot_lease l ON l.instance_id = i.id";
999
1000fn instance_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Instance>> {
1001    let (id, env, revision, desired): (String, String, u32, String) =
1002        (r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?);
1003    let (overrides, sources, slot): (String, String, Option<u16>) =
1004        (r.get(4)?, r.get(5)?, r.get(6)?);
1005    Ok((|| {
1006        Ok(Instance {
1007            id: InstanceId(id),
1008            environment: EnvironmentId(env),
1009            revision,
1010            desired: Desired::parse(&desired),
1011            overrides: from_json(&overrides)?,
1012            sources: from_json(&sources)?,
1013            slot,
1014        })
1015    })())
1016}