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
+40 -26
View File
@@ -42,6 +42,20 @@ fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
/// Assert that `actions` is exactly one `Action::ExecuteDsl`
/// whose parsed command equals `expected`. The original source
/// text carried alongside the command is allowed to be
/// anything — tests construct the expected `Command` directly
/// and don't care about the verbatim user input.
#[track_caller]
fn assert_one_execute_dsl(actions: &[Action], expected: &Command) {
assert_eq!(actions.len(), 1, "expected exactly one action; got {actions:?}");
match &actions[0] {
Action::ExecuteDsl { command, .. } => assert_eq!(command, expected),
other => panic!("expected ExecuteDsl, got {other:?}"),
}
}
fn rendered_text(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
@@ -72,16 +86,16 @@ fn typing_then_submitting_a_dsl_command_emits_execute_action() {
);
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::CreateTable {
assert_one_execute_dsl(
&actions,
&Command::CreateTable {
name: "Customers".to_string(),
columns: vec![ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
})]
},
);
assert!(app.input.is_empty(), "input buffer cleared on submit");
let post_render = rendered_text(&mut app, &theme, 80, 24);
@@ -262,7 +276,7 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
}],
primary_key: vec!["id".to_string()],
};
assert_eq!(actions, vec![Action::ExecuteDsl(expected_cmd.clone())]);
assert_one_execute_dsl(&actions, &expected_cmd);
// Runtime would now dispatch and feed back DslSucceeded + TablesRefreshed.
let desc = fake_table("Customers", &[("id", Type::Serial, true)]);
@@ -302,13 +316,13 @@ fn add_column_flow_updates_structure_view() {
type_str(&mut app, "add column to table Customers: Name (text)");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::AddColumn {
assert_one_execute_dsl(
&actions,
&Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
})]
},
);
let updated = fake_table(
@@ -336,11 +350,11 @@ fn drop_table_flow_clears_items_list() {
type_str(&mut app, "drop table Customers");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::DropTable {
name: "Customers".to_string()
})]
assert_one_execute_dsl(
&actions,
&Command::DropTable {
name: "Customers".to_string(),
},
);
app.update(AppEvent::DslSucceeded {
@@ -366,9 +380,9 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade",
);
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::AddRelationship {
assert_one_execute_dsl(
&actions,
&Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_column: "Id".to_string(),
@@ -377,7 +391,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
create_fk: false,
})]
},
);
// The runtime now feeds back the parent (Customers) so the
@@ -470,13 +484,13 @@ fn insert_flow_emits_action_and_renders_data() {
type_str(&mut app, "insert into Customers values ('Alice')");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::Insert {
assert_one_execute_dsl(
&actions,
&Command::Insert {
table: "Customers".to_string(),
columns: None,
values: vec![Value::Text("Alice".to_string())],
})]
},
);
// Simulate the runtime feeding back an InsertResult.
@@ -516,12 +530,12 @@ fn delete_with_all_rows_emits_correct_action() {
let mut app = App::new();
type_str(&mut app, "delete from Customers --all-rows");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::Delete {
assert_one_execute_dsl(
&actions,
&Command::Delete {
table: "Customers".to_string(),
filter: RowFilter::AllRows,
})]
},
);
}