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
+8 -54
View File
@@ -15,7 +15,7 @@ use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, DATA_DIR, HISTORY_LOG, PROJECT_YAML,
self, DATA_DIR, PROJECT_YAML,
};
fn tempdir() -> tempfile::TempDir {
@@ -44,11 +44,6 @@ fn open_project(
(project, db, path)
}
fn read_history(project_path: &Path) -> Vec<String> {
let body = fs::read_to_string(project_path.join(HISTORY_LOG)).unwrap_or_default();
body.lines().map(str::to_string).collect()
}
fn read_yaml(project_path: &Path) -> String {
fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml")
}
@@ -82,9 +77,9 @@ fn create_table_writes_yaml_and_history() {
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
assert!(yaml.contains("type: text"), "yaml: {yaml}");
let history = read_history(&path);
assert_eq!(history.len(), 1, "expected one history line; got {history:?}");
assert!(history[0].ends_with("|ok|create table Customers with pk id(serial)"));
// ADR-0052: journaling moved to the dispatch layer (the worker no
// longer writes history.log); this test verifies only the yaml state.
// Journaling is covered by the history.rs/app.rs/replay tests.
}
#[test]
@@ -119,11 +114,8 @@ fn insert_writes_csv_and_history() {
assert_eq!(lines[0], "id,Name");
assert_eq!(lines[1], "1,Alice");
let history = read_history(&path);
assert!(
history.iter().any(|l| l.ends_with("|ok|insert into Customers ('Alice')")),
"history missing insert: {history:?}",
);
// ADR-0052: journaling moved off the worker; this test verifies the
// csv state only (journaling covered elsewhere).
}
#[test]
@@ -322,39 +314,6 @@ fn delete_all_rows_removes_csv() {
);
}
#[test]
fn show_table_appends_history_only() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
let yaml_before = read_yaml(&path);
db.describe_table(
"Customers".to_string(),
Some("show table Customers".to_string()),
)
.await
.unwrap();
let yaml_after = read_yaml(&path);
// YAML body did not change for a read-only command.
assert_eq!(yaml_before, yaml_after);
});
let history = read_history(&path);
assert!(
history.iter().any(|l| l.ends_with("|ok|show table Customers")),
"history missing show entry: {history:?}",
);
}
#[test]
fn failed_command_does_not_append_history_or_change_yaml() {
let data = tempdir();
@@ -387,13 +346,8 @@ fn failed_command_does_not_append_history_or_change_yaml() {
assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml");
});
let history = read_history(&path);
// Only the first (successful) create_table should have logged.
let create_count = history
.iter()
.filter(|l| l.contains("|ok|create table Customers"))
.count();
assert_eq!(create_count, 1, "expected exactly one logged create; got: {history:?}");
// ADR-0052: journaling moved off the worker; this test now verifies
// only that a failed command does not change the yaml state.
}
#[test]
+3 -6
View File
@@ -178,10 +178,7 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
assert_eq!(rows.rows.len(), 1);
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
// history.log should contain the rebuild entry.
let history = fs::read_to_string(project_path.join("history.log")).unwrap();
assert!(
history.lines().any(|l| l.ends_with("|ok|rebuild")),
"history.log missing rebuild entry:\n{history}",
);
// ADR-0052: `rebuild` journaling moved to the dispatch layer
// (`spawn_rebuild`), so the direct worker call here no longer writes
// history.log; this test verifies the wipe/reload behaviour only.
}
+32 -6
View File
@@ -141,9 +141,9 @@ fn read_recent_history_returns_appended_entries_in_order() {
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("create table B with pk").unwrap();
p.append_history("create table C with pk").unwrap();
p.append_history("create table A with pk", false).unwrap();
p.append_history("create table B with pk", false).unwrap();
p.append_history("create table C with pk", false).unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
entries,
@@ -165,9 +165,9 @@ fn hydration_reads_both_ok_and_err_records() {
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();
p.append_history("create table A with pk", false).unwrap();
p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
p.append_history("show data A", false).unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
entries,
@@ -194,6 +194,32 @@ fn seed_history_replaces_in_memory_history() {
assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]);
}
#[test]
fn advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple() {
// ADR-0052 (issue #30) — the headline cross-session regression: an
// advanced command journalled `ok:adv`, then hydrated on a fresh
// session, recalls WITH its `:` so it re-runs in simple mode. (Before
// the fix, the `:` was lost on disk and the command came back bare.)
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
// The dispatch layer journals the canonical source + advanced flag.
p.append_history("select * from T", true).unwrap();
p.append_history("create table T with pk", false).unwrap();
// Fresh session: hydrate the ring from disk.
let entries = p.read_recent_history(10).unwrap();
let mut app = App::new();
app.seed_history(entries);
// In simple mode the simple command recalls bare, the advanced one
// recalls `:`-prefixed (runnable via the one-shot escape).
app.update(key(KeyCode::Up));
assert_eq!(app.input, "create table T with pk");
app.update(key(KeyCode::Up));
assert_eq!(app.input, ": select * from T");
}
#[test]
fn seed_history_preserves_chronological_order_for_navigation() {
let mut app = App::new();
-18
View File
@@ -430,24 +430,6 @@ fn seed_is_reproducible_with_a_fixed_seed() {
assert_eq!(csv1, csv2, "the same --seed must reproduce identical data");
}
#[test]
fn seed_writes_exactly_one_history_line() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_people(&db, &rt);
rt.block_on(db.seed("People".into(), None, Some(5), Vec::new(), Some(1), Some("seed People 5".into())))
.expect("seed succeeds");
let history = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log exists");
let seed_lines = history.lines().filter(|l| l.contains("seed People 5")).count();
assert_eq!(
seed_lines, 1,
"a seed of 5 rows must write exactly one history line:\n{history}"
);
}
// — FK sampling, empty-parent error, block guard (ADR-0048 D14 / D1) —
/// `Users(id serial pk, name text)` + `Orders(id serial pk, user_id
+3 -3
View File
@@ -141,7 +141,7 @@ fn create_unique_index_on_duplicate_data_is_refused() {
#[test]
fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
let (p, db, _d) = open(false);
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
r.block_on(db.sql_create_index(
@@ -169,8 +169,8 @@ fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
CreateIndexOutcome::Skipped(name) => assert_eq!(name, "ix"),
CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"),
}
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
assert!(log.contains(line), "the skipped create should be journalled; log:\n{log}");
// ADR-0052: journaling moved to the dispatch layer; this test now
// asserts only the no-op `Skipped` outcome.
}
#[test]
+3 -3
View File
@@ -590,7 +590,7 @@ fn if_not_exists_noop_is_journalled() {
// A successful no-op is still a submission and belongs in the
// complete journal (ADR-0034) — like read-only `show table`, and
// unlike a *failed* duplicate-create (journalled `err`).
let (p, db, _d) = open(false);
let (_p, db, _d) = open(false);
let r = rt();
r.block_on(db.sql_create_table(
"T".to_string(),
@@ -617,8 +617,8 @@ fn if_not_exists_noop_is_journalled() {
))
.expect("no-op");
assert!(matches!(out, CreateOutcome::Skipped(_)));
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
assert!(log.contains(noop), "the no-op skip should be journalled; log:\n{log}");
// ADR-0052: journaling moved to the dispatch layer; this test now
// asserts only the no-op `Skipped` outcome.
}
#[test]
-13
View File
@@ -261,19 +261,6 @@ fn r2_cascade_with_subquery_where() {
"only Bob's order remains: {orders_csv:?}");
}
#[test]
fn delete_appends_literal_line_to_history() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'x')", "t");
let input = "delete from t where id = 1";
run_delete(&db, &rt, input).expect("delete runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present");
assert!(body.contains(input), "history records the literal line: {body:?}");
}
#[test]
fn cascade_to_two_children_reports_both() {
// DA gate (untested branch): a parent with TWO cascade children
+3 -3
View File
@@ -84,7 +84,7 @@ fn drop_index_removes_an_existing_index_and_shows_the_table() {
#[test]
fn if_exists_on_an_absent_index_is_a_noop_and_journalled() {
let (p, db, _d) = open(false);
let (_p, db, _d) = open(false);
let r = rt();
let line = "drop index if exists ghost_idx";
let out = r
@@ -92,8 +92,8 @@ fn if_exists_on_an_absent_index_is_a_noop_and_journalled() {
.expect("IF EXISTS on an absent index succeeds as a no-op");
assert!(matches!(out, DropIndexOutcome::Skipped));
// The no-op is still journalled (ADR-0034), like the create-skip.
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}");
// ADR-0052: journaling moved to the dispatch layer; this test now
// asserts only the no-op `Skipped` outcome.
}
#[test]
+3 -3
View File
@@ -59,7 +59,7 @@ fn drop_table_removes_an_existing_table() {
#[test]
fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
let (p, db, _d) = open(false);
let (_p, db, _d) = open(false);
let r = rt();
let line = "drop table if exists Ghost";
let out = r
@@ -67,8 +67,8 @@ fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
.expect("IF EXISTS on an absent table succeeds as a no-op");
assert!(matches!(out, DropOutcome::Skipped));
// The no-op is still journalled (ADR-0034), like the create-skip.
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}");
// ADR-0052: journaling moved to the dispatch layer; this test now
// asserts only the no-op `Skipped` outcome.
}
#[test]
-44
View File
@@ -132,30 +132,6 @@ fn no_column_list_full_arity_insert_persists() {
assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}");
}
#[test]
fn insert_appends_literal_line_to_history() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_t(&db, &rt);
// ADR-0030 §11: the literal submitted line lands in history.log.
let source = "insert into T (a, b) values (1, 'logged')";
rt.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'logged')".to_string(),
Some(source.to_string()),
"T".to_string(),
Vec::new(),
String::new(),
false,
))
.expect("insert runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present after an INSERT");
assert!(
body.contains(source),
"history.log records the literal INSERT line: {body:?}",
);
}
#[test]
fn failed_insert_rolls_back_and_does_not_repersist() {
let (project, db, _dir) = open_project_db();
@@ -583,26 +559,6 @@ fn combined_serial_and_shortid_autofill() {
assert_eq!(rows[0][2], "x", "name preserved: {rows:?}");
}
#[test]
fn autofill_logs_original_source_not_rewritten_sql() {
// ADR-0030 §11: even though the worker rewrites the executed
// statement to bind synthesised shortids, history.log records
// the user's original line verbatim.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
let input = "insert into t (label) values ('x')";
run_sqlinsert(&db, &rt, input).expect("auto-fill insert runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present");
assert!(body.contains(input), "original line logged: {body:?}");
// The rewritten parameterised INSERT must not leak into history.
assert!(
!body.contains("INSERT INTO") && !body.contains("?1"),
"rewritten SQL must not be logged: {body:?}",
);
}
#[test]
fn shortid_autofill_respects_mixed_case_column_name() {
// ADR-0009 / 3d DA gate: identifiers are case-preserving. The
-20
View File
@@ -732,23 +732,3 @@ fn database_run_select_recovers_all_ten_playground_types() {
);
}
}
#[test]
fn database_run_select_appends_to_history_when_source_present() {
let (project, db, _dir) = open_project_db();
let history_path = project.path().join("history.log");
// ADR-0030 §11: the literal submitted line lands in
// history.log so replay re-runs it.
let _ = rt()
.block_on(db.run_select(
"select 1".to_string(),
Some("select 1".to_string()),
))
.expect("SELECT runs");
let body = std::fs::read_to_string(&history_path)
.expect("history.log present after a SELECT");
assert!(
body.contains("select 1"),
"history.log records the literal SELECT line: {body:?}",
);
}
-13
View File
@@ -205,19 +205,6 @@ fn update_matching_no_rows_is_ok() {
assert!(csv.contains("keep") && !csv.contains('x'), "unchanged: {csv:?}");
}
#[test]
fn update_appends_literal_line_to_history() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'old')", "t");
let input = "update t set v = 'new' where id = 1";
run_update(&db, &rt, input).expect("update runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present");
assert!(body.contains(input), "history records the literal line: {body:?}");
}
// =================================================================
// ADR-0036 Phase 2 — `SET` literal value validation
// =================================================================
+1
View File
@@ -661,6 +661,7 @@ fn dsl_failure_shows_friendly_error_in_output() {
},
facts: rdbms_playground::friendly::FailureContext::default(),
source: String::new(),
advanced: false,
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(