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:
@@ -182,8 +182,13 @@ fn import_with_empty_target_after_as_errors() {
|
||||
let actions = submit(&mut app);
|
||||
// "as " trailing whitespace is trimmed by .split_once + .trim,
|
||||
// making the as-target empty. We surface this as a usage
|
||||
// error rather than silently importing without a target.
|
||||
assert!(actions.is_empty());
|
||||
// error rather than silently importing without a target. The
|
||||
// failed line is journalled `err` (ADR-0034) but no import
|
||||
// dispatches.
|
||||
assert!(
|
||||
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
||||
"expected only a JournalFailure, no import dispatch; got {actions:?}",
|
||||
);
|
||||
// The friendly parse-error rendering produces multiple
|
||||
// output lines (caret, message, usage). Scan for the anchor
|
||||
// phrase rather than asserting on the final line. The
|
||||
|
||||
@@ -155,6 +155,31 @@ fn read_recent_history_returns_appended_entries_in_order() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydration_reads_both_ok_and_err_records() {
|
||||
// ADR-0034 §1/§2: failed commands are journalled `err`, and
|
||||
// input-history hydration reads ALL records (ok + err) so a
|
||||
// typo'd / rejected command from a previous session is
|
||||
// recallable after restart — matching the in-session ring's
|
||||
// "record everything" behaviour.
|
||||
let tmp = tempdir();
|
||||
let project = Project::create_temp(tmp.path()).unwrap();
|
||||
let p = Persistence::new(project.path().to_path_buf());
|
||||
p.append_history("create table A with pk").unwrap();
|
||||
p.append_history_failure("insert into A (1, 2, 3)").unwrap();
|
||||
p.append_history("show data A").unwrap();
|
||||
let entries = p.read_recent_history(10).unwrap();
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
"create table A with pk".to_string(),
|
||||
"insert into A (1, 2, 3)".to_string(), // the err record is recalled
|
||||
"show data A".to_string(),
|
||||
],
|
||||
"hydration includes the err record",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_history_replaces_in_memory_history() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -34,15 +34,16 @@ fn submit(app: &mut App) -> Vec<Action> {
|
||||
}
|
||||
|
||||
/// Run `input` through the app and return every error-kind
|
||||
/// output line. Asserts the submission produced no actions
|
||||
/// (i.e. the parse failed).
|
||||
/// output line. Asserts the submission parse-failed — which now
|
||||
/// emits exactly a `JournalFailure` (ADR-0034: the failed line is
|
||||
/// journalled `err`) and dispatches no command to the worker.
|
||||
fn error_lines_for(input: &str) -> Vec<String> {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, input);
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
actions.is_empty(),
|
||||
"expected parse failure (no actions) for {input:?}, got {actions:?}",
|
||||
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
||||
"expected parse failure (only a JournalFailure) for {input:?}, got {actions:?}",
|
||||
);
|
||||
app.output
|
||||
.iter()
|
||||
|
||||
+138
-9
@@ -113,6 +113,123 @@ fn replay_three_lines_dispatches_three_commands() {
|
||||
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
|
||||
// ADR-0034 §3 + Problem 3 (handoff-34 §4): `replay history.log`
|
||||
// must work. The journal is the pipe format
|
||||
// `<iso8601>|<status>|<source>`; replay extracts `<source>`, runs
|
||||
// `ok` records, and skips `err` ones (like blank / `#` lines — a
|
||||
// skipped failure is not a replay failure).
|
||||
//
|
||||
// This is the ADR-0034 headline reproduction. It is RED before the
|
||||
// fix: today `run_replay` feeds the whole `2026-…|ok|…` line to the
|
||||
// parser, which dies on line 1 (the timestamp is not a command).
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(
|
||||
project.path(),
|
||||
"history.log",
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
|
||||
2026-05-24T10:00:01Z|ok|add column T: v (text)\n\
|
||||
2026-05-24T10:00:02Z|err|insert into T values (1, 2, 3, 4)\n\
|
||||
2026-05-24T10:00:03Z|ok|insert into T (id, v) values (1, 'alpha')\n",
|
||||
);
|
||||
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
// Three `ok` records replayed; the `err` record is skipped (not
|
||||
// counted, not a failure).
|
||||
assert_completed(&events, 3);
|
||||
|
||||
let data_result = rt()
|
||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
||||
.expect("query_data");
|
||||
assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied");
|
||||
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_skips_app_lifecycle_commands_silently() {
|
||||
// ADR-0034: a real `history.log` contains app-lifecycle commands
|
||||
// (`save as` / `load` / `new` / `export` / `mode` / `rebuild` …).
|
||||
// Replay skips them — they are session navigation, not schema/data
|
||||
// reconstruction, and the worker dispatch cannot run them (it would
|
||||
// panic on a parsed app command, or abort on the modal forms that
|
||||
// don't parse). These skip SILENTLY (no warning).
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
// Every silent-skip app-lifecycle form, including the modal forms
|
||||
// that don't parse on the command line (`save as` / `load` / `new`),
|
||||
// the bare incomplete form (`mode`), and the safety-critical `quit`
|
||||
// (a journalled quit must NOT quit during replay). None may abort;
|
||||
// none warns.
|
||||
write_script(
|
||||
project.path(),
|
||||
"history.log",
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
|
||||
2026-05-24T10:00:01Z|ok|save as backup\n\
|
||||
2026-05-24T10:00:02Z|ok|load other\n\
|
||||
2026-05-24T10:00:03Z|ok|new scratch\n\
|
||||
2026-05-24T10:00:04Z|ok|mode advanced\n\
|
||||
2026-05-24T10:00:05Z|ok|mode\n\
|
||||
2026-05-24T10:00:06Z|ok|messages verbose\n\
|
||||
2026-05-24T10:00:07Z|ok|export out.zip\n\
|
||||
2026-05-24T10:00:08Z|ok|rebuild\n\
|
||||
2026-05-24T10:00:09Z|ok|help\n\
|
||||
2026-05-24T10:00:10Z|ok|quit\n\
|
||||
2026-05-24T10:00:11Z|ok|add column T: v (text)\n\
|
||||
2026-05-24T10:00:12Z|ok|insert into T (id, v) values (1, 'alpha')\n",
|
||||
);
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
// Three data/schema commands ran; every app-lifecycle line was
|
||||
// skipped silently (no panic, no abort, no warnings, no quit).
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
assert_eq!(*count, 3, "only the 3 write commands ran; events: {events:?}");
|
||||
assert!(warnings.is_empty(), "these skips are silent; got {warnings:?}");
|
||||
}
|
||||
other => panic!("expected ReplayCompleted, got {other:?}"),
|
||||
}
|
||||
let data_result = rt()
|
||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
||||
.expect("query_data");
|
||||
assert!(
|
||||
data_result.columns.iter().any(|c| c == "v"),
|
||||
"the add-column line applied; columns: {:?}",
|
||||
data_result.columns,
|
||||
);
|
||||
assert_eq!(data_result.rows.len(), 1, "the insert applied");
|
||||
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_skips_import_with_a_warning() {
|
||||
// ADR-0034: `import` is skipped like other app commands, but warns
|
||||
// — skipping it can leave the replayed state incomplete (the
|
||||
// imported data is not reconstructed).
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(
|
||||
project.path(),
|
||||
"history.log",
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
|
||||
2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n",
|
||||
);
|
||||
let events =
|
||||
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
assert_eq!(*count, 1, "only the create ran; events: {events:?}");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("import shared.zip")),
|
||||
"expected an import skip warning; got {warnings:?}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected ReplayCompleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_skips_blank_lines_and_comments() {
|
||||
let data = tempdir();
|
||||
@@ -307,7 +424,12 @@ fn replay_aborts_on_first_runtime_failure_and_reports_line() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_refuses_nested_replay() {
|
||||
fn replay_skips_nested_replay_with_a_warning() {
|
||||
// ADR-0034: a nested `replay` is no longer refused (which would
|
||||
// force a user to hand-edit a journal that happens to contain a
|
||||
// `replay` they once ran). It is skipped — sidestepping the
|
||||
// infinite-loop footgun by construction — and warned about,
|
||||
// because the nested file's commands are not reconstructed.
|
||||
let data = tempdir();
|
||||
let (project, db) = open_project_db(data.path());
|
||||
write_script(project.path(), "inner.commands", "create table T with pk id(int)\n");
|
||||
@@ -320,14 +442,21 @@ fn replay_refuses_nested_replay() {
|
||||
let events = rt().block_on(async {
|
||||
run_replay(&db, project.path(), "outer.commands").await
|
||||
});
|
||||
let failed = assert_failed_at(&events, 2);
|
||||
let AppEvent::ReplayFailed { error, .. } = failed else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
error.contains("nested `replay`"),
|
||||
"expected nested-replay refusal: {error}"
|
||||
);
|
||||
// The outer `create table U` ran; the nested `replay` was
|
||||
// skipped (count 1), with a warning.
|
||||
match events.last().expect("event") {
|
||||
AppEvent::ReplayCompleted { count, warnings, .. } => {
|
||||
assert_eq!(*count, 1, "only the outer create ran; events: {events:?}");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("nested") && w.contains("replay inner.commands")),
|
||||
"expected a nested-replay skip warning; got {warnings:?}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected ReplayCompleted (nested replay skipped), got {other:?}"),
|
||||
}
|
||||
// The nested file's table was NOT created (the replay was skipped).
|
||||
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None, None).await });
|
||||
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-4
@@ -81,9 +81,11 @@ fn simple_mode_select_yields_sql_hint_and_does_not_dispatch() {
|
||||
assert_eq!(app.mode, Mode::Simple);
|
||||
type_str(&mut app, "select * from anywhere");
|
||||
let actions = submit(&mut app);
|
||||
// The failed simple-mode submission is journalled `err`
|
||||
// (ADR-0034) but dispatches no command.
|
||||
assert!(
|
||||
actions.is_empty(),
|
||||
"simple-mode `select` must not produce a dispatch action; got {actions:?}",
|
||||
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
||||
"simple-mode `select` must not dispatch (only journal err); got {actions:?}",
|
||||
);
|
||||
// The error output spans multiple lines (the message and a
|
||||
// caret pointer). The hint catalog key
|
||||
@@ -135,8 +137,8 @@ fn advanced_mode_select_from_internal_table_is_rejected() {
|
||||
type_str(&mut app, "select * from __rdbms_playground_columns");
|
||||
let actions = submit(&mut app);
|
||||
assert!(
|
||||
actions.is_empty(),
|
||||
"internal-table reference must not dispatch; got {actions:?}",
|
||||
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
||||
"internal-table reference must not dispatch (only journal err); got {actions:?}",
|
||||
);
|
||||
let error_text: String = app
|
||||
.output
|
||||
|
||||
@@ -108,7 +108,12 @@ fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
|
||||
let theme = Theme::dark();
|
||||
type_str(&mut app, "hello world");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
// The failed line journals `err` (ADR-0034) but does not echo
|
||||
// or dispatch a command.
|
||||
assert!(
|
||||
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
||||
"expected only a JournalFailure; got {actions:?}",
|
||||
);
|
||||
let rendered = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(
|
||||
rendered.contains("parse error"),
|
||||
@@ -605,6 +610,7 @@ fn dsl_failure_shows_friendly_error_in_output() {
|
||||
kind: rdbms_playground::db::SqliteErrorKind::NoSuchTable,
|
||||
},
|
||||
facts: rdbms_playground::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
});
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user