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:
claude@clouddev1
2026-05-07 21:09:15 +00:00
parent 601d3b6c51
commit 5c076f6d8f
15 changed files with 2275 additions and 213 deletions
+46 -11
View File
@@ -106,6 +106,11 @@ pub struct App {
/// during very-early startup before the runtime has opened a
/// project; otherwise always populated.
pub project_name: Option<String>,
/// Set when a fatal persistence failure has occurred
/// (ADR-0015 §8). The runtime reads this after the event
/// loop exits and prints it to stderr post-teardown so the
/// banner remains above the shell prompt.
pub fatal_message: Option<String>,
}
const PAGE_SCROLL_LINES: usize = 5;
@@ -136,6 +141,7 @@ impl App {
last_output_visible: 0,
last_output_total_wrapped: 0,
project_name: None,
fatal_message: None,
}
}
@@ -208,6 +214,20 @@ impl App {
self.tables = tables;
Vec::new()
}
AppEvent::PersistenceFatal {
operation,
path,
message,
} => {
let banner = format!(
"FATAL: failed to {operation} `{}` — {message}. \
Quitting; investigate and restart.",
path.display(),
);
self.note_error(banner.clone());
self.fatal_message = Some(banner);
vec![Action::Quit]
}
}
}
@@ -469,7 +489,10 @@ impl App {
kind: OutputKind::Echo,
mode_at_submission: submission_mode,
});
vec![Action::ExecuteDsl(cmd)]
vec![Action::ExecuteDsl {
command: cmd,
source: input.to_string(),
}]
}
Err(ParseError::Empty) => Vec::new(),
Err(err) => {
@@ -820,16 +843,20 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "create table Customers with pk");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
let Action::ExecuteDsl { command, .. } = &actions[0] else {
panic!("expected ExecuteDsl, got {:?}", actions[0]);
};
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::CreateTable {
command,
&Command::CreateTable {
name: "Customers".to_string(),
columns: vec![crate::dsl::ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
})]
},
);
// The input is echoed back as a "running:" notice so the
// user sees something happened while the DB worker runs.
@@ -1071,11 +1098,15 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "drop table T");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
let Action::ExecuteDsl { command, .. } = &actions[0] else {
panic!("expected ExecuteDsl, got {:?}", actions[0]);
};
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::DropTable {
name: "T".to_string()
})]
command,
&Command::DropTable {
name: "T".to_string(),
},
);
}
@@ -1425,13 +1456,17 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "add column to table T: Name (text)");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
let Action::ExecuteDsl { command, .. } = &actions[0] else {
panic!("expected ExecuteDsl, got {:?}", actions[0]);
};
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::AddColumn {
command,
&Command::AddColumn {
table: "T".to_string(),
column: "Name".to_string(),
ty: Type::Text,
})]
},
);
}
}