//! Per-command persistence to `project.yaml`, `data/*.csv`, //! and `history.log` (ADR-0015 §3–§6). //! //! Iteration 2 wiring: every successful user command, after //! its SQLite mutations are staged but before the transaction //! commits, asks `Persistence` to write the affected text //! targets atomically (write-temp + fsync + rename). The //! commit-db-last ordering (ADR-0015 §6) is enforced in //! `db.rs`; this module owns the file-format details and the //! atomic-write primitive. //! //! Failure semantics: any write or rename failure produces a //! `PersistenceError`. The caller (the db worker) is //! responsible for translating that into a fatal error and //! letting the SQLite tx roll back. use std::cell::Cell; use std::fs; use std::io::Write as _; use std::path::{Path, PathBuf}; use crate::dsl::action::ReferentialAction; use crate::dsl::types::Type; use crate::mode::Mode; use crate::project::{DATA_DIR, HISTORY_LOG, PROJECT_YAML}; // Submodules are private; the few items the db worker needs // during rebuild (ADR-0015 §7) are re-exported below. mod csv_io; mod history; pub mod migrations; mod yaml; pub(crate) use csv_io::{decode_cell, parse_csv}; pub(crate) use yaml::parse_schema; /// Current UTC time as an ISO-8601 `Z` string — the same shape /// `history.log` records (ADR-0015 §5). Exposed crate-wide so the /// undo snapshot ring (ADR-0006) timestamps entries identically. #[must_use] pub(crate) fn utc_iso8601_now() -> String { history::utc_iso8601_now() } /// Owns persistence to a single project on disk. Cheap to /// move; the db worker holds one instance for its lifetime. /// /// Carries the **current input mode** (ADR-0015 mode-restore /// amendment, issue #14). Mode is live UI state, not schema, so /// it is not stored in the database — instead the worker holds /// the current value here and stamps it into `project.yaml` on /// every write, so the file always reflects the mode the user is /// actually in. Interior mutability (`Cell`) lets the worker /// update it through the `&self` write path; the worker thread /// owns the single instance, so no synchronisation is needed. #[derive(Debug, Clone)] pub struct Persistence { project_path: PathBuf, current_mode: Cell, } #[derive(Debug)] pub enum PersistenceError { Io { operation: &'static str, path: PathBuf, source: std::io::Error, }, Encode { kind: &'static str, path: PathBuf, message: String, }, } impl std::fmt::Display for PersistenceError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io { operation, path, source, } => f.write_str(&crate::t!( "persistence.io", operation = operation, path = path.display(), source = source, )), Self::Encode { kind, path, message, } => f.write_str(&crate::t!( "persistence.encode", kind = kind, path = path.display(), message = message, )), } } } impl std::error::Error for PersistenceError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io { source, .. } => Some(source), Self::Encode { .. } => None, } } } impl PersistenceError { /// Path the failure was associated with. #[must_use] pub fn path(&self) -> &Path { match self { Self::Io { path, .. } | Self::Encode { path, .. } => path, } } /// Short label for the kind of operation that failed, /// suitable for the fatal banner. #[must_use] pub const fn operation(&self) -> &'static str { match self { Self::Io { operation, .. } => operation, Self::Encode { .. } => "encode", } } } /// Snapshot of the full schema as it is to be written to /// `project.yaml`. /// /// Read from the database after the in-flight mutation has /// staged its changes (within the same SQLite tx) so the YAML /// reflects the post-mutation state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SchemaSnapshot { pub created_at: String, /// The input mode recorded in `project.yaml` (ADR-0015 /// mode-restore amendment, issue #14). On **read** this is /// the project's stored mode (defaulting to `Simple` for /// pre-#14 files with no `mode:` field). On **write** the /// persister stamps the live `Persistence::current_mode` /// here before serialising, so the file always reflects the /// mode the user is actually in. pub mode: Mode, pub tables: Vec, pub relationships: Vec, /// Indexes across all tables (ADR-0025). Carried as a flat /// list mirroring `relationships`; each entry names its /// table. Empty for project files written before indexes /// existed — the YAML field is optional on read. pub indexes: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TableSchema { pub name: String, pub primary_key: Vec, pub columns: Vec, /// Composite (multi-column) `UNIQUE` constraints (ADR-0035 /// §4a.2). Single-column UNIQUE is carried on the column's /// `ColumnSchema::unique` flag instead. Empty for project files /// written before composite UNIQUE existed — the YAML field is /// optional on read. pub unique_constraints: Vec>, /// Table-level `CHECK ()` constraints, in declaration /// order, as raw SQL text with an optional name (ADR-0035 §4a.3, /// named in §4g). The engine reports no CHECK constraints, so these /// are the source of truth (held in /// `__rdbms_playground_table_checks`) and echoed verbatim into the /// rebuilt DDL. Empty for project files written before table-level /// CHECK existed — the YAML field is optional on read. pub check_constraints: Vec, } /// A table-level `CHECK` constraint with an optional name (ADR-0035 §4g). /// /// The name is `Some` only for a `CONSTRAINT CHECK (…)` added via /// `ALTER TABLE` (the source of `DROP CONSTRAINT `); a `CREATE /// TABLE` table-CHECK and any pre-4g project file are unnamed (`None`). /// The YAML carries a bare string for the unnamed form (back-compatible) /// and an `{expr, name}` mapping for the named form. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TableCheck { pub name: Option, pub expr: String, } impl TableCheck { /// An unnamed table-CHECK (the `CREATE TABLE` / pre-4g form). #[must_use] pub fn unnamed(expr: impl Into) -> Self { Self { name: None, expr: expr.into(), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ColumnSchema { pub name: String, pub user_type: Type, /// Whether this column carries a single-column UNIQUE /// constraint (ADR-0018 §4). Stored explicitly in the /// project YAML so that a `serial → int` round-trip /// (which leaves UNIQUE in place) is preserved across a /// save/load cycle. Defaults to `false` when missing in /// older project files. pub unique: bool, /// `NOT NULL` constraint (ADR-0029). Defaults to `false` /// when missing in older project files. pub not_null: bool, /// `DEFAULT` expression as a SQL literal (ADR-0029) — the /// form SQLite reports and `schema_to_ddl` echoes verbatim. /// `None` when the column has no default. pub default: Option, /// `CHECK` constraint in compiled-SQL form (ADR-0029 §7), /// echoed verbatim into the rebuilt DDL. `None` when the /// column has no check. pub check: Option, } /// One index as recorded in `project.yaml` (ADR-0025). #[derive(Debug, Clone, PartialEq, Eq)] pub struct IndexSchema { pub name: String, pub table: String, /// The indexed columns, in index order. pub columns: Vec, /// Whether this is a `UNIQUE` index (ADR-0035 §4d — advanced-mode /// `CREATE UNIQUE INDEX`). The engine reports it via /// `pragma_index_list`'s `unique` column, so it is read back rather /// than stored in any `__rdbms_*` table; it is carried here so it /// round-trips through `project.yaml` and survives `rebuild`. /// Defaults to `false` when missing in older project files (the YAML /// field is optional on read); `version` stays `1`. pub unique: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelationshipSchema { pub name: String, pub parent_table: String, pub parent_column: String, pub child_table: String, pub child_column: String, pub on_delete: ReferentialAction, pub on_update: ReferentialAction, } /// Snapshot of one table's full row data, for writing /// `data/.csv`. The column order matches the table's /// declaration order; the row tuples are aligned to it. #[derive(Debug, Clone, PartialEq)] pub struct TableSnapshot { pub name: String, pub columns: Vec, pub rows: Vec>, } /// A scalar cell value, in the small ADT understood by the /// CSV encoder. /// /// `Null` and `Text("")` are distinct. `Eq` is intentionally /// NOT derived because `Real(f64)` does not satisfy it (NaN); /// use `PartialEq` for comparison. #[derive(Debug, Clone, PartialEq)] pub enum CellValue { Null, Integer(i64), Real(f64), Text(String), Blob(Vec), } impl Persistence { #[must_use] pub const fn new(project_path: PathBuf) -> Self { Self { project_path, current_mode: Cell::new(Mode::Simple), } } /// Builder: set the initial input mode this handle stamps into /// `project.yaml`. Used at boot / project-switch once the /// mode to restore has been resolved (ADR-0015 mode-restore /// amendment, issue #14). #[must_use] pub fn with_mode(self, mode: Mode) -> Self { self.current_mode.set(mode); self } /// The input mode this handle currently stamps into /// `project.yaml` writes. #[must_use] pub const fn current_mode(&self) -> Mode { self.current_mode.get() } /// Update the current input mode. The next `project.yaml` /// write records it. Called by the worker when the user /// changes mode mid-session (the `mode` command). pub fn set_mode(&self, mode: Mode) { self.current_mode.set(mode); } /// Read the mode recorded in an existing `project.yaml`, for /// restore-on-open (issue #14). Best-effort: a missing file, /// a parse failure, or an absent `mode:` field all yield /// `None` so the caller falls back to the default. A pre-#14 /// project (no `mode:` field) parses with the default mode, /// which we report as `None` to keep "no stored preference" /// distinct from an explicit `simple`. #[must_use] pub fn read_stored_mode(project_path: &Path) -> Option { let body = fs::read_to_string(project_path.join(PROJECT_YAML)).ok()?; yaml::parse_stored_mode(&body) } /// Project root directory. Used in tests and diagnostics. #[must_use] pub fn project_path(&self) -> &Path { &self.project_path } /// Write `project.yaml` from a full schema snapshot. /// Atomic: writes to `project.yaml.tmp`, fsyncs, then /// renames over the destination. pub fn write_schema(&self, schema: &SchemaSnapshot) -> Result<(), PersistenceError> { let body = yaml::serialize_schema(schema); atomic_write(&self.project_path.join(PROJECT_YAML), body.as_bytes()) } /// Write `data/
.csv` from a table snapshot. Atomic /// per file. Creates the `data/` directory if missing /// (tolerant of fresh projects). /// /// **Empty tables produce no CSV.** A header-only file /// would carry no information beyond what `project.yaml` /// already records, so an empty snapshot is treated /// identically to "drop this table's data file": the CSV /// is removed if it exists, no file is created if it /// doesn't. This keeps the rule "data lives in CSV; no /// data, no CSV" consistent and avoids surprising users /// with files they didn't ask for. pub fn write_table_data(&self, table: &TableSnapshot) -> Result<(), PersistenceError> { if table.rows.is_empty() { return self.delete_table_data(&table.name); } let data_dir = self.project_path.join(DATA_DIR); fs::create_dir_all(&data_dir).map_err(|source| PersistenceError::Io { operation: "create", path: data_dir.clone(), source, })?; let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode { kind: "CSV", path: data_dir.join(format!("{}.csv", table.name)), message, })?; atomic_write(&data_dir.join(format!("{}.csv", table.name)), &body) } /// Remove `data/
.csv` if present. Used when a /// table is dropped so stale CSVs don't linger. pub fn delete_table_data(&self, table_name: &str) -> Result<(), PersistenceError> { let path = self .project_path .join(DATA_DIR) .join(format!("{table_name}.csv")); match fs::remove_file(&path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(source) => Err(PersistenceError::Io { operation: "delete", path, source, }), } } /// Append one successful-command record to `history.log`. pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> { let path = self.project_path.join(HISTORY_LOG); let line = history::format_record(command_text, history::utc_iso8601_now()); history::append(&path, &line) } /// Append a failed-command record to `history.log`, tagged /// `err` (ADR-0034 §1). Used by the runtime's error path so a /// command that failed to parse or to execute is still /// recallable across sessions (it never reaches the worker's /// transactional `ok` journal). Best-effort at the call site: /// a failure to record a failure must never escalate a user /// error into a fatal (ADR-0034 §4). pub fn append_history_failure(&self, command_text: &str) -> Result<(), PersistenceError> { let path = self.project_path.join(HISTORY_LOG); let line = history::format_record_with_status( command_text, history::utc_iso8601_now(), history::STATUS_ERR, ); history::append(&path, &line) } /// Read the most-recent `max_n` sources out of /// `history.log` for input-history hydration on project /// open (ADR-0015 §12). Returned in chronological order /// (oldest first). A missing file is `Ok(Vec::new())`. pub fn read_recent_history(&self, max_n: usize) -> Result, PersistenceError> { let path = self.project_path.join(HISTORY_LOG); history::read_recent_sources(&path, max_n) } } /// How `run_replay` should treat one already-trimmed, /// non-blank, non-`#` line (ADR-0034 §3). pub(crate) enum ReplayLine { /// Run this command text — either a journal `ok` record's /// extracted source, or a bare command verbatim. Run(String), /// A journal record whose status is not `ok` — skip it /// silently (a skipped failure is not a replay failure). Skip, } /// Classify one replay input line (ADR-0034 §3). A journal /// record (`||`) runs its source only when /// `ok` and is skipped otherwise; any other line is a bare /// command run verbatim. Detection is by the leading /// timestamp+status prefix, so a bare command that merely /// contains `|` (e.g. `select 'a|b' from t`) is run as-is. pub(crate) fn classify_replay_line(line: &str) -> ReplayLine { match history::parse_journal_record(line) { Some(rec) if rec.status_is_ok => ReplayLine::Run(rec.source), Some(_) => ReplayLine::Skip, None => ReplayLine::Run(line.to_string()), } } /// Write `body` to `path` atomically via temp file + fsync + /// rename. The temp file is named `.tmp` in the same /// directory so the rename stays on the same filesystem. fn atomic_write(path: &Path, body: &[u8]) -> Result<(), PersistenceError> { let tmp_path = path.with_extension(extension_with_tmp(path)); { let mut tmp = fs::File::create(&tmp_path).map_err(|source| PersistenceError::Io { operation: "create", path: tmp_path.clone(), source, })?; tmp.write_all(body).map_err(|source| PersistenceError::Io { operation: "write", path: tmp_path.clone(), source, })?; tmp.sync_all().map_err(|source| PersistenceError::Io { operation: "fsync", path: tmp_path.clone(), source, })?; } fs::rename(&tmp_path, path).map_err(|source| PersistenceError::Io { operation: "rename", path: path.to_path_buf(), source, })?; Ok(()) } /// Build the `.tmp` extension for a path. /// /// If the path already has an extension (`project.yaml`), the /// tmp variant is `project.yaml.tmp`. If the path has no /// extension, the extension becomes plain `tmp`. fn extension_with_tmp(path: &Path) -> String { path.extension().map_or_else( || "tmp".to_string(), |ext| format!("{}.tmp", ext.to_string_lossy()), ) } #[cfg(test)] mod tests { use super::*; fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } #[test] fn extension_with_tmp_appends_to_existing_extension() { assert_eq!(extension_with_tmp(Path::new("a/b/project.yaml")), "yaml.tmp"); assert_eq!(extension_with_tmp(Path::new("a/b/Customers.csv")), "csv.tmp"); assert_eq!(extension_with_tmp(Path::new("a/b/lockfile")), "tmp"); } #[test] fn atomic_write_roundtrips() { let dir = tempdir(); let target = dir.path().join("file.txt"); atomic_write(&target, b"hello\n").unwrap(); assert_eq!(fs::read_to_string(&target).unwrap(), "hello\n"); // Calling again replaces atomically — no .tmp left behind. atomic_write(&target, b"world\n").unwrap(); assert_eq!(fs::read_to_string(&target).unwrap(), "world\n"); assert!(!target.with_extension("txt.tmp").exists()); } #[test] fn write_schema_writes_yaml() { let dir = tempdir(); let p = Persistence::new(dir.path().to_path_buf()); let schema = SchemaSnapshot { created_at: "2026-05-07T14:30:12Z".to_string(), mode: Mode::Simple, tables: vec![], relationships: vec![], indexes: vec![], }; p.write_schema(&schema).unwrap(); let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap(); assert!(body.contains("version: 1")); assert!(body.contains("created_at:")); } #[test] fn write_and_delete_table_data() { let dir = tempdir(); let p = Persistence::new(dir.path().to_path_buf()); let table = TableSnapshot { name: "Customers".to_string(), columns: vec![ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None, }], rows: vec![vec![CellValue::Text("Alice".to_string())]], }; p.write_table_data(&table).unwrap(); let csv_path = dir.path().join(DATA_DIR).join("Customers.csv"); assert!(csv_path.exists()); let body = fs::read_to_string(&csv_path).unwrap(); assert!(body.contains("Name")); assert!(body.contains("Alice")); p.delete_table_data("Customers").unwrap(); assert!(!csv_path.exists()); // Idempotent on a missing file. p.delete_table_data("Customers").unwrap(); } #[test] fn append_history_creates_and_appends() { let dir = tempdir(); let p = Persistence::new(dir.path().to_path_buf()); p.append_history("create table Foo with pk id(serial)").unwrap(); p.append_history("insert into Foo (1)").unwrap(); let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap(); let lines: Vec<&str> = body.trim_end().lines().collect(); assert_eq!(lines.len(), 2); assert!(lines[0].ends_with("|ok|create table Foo with pk id(serial)")); assert!(lines[1].ends_with("|ok|insert into Foo (1)")); } #[test] fn read_stored_mode_round_trips_a_written_project_yaml() { // ADR-0015 mode-restore amendment (issue #14): a mode // written into project.yaml reads back via read_stored_mode. let dir = tempdir(); let p = Persistence::new(dir.path().to_path_buf()); let schema = SchemaSnapshot { created_at: "2026-05-31T00:00:00Z".to_string(), mode: Mode::Advanced, tables: vec![], relationships: vec![], indexes: vec![], }; p.write_schema(&schema).unwrap(); assert_eq!( Persistence::read_stored_mode(dir.path()), Some(Mode::Advanced), ); } #[test] fn read_stored_mode_is_none_for_a_missing_project_yaml() { let dir = tempdir(); assert_eq!(Persistence::read_stored_mode(dir.path()), None); } }