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