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:
+136
-2
@@ -22,10 +22,26 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::PersistenceError;
|
||||
|
||||
/// Format a single log record. Pure; no I/O.
|
||||
/// Journal-record status tokens (ADR-0034 §1). Kept as named
|
||||
/// constants so the writer and the readers (hydration + replay)
|
||||
/// cannot drift on the spelling.
|
||||
pub(super) const STATUS_OK: &str = "ok";
|
||||
pub(super) const STATUS_ERR: &str = "err";
|
||||
|
||||
/// Format a successful-command record. Pure; no I/O.
|
||||
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
|
||||
format_record_with_status(command_text, timestamp_iso, STATUS_OK)
|
||||
}
|
||||
|
||||
/// Format a record with an explicit status token (ADR-0034 §1).
|
||||
/// Pure; no I/O.
|
||||
pub(super) fn format_record_with_status(
|
||||
command_text: &str,
|
||||
timestamp_iso: String,
|
||||
status: &str,
|
||||
) -> String {
|
||||
let escaped = escape_command(command_text);
|
||||
format!("{timestamp_iso}|ok|{escaped}\n")
|
||||
format!("{timestamp_iso}|{status}|{escaped}\n")
|
||||
}
|
||||
|
||||
/// Read the most-recent `max_n` user-issued command sources
|
||||
@@ -89,6 +105,54 @@ fn parse_record_source(line: &str) -> Option<String> {
|
||||
Some(unescape_command(source))
|
||||
}
|
||||
|
||||
/// A parsed journal record (ADR-0034 §3). `source` is already
|
||||
/// unescaped.
|
||||
pub(super) struct JournalRecord {
|
||||
pub status_is_ok: bool,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// Classify `line` as a journal record or a bare command
|
||||
/// (ADR-0034 §3). Returns `Some(JournalRecord)` only when the
|
||||
/// line begins with a valid `<iso8601-timestamp>|<status>|`
|
||||
/// prefix — so a bare command containing `|` (e.g.
|
||||
/// `select 'a|b' from t`) is `None` (treated as bare by the
|
||||
/// caller) because it does not start with a timestamp. A valid
|
||||
/// timestamp prefix with a non-`ok` (or unrecognised) status is
|
||||
/// still a journal record, reported with `status_is_ok = false`
|
||||
/// so replay skips it rather than mis-running it as a command.
|
||||
pub(super) fn parse_journal_record(line: &str) -> Option<JournalRecord> {
|
||||
let mut parts = line.splitn(3, '|');
|
||||
let ts = parts.next()?;
|
||||
let status = parts.next()?;
|
||||
let source = parts.next()?;
|
||||
if !looks_like_iso8601(ts) {
|
||||
return None;
|
||||
}
|
||||
Some(JournalRecord {
|
||||
status_is_ok: status == STATUS_OK,
|
||||
source: unescape_command(source),
|
||||
})
|
||||
}
|
||||
|
||||
/// True when `s` is exactly an `YYYY-MM-DDTHH:MM:SSZ` timestamp
|
||||
/// — the shape `utc_iso8601_now` emits. Used to distinguish a
|
||||
/// journal record's leading field from a bare command that
|
||||
/// merely contains `|`.
|
||||
fn looks_like_iso8601(s: &str) -> bool {
|
||||
let b = s.as_bytes();
|
||||
if b.len() != 20 {
|
||||
return false;
|
||||
}
|
||||
let digit = |i: usize| b[i].is_ascii_digit();
|
||||
digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-'
|
||||
&& digit(5) && digit(6) && b[7] == b'-'
|
||||
&& digit(8) && digit(9) && b[10] == b'T'
|
||||
&& digit(11) && digit(12) && b[13] == b':'
|
||||
&& digit(14) && digit(15) && b[16] == b':'
|
||||
&& digit(17) && digit(18) && b[19] == b'Z'
|
||||
}
|
||||
|
||||
fn unescape_command(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
@@ -211,6 +275,76 @@ mod tests {
|
||||
assert_eq!(line, "T|ok|foo\\nbar\n");
|
||||
}
|
||||
|
||||
// ---- ADR-0034 §3 — journal-record detection for replay ----
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_ok_extracts_unescaped_source() {
|
||||
let rec = parse_journal_record(
|
||||
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)",
|
||||
)
|
||||
.expect("valid ok journal record");
|
||||
assert!(rec.status_is_ok);
|
||||
assert_eq!(rec.source, "create table T with pk id(int)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_err_is_record_but_not_ok() {
|
||||
// A valid timestamp + `err` status is a journal record (so
|
||||
// replay treats it as skippable), reported `status_is_ok =
|
||||
// false`.
|
||||
let rec = parse_journal_record("2026-05-24T10:00:02Z|err|insert into T values (1)")
|
||||
.expect("valid err journal record");
|
||||
assert!(!rec.status_is_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_unknown_status_is_record_but_not_ok() {
|
||||
// A valid timestamp + an unrecognised status is still a
|
||||
// journal record (ADR-0034 §1 "readers ignore values they
|
||||
// do not recognise"); replay skips it rather than running it.
|
||||
let rec = parse_journal_record("2026-05-24T10:00:02Z|frobnicate|whatever")
|
||||
.expect("valid-ts record with unknown status");
|
||||
assert!(!rec.status_is_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_rejects_bare_command_with_pipe() {
|
||||
// A bare command that merely contains `|` must NOT be read
|
||||
// as a journal record — its first field is not a timestamp.
|
||||
assert!(parse_journal_record("select 'a|b' from t").is_none());
|
||||
assert!(parse_journal_record("show data Orders").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_rejects_timestamp_ish_but_invalid_prefix() {
|
||||
// Boundary: looks vaguely date-y but isn't the exact
|
||||
// `YYYY-MM-DDTHH:MM:SSZ` shape → bare command.
|
||||
assert!(parse_journal_record("2026-5-24|ok|x").is_none());
|
||||
assert!(parse_journal_record("2026-05-24 10:00:00|ok|x").is_none());
|
||||
assert!(parse_journal_record("notatimestamp|ok|x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_preserves_pipe_in_source() {
|
||||
// `|` is not escaped by the writer (it's a valid SQL char);
|
||||
// `splitn(3, '|')` keeps everything after the second `|`.
|
||||
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t")
|
||||
.expect("ok record");
|
||||
assert_eq!(rec.source, "select 'a|b' from t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_round_trips_a_written_record() {
|
||||
// What `format_record` writes, `parse_journal_record` reads
|
||||
// back to the original command (escape→unescape lossless for
|
||||
// the awkward cases).
|
||||
let cmd = "update T set v = 'x\\y' where id = 1";
|
||||
let line = format_record(cmd, "2026-05-24T10:00:00Z".to_string());
|
||||
let rec = parse_journal_record(line.trim_end()).expect("round-trip record");
|
||||
assert!(rec.status_is_ok);
|
||||
assert_eq!(rec.source, cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_is_escaped() {
|
||||
let line = format_record("a\\b", "T".to_string());
|
||||
|
||||
+43
-1
@@ -272,13 +272,30 @@ impl Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one record to `history.log`.
|
||||
/// Append one successful-command record to `history.log`.
|
||||
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let line = history::format_record(command_text, history::utc_iso8601_now());
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
/// Append a failed-command record to `history.log`, tagged
|
||||
/// `err` (ADR-0034 §1). Used by the runtime's error path so a
|
||||
/// command that failed to parse or to execute is still
|
||||
/// recallable across sessions (it never reaches the worker's
|
||||
/// transactional `ok` journal). Best-effort at the call site:
|
||||
/// a failure to record a failure must never escalate a user
|
||||
/// error into a fatal (ADR-0034 §4).
|
||||
pub fn append_history_failure(&self, command_text: &str) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let line = history::format_record_with_status(
|
||||
command_text,
|
||||
history::utc_iso8601_now(),
|
||||
history::STATUS_ERR,
|
||||
);
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
/// Read the most-recent `max_n` sources out of
|
||||
/// `history.log` for input-history hydration on project
|
||||
/// open (ADR-0015 §12). Returned in chronological order
|
||||
@@ -289,6 +306,31 @@ impl Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
/// How `run_replay` should treat one already-trimmed,
|
||||
/// non-blank, non-`#` line (ADR-0034 §3).
|
||||
pub(crate) enum ReplayLine {
|
||||
/// Run this command text — either a journal `ok` record's
|
||||
/// extracted source, or a bare command verbatim.
|
||||
Run(String),
|
||||
/// A journal record whose status is not `ok` — skip it
|
||||
/// silently (a skipped failure is not a replay failure).
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Classify one replay input line (ADR-0034 §3). A journal
|
||||
/// record (`<ts>|<status>|<source>`) runs its source only when
|
||||
/// `ok` and is skipped otherwise; any other line is a bare
|
||||
/// command run verbatim. Detection is by the leading
|
||||
/// timestamp+status prefix, so a bare command that merely
|
||||
/// contains `|` (e.g. `select 'a|b' from t`) is run as-is.
|
||||
pub(crate) fn classify_replay_line(line: &str) -> ReplayLine {
|
||||
match history::parse_journal_record(line) {
|
||||
Some(rec) if rec.status_is_ok => ReplayLine::Run(rec.source),
|
||||
Some(_) => ReplayLine::Skip,
|
||||
None => ReplayLine::Run(line.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `body` to `path` atomically via temp file + fsync +
|
||||
/// rename. The temp file is named `<final>.tmp` in the same
|
||||
/// directory so the rename stays on the same filesystem.
|
||||
|
||||
Reference in New Issue
Block a user