feat(history): mode-tagged history + top-of-chain journaling (#30)
Record the submission mode per history entry so advanced commands are reusable in simple mode, and fix the bug where a ':'-one-shot command lost its ':' across sessions (ADR-0052, closing #30). Format: the history.log status token gains an optional ':adv' suffix (ok / ok:adv / err / err:adv); 'source' stays last and canonical, so replay is unaffected. The in-memory ring (still Vec<String>) stores advanced entries ': '-prefixed; recall strips the ':' in advanced mode and keeps it in simple; hydration reconstructs the prefix from the tag. Journaling moved from the worker to the dispatch layer (spawn_dsl_- dispatch / run_replay / app-command sites), where the mode is in scope with no worker plumbing; finalize_persistence writes only yaml/csv (commit-db-last still atomic for state). The journal write is now best-effort (command already committed), consistent with the failure path. App commands journal simple, so they recall bare. Journaling is now uniform (every successful command, per ADR-0034) — closing a gap where show tables/relationships/explain didn't journal. Amends ADR-0034 (status tag + journaling location), ADR-0015 §6 (history.log out of the worker tx), ADR-0040 (journal-write best-effort). 15 worker-level journaling tests retired, re-covered at the new layer (history.rs format, app.rs recall matrix, iteration6 cross-session regression, replay). 2471 pass / 0 fail / 0 skip, clippy clean.
This commit is contained in:
+112
-4
@@ -28,7 +28,35 @@ use super::PersistenceError;
|
||||
pub(super) const STATUS_OK: &str = "ok";
|
||||
pub(super) const STATUS_ERR: &str = "err";
|
||||
|
||||
/// Format a successful-command record. Pure; no I/O.
|
||||
/// The optional status suffix marking an advanced-mode submission
|
||||
/// (ADR-0052, issue #30): `ok:adv` / `err:adv`. Recorded so that
|
||||
/// hydration can reconstruct the `:`-prefixed runnable form of an
|
||||
/// advanced command, making advanced history reusable in simple mode.
|
||||
pub(super) const ADV_SUFFIX: &str = "adv";
|
||||
|
||||
/// Build the status token for a `base` (`ok`/`err`) and submission mode.
|
||||
pub(super) fn status_token(base: &str, advanced: bool) -> String {
|
||||
if advanced {
|
||||
format!("{base}:{ADV_SUFFIX}")
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a status token into `(is_ok, advanced)` (ADR-0052). The base
|
||||
/// (`ok` ⇒ replayable, anything else ⇒ skip) precedes an optional
|
||||
/// `:adv` mode suffix. An unknown base degrades to `(false, _)`, so
|
||||
/// replay skips it rather than mis-running it.
|
||||
pub(super) fn parse_status(status: &str) -> (bool, bool) {
|
||||
let (base, suffix) = status.split_once(':').unwrap_or((status, ""));
|
||||
(base == STATUS_OK, suffix == ADV_SUFFIX)
|
||||
}
|
||||
|
||||
/// Format a successful-command record. Pure; no I/O. (Simple-mode
|
||||
/// convenience used by tests; production threads the mode through
|
||||
/// [`format_record_with_status`] + [`status_token`], so this is
|
||||
/// test-only since ADR-0052.)
|
||||
#[cfg(test)]
|
||||
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
|
||||
format_record_with_status(command_text, timestamp_iso, STATUS_OK)
|
||||
}
|
||||
@@ -100,9 +128,20 @@ fn parse_record_source(line: &str) -> Option<String> {
|
||||
// characters) is preserved.
|
||||
let mut parts = line.splitn(3, '|');
|
||||
let _ts = parts.next()?;
|
||||
let _status = parts.next()?;
|
||||
let status = parts.next()?;
|
||||
let source = parts.next()?;
|
||||
Some(unescape_command(source))
|
||||
let (_is_ok, advanced) = parse_status(status);
|
||||
let command = unescape_command(source);
|
||||
// ADR-0052: an advanced record is hydrated in its `:`-prefixed
|
||||
// simple-mode runnable form, so cross-session recall matches the
|
||||
// in-session ring (and recall strips the `:` again in advanced
|
||||
// mode). A simple record hydrates bare. Old `ok`/`err` logs have no
|
||||
// `:adv` suffix → read as simple, unchanged.
|
||||
Some(if advanced {
|
||||
format!(": {command}")
|
||||
} else {
|
||||
command
|
||||
})
|
||||
}
|
||||
|
||||
/// A parsed journal record (ADR-0034 §3). `source` is already
|
||||
@@ -129,8 +168,11 @@ pub(super) fn parse_journal_record(line: &str) -> Option<JournalRecord> {
|
||||
if !looks_like_iso8601(ts) {
|
||||
return None;
|
||||
}
|
||||
// ADR-0052: the status may carry a `:adv` mode suffix; replayability
|
||||
// keys off the base token only (`ok` / `ok:adv` are both ok).
|
||||
let (status_is_ok, _advanced) = parse_status(status);
|
||||
Some(JournalRecord {
|
||||
status_is_ok: status == STATUS_OK,
|
||||
status_is_ok,
|
||||
source: unescape_command(source),
|
||||
})
|
||||
}
|
||||
@@ -436,4 +478,70 @@ mod tests {
|
||||
let body = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(body, "first|ok|a\nsecond|ok|b\n");
|
||||
}
|
||||
|
||||
// ---- ADR-0052 (issue #30): mode tag in the status field ----
|
||||
|
||||
#[test]
|
||||
fn status_token_builds_and_parses_the_adv_suffix() {
|
||||
assert_eq!(status_token(STATUS_OK, false), "ok");
|
||||
assert_eq!(status_token(STATUS_OK, true), "ok:adv");
|
||||
assert_eq!(status_token(STATUS_ERR, true), "err:adv");
|
||||
assert_eq!(parse_status("ok"), (true, false));
|
||||
assert_eq!(parse_status("ok:adv"), (true, true));
|
||||
assert_eq!(parse_status("err"), (false, false));
|
||||
assert_eq!(parse_status("err:adv"), (false, true));
|
||||
// Unknown base → not ok (replay skips it), simple.
|
||||
assert_eq!(parse_status("frobnicate"), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recent_sources_reconstructs_colon_prefix_for_advanced() {
|
||||
// An advanced record (`ok:adv`) hydrates in its `:`-prefixed
|
||||
// simple-mode runnable form; a simple record stays bare. This is
|
||||
// the cross-session half of the issue #30 fix.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
let adv = format_record_with_status(
|
||||
"select * from T",
|
||||
"2026-06-13T10:00:00Z".to_string(),
|
||||
&status_token(STATUS_OK, true),
|
||||
);
|
||||
let simple = format_record_with_status(
|
||||
"create table T with pk",
|
||||
"2026-06-13T10:00:01Z".to_string(),
|
||||
&status_token(STATUS_OK, false),
|
||||
);
|
||||
std::fs::write(&path, format!("{adv}{simple}")).unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
": select * from T".to_string(),
|
||||
"create table T with pk".to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_treats_ok_adv_as_ok() {
|
||||
// Replay keys off the base token, so `ok:adv` replays like `ok`
|
||||
// (source stays canonical).
|
||||
let rec = parse_journal_record("2026-06-13T10:00:00Z|ok:adv|select * from T")
|
||||
.expect("ok:adv journal record");
|
||||
assert!(rec.status_is_ok);
|
||||
assert_eq!(rec.source, "select * from T");
|
||||
let err = parse_journal_record("2026-06-13T10:00:00Z|err:adv|select bad")
|
||||
.expect("err:adv journal record");
|
||||
assert!(!err.status_is_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_three_field_log_reads_as_simple() {
|
||||
// Back-compat: a pre-ADR-0052 log (no `:adv`) hydrates bare.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
std::fs::write(&path, "2026-01-01T00:00:00Z|ok|select 1\n").unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(got, vec!["select 1".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user