Files
rdbms-playground/docs/plans/20260613-issue-30-top-of-chain-journaling.md
claude@clouddev1 4aeea55984 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.
2026-06-14 11:20:55 +00:00

13 KiB

Plan — issue #30: mode-tagged history + top-of-chain journaling

Status: draft for /runda review (2026-06-13). Issue: #30 — advanced history reusable in simple mode (prepend :), and the bug: the : one-shot prefix is lost across sessions. ADR: ADR-0052 (new); amends ADR-0015 §6, ADR-0034, ADR-0040; references ADR-0003.

1. Goal & root cause

Two coupled needs, one root cause — history entries carry no mode:

  • Bug: the in-memory ring stores the raw :select 1, but the worker journals the stripped select 1, so cross-session the : is lost and the command recalls bare (unusable in simple mode).
  • Feature: persistent-advanced commands (select 1 typed in advanced mode) can't be told apart from simple DSL, so they can't be offered back with a : in simple mode.

Fix: record the submission mode per entry (status tag :adv), keep the on-disk source canonical, and have recall prepend/strip : for the current mode.

2. The architecture insight (why this plan is shaped this way)

Journaling success lives deep in the worker: finalize_persistence (db.rs:3096-3099) writes history.log inside the db transaction, before tx.commit(), alongside yaml/csv — plus four no-op-skip sites and three read-only helpers. Failure journaling already lives at the top (runtime.rs:484-495, best-effort). Threading the mode down to the worker would mean ~30 Request variants + Database methods + execute_command_typed arms — because the journal write is far from where the mode is known.

So instead: move success journaling up to the dispatch layer, next to where failure journaling already is and where mode + outcome + source are all in scope. The mode then needs no plumbing. This is the correct separation anyway — history.log is an append-only journal of what was typed, not state; the state sources (yaml/csv/db) stay atomic in the worker.

Semantic changes this entails (must be vetted)

  1. history.log leaves the worker transaction (amends ADR-0015 §6). commit-db-last still governs yaml/csv/db (the state); the journal is written after the worker replies (i.e. after tx.commit), at the dispatch layer.
  2. Success-journal write failure: fatal → best-effort (amends ADR-0040). Today a failed history.log write on a successful command rolls the command back and shows a fatal banner. After: the command stays committed; the journal write is best-effort (logged + ignored), exactly like the failure path already is. The two journal paths become consistent.
  3. Consequence: on a rare journal-write failure (disk full / permissions) a successful command is applied but may be missing from history.log — not recallable next session, not replayable. The state (yaml/csv/db) is unaffected and consistent. This is a graceful degradation, not corruption, and is logged. (Today the same disk-full instead kills the app mid-command.)

Open question for review/user: is trading "fatal on journal-write failure" for "best-effort, command still succeeds" acceptable? The plan assumes yes (a journal is auxiliary; killing the app over it is worse UX). If not, journaling must stay coupled in the worker and we pay the ~30-site mode plumbing instead.

3. On-disk format (mode tag in status — already chosen + partly built)

Record stays <ts>|<status>|<source>; the status token gains an optional :adv suffix (ADR-0052). source stays canonical so replay is unaffected.

Submission Success Failure
Simple / app command ok err
Advanced (SQL, persistent or one-shot) ok:adv err:adv

Done already (history.rs / mod.rs):

  • status_token(base, advanced), parse_status(status) -> (is_ok, advanced).
  • parse_record_source reconstructs ": {cmd}" for :adv records.
  • parse_journal_record.status_is_ok via parse_status (so ok:adv replays).
  • append_history(text, advanced), append_history_failure(text, advanced).

Back-compat: old ok/err logs → simple; nothing migrates.

4. In-memory ring & recall (app.rs) — the #30 behaviour

