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,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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user