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:
claude@clouddev1
2026-06-14 11:20:55 +00:00
parent eceedc19b7
commit 4aeea55984
26 changed files with 955 additions and 294 deletions
+5
View File
@@ -41,6 +41,11 @@ pub enum Action {
/// §4). `source` is the original user-typed text.
JournalFailure {
source: String,
/// Whether the failed submission was advanced (ADR-0052): tags the
/// `err` record `err:adv` so a failed advanced command hydrates in
/// its `:`-prefixed form, recallable in simple mode. App commands
/// (mode-agnostic) are `false`.
advanced: bool,
},
/// User issued the `rebuild` app-level command (ADR-0015
/// §7, §11). Runtime computes a summary from
+137 -20
View File
@@ -874,13 +874,16 @@ impl App {
error,
facts,
source,
advanced,
} => {
self.handle_dsl_failure(&command, error, facts);
// 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 }]
// emits the intent; the runtime does the append. The
// mode rides along (ADR-0052) so an advanced failure
// tags `err:adv`.
vec![Action::JournalFailure { source, advanced }]
}
AppEvent::TablesRefreshed(tables) => {
trace!(count = tables.len(), "tables refreshed");
@@ -1648,11 +1651,27 @@ impl App {
Some(i) => i - 1,
};
self.history_cursor = Some(next_index);
self.input = self.history[next_index].clone();
let stored = self.history[next_index].clone();
self.input = self.recall_display(&stored);
self.input_cursor = self.input.len();
self.input_scroll_offset = 0;
}
/// The display form of a stored history entry for the current mode
/// (ADR-0052, issue #30). An advanced entry is stored in its
/// `:`-prefixed simple-mode runnable form; in **advanced** mode the
/// `:` is stripped so it runs as bare SQL, while in **simple** mode it
/// stays prefixed and runs via the one-shot escape. A simple entry
/// (never starting with `:`) is returned unchanged in either mode.
fn recall_display(&self, stored: &str) -> String {
if self.mode == Mode::Advanced
&& let Some(rest) = stored.strip_prefix(':')
{
return rest.trim_start().to_string();
}
stored.to_string()
}
/// Move forwards in history (towards newer entries; eventually
/// returning to the user's saved draft).
fn history_forward(&mut self) {
@@ -1661,7 +1680,8 @@ impl App {
};
if i + 1 < self.history.len() {
self.history_cursor = Some(i + 1);
self.input = self.history[i + 1].clone();
let stored = self.history[i + 1].clone();
self.input = self.recall_display(&stored);
} else {
// Past the most recent entry — restore the draft and
// exit navigation mode.
@@ -1709,10 +1729,6 @@ impl App {
if trimmed.is_empty() {
return Vec::new();
}
// Record the original (trimmed) line in history regardless
// of whether it parses, so users can recall and edit
// typo'd commands.
self.push_history(trimmed);
// `:` one-shot escape: in simple mode, a leading `:` means
// treat *this single submission* as advanced. The persistent
@@ -1729,6 +1745,9 @@ impl App {
};
if effective_input.is_empty() {
// A bare `:` (one-shot with nothing after it) executes
// nothing and is not recorded — the push moved below the
// strip (ADR-0052), so it no longer lands in history.
return Vec::new();
}
@@ -1739,16 +1758,31 @@ impl App {
"submit"
);
// Parse-first: app-level commands and DSL commands now
// share the chumsky parser (per the round-5 refactor).
// App commands work in both modes — they're not gated by
// `effective_mode`. Anything that parses to a non-App
// variant falls through to the existing mode-specific
// path: simple → DSL execution; advanced → SQL placeholder.
// Anything that fails to parse falls through too — the
// simple-mode path renders the friendly parse error, the
// advanced-mode path renders the SQL placeholder.
if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) {
// Parse-first: app-level commands and DSL commands share the
// parser. App commands work in both modes — they're not gated by
// `effective_mode`. Anything that parses to a non-App variant (or
// fails to parse) falls through to the mode-specific path.
let parsed = parse_command(&effective_input);
// ADR-0052 (issue #30): record the command for cross-mode recall.
// An **advanced** (SQL) command is stored in its `:`-prefixed
// simple-mode runnable form, so it can be recalled and re-run in
// simple mode (recall strips the `:` again in advanced mode). A
// simple command — and **any app command**, which runs in either
// mode and so must not gain a `:` — is stored bare. Recorded
// regardless of whether it parses, so typo'd commands stay
// recallable. The canonical (un-prefixed) text is what reaches
// the journal via `ExecuteDsl.source`.
let is_app = matches!(&parsed, Ok(Command::App(_)));
let advanced = submission_mode.is_advanced() && !is_app;
let ring_line = if advanced {
format!(": {effective_input}")
} else {
effective_input.clone()
};
self.push_history(&ring_line);
if let Ok(Command::App(app_cmd)) = parsed {
return self.dispatch_app_command(app_cmd, &effective_input);
}
@@ -1961,6 +1995,7 @@ impl App {
self.note_error(note);
return vec![Action::JournalFailure {
source: input.to_string(),
advanced: submission_mode.is_advanced(),
}];
}
// Issue #17: simple-mode (DSL) counterpart. A wrong-count
@@ -1988,6 +2023,7 @@ impl App {
self.note_error(render_usage_block(input, mode));
return vec![Action::JournalFailure {
source: input.to_string(),
advanced: submission_mode.is_advanced(),
}];
}
self.push_output(OutputLine::echo(input, mode));
@@ -2074,6 +2110,7 @@ impl App {
// append; the App only emits the intent.
vec![Action::JournalFailure {
source: input.to_string(),
advanced: submission_mode.is_advanced(),
}]
}
}
@@ -5493,6 +5530,7 @@ mod tests {
},
facts: crate::friendly::FailureContext::default(),
source: String::new(),
advanced: false,
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
@@ -5551,6 +5589,7 @@ mod tests {
error: err,
facts,
source: String::new(),
advanced: false,
});
let body = app
.output
@@ -5600,6 +5639,7 @@ mod tests {
error: err,
facts,
source: String::new(),
advanced: false,
});
let body = app
.output
@@ -5632,6 +5672,7 @@ mod tests {
error: err(),
facts: crate::friendly::FailureContext::default(),
source: String::new(),
advanced: false,
});
let verbose_text = app
.output
@@ -5652,6 +5693,7 @@ mod tests {
error: err(),
facts: crate::friendly::FailureContext::default(),
source: String::new(),
advanced: false,
});
let short_text = app
.output
@@ -6327,7 +6369,7 @@ mod tests {
assert!(
matches!(
actions.as_slice(),
[Action::JournalFailure { source }] if source == "florp glorp"
[Action::JournalFailure { source, .. }] if source == "florp glorp"
),
"expected JournalFailure for the typo'd line; got {actions:?}",
);
@@ -6350,11 +6392,12 @@ mod tests {
},
facts: crate::friendly::FailureContext::default(),
source: "drop table Ghost".to_string(),
advanced: false,
});
assert!(
matches!(
actions.as_slice(),
[Action::JournalFailure { source }] if source == "drop table Ghost"
[Action::JournalFailure { source, .. }] if source == "drop table Ghost"
),
"expected JournalFailure carrying the source; got {actions:?}",
);
@@ -6483,6 +6526,80 @@ mod tests {
assert_eq!(app.input, "drop table AX");
}
// ---- ADR-0052 (issue #30): mode-aware history recall ----
#[test]
fn one_shot_advanced_command_recalls_with_colon_in_simple_mode() {
// The bug: a `:`-one-shot advanced command must recall WITH the
// `:` so it re-runs in simple mode (in-session and, via the
// `:`-prefixed ring form, across sessions too).
let mut app = App::new();
type_str(&mut app, ": select 1");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, ": select 1");
}
#[test]
fn persistent_advanced_command_recalls_with_colon_back_in_simple_mode() {
// The feature: a command typed in *persistent* advanced mode
// recalls into simple mode with a `:` so it stays runnable.
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
submit(&mut app);
// Switch back to simple and recall.
app.mode = Mode::Simple;
app.update(key(KeyCode::Up));
assert_eq!(app.input, ": select 1");
}
#[test]
fn advanced_command_recalls_bare_in_advanced_mode() {
// In advanced mode the stored `:`-prefix is stripped so it runs
// as bare SQL.
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "select 1");
}
#[test]
fn simple_command_recalls_bare_in_either_mode() {
let mut app = App::new();
type_str(&mut app, "drop table T");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table T");
app.mode = Mode::Advanced;
app.update(key(KeyCode::Down)); // back to draft
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table T");
}
#[test]
fn app_command_recalls_bare_even_when_typed_with_colon() {
// An app command runs in any mode, so it must NOT gain a `:` on
// recall even when entered via the one-shot escape.
let mut app = App::new();
type_str(&mut app, ": mode advanced");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "mode advanced");
}
#[test]
fn a_bare_colon_is_not_recorded_in_history() {
let mut app = App::new();
type_str(&mut app, ":");
submit(&mut app);
// Nothing recallable.
app.update(key(KeyCode::Up));
assert_eq!(app.input, "");
}
#[test]
fn add_column_with_text_type_emits_execute_action() {
let mut app = App::new();
+51 -64
View File
@@ -2262,12 +2262,10 @@ fn handle_request(
// (`show table`), it belongs in the complete journal
// (ADR-0034). ADR-0035 §4.
if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) {
let result = do_describe_table(conn, &name).and_then(|desc| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(CreateOutcome::Skipped(desc))
});
// ADR-0052: journaling moved to the dispatch layer; this
// no-op skip is an `Ok` outcome there and is journalled by
// the spawn like any other.
let result = do_describe_table(conn, &name).map(CreateOutcome::Skipped);
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
@@ -2306,12 +2304,8 @@ fn handle_request(
// line is still journalled — like the `CREATE TABLE IF NOT
// EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4.
if if_exists && !user_table_exists(conn, &name).unwrap_or(false) {
let result = (|| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(DropOutcome::Skipped)
})();
// ADR-0052: journaling moved to the dispatch layer.
let result: Result<DropOutcome, DbError> = Ok(DropOutcome::Skipped);
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
@@ -2519,12 +2513,9 @@ fn handle_request(
// ADR-0035 §4). Existence uses the same user-index lookup as
// `do_drop_index` (`sql IS NOT NULL`).
if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
let result = (|| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(DropIndexOutcome::Skipped)
})();
// ADR-0052: journaling moved to the dispatch layer.
let result: Result<DropIndexOutcome, DbError> =
Ok(DropIndexOutcome::Skipped);
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
@@ -2555,12 +2546,9 @@ fn handle_request(
// hits `do_add_index`'s redundant-set refusal (ADR-0025).
let resolved = resolve_index_name(name.as_deref(), &table, &columns);
if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
let result = (|| {
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
p.append_history(text).map_err(DbError::from_persistence)?;
}
Ok(CreateIndexOutcome::Skipped(resolved.clone()))
})();
// ADR-0052: journaling moved to the dispatch layer.
let result: Result<CreateIndexOutcome, DbError> =
Ok(CreateIndexOutcome::Skipped(resolved));
let _ = reply.send(result);
} else {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
@@ -3065,10 +3053,21 @@ struct Changes {
/// Read-only requests (no schema change, no row writes, no
/// drops) still use this to append `history.log` if `source`
/// is set; they pass an empty `Changes`.
// Persist the **state** sources (project.yaml + data/*.csv) for a
// committed mutation, inside the worker transaction (ADR-0015 §6
// commit-db-last). `history.log` is NOT written here — ADR-0052 moved
// journaling to the dispatch layer (runtime), so the command's mode is
// available without plumbing it through the worker, and a journal-write
// failure no longer rolls back a committed command (it is best-effort,
// like the failure path).
fn finalize_persistence(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
// Vestigial since ADR-0052 (the `history.log` write that used it moved
// to the dispatch layer). Retained so the ~28 worker handlers that
// thread `source` to here keep a use for it, rather than orphaning the
// param across all of them; a later cleanup could unwind that plumbing.
_source: Option<&str>,
changes: &Changes,
) -> Result<(), DbError> {
let Some(p) = persistence else {
@@ -3093,10 +3092,6 @@ fn finalize_persistence(
p.delete_table_data(table)
.map_err(DbError::from_persistence)?;
}
if let Some(text) = source {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
Ok(())
}
@@ -8361,18 +8356,18 @@ fn do_drop_index(
/// Read-only wrapper around `do_describe_table` that runs an
/// auxiliary `history.log` append for user-issued
/// `show table` commands.
// ADR-0052: journaling moved to the dispatch layer, so this read-only
// `show table` wrapper no longer appends to `history.log` — the spawn
// journals the `Ok` outcome. Kept as a thin delegate (a later cleanup
// could inline `do_describe_table` at the one call site); `_persistence`
// / `_source` are vestigial.
fn do_describe_table_request(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
_persistence: Option<&Persistence>,
_source: Option<&str>,
name: &str,
) -> Result<TableDescription, DbError> {
let description = do_describe_table(conn, name)?;
if let (Some(p), Some(text)) = (persistence, source) {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
Ok(description)
do_describe_table(conn, name)
}
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
@@ -9981,40 +9976,32 @@ fn do_delete(
})
}
/// Read-only wrapper that adds the `history.log` append for
/// `show data` user commands.
/// Read-only `show data` wrapper. ADR-0052: journaling moved to the
/// dispatch layer (`_persistence` / `_source` vestigial).
fn do_query_data_request(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
_persistence: Option<&Persistence>,
_source: Option<&str>,
table: &str,
filter: Option<&Expr>,
limit: Option<u64>,
) -> Result<DataResult, DbError> {
let data = do_query_data(conn, table, filter, limit)?;
if let (Some(p), Some(text)) = (persistence, source) {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
Ok(data)
// ADR-0052: journaling moved to the dispatch layer (`_persistence` /
// `_source` vestigial; the spawn journals the `Ok` outcome).
do_query_data(conn, table, filter, limit)
}
/// Worker handler for `Request::RunSelect` (ADR-0030 §6,
/// ADR-0031). Mirrors `do_query_data_request`: run the
/// statement, append the literal line to `history.log` so a
/// Worker handler for `Request::RunSelect` (ADR-0030 §6, ADR-0031).
/// ADR-0052: journaling moved to the dispatch layer, so this no longer
/// appends to `history.log` — the spawn journals the literal line so a
/// replay re-runs it (ADR-0030 §11).
fn do_run_select_request(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
_persistence: Option<&Persistence>,
_source: Option<&str>,
sql: &str,
) -> Result<DataResult, DbError> {
let data = do_run_select(conn, sql)?;
if let (Some(p), Some(text)) = (persistence, source) {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
Ok(data)
do_run_select(conn, sql)
}
/// Currently-stored non-NULL values of one column, for shortid
@@ -11119,8 +11106,10 @@ fn read_relationships_inbound(
/// violation aborts with a fatal error.
fn do_rebuild_from_text(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
// Vestigial since ADR-0052: `rebuild` is journalled at the dispatch
// layer (`spawn_rebuild`), not here.
_persistence: Option<&Persistence>,
_source: Option<&str>,
project_path: &Path,
) -> Result<(), DbError> {
debug!(path = %project_path.display(), "rebuild_from_text");
@@ -11320,10 +11309,8 @@ fn do_rebuild_from_text(
// 7. Append `history.log` if this rebuild was
// user-initiated (the silent on-load case has
// `source = None`).
if let (Some(p), Some(text)) = (persistence, source) {
p.append_history(text)
.map_err(DbError::from_persistence)?;
}
// ADR-0052: `rebuild` is journalled at the dispatch layer
// (`spawn_rebuild`), not here — journaling left the worker.
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(())
+5
View File
@@ -161,6 +161,11 @@ pub enum AppEvent {
/// commands, so an execution failure would otherwise be
/// lost across sessions.
source: String,
/// Whether the rejected command was submitted in an advanced
/// effective mode (ADR-0052): threaded so the App can tag the
/// `err` record `err:adv` and the failed advanced command
/// hydrates in its `:`-prefixed, simple-mode-recallable form.
advanced: bool,
},
/// Refreshed list of tables in the database.
TablesRefreshed(Vec<String>),
+112 -4
View File
@@ -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()]);
}
}
+32 -9
View File
@@ -395,11 +395,26 @@ impl Persistence {
}
}
/// Append one successful-command record to `history.log`.
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> {
/// Append one successful-command record to `history.log`. `advanced`
/// (ADR-0052) tags the record `ok:adv` when the command was submitted
/// in an advanced effective mode, so hydration can reconstruct its
/// `:`-prefixed form for reuse in simple mode.
pub fn append_history(
&self,
command_text: &str,
advanced: bool,
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let line = history::format_record(command_text, history::utc_iso8601_now());
debug!(len = command_text.len(), "persist: append ok record to history.log");
let status = history::status_token(history::STATUS_OK, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
&status,
);
debug!(
len = command_text.len(),
advanced, "persist: append ok record to history.log"
);
history::append(&path, &line)
}
@@ -410,14 +425,22 @@ impl Persistence {
/// 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> {
pub fn append_history_failure(
&self,
command_text: &str,
advanced: bool,
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_ERR, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
history::STATUS_ERR,
&status,
);
debug!(
len = command_text.len(),
advanced, "persist: append err record to history.log"
);
debug!(len = command_text.len(), "persist: append err record to history.log");
history::append(&path, &line)
}
@@ -577,8 +600,8 @@ mod tests {
fn append_history_creates_and_appends() {
let dir = tempdir();
let p = Persistence::new(dir.path().to_path_buf());
p.append_history("create table Foo with pk id(serial)").unwrap();
p.append_history("insert into Foo (1)").unwrap();
p.append_history("create table Foo with pk id(serial)", false).unwrap();
p.append_history("insert into Foo (1)", false).unwrap();
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
let lines: Vec<&str> = body.trim_end().lines().collect();
assert_eq!(lines.len(), 2);
+48 -9
View File
@@ -479,17 +479,19 @@ async fn run_loop(
command,
source,
submission_mode,
session.project().path().to_path_buf(),
);
}
Action::JournalFailure { source } => {
Action::JournalFailure { source, advanced } => {
// ADR-0034 §1/§4: record a failed command as an
// `err` record. Best-effort — a failure to record
// a failure must never escalate a user error into
// a fatal, so the result is logged and ignored.
// `err` record (ADR-0052: `err:adv` when advanced).
// Best-effort — a failure to record a failure must
// never escalate a user error into a fatal, so the
// result is logged and ignored.
if let Err(e) = crate::persistence::Persistence::new(
session.project().path().to_path_buf(),
)
.append_history_failure(&source)
.append_history_failure(&source, advanced)
{
tracing::warn!(error = %e, "failed to journal err record (ignored)");
}
@@ -971,7 +973,9 @@ async fn perform_switch(
// history.log. The worker's persistence is wired but not
// directly addressable from here, so we use a fresh
// Persistence handle for this single line.
let _ = Persistence::new(new_path.clone()).append_history(&source);
// App-lifecycle command (save-as/load/new): journalled simple
// (ADR-0052 — app commands run in any mode, so no `:` on recall).
let _ = Persistence::new(new_path.clone()).append_history(&source, false);
// Update the resume pointer so the next `--resume` launch
// reopens the project we just switched to — unless it is a
@@ -1040,7 +1044,9 @@ fn spawn_export(
source: String,
event_tx: mpsc::Sender<AppEvent>,
) {
let _ = crate::persistence::Persistence::new(project_path.clone()).append_history(&source);
// `export` app command: journalled simple (ADR-0052).
let _ = crate::persistence::Persistence::new(project_path.clone())
.append_history(&source, false);
tokio::spawn(async move {
let outcome = tokio::task::spawn_blocking(move || {
do_export(&project_path, &project_name, &data_root, target.as_deref())
@@ -1295,11 +1301,20 @@ fn spawn_rebuild(
source: String,
) {
tokio::spawn(async move {
let source_for_journal = source.clone();
match database
.rebuild_from_text(project_path.clone(), Some(source))
.await
{
Ok(()) => {
// ADR-0052: journal `rebuild` at the dispatch layer (the
// worker no longer journals); simple (app command),
// best-effort.
if let Err(e) = crate::persistence::Persistence::new(project_path.clone())
.append_history(&source_for_journal, false)
{
warn!(error = %e, "failed to journal rebuild (ignored)");
}
let summary = summarize_project(&project_path)
.unwrap_or_else(|_| "rebuild complete".to_string());
let _ = event_tx
@@ -1407,10 +1422,11 @@ fn spawn_dsl_dispatch(
command: Command,
source: String,
submission_mode: crate::app::EffectiveMode,
project_path: std::path::PathBuf,
) {
tokio::spawn(async move {
// Retain the source for `DslFailed` so the App can journal a
// rejected command as `err` (ADR-0034 §1/§2).
// Retain the source for journaling (ADR-0034 §1/§2; ADR-0052
// moved success journaling here, next to the failure path).
let source_for_journal = source.clone();
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
// command submitted in an advanced effective mode (ADR-0037).
@@ -1431,6 +1447,19 @@ fn spawn_dsl_dispatch(
let lookups = collect_echo_lookups(&database, &command, submission_mode).await;
let echo = crate::echo::echo_for(&command, submission_mode);
let outcome = execute_command_typed(&database, command.clone(), source).await;
// ADR-0052 (issue #30): journal a SUCCESSFUL command here, at the
// top of the chain — the canonical source + submission mode are
// both in scope, so no mode-plumbing into the worker is needed.
// Best-effort (ADR-0040 amended): the command is already committed;
// a journal-write failure is logged, never fatal. Failures stay on
// the `JournalFailure` path (Ok/Err are exclusive — no double
// journal). `:adv` tags an advanced submission (ADR-0052).
if outcome.is_ok()
&& let Err(e) = crate::persistence::Persistence::new(project_path)
.append_history(&source_for_journal, submission_mode.is_advanced())
{
warn!(error = %e, "failed to journal ok record (ignored)");
}
let event = match outcome {
Ok(CommandOutcome::Schema(description)) => {
let schema_echo = build_schema_echo(
@@ -1569,6 +1598,7 @@ fn spawn_dsl_dispatch(
error,
facts,
source: source_for_journal,
advanced: submission_mode.is_advanced(),
}
}
};
@@ -2540,6 +2570,15 @@ pub async fn run_replay(
execute_command_typed(database, command, command_text.clone()).await;
match outcome {
Ok(_) => {
// ADR-0052: journal the replayed line at the dispatch
// layer (the worker no longer journals). Replay is
// mode-agnostic, so the re-written record is tagged
// simple; best-effort, like the interactive path.
if let Err(e) = crate::persistence::Persistence::new(project_root.to_path_buf())
.append_history(&command_text, false)
{
warn!(error = %e, "failed to journal replayed line (ignored)");
}
count += 1;
}
Err(DbError::PersistenceFatal {