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:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user