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:
+40
-26
@@ -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,
|
||||
})]
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user