The ring stays Vec<String>. An advanced entry is stored in its : -prefixed simple-mode runnable form (matching the existing in-session one-shot ring); a simple entry bare. A leading : unambiguously marks advanced (simple DSL can never start with :).

  • submit (app.rs:1704): compute effective_input + submission_mode, parse once for the app-command check (already done at 1751), then build the ring line. The advanced flag excludes app commandsadvanced = submission_mode.is_advanced() && !is_app_command — because app commands (undo, mode …, save as, …) run in any mode and must not get a : on recall. Ring line: ": " + effective_input if advanced, else effective_input; push_history(&ring_line). (Today it pushes the raw trimmed before stripping; the reorder also drops a bare :, which executed nothing, and is what lets the app-command check precede the push.) ExecuteDsl.source stays the canonical effective_input.
    • Why the app-command exclusion matters (DA finding): without it, : save as foo (an app command via the one-shot) would store : save as foo in the ring but journal save as foo (app commands journal simple at their own sites, §5) — the very in-session-vs-cross-session divergence #30 is fixing, re-introduced for app commands. Excluding them keeps ring and disk agreeing (both bare).
  • history_back / history_forward: after cloning the stored entry into self.input, strip a leading : iff self.mode == Advanced (so an advanced entry runs as bare SQL in advanced mode, and as : … one-shot in simple mode). A small helper recall_display(stored).
  • seed_history / ProjectSwitched payload: unchanged (Vec<String>); hydration already returns the : -prefixed form (§3).

Recall matrix:

entry \ current mode Simple Advanced
advanced (: select 1) : select 1 (one-shot) select 1 (SQL)
simple (create …) create … create …

5. Move success journaling worker → dispatch layer

Remove (worker stops journaling success):

  • finalize_persistence history write (db.rs:3096-3099). Keep yaml/csv. The now-unused source param: remove it + drop the arg at its ~30 callers (mechanical, compiler-guided). (Handlers keep their own source for snapshot_then.)
  • The 4 no-op-skip append_history (db.rs:2267, 2311, 2524, 2560) — these outcomes (SchemaSkipped etc.) are Ok at the dispatch layer, so the new top-level journal covers them.
  • The 3 read-only helper append_history (db.rs:8372 show table, 9996 show data, 10014 select) — Ok(Query)/Ok(ShowList) at the top.

Add (dispatch-layer journaling, all best-effort + logged):

  • spawn_dsl_dispatch (runtime.rs ~1433): pass project_path in; after execute_command_typed, if outcome.is_ok() { Persistence::new(path).append_history(&source_for_journal, submission_mode.is_advanced()) }. (Failures stay in the existing path, §6 — no double-journal, since Ok and Err are exclusive.)
  • run_replay (runtime.rs ~2540): after each line's execute_command_typed, if outcome.is_ok() { append_history( &command_text, false) } — replay is mode-agnostic, journalled simple. (Preserves ADR-0034 §3 "replayed sub-commands land in history"; a replayed advanced command re-journals without :adv — a documented OOS, not a regression: today it re-journals as plain ok.)
  • spawn_rebuild (runtime.rs ~503): after a successful rebuild, append_history("rebuild"/source, false). (Rebuild journalled via finalize_persistence today; that write is gone, so add it here.)

