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
+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(())