feat: ADR-0034 — history journal records err + replay parses/filters the journal
Replay (§3): run_replay parses <ts>|<status>|<source> journal records — runs ok, skips non-ok — while still accepting bare .commands scripts (prefix-detected so a | inside a bare command isn't misread). Fixes replay history.log, which died on line 1. Journal failures (§1/§2): failed commands are recorded err via a new Action::JournalFailure, emitted by the pure-sync App for both parse failures and worker-execution failures (runtime appends best-effort, never fatal). Hydration reads all records so typo'd/rejected commands are recallable across sessions. Amendment 1 — replay filters app-lifecycle commands: a working replay history.log exposed that the journal also records save as/load/new/export/import/rebuild/mode (which would panic the worker dispatch or abort replay). Replay now re-applies only schema/data writes and skips every app-lifecycle command + nested replay, classified by entry word so modal/incomplete forms (save as, bare mode) and quit skip uniformly rather than aborting. All skips continue (reversing the nested-replay refusal); import and nested replay warn. replay.error_nested removed; replay.skipped_import/_replay added; ReplayCompleted carries warnings. requirements.md U3/U4 updated; app-command runtime-failure journalling tracked as a follow-up. 1659 passing / 0 failing / 0 skipped / 1 ignored. Clippy clean.
This commit is contained in:
+117
-7
@@ -455,9 +455,14 @@ impl App {
|
||||
command,
|
||||
error,
|
||||
facts,
|
||||
source,
|
||||
} => {
|
||||
self.handle_dsl_failure(&command, error, facts);
|
||||
Vec::new()
|
||||
// ADR-0034 §1/§2: an execution failure is journalled
|
||||
// `err` so it is recallable across sessions (the
|
||||
// worker only journals successful commands). The App
|
||||
// emits the intent; the runtime does the append.
|
||||
vec![Action::JournalFailure { source }]
|
||||
}
|
||||
AppEvent::TablesRefreshed(tables) => {
|
||||
trace!(count = tables.len(), "tables refreshed");
|
||||
@@ -556,12 +561,22 @@ impl App {
|
||||
self.note_error(crate::t!("project.export_failed", error = error));
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::ReplayCompleted { path, count } => {
|
||||
AppEvent::ReplayCompleted {
|
||||
path,
|
||||
count,
|
||||
warnings,
|
||||
} => {
|
||||
self.note_system(crate::t!(
|
||||
"replay.completed",
|
||||
path = path,
|
||||
count = count
|
||||
));
|
||||
// ADR-0034: surface `[skip]` warnings for app-lifecycle
|
||||
// commands whose omission can leave the replayed state
|
||||
// incomplete (`import`, nested `replay`).
|
||||
for warning in warnings {
|
||||
self.note_system(warning);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::ReplayFailed {
|
||||
@@ -1207,7 +1222,14 @@ impl App {
|
||||
if let ParseError::Invalid { .. } = &err {
|
||||
self.note_error(render_usage_block(input));
|
||||
}
|
||||
Vec::new()
|
||||
// ADR-0034 §1/§2: a submitted line that failed to
|
||||
// parse is journalled `err` so it is recallable
|
||||
// across sessions (the same `source` an `ok`
|
||||
// command would record). The runtime does the
|
||||
// append; the App only emits the intent.
|
||||
vec![Action::JournalFailure {
|
||||
source: input.to_string(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2308,7 +2330,12 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "create table Customers");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
// A definite parse error journals `err` (ADR-0034) and does
|
||||
// not dispatch a command to the worker.
|
||||
assert!(
|
||||
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
||||
"expected only a JournalFailure, no dispatch; got {actions:?}",
|
||||
);
|
||||
// Parse-error rendering is now multi-line (ADR-0021):
|
||||
// caret + "parse error: …" + "usage: …" — the test
|
||||
// checks that some error line mentions `with pk`.
|
||||
@@ -2328,7 +2355,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "frobulate widgets");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
assert!(
|
||||
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
||||
"a definite parse error journals err without dispatching; got {actions:?}",
|
||||
);
|
||||
let has_parse_error = app
|
||||
.output
|
||||
.iter()
|
||||
@@ -2351,7 +2381,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "insert into T values (1, 2), (3, 4)");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty(), "the bad line must not dispatch");
|
||||
assert!(
|
||||
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
||||
"the bad line journals err but must not dispatch; got {actions:?}",
|
||||
);
|
||||
let has_pointer = app
|
||||
.output
|
||||
.iter()
|
||||
@@ -2663,6 +2696,7 @@ mod tests {
|
||||
app.update(AppEvent::ReplayCompleted {
|
||||
path: "seed.commands".to_string(),
|
||||
count: 4,
|
||||
warnings: Vec::new(),
|
||||
});
|
||||
let last = app.output.back().unwrap();
|
||||
assert_eq!(last.kind, OutputKind::System);
|
||||
@@ -2671,6 +2705,30 @@ mod tests {
|
||||
assert!(last.text.contains("seed.commands"), "{}", last.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_completed_event_renders_skip_warnings() {
|
||||
// ADR-0034 Amendment 1: `[skip]` warnings (import / nested
|
||||
// replay) surface in the output after the summary line.
|
||||
let mut app = App::new();
|
||||
app.update(AppEvent::ReplayCompleted {
|
||||
path: "history.log".to_string(),
|
||||
count: 2,
|
||||
warnings: vec![
|
||||
"[skip] line 3: `import a.zip` — replay does not re-import".to_string(),
|
||||
"[skip] line 7: nested `replay x` — its commands were not replayed".to_string(),
|
||||
],
|
||||
});
|
||||
let text: String = app
|
||||
.output
|
||||
.iter()
|
||||
.map(|l| l.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("[ok] replay"), "summary present:\n{text}");
|
||||
assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}");
|
||||
assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_failed_event_renders_line_number_and_command_echo() {
|
||||
let mut app = App::new();
|
||||
@@ -2731,6 +2789,7 @@ mod tests {
|
||||
kind: crate::db::SqliteErrorKind::NoSuchTable,
|
||||
},
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
});
|
||||
let last = app.output.back().unwrap();
|
||||
assert_eq!(last.kind, OutputKind::Error);
|
||||
@@ -2788,6 +2847,7 @@ mod tests {
|
||||
command: cmd,
|
||||
error: err,
|
||||
facts,
|
||||
source: String::new(),
|
||||
});
|
||||
let body = app
|
||||
.output
|
||||
@@ -2836,6 +2896,7 @@ mod tests {
|
||||
command: cmd,
|
||||
error: err,
|
||||
facts,
|
||||
source: String::new(),
|
||||
});
|
||||
let body = app
|
||||
.output
|
||||
@@ -2867,6 +2928,7 @@ mod tests {
|
||||
command: cmd.clone(),
|
||||
error: err(),
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
});
|
||||
let verbose_text = app
|
||||
.output
|
||||
@@ -2886,6 +2948,7 @@ mod tests {
|
||||
command: cmd,
|
||||
error: err(),
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
});
|
||||
let short_text = app
|
||||
.output
|
||||
@@ -2920,7 +2983,10 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "add column to table T: c (varchar)");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
assert!(
|
||||
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
||||
"expected only a JournalFailure, no dispatch; got {actions:?}",
|
||||
);
|
||||
let mentions_varchar = app
|
||||
.output
|
||||
.iter()
|
||||
@@ -3193,6 +3259,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submitting_an_unparseable_line_emits_journal_failure() {
|
||||
// ADR-0034 §1/§2: a submitted line that fails to parse is
|
||||
// journalled `err` (recallable across sessions). The
|
||||
// pure-sync App emits the intent; the runtime does the I/O.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "florp glorp");
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
matches!(
|
||||
actions.as_slice(),
|
||||
[Action::JournalFailure { source }] if source == "florp glorp"
|
||||
),
|
||||
"expected JournalFailure for the typo'd line; got {actions:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_failure_event_emits_journal_failure_carrying_the_source() {
|
||||
// ADR-0034 §1/§2: an execution failure (the worker rejected
|
||||
// a parsed command) is journalled `err` too. The runtime
|
||||
// forwards the source on `DslFailed`; the App turns it into
|
||||
// a `JournalFailure` action.
|
||||
let mut app = App::new();
|
||||
let actions = app.update(AppEvent::DslFailed {
|
||||
command: Command::DropTable {
|
||||
name: "Ghost".to_string(),
|
||||
},
|
||||
error: crate::db::DbError::Sqlite {
|
||||
message: "no such table: Ghost".to_string(),
|
||||
kind: crate::db::SqliteErrorKind::NoSuchTable,
|
||||
},
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: "drop table Ghost".to_string(),
|
||||
});
|
||||
assert!(
|
||||
matches!(
|
||||
actions.as_slice(),
|
||||
[Action::JournalFailure { source }] if source == "drop table Ghost"
|
||||
),
|
||||
"expected JournalFailure carrying the source; got {actions:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_skips_consecutive_duplicates() {
|
||||
let mut app = App::new();
|
||||
|
||||
Reference in New Issue
Block a user