Unchanged (already at the dispatch layer, app commands):

  • perform_switch (974: save-as/load/new) and spawn_export (1043) — already best-effort append_history(&source); add the new advanced arg as false (app commands run in any mode → no : needed on recall; this also fixes the would-be "redundant : undo" — app commands journal simple because they're dispatched here, never via ExecuteDsl/the spawn).
  • undo/redo/copy/help/quit: not journalled today; unchanged.
  • The replay command itself: dispatched as Action::Replay, never reaches the spawn → not journalled (preserves the ADR-0034 §3 exclusion without extra work); nested replay skip in run_replay unchanged.

DA-confirmed design choice: split, don't unify

Success journals in the spawn (Ok arm); all failures stay in the existing App→JournalFailure→runtime path (just gaining the mode). Considered and rejected: moving worker-rejection failures into the spawn too (to "unify"). It doesn't actually unify — parse failures never reach the spawn, so they'd stay in the App path regardless — and it adds a double-journal hazard (must also strip the App's DslFailedJournalFailure emission). The split keeps the failure path untouched in structure (lowest risk); Ok/Err are exclusive so there is no double-journal. Verified safe: undo/redo never touches history.log (the snapshot copies db+yaml+csv only, undo.rs:15-16), and snapshot_then's redo-clear keys on source.is_some(), independent of journaling — so removing the worker journal write does not perturb undo/snapshot at all.

6. Failure journaling — add the mode (location unchanged)

Keep both failure origins where they are (best-effort, dispatch/App layer); thread the mode so they tag err:adv:

  • Action::JournalFailure (action.rs:42): add advanced: bool (or submission_mode).
  • AppEvent::DslFailed (event.rs): add submission_mode (the worker-rejection path — the App can't recover the mode from an async reply otherwise).
  • App: the parse-failure path (dispatch_dsl Err arm) has submission_mode directly; the DslFailed handler reads it off the event. Both emit JournalFailure { source, advanced }.
  • runtime.rs:492: append_history_failure(&source, advanced).

7. Tests

  • history.rs (Tier-1): status_token/parse_status round-trip; read_recent_sources reconstructs ": …" for :adv and leaves ok/err bare; status_is_ok true for ok & ok:adv; old-log back-compat.
  • app.rs (Tier-1): advanced submission stored : -prefixed; recall prepends in simple / strips in advanced; simple bare in both; bare : not stored; a parse-failure is still recallable; dedup/cap hold.
  • iteration6_resume_history (Tier-3) — headline regression: journal an advanced command (append_history(text, true)), hydrate, recall in simple → : …; and the full bug repro through submit + journal + hydrate if feasible.
  • replay_command (Tier-3): replayed commands still land in history.log (now via run_replay's call); the replay-self-exclusion
    • nested-skip still hold; advanced lines replay (status ok:adv treated as ok).
  • Journaling relocation: a success no longer fatals on a journal write failure (best-effort) — if cheaply testable; at minimum a worker test that previously asserted worker-side journaling is updated/removed.
  • Update mechanical call sites: append_history(_, advanced) / append_history_failure(_, advanced) at the db.rs inline tests (8372/9996/10014/11324 — likely now removed with the production sites), iteration6 (144-170), mod.rs (600).

8. ADR work

  • ADR-0052 (new): the #30 feature + bug, the status-tag format, the : -prefixed ring + recall, AND the journaling relocation (it's the enabling refactor). Forks: status-tag format; unified scope; dispatch-layer journaling (best-effort).
  • ADR-0015 §6 amendment: history.log out of the worker transaction; commit-db-last now scopes yaml/csv/db; journal is a dispatch-layer best-effort side-record.
  • ADR-0034 amendment: journaling location (dispatch layer); status-field :adv extension (it already reserved the field).
  • ADR-0040 amendment: a success-path journal-write failure is no longer fatal — best-effort, consistent with the failure path.
  • README index upkeep for every ADR touched.

9. Risks / watch-list

  • Double-journaling: ensure Ok→spawn and Err→App-path stay exclusive; do NOT also leave a worker journal.
  • Under/over-journaling vs today: top-level "journal on every Ok" must match today's "journal every command with a source" — verified: reads + skips are Ok outcomes, internal ops never reach the spawn.
  • finalize_persistence source-param removal: 30 mechanical call-site edits; compiler-guided.
  • Replay re-journal mode fidelity: replayed advanced commands re-journal as simple (OOS, not a regression).
  • best-effort journal: rare write-failure leaves a command unjournaled (logged). User decision (§2 open question).
  • app-command mode: journalled simple by construction (dispatched outside the spawn) — this is correct (they run in any mode), and resolves the earlier "redundant : undo" worry.