Iteration 2: per-command write-through to project.yaml, CSVs, history.log
Every successful user command now persists through to YAML, the
affected CSVs, and history.log inside the same SQLite transaction,
with the commit-db-last ordering from ADR-0015 §6: validate ->
mutate -> stage text + fsync -> atomic rename -> append history ->
commit. A failure in any text-write step rolls back the SQLite tx,
so disk state is unchanged on failure. Persistence failures are
routed through a new AppEvent::PersistenceFatal which sets a
fatal_message on the App, emits Action::Quit, and is printed to
stderr after terminal teardown so the banner remains above the
shell prompt (ADR-0015 §8).
New persistence module owns the file formats: hand-rolled YAML
schema writer, per-type CSV encoder (RFC 4180, NULL distinct from
empty string, base64 blobs), append-only history.log with ISO-8601
timestamps and successful-only entries. Atomic per-file writes via
tmp + fsync + rename.
The db worker holds an Option<Persistence>; tests still use
Database::open(":memory:") with no persistence. Action::ExecuteDsl
gains a source field carrying the user-typed text, threaded
through to history.log.
Tests: 289 passing (256 lib + 7 new integration + 9 lifecycle + 17
walking-skeleton), 0 failing, 0 skipped. Clippy clean with nursery
lints.
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
//! Per-type CSV writer (ADR-0015 §4).
|
||||
//!
|
||||
//! Encoding rules per type are exactly as specified in the
|
||||
//! ADR; the cell-level encoder lives in `encode_cell`. The
|
||||
//! `csv` crate handles RFC 4180 quoting around our encoded
|
||||
//! strings.
|
||||
//!
|
||||
//! NULL representation: an empty unquoted field. The `csv`
|
||||
//! crate's writer emits a non-quoted empty field for an empty
|
||||
//! string by default; we map `CellValue::Null` to that, and
|
||||
//! `CellValue::Text(String::new())` to a *quoted* empty
|
||||
//! field (`""`) by emitting a sentinel that round-trips.
|
||||
//!
|
||||
//! For the writer, the trick is: `WriterBuilder::quote_style(QuoteStyle::Necessary)`
|
||||
//! is the default and quotes only when needed (separator,
|
||||
//! quote, newline). We handle the empty-string-vs-null
|
||||
//! distinction manually by always quoting non-null empty
|
||||
//! Text and never quoting Null.
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
use super::{CellValue, TableSnapshot};
|
||||
|
||||
/// Serialize a `TableSnapshot` to a CSV body. Returns the raw
|
||||
/// bytes (UTF-8) ready to be written to disk.
|
||||
pub(super) fn serialize_table(table: &TableSnapshot) -> Result<Vec<u8>, String> {
|
||||
// We bypass the `csv` crate for cell-level emission so the
|
||||
// NULL-vs-empty distinction stays under our control. The
|
||||
// header and per-line framing are still simple enough to
|
||||
// emit directly.
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
write_record(
|
||||
&mut out,
|
||||
table.columns.iter().map(|c| Cell::Plain(c.name.clone())),
|
||||
)?;
|
||||
for row in &table.rows {
|
||||
if row.len() != table.columns.len() {
|
||||
return Err(format!(
|
||||
"row width {} does not match column count {} for table `{}`",
|
||||
row.len(),
|
||||
table.columns.len(),
|
||||
table.name,
|
||||
));
|
||||
}
|
||||
let mut cells: Vec<Cell> = Vec::with_capacity(row.len());
|
||||
for (col, value) in table.columns.iter().zip(row.iter()) {
|
||||
cells.push(encode_cell(col.user_type, value)?);
|
||||
}
|
||||
write_record(&mut out, cells.into_iter())?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One cell to write. `Plain` is unquoted; `Quoted` is
|
||||
/// always RFC 4180 double-quoted (used for the empty-string
|
||||
/// vs NULL distinction).
|
||||
enum Cell {
|
||||
Plain(String),
|
||||
Quoted(String),
|
||||
}
|
||||
|
||||
/// Emit a record (header or row) to `out`. Adds the trailing
|
||||
/// `\n` (RFC 4180 says CRLF, but `\n` is universally accepted
|
||||
/// and matches what every CSV reader on every platform
|
||||
/// handles cleanly; line endings are deliberately uniform
|
||||
/// across our generated artefacts).
|
||||
fn write_record<I: Iterator<Item = Cell>>(out: &mut Vec<u8>, cells: I) -> Result<(), String> {
|
||||
let mut first = true;
|
||||
for cell in cells {
|
||||
if !first {
|
||||
out.push(b',');
|
||||
}
|
||||
first = false;
|
||||
match cell {
|
||||
Cell::Plain(s) => {
|
||||
if needs_quoting(&s) {
|
||||
write_quoted(out, &s);
|
||||
} else {
|
||||
out.write_all(s.as_bytes()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Cell::Quoted(s) => write_quoted(out, &s),
|
||||
}
|
||||
}
|
||||
out.push(b'\n');
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_quoted(out: &mut Vec<u8>, s: &str) {
|
||||
out.push(b'"');
|
||||
for &b in s.as_bytes() {
|
||||
if b == b'"' {
|
||||
out.extend_from_slice(b"\"\"");
|
||||
} else {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
out.push(b'"');
|
||||
}
|
||||
|
||||
fn needs_quoting(s: &str) -> bool {
|
||||
s.bytes().any(|b| matches!(b, b',' | b'"' | b'\n' | b'\r'))
|
||||
}
|
||||
|
||||
/// Encode a single cell per type (ADR-0015 §4 table). Returns
|
||||
/// the cell wrapped in `Plain` or `Quoted` as appropriate for
|
||||
/// the NULL/empty distinction.
|
||||
fn encode_cell(ty: Type, value: &CellValue) -> Result<Cell, String> {
|
||||
if matches!(value, CellValue::Null) {
|
||||
return Ok(Cell::Plain(String::new()));
|
||||
}
|
||||
match ty {
|
||||
Type::Text => match value {
|
||||
CellValue::Text(s) if s.is_empty() => Ok(Cell::Quoted(String::new())),
|
||||
CellValue::Text(s) => Ok(Cell::Plain(s.clone())),
|
||||
other => Err(format!("expected text, got {other:?}")),
|
||||
},
|
||||
Type::Int => match value {
|
||||
CellValue::Integer(n) => Ok(Cell::Plain(n.to_string())),
|
||||
other => Err(format!("expected int, got {other:?}")),
|
||||
},
|
||||
Type::Real => match value {
|
||||
CellValue::Real(f) => Ok(Cell::Plain(format_real(*f))),
|
||||
other => Err(format!("expected real, got {other:?}")),
|
||||
},
|
||||
Type::Decimal => match value {
|
||||
// Decimals are stored as TEXT to preserve precision.
|
||||
CellValue::Text(s) if s.is_empty() => Ok(Cell::Quoted(String::new())),
|
||||
CellValue::Text(s) => Ok(Cell::Plain(s.clone())),
|
||||
other => Err(format!("expected decimal (text), got {other:?}")),
|
||||
},
|
||||
Type::Bool => match value {
|
||||
CellValue::Integer(0) => Ok(Cell::Plain("false".to_string())),
|
||||
CellValue::Integer(1) => Ok(Cell::Plain("true".to_string())),
|
||||
other => Err(format!("expected bool (0 or 1), got {other:?}")),
|
||||
},
|
||||
Type::Date | Type::DateTime => match value {
|
||||
CellValue::Text(s) if s.is_empty() => Ok(Cell::Quoted(String::new())),
|
||||
CellValue::Text(s) => Ok(Cell::Plain(s.clone())),
|
||||
other => Err(format!("expected date/datetime (text), got {other:?}")),
|
||||
},
|
||||
Type::Blob => match value {
|
||||
CellValue::Blob(bytes) => Ok(Cell::Plain(base64::engine::general_purpose::STANDARD.encode(bytes))),
|
||||
other => Err(format!("expected blob, got {other:?}")),
|
||||
},
|
||||
Type::Serial => match value {
|
||||
CellValue::Integer(n) => Ok(Cell::Plain(n.to_string())),
|
||||
other => Err(format!("expected serial (int), got {other:?}")),
|
||||
},
|
||||
Type::ShortId => match value {
|
||||
CellValue::Text(s) if s.is_empty() => Ok(Cell::Quoted(String::new())),
|
||||
CellValue::Text(s) => Ok(Cell::Plain(s.clone())),
|
||||
other => Err(format!("expected shortid (text), got {other:?}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn format_real(f: f64) -> String {
|
||||
if f.is_nan() {
|
||||
"nan".to_string()
|
||||
} else if f.is_infinite() {
|
||||
if f > 0.0 { "inf".to_string() } else { "-inf".to_string() }
|
||||
} else {
|
||||
// Default `{}` formatting on f64 emits a shortest
|
||||
// round-tripping decimal — exactly what the ADR asks
|
||||
// for.
|
||||
format!("{f}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::persistence::ColumnSchema;
|
||||
|
||||
fn col(name: &str, ty: Type) -> ColumnSchema {
|
||||
ColumnSchema { name: name.to_string(), user_type: ty }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_table_emits_header_only() {
|
||||
let body = serialize_table(&TableSnapshot {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![col("id", Type::Serial), col("Name", Type::Text)],
|
||||
rows: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(body).unwrap(), "id,Name\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_is_empty_unquoted_field() {
|
||||
let body = serialize_table(&TableSnapshot {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("Name", Type::Text)],
|
||||
rows: vec![vec![CellValue::Null]],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(body).unwrap(), "Name\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_double_quoted() {
|
||||
let body = serialize_table(&TableSnapshot {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("Name", Type::Text)],
|
||||
rows: vec![vec![CellValue::Text(String::new())]],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(body).unwrap(), "Name\n\"\"\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_comma_or_quote_is_rfc4180_quoted() {
|
||||
let body = serialize_table(&TableSnapshot {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("Name", Type::Text)],
|
||||
rows: vec![
|
||||
vec![CellValue::Text("hello, world".to_string())],
|
||||
vec![CellValue::Text("she said \"hi\"".to_string())],
|
||||
],
|
||||
})
|
||||
.unwrap();
|
||||
let s = String::from_utf8(body).unwrap();
|
||||
assert!(s.contains("\"hello, world\""));
|
||||
assert!(s.contains("\"she said \"\"hi\"\"\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ints_and_reals_round_trip_simply() {
|
||||
let body = serialize_table(&TableSnapshot {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("n", Type::Int), col("r", Type::Real)],
|
||||
rows: vec![
|
||||
vec![CellValue::Integer(42), CellValue::Real(std::f64::consts::PI)],
|
||||
vec![CellValue::Integer(-7), CellValue::Real(0.0)],
|
||||
],
|
||||
})
|
||||
.unwrap();
|
||||
let s = String::from_utf8(body).unwrap();
|
||||
let lines: Vec<&str> = s.trim_end().lines().collect();
|
||||
assert_eq!(lines[0], "n,r");
|
||||
assert!(lines[1].starts_with("42,"));
|
||||
assert_eq!(lines[2], "-7,0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bools_use_words_not_digits() {
|
||||
let body = serialize_table(&TableSnapshot {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("b", Type::Bool)],
|
||||
rows: vec![
|
||||
vec![CellValue::Integer(1)],
|
||||
vec![CellValue::Integer(0)],
|
||||
],
|
||||
})
|
||||
.unwrap();
|
||||
let s = String::from_utf8(body).unwrap();
|
||||
assert_eq!(s, "b\ntrue\nfalse\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobs_use_base64() {
|
||||
let body = serialize_table(&TableSnapshot {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("blob", Type::Blob)],
|
||||
rows: vec![vec![CellValue::Blob(b"hello".to_vec())]],
|
||||
})
|
||||
.unwrap();
|
||||
let s = String::from_utf8(body).unwrap();
|
||||
assert!(s.contains("aGVsbG8="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_and_datetimes_pass_through() {
|
||||
let body = serialize_table(&TableSnapshot {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("d", Type::Date), col("ts", Type::DateTime)],
|
||||
rows: vec![vec![
|
||||
CellValue::Text("2026-05-07".to_string()),
|
||||
CellValue::Text("2026-05-07T14:30:12Z".to_string()),
|
||||
]],
|
||||
})
|
||||
.unwrap();
|
||||
let s = String::from_utf8(body).unwrap();
|
||||
assert!(s.contains("2026-05-07,2026-05-07T14:30:12Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_width_mismatch_errors() {
|
||||
let err = serialize_table(&TableSnapshot {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("a", Type::Int), col("b", Type::Int)],
|
||||
rows: vec![vec![CellValue::Integer(1)]],
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("row width"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Append-only `history.log` writer (ADR-0015 §5).
|
||||
//!
|
||||
//! Format: one record per line, three pipe-separated fields:
|
||||
//!
|
||||
//! ```text
|
||||
//! 2026-05-07T14:30:12Z|ok|create table Customers with pk id:serial
|
||||
//! ```
|
||||
//!
|
||||
//! Status is always `ok` in v1; failed commands are not
|
||||
//! recorded. The status field is kept in the line shape so
|
||||
//! future use cases can carry additional values without a
|
||||
//! format break.
|
||||
//!
|
||||
//! Newlines inside the command (which do not yet appear, but
|
||||
//! will when multi-line input I1 lands) are escaped to a
|
||||
//! literal `\n`.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::PersistenceError;
|
||||
|
||||
/// Format a single log record. Pure; no I/O.
|
||||
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
|
||||
let escaped = escape_command(command_text);
|
||||
format!("{timestamp_iso}|ok|{escaped}\n")
|
||||
}
|
||||
|
||||
/// Append `line` (which already ends in `\n`) to the file at
|
||||
/// `path`. Creates the file if it doesn't exist. fsyncs after
|
||||
/// the write so a power-cut doesn't lose the latest entry.
|
||||
pub(super) fn append(path: &Path, line: &str) -> Result<(), PersistenceError> {
|
||||
let mut f = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|source| PersistenceError::Io {
|
||||
operation: "open",
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
f.write_all(line.as_bytes())
|
||||
.map_err(|source| PersistenceError::Io {
|
||||
operation: "write",
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
f.sync_all().map_err(|source| PersistenceError::Io {
|
||||
operation: "fsync",
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn escape_command(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Current UTC time as ISO-8601 with second precision and a
|
||||
/// `Z` suffix.
|
||||
pub(super) fn utc_iso8601_now() -> String {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
iso8601_from_unix_secs(secs)
|
||||
}
|
||||
|
||||
fn iso8601_from_unix_secs(secs: i64) -> String {
|
||||
let day_secs = secs.rem_euclid(86_400);
|
||||
let h = day_secs / 3600;
|
||||
let m = (day_secs % 3600) / 60;
|
||||
let s = day_secs % 60;
|
||||
let (y, mo, d) = ymd_from_unix_secs(secs);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
const fn ymd_from_unix_secs(secs: i64) -> (u32, u32, u32) {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y as u32, m as u32, d as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn record_format() {
|
||||
let line = format_record(
|
||||
"create table Customers with pk id:serial",
|
||||
"2026-05-07T14:30:12Z".to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
line,
|
||||
"2026-05-07T14:30:12Z|ok|create table Customers with pk id:serial\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newlines_in_command_are_escaped() {
|
||||
let line = format_record("foo\nbar", "T".to_string());
|
||||
assert_eq!(line, "T|ok|foo\\nbar\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_is_escaped() {
|
||||
let line = format_record("a\\b", "T".to_string());
|
||||
assert_eq!(line, "T|ok|a\\\\b\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_format_is_well_formed() {
|
||||
let s = utc_iso8601_now();
|
||||
// YYYY-MM-DDTHH:MM:SSZ has length 20.
|
||||
assert_eq!(s.len(), 20);
|
||||
assert!(s.ends_with('Z'));
|
||||
assert_eq!(&s[4..5], "-");
|
||||
assert_eq!(&s[10..11], "T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_known_seconds() {
|
||||
assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_creates_and_grows_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
|
||||
append(&path, "first|ok|a\n").unwrap();
|
||||
append(&path, "second|ok|b\n").unwrap();
|
||||
let body = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(body, "first|ok|a\nsecond|ok|b\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! 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::fs;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::project::{DATA_DIR, HISTORY_LOG, PROJECT_YAML};
|
||||
|
||||
mod csv_io;
|
||||
mod history;
|
||||
mod yaml;
|
||||
|
||||
/// Owns persistence to a single project on disk. Cheap to
|
||||
/// move; the db worker holds one instance for its lifetime.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Persistence {
|
||||
project_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PersistenceError {
|
||||
#[error("could not {operation} `{path}`: {source}")]
|
||||
Io {
|
||||
operation: &'static str,
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("could not encode {kind} for `{path}`: {message}")]
|
||||
Encode {
|
||||
kind: &'static str,
|
||||
path: PathBuf,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
pub tables: Vec<TableSchema>,
|
||||
pub relationships: Vec<RelationshipSchema>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TableSchema {
|
||||
pub name: String,
|
||||
pub primary_key: Vec<String>,
|
||||
pub columns: Vec<ColumnSchema>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ColumnSchema {
|
||||
pub name: String,
|
||||
pub user_type: Type,
|
||||
}
|
||||
|
||||
#[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/<table>.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<ColumnSchema>,
|
||||
pub rows: Vec<Vec<CellValue>>,
|
||||
}
|
||||
|
||||
/// 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<u8>),
|
||||
}
|
||||
|
||||
impl Persistence {
|
||||
#[must_use]
|
||||
pub const fn new(project_path: PathBuf) -> Self {
|
||||
Self { project_path }
|
||||
}
|
||||
|
||||
/// 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/<table>.csv` from a table snapshot. Atomic
|
||||
/// per file. Creates the `data/` directory if missing
|
||||
/// (tolerant of fresh projects).
|
||||
pub fn write_table_data(&self, table: &TableSnapshot) -> Result<(), PersistenceError> {
|
||||
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/<table>.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 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `body` to `path` atomically via temp file + fsync +
|
||||
/// rename. The temp file is named `<final>.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(),
|
||||
tables: vec![],
|
||||
relationships: 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,
|
||||
}],
|
||||
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)"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Hand-rolled `project.yaml` writer (ADR-0015 §3).
|
||||
//!
|
||||
//! The schema YAML uses a small, fixed set of structures —
|
||||
//! tables, columns, relationships — and the values it carries
|
||||
//! are all known-safe (identifiers from the DSL, types from
|
||||
//! the fixed `Type` enum, action names from `ReferentialAction`).
|
||||
//! Hand-rolling the writer avoids pulling a YAML serializer
|
||||
//! dep just for this file. The reader (Iteration 3) will use
|
||||
//! a real YAML parser.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
|
||||
use super::{ColumnSchema, RelationshipSchema, SchemaSnapshot, TableSchema};
|
||||
|
||||
/// Serialize a `SchemaSnapshot` to a `project.yaml` body.
|
||||
#[must_use]
|
||||
pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String {
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "version: 1");
|
||||
let _ = writeln!(out, "project:");
|
||||
let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at));
|
||||
|
||||
if schema.tables.is_empty() {
|
||||
let _ = writeln!(out, "tables: []");
|
||||
} else {
|
||||
let _ = writeln!(out, "tables:");
|
||||
for table in &schema.tables {
|
||||
write_table(&mut out, table);
|
||||
}
|
||||
}
|
||||
|
||||
if schema.relationships.is_empty() {
|
||||
let _ = writeln!(out, "relationships: []");
|
||||
} else {
|
||||
let _ = writeln!(out, "relationships:");
|
||||
for rel in &schema.relationships {
|
||||
write_relationship(&mut out, rel);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn write_table(out: &mut String, table: &TableSchema) {
|
||||
let _ = writeln!(out, " - name: {}", quote_if_needed(&table.name));
|
||||
write!(out, " primary_key: [").unwrap();
|
||||
for (i, key) in table.primary_key.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
out.push_str("e_if_needed(key));
|
||||
}
|
||||
let _ = writeln!(out, "]");
|
||||
let _ = writeln!(out, " columns:");
|
||||
for col in &table.columns {
|
||||
write_column(out, col);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_column(out: &mut String, col: &ColumnSchema) {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" - {{ name: {}, type: {} }}",
|
||||
quote_if_needed(&col.name),
|
||||
col.user_type.keyword(),
|
||||
);
|
||||
}
|
||||
|
||||
fn write_relationship(out: &mut String, rel: &RelationshipSchema) {
|
||||
let _ = writeln!(out, " - name: {}", quote_if_needed(&rel.name));
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" parent: {{ table: {}, column: {} }}",
|
||||
quote_if_needed(&rel.parent_table),
|
||||
quote_if_needed(&rel.parent_column),
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" child: {{ table: {}, column: {} }}",
|
||||
quote_if_needed(&rel.child_table),
|
||||
quote_if_needed(&rel.child_column),
|
||||
);
|
||||
let _ = writeln!(out, " on_delete: {}", action_keyword(rel.on_delete));
|
||||
let _ = writeln!(out, " on_update: {}", action_keyword(rel.on_update));
|
||||
}
|
||||
|
||||
const fn action_keyword(action: ReferentialAction) -> &'static str {
|
||||
match action {
|
||||
ReferentialAction::NoAction => "no_action",
|
||||
ReferentialAction::Restrict => "restrict",
|
||||
ReferentialAction::SetNull => "set_null",
|
||||
ReferentialAction::Cascade => "cascade",
|
||||
}
|
||||
}
|
||||
|
||||
/// Quote a string for safe inclusion as a YAML scalar.
|
||||
///
|
||||
/// We're conservative: anything not made of safe characters
|
||||
/// (alphanumerics, `_`, `-`, `:` for ISO timestamps, `.`)
|
||||
/// gets double-quoted with `"` and `\` escaped. Common
|
||||
/// identifiers from the DSL (which restricts to alnum + `_`)
|
||||
/// pass through unquoted, which keeps the YAML pleasantly
|
||||
/// readable.
|
||||
fn quote_if_needed(s: &str) -> String {
|
||||
if needs_quoting(s) {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_quoting(s: &str) -> bool {
|
||||
if s.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// YAML reserves several leading characters and the empty
|
||||
// string. Be defensive on anything outside the safe set.
|
||||
let first = s.chars().next().unwrap();
|
||||
if !is_safe_yaml_char(first) || first == '-' {
|
||||
return true;
|
||||
}
|
||||
// Scalar text that looks like a YAML keyword needs quoting
|
||||
// even if every character is safe.
|
||||
if matches!(s, "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off") {
|
||||
return true;
|
||||
}
|
||||
s.chars().any(|c| !is_safe_yaml_char(c))
|
||||
}
|
||||
|
||||
const fn is_safe_yaml_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
fn snapshot() -> SchemaSnapshot {
|
||||
SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
tables: vec![
|
||||
TableSchema {
|
||||
name: "Customers".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "id".to_string(), user_type: Type::Serial },
|
||||
ColumnSchema { name: "Name".to_string(), user_type: Type::Text },
|
||||
],
|
||||
},
|
||||
TableSchema {
|
||||
name: "Orders".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "id".to_string(), user_type: Type::Serial },
|
||||
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int },
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: vec![RelationshipSchema {
|
||||
name: "Customers_id_to_Orders_CustId".to_string(),
|
||||
parent_table: "Customers".to_string(),
|
||||
parent_column: "id".to_string(),
|
||||
child_table: "Orders".to_string(),
|
||||
child_column: "CustId".to_string(),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_expected_yaml_for_full_schema() {
|
||||
let body = serialize_schema(&snapshot());
|
||||
// Spot-check structural lines rather than asserting on
|
||||
// the whole blob — easier to read in failure output.
|
||||
assert!(body.contains("version: 1"));
|
||||
assert!(body.contains("created_at: 2026-05-07T14:30:12Z"));
|
||||
assert!(body.contains("- name: Customers"));
|
||||
assert!(body.contains("primary_key: [id]"));
|
||||
assert!(body.contains("{ name: id, type: serial }"));
|
||||
assert!(body.contains("{ name: Name, type: text }"));
|
||||
assert!(body.contains("- name: Customers_id_to_Orders_CustId"));
|
||||
assert!(body.contains("parent: { table: Customers, column: id }"));
|
||||
assert!(body.contains("child: { table: Orders, column: CustId }"));
|
||||
assert!(body.contains("on_delete: cascade"));
|
||||
assert!(body.contains("on_update: no_action"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_lists_use_inline_brackets() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
tables: vec![],
|
||||
relationships: vec![],
|
||||
});
|
||||
assert!(body.contains("tables: []"));
|
||||
assert!(body.contains("relationships: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_yaml_keywords_used_as_identifiers() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
tables: vec![TableSchema {
|
||||
name: "true".to_string(), // reserved keyword
|
||||
primary_key: vec!["id".to_string()],
|
||||
columns: vec![ColumnSchema {
|
||||
name: "yes".to_string(),
|
||||
user_type: Type::Bool,
|
||||
}],
|
||||
}],
|
||||
relationships: vec![],
|
||||
});
|
||||
assert!(body.contains("- name: \"true\""));
|
||||
assert!(body.contains("{ name: \"yes\", type: bool }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_strings_with_unsafe_characters() {
|
||||
assert_eq!(quote_if_needed("My Project"), "\"My Project\"");
|
||||
assert_eq!(quote_if_needed("with\"quote"), "\"with\\\"quote\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_compound_primary_key_order() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
tables: vec![TableSchema {
|
||||
name: "Items".to_string(),
|
||||
primary_key: vec!["a".to_string(), "b".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "a".to_string(), user_type: Type::Int },
|
||||
ColumnSchema { name: "b".to_string(), user_type: Type::Int },
|
||||
],
|
||||
}],
|
||||
relationships: vec![],
|
||||
});
|
||||
assert!(body.contains("primary_key: [a, b]"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user