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:
@@ -213,6 +213,14 @@ working copy.
|
||||
|
||||
### 6. Persistence ordering
|
||||
|
||||
> **Amended by ADR-0052 (2026-06-13, issue #30):** `history.log` is no
|
||||
> longer written inside the worker transaction. It is a *journal* of typed
|
||||
> commands, not state, so success journaling moved to the dispatch layer
|
||||
> (next to the already-top-level failure journaling); `commit-db-last` now
|
||||
> governs the three **state** targets only (db + `project.yaml` +
|
||||
> `data/*.csv`), which still commit atomically in the worker. The journal
|
||||
> write is best-effort (amends ADR-0040).
|
||||
|
||||
A successful user command produces effects in four targets:
|
||||
the SQLite database, `project.yaml`, the relevant
|
||||
`data/<table>.csv` file(s), and `history.log`. INV-2 from the
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted. **Amended by ADR-0052 (2026-06-13, issue #30):** the status
|
||||
field gains an optional `:adv` mode suffix (`ok:adv` / `err:adv`) — the
|
||||
"non-breaking future extension" this ADR reserved — and **success
|
||||
journaling moves out of the worker to the dispatch layer**
|
||||
(`spawn_dsl_dispatch` / `run_replay` / app-command sites), next to the
|
||||
failure path, where the submission mode is in scope. `status_is_ok` keys
|
||||
off the base token, so `ok:adv` replays like `ok`.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
|
||||
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
|
||||
ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo
|
||||
line.
|
||||
line. **Amended by ADR-0052 (2026-06-13, issue #30):** a `history.log`
|
||||
*journal*-write failure on a **successful** command is no longer fatal —
|
||||
journaling moved to the dispatch layer (after the db commit), so it is
|
||||
best-effort (logged + ignored), consistent with the failure-journal path.
|
||||
State-write failures (yaml/csv/db) remain fatal.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# ADR-0052: Mode-tagged history for cross-mode recall
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted + implemented 2026-06-13 (issue #30).** Closes Gitea **#30** —
|
||||
both the feature ("reuse advanced history commands in simple mode by
|
||||
prepending `:`") and the bug reported in its comment (the `:` one-shot
|
||||
prefix lost across sessions). All forks user-chosen before any code.
|
||||
**Amends ADR-0034** (journal status field gains a `:adv` tag; *journaling
|
||||
moves from the worker to the dispatch layer*), **ADR-0015 §5/§6**
|
||||
(history.log leaves the worker transaction — `commit-db-last` now scopes
|
||||
yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure
|
||||
is best-effort, no longer fatal); references ADR-0003 (the `:` one-shot
|
||||
sigil). Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`
|
||||
(pre-build `/runda`, then a second `/runda` that drove the journaling
|
||||
relocation + the app-command exclusion). **2471 tests pass / 0 fail / 0
|
||||
skip (1 ignored), clippy clean.**
|
||||
|
||||
> **Why journaling moved (the key architectural turn).** The first draft
|
||||
> kept journaling in the worker and threaded the mode down to it (~30-site
|
||||
> plumbing). On review the user asked the right question: why is the
|
||||
> journal written deep in the worker at all, when the failure path already
|
||||
> journals at the top of the chain where command + mode + outcome are all
|
||||
> in scope? It shouldn't — `history.log` is a *journal of typed commands*,
|
||||
> not *state*. So success journaling moved up next to the failure path
|
||||
> (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), the
|
||||
> mode-plumbing dilemma dissolved, and the worker's `finalize_persistence`
|
||||
> now writes only the state sources (yaml/csv). Consequence: the journal
|
||||
> write is best-effort (the command is already committed), consistent with
|
||||
> the failure path — see §5.
|
||||
|
||||
## Context
|
||||
|
||||
The input-history ring and `history.log` carry **no mode information**,
|
||||
which causes two coupled problems:
|
||||
|
||||
1. **Feature gap.** A command typed in advanced mode (`select * from T`)
|
||||
is stored bare. Recalled in simple mode it is not valid DSL → it just
|
||||
errors. There is no way to know it was an advanced (SQL) command and
|
||||
offer it back in a runnable form.
|
||||
|
||||
2. **Bug (issue #30 comment).** A `:`-one-shot advanced command in simple
|
||||
mode recalls correctly **in-session** (the in-memory ring stores the
|
||||
raw `:select 1`), but after quit+resume it comes back **without** the
|
||||
`:` and is unusable. Root cause: the ring stores the raw input
|
||||
(`:select 1`), but the worker journals the **stripped** `effective_input`
|
||||
(`select 1`) — submission strips the `:` before dispatch (ADR-0003) —
|
||||
so the on-disk `source` never carried the `:`, and hydration loses it.
|
||||
|
||||
Both reduce to: **history does not record the submission mode**, and the
|
||||
in-memory and on-disk representations disagree about the `:`.
|
||||
|
||||
## Decision
|
||||
|
||||
Record the **submission mode** per history entry, keep the on-disk
|
||||
`source` **canonical** (stripped — replay is unaffected), and have
|
||||
**recall reconstruct the runnable line** for the current mode.
|
||||
|
||||
### 1. In-memory ring stores the `:`-prefixed runnable form
|
||||
|
||||
`App.history` stays `Vec<String>` — no type change, so the public ring,
|
||||
the `ProjectSwitched` payload, and `seed_history` are untouched. An
|
||||
**advanced** entry is stored in its **simple-mode runnable form**, the
|
||||
`: `-prefixed string (e.g. `: select * from T`); a **simple** entry is
|
||||
stored bare. This is exactly what the in-session one-shot ring already
|
||||
does (`:select 1` recalls as typed) — generalised to *persistent*-advanced
|
||||
commands too, and made reconstructable on hydration. Because a simple
|
||||
DSL command can never begin with `:` (the sole sigil, ADR-0003), a
|
||||
leading `:` unambiguously marks an advanced entry.
|
||||
|
||||
`submit` builds the stored line from the submission: advanced →
|
||||
`": " + effective_input` (the `: ` matches the auto-space the typed
|
||||
one-shot inserts), simple → `effective_input`. This is computed **after**
|
||||
`effective_input` (today `push_history` runs on the raw `trimmed` before
|
||||
stripping; the reorder also drops a bare `:`, which never executed). The
|
||||
draft (`history_draft`) stays a plain `String`. `push_history` itself is
|
||||
unchanged — it still takes one `&str`.
|
||||
|
||||
### 2. Recall strips the `:` for advanced mode
|
||||
|
||||
`history_back` / `history_forward` set `self.input` from the stored
|
||||
string, then strip a leading `:` **iff the current persistent mode is
|
||||
Advanced**:
|
||||
|
||||
```
|
||||
if self.mode == Mode::Advanced && stored.starts_with(':') { stored[1..].trim_start() } else { stored }
|
||||
```
|
||||
|
||||
So an advanced entry recalls as `: select * from T` in **simple** mode
|
||||
(runs via the one-shot escape — the feature, and the cross-session bug
|
||||
fix) and bare `select * from T` in **advanced** mode (runs as SQL). A
|
||||
simple entry recalls bare in either mode (simple DSL already runs in
|
||||
advanced mode — issue #30). In-session and cross-session paths share the
|
||||
same stored form, so they finally agree.
|
||||
|
||||
### 3. On-disk: a mode tag in the status field
|
||||
|
||||
The record stays three pipe-separated fields `<ts>|<status>|<source>`
|
||||
(so `source` remains the last, pipe-tolerant, canonical field — replay
|
||||
reads it unchanged). The **status token** gains an optional `:adv`
|
||||
suffix:
|
||||
|
||||
| Submission | Success | Failure |
|
||||
|---|---|---|
|
||||
| Simple | `ok` | `err` |
|
||||
| Advanced (persistent or one-shot) | `ok:adv` | `err:adv` |
|
||||
|
||||
ADR-0034 §1 already reserved the status field for "additional values …
|
||||
a non-breaking future extension"; this is that extension. The status
|
||||
parser splits the token on `:`: the base (`ok`/`err`) gives replayability
|
||||
(`status_is_ok` ⇔ base == `ok`), the `adv` suffix gives the mode — so an
|
||||
unknown future token degrades to "not ok, simple" rather than mis-parsing.
|
||||
|
||||
### Journaling location: the dispatch layer, not the worker
|
||||
|
||||
Both tags are written **at the dispatch layer**, where command + mode +
|
||||
outcome are all in scope — so the mode needs no plumbing into the worker:
|
||||
|
||||
- **Success:** `spawn_dsl_dispatch`, immediately after
|
||||
`execute_command_typed` returns `Ok`, calls
|
||||
`append_history(source, submission_mode.is_advanced())` (best-effort).
|
||||
`run_replay` does the same per replayed line (tagged simple — replay is
|
||||
mode-agnostic), and the app-command sites (`perform_switch` /
|
||||
`spawn_export` / `spawn_rebuild`) journal **simple** (`advanced = false`
|
||||
— app commands run in any mode, so no `:` on recall; this also avoids a
|
||||
redundant `: undo`).
|
||||
- **Failure:** unchanged location (the App→`JournalFailure`→runtime path,
|
||||
already at the top), now carrying the mode — `JournalFailure` gains
|
||||
`advanced`, and `DslFailed` gains `submission_mode` for the
|
||||
worker-rejection sub-path (the parse-failure sub-path has it in
|
||||
`dispatch_dsl`). `Ok`/`Err` are exclusive, so success-in-spawn and
|
||||
failure-in-App-path never double-journal.
|
||||
|
||||
The worker's `finalize_persistence` and the four no-op-skip / three
|
||||
read-only sites **no longer journal** — they leave the state writes
|
||||
(yaml/csv) in the worker transaction and let the dispatch layer journal
|
||||
the `Ok` outcome.
|
||||
|
||||
### 4. Hydration reconstructs the `:`-prefixed form
|
||||
|
||||
`read_recent_sources` parses each record's status tag and, for an
|
||||
advanced record, **reconstructs** the `: `-prefixed string from the
|
||||
canonical `source` (`format!(": {source}")`); simple records pass through
|
||||
bare. It still returns `Vec<String>`, so `read_history_seed`,
|
||||
`seed_history`, and the `ProjectSwitched` payload are **unchanged**. A
|
||||
hydrated entry is therefore byte-identical to its in-session form, and
|
||||
recall behaves identically.
|
||||
|
||||
### Back-compatibility
|
||||
|
||||
Old `history.log` files have only `ok` / `err` tokens → parsed as
|
||||
`advanced = false` (simple). Their advanced commands stay un-`:`-able on
|
||||
recall — the pre-existing behaviour, not a regression; nothing migrates.
|
||||
`status_is_ok` keys off the base token, so `ok:adv` records replay
|
||||
exactly as `ok` does today (source is canonical either way).
|
||||
|
||||
### Journal write is best-effort (amends ADR-0040)
|
||||
|
||||
Because the journal is now written *after* the worker replies (i.e. after
|
||||
`tx.commit`), a journal-write failure can no longer roll the command back.
|
||||
It is **best-effort** — logged and ignored, exactly like the failure path
|
||||
already is (ADR-0034 §4) — so the two journal paths are finally
|
||||
consistent. State integrity is unchanged: yaml/csv/db still commit
|
||||
atomically in the worker (a *state*-write failure still rolls back and is
|
||||
fatal). The only property given up: on a rare journal-write failure (disk
|
||||
full) a committed command may be missing from `history.log` — not
|
||||
recallable/replayable next session, but the state is correct. User-chosen
|
||||
over keeping journaling coupled in the worker (which would have needed the
|
||||
~30-site mode plumbing). See the plan's §2 for the full trade-off.
|
||||
|
||||
## Forks (user-chosen)
|
||||
|
||||
- **Format = mode tag in the status field** (`ok:adv`/`err:adv`), over a
|
||||
new 4th field (ambiguous with unescaped pipes in old `source`s without
|
||||
a version bump) or a `:`-prefix in `source` (would make `source`
|
||||
non-canonical and force replay to strip it).
|
||||
- **Scope = unified** (bug + feature) over bug-only: one mechanism does
|
||||
both, and keeping `source` canonical for replay needs the mode tag
|
||||
regardless, so bug-only is barely smaller and leaves the main ask open.
|
||||
- **Journaling location = dispatch layer, best-effort** over keeping it
|
||||
worker-coupled-and-fatal (which needed the ~30-site mode plumbing). The
|
||||
user's architectural call (§Status).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Advanced history is reusable in simple mode; the `:` one-shot survives
|
||||
resume. The in-memory and on-disk representations agree.
|
||||
- **Journaling left the worker.** `finalize_persistence` and the
|
||||
no-op-skip / read-only sites no longer journal; success is journalled at
|
||||
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
|
||||
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
|
||||
are untouched. The vestigial worker `source` plumbing (the `_source`
|
||||
param on `finalize_persistence` / `do_rebuild_from_text` and the thin
|
||||
read-only `*_request` wrappers) is left in place — a clean follow-up.
|
||||
- **App commands recall bare.** Because they are dispatched outside the
|
||||
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
|
||||
false`) at their own sites, and `submit` excludes them from the ring's
|
||||
`advanced` flag (`!is_app_command`) — so `mode advanced` / `undo` recall
|
||||
bare and run fine in simple mode, with no redundant `:`.
|
||||
- **Journaling is now uniform (user-confirmed).** The spawn journals on
|
||||
`outcome.is_ok()`, so **every** successful command is recorded — closing
|
||||
a pre-existing gap where `show table` / `show data` / `select` journalled
|
||||
but `show tables`/`show relationships`/`show indexes`, `show relationship
|
||||
<name>`, and `explain` did **not** (their worker arms carried no
|
||||
`source` / no journal call). The new behaviour matches ADR-0034 §1
|
||||
("record every submitted command"); those reads are now recallable and
|
||||
are re-run harmlessly on replay (`explain` never executes; shows produce
|
||||
output, no state change). A DA finding, accepted as the more-correct
|
||||
behaviour over re-adding command-outcome gating to preserve the old
|
||||
inconsistency.
|
||||
- **Replay re-journaling.** When `replay` re-dispatches a line, the
|
||||
re-written record is tagged from how replay dispatched (mode-agnostic →
|
||||
`ok`), so a replayed advanced command may be re-journalled without
|
||||
`:adv`. Replay correctness of execution is unchanged (it already parses
|
||||
mode-agnostically); this only affects the *tag* of the re-written line.
|
||||
Noted; not addressed here (replay's own mode-fidelity is out of scope).
|
||||
|
||||
## Tests
|
||||
|
||||
- **Tier-1 (`app.rs`):** an advanced one-shot / persistent-advanced
|
||||
submission is stored `: `-prefixed; it recalls as `: …` in simple mode
|
||||
and bare in advanced mode; a simple entry recalls bare in both; a bare
|
||||
`:` is not stored; a parse-failure is still recallable; dedup/cap hold.
|
||||
- **Tier-1 (`history.rs`):** the writer emits `ok:adv`/`err:adv`;
|
||||
`read_recent_sources` reconstructs the `: `-prefix for `:adv` records
|
||||
and leaves `ok`/`err` records bare (so old logs read as simple);
|
||||
`status_is_ok` is true for `ok` and `ok:adv`.
|
||||
- **Tier-3 (`iteration6_resume_history` / it):** the headline
|
||||
**regression** — type a `:`-one-shot advanced command, journal +
|
||||
hydrate, and assert it recalls **with** `:` in simple mode (fails on
|
||||
current code); plus a persistent-advanced command round-tripping to a
|
||||
`: …` recall.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replay re-journaling mode-fidelity (above).
|
||||
- Special-casing app commands to avoid the redundant recall `: `.
|
||||
- Distinguishing one-shot from persistent advanced on recall (both are
|
||||
simply "advanced" — the `:` is what simple mode needs either way).
|
||||
- A format version marker / pipe-escaping in `source` (unneeded — the
|
||||
status-tag approach keeps `source` last and canonical).
|
||||
@@ -57,3 +57,4 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank
|
||||
- [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table <T>` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself)
|
||||
- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget)
|
||||
- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec<String>`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: unwinding the now-vestigial worker `source` plumbing (`_source` params + thin `*_request` wrappers — a clean follow-up); replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression)
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# 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 commands** —
|
||||
`advanced = 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 `DslFailed`→
|
||||
`JournalFailure` 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// =================================================================
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user