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
|
### 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:
|
A successful user command produces effects in four targets:
|
||||||
the SQLite database, `project.yaml`, the relevant
|
the SQLite database, `project.yaml`, the relevant
|
||||||
`data/<table>.csv` file(s), and `history.log`. INV-2 from the
|
`data/<table>.csv` file(s), and `history.log`. INV-2 from the
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
## Status
|
## 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
|
## Context
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
|
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
|
||||||
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
|
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
|
||||||
ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo
|
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
|
## 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-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-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-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.
|
/// §4). `source` is the original user-typed text.
|
||||||
JournalFailure {
|
JournalFailure {
|
||||||
source: String,
|
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
|
/// User issued the `rebuild` app-level command (ADR-0015
|
||||||
/// §7, §11). Runtime computes a summary from
|
/// §7, §11). Runtime computes a summary from
|
||||||
|
|||||||
+137
-20
@@ -874,13 +874,16 @@ impl App {
|
|||||||
error,
|
error,
|
||||||
facts,
|
facts,
|
||||||
source,
|
source,
|
||||||
|
advanced,
|
||||||
} => {
|
} => {
|
||||||
self.handle_dsl_failure(&command, error, facts);
|
self.handle_dsl_failure(&command, error, facts);
|
||||||
// ADR-0034 §1/§2: an execution failure is journalled
|
// ADR-0034 §1/§2: an execution failure is journalled
|
||||||
// `err` so it is recallable across sessions (the
|
// `err` so it is recallable across sessions (the
|
||||||
// worker only journals successful commands). The App
|
// worker only journals successful commands). The App
|
||||||
// emits the intent; the runtime does the append.
|
// emits the intent; the runtime does the append. The
|
||||||
vec![Action::JournalFailure { source }]
|
// mode rides along (ADR-0052) so an advanced failure
|
||||||
|
// tags `err:adv`.
|
||||||
|
vec![Action::JournalFailure { source, advanced }]
|
||||||
}
|
}
|
||||||
AppEvent::TablesRefreshed(tables) => {
|
AppEvent::TablesRefreshed(tables) => {
|
||||||
trace!(count = tables.len(), "tables refreshed");
|
trace!(count = tables.len(), "tables refreshed");
|
||||||
@@ -1648,11 +1651,27 @@ impl App {
|
|||||||
Some(i) => i - 1,
|
Some(i) => i - 1,
|
||||||
};
|
};
|
||||||
self.history_cursor = Some(next_index);
|
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_cursor = self.input.len();
|
||||||
self.input_scroll_offset = 0;
|
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
|
/// Move forwards in history (towards newer entries; eventually
|
||||||
/// returning to the user's saved draft).
|
/// returning to the user's saved draft).
|
||||||
fn history_forward(&mut self) {
|
fn history_forward(&mut self) {
|
||||||
@@ -1661,7 +1680,8 @@ impl App {
|
|||||||
};
|
};
|
||||||
if i + 1 < self.history.len() {
|
if i + 1 < self.history.len() {
|
||||||
self.history_cursor = Some(i + 1);
|
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 {
|
} else {
|
||||||
// Past the most recent entry — restore the draft and
|
// Past the most recent entry — restore the draft and
|
||||||
// exit navigation mode.
|
// exit navigation mode.
|
||||||
@@ -1709,10 +1729,6 @@ impl App {
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Vec::new();
|
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
|
// `:` one-shot escape: in simple mode, a leading `:` means
|
||||||
// treat *this single submission* as advanced. The persistent
|
// treat *this single submission* as advanced. The persistent
|
||||||
@@ -1729,6 +1745,9 @@ impl App {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if effective_input.is_empty() {
|
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();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1739,16 +1758,31 @@ impl App {
|
|||||||
"submit"
|
"submit"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse-first: app-level commands and DSL commands now
|
// Parse-first: app-level commands and DSL commands share the
|
||||||
// share the chumsky parser (per the round-5 refactor).
|
// parser. App commands work in both modes — they're not gated by
|
||||||
// App commands work in both modes — they're not gated by
|
// `effective_mode`. Anything that parses to a non-App variant (or
|
||||||
// `effective_mode`. Anything that parses to a non-App
|
// fails to parse) falls through to the mode-specific path.
|
||||||
// variant falls through to the existing mode-specific
|
let parsed = parse_command(&effective_input);
|
||||||
// path: simple → DSL execution; advanced → SQL placeholder.
|
|
||||||
// Anything that fails to parse falls through too — the
|
// ADR-0052 (issue #30): record the command for cross-mode recall.
|
||||||
// simple-mode path renders the friendly parse error, the
|
// An **advanced** (SQL) command is stored in its `:`-prefixed
|
||||||
// advanced-mode path renders the SQL placeholder.
|
// simple-mode runnable form, so it can be recalled and re-run in
|
||||||
if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) {
|
// 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);
|
return self.dispatch_app_command(app_cmd, &effective_input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1961,6 +1995,7 @@ impl App {
|
|||||||
self.note_error(note);
|
self.note_error(note);
|
||||||
return vec![Action::JournalFailure {
|
return vec![Action::JournalFailure {
|
||||||
source: input.to_string(),
|
source: input.to_string(),
|
||||||
|
advanced: submission_mode.is_advanced(),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
// Issue #17: simple-mode (DSL) counterpart. A wrong-count
|
// Issue #17: simple-mode (DSL) counterpart. A wrong-count
|
||||||
@@ -1988,6 +2023,7 @@ impl App {
|
|||||||
self.note_error(render_usage_block(input, mode));
|
self.note_error(render_usage_block(input, mode));
|
||||||
return vec![Action::JournalFailure {
|
return vec![Action::JournalFailure {
|
||||||
source: input.to_string(),
|
source: input.to_string(),
|
||||||
|
advanced: submission_mode.is_advanced(),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
self.push_output(OutputLine::echo(input, mode));
|
self.push_output(OutputLine::echo(input, mode));
|
||||||
@@ -2074,6 +2110,7 @@ impl App {
|
|||||||
// append; the App only emits the intent.
|
// append; the App only emits the intent.
|
||||||
vec![Action::JournalFailure {
|
vec![Action::JournalFailure {
|
||||||
source: input.to_string(),
|
source: input.to_string(),
|
||||||
|
advanced: submission_mode.is_advanced(),
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5493,6 +5530,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
facts: crate::friendly::FailureContext::default(),
|
facts: crate::friendly::FailureContext::default(),
|
||||||
source: String::new(),
|
source: String::new(),
|
||||||
|
advanced: false,
|
||||||
});
|
});
|
||||||
let last = app.output.back().unwrap();
|
let last = app.output.back().unwrap();
|
||||||
assert_eq!(last.kind, OutputKind::Error);
|
assert_eq!(last.kind, OutputKind::Error);
|
||||||
@@ -5551,6 +5589,7 @@ mod tests {
|
|||||||
error: err,
|
error: err,
|
||||||
facts,
|
facts,
|
||||||
source: String::new(),
|
source: String::new(),
|
||||||
|
advanced: false,
|
||||||
});
|
});
|
||||||
let body = app
|
let body = app
|
||||||
.output
|
.output
|
||||||
@@ -5600,6 +5639,7 @@ mod tests {
|
|||||||
error: err,
|
error: err,
|
||||||
facts,
|
facts,
|
||||||
source: String::new(),
|
source: String::new(),
|
||||||
|
advanced: false,
|
||||||
});
|
});
|
||||||
let body = app
|
let body = app
|
||||||
.output
|
.output
|
||||||
@@ -5632,6 +5672,7 @@ mod tests {
|
|||||||
error: err(),
|
error: err(),
|
||||||
facts: crate::friendly::FailureContext::default(),
|
facts: crate::friendly::FailureContext::default(),
|
||||||
source: String::new(),
|
source: String::new(),
|
||||||
|
advanced: false,
|
||||||
});
|
});
|
||||||
let verbose_text = app
|
let verbose_text = app
|
||||||
.output
|
.output
|
||||||
@@ -5652,6 +5693,7 @@ mod tests {
|
|||||||
error: err(),
|
error: err(),
|
||||||
facts: crate::friendly::FailureContext::default(),
|
facts: crate::friendly::FailureContext::default(),
|
||||||
source: String::new(),
|
source: String::new(),
|
||||||
|
advanced: false,
|
||||||
});
|
});
|
||||||
let short_text = app
|
let short_text = app
|
||||||
.output
|
.output
|
||||||
@@ -6327,7 +6369,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
actions.as_slice(),
|
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:?}",
|
"expected JournalFailure for the typo'd line; got {actions:?}",
|
||||||
);
|
);
|
||||||
@@ -6350,11 +6392,12 @@ mod tests {
|
|||||||
},
|
},
|
||||||
facts: crate::friendly::FailureContext::default(),
|
facts: crate::friendly::FailureContext::default(),
|
||||||
source: "drop table Ghost".to_string(),
|
source: "drop table Ghost".to_string(),
|
||||||
|
advanced: false,
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
actions.as_slice(),
|
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:?}",
|
"expected JournalFailure carrying the source; got {actions:?}",
|
||||||
);
|
);
|
||||||
@@ -6483,6 +6526,80 @@ mod tests {
|
|||||||
assert_eq!(app.input, "drop table AX");
|
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]
|
#[test]
|
||||||
fn add_column_with_text_type_emits_execute_action() {
|
fn add_column_with_text_type_emits_execute_action() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -2262,12 +2262,10 @@ fn handle_request(
|
|||||||
// (`show table`), it belongs in the complete journal
|
// (`show table`), it belongs in the complete journal
|
||||||
// (ADR-0034). ADR-0035 §4.
|
// (ADR-0034). ADR-0035 §4.
|
||||||
if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) {
|
if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) {
|
||||||
let result = do_describe_table(conn, &name).and_then(|desc| {
|
// ADR-0052: journaling moved to the dispatch layer; this
|
||||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
// no-op skip is an `Ok` outcome there and is journalled by
|
||||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
// the spawn like any other.
|
||||||
}
|
let result = do_describe_table(conn, &name).map(CreateOutcome::Skipped);
|
||||||
Ok(CreateOutcome::Skipped(desc))
|
|
||||||
});
|
|
||||||
let _ = reply.send(result);
|
let _ = reply.send(result);
|
||||||
} else {
|
} else {
|
||||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
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
|
// line is still journalled — like the `CREATE TABLE IF NOT
|
||||||
// EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4.
|
// EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4.
|
||||||
if if_exists && !user_table_exists(conn, &name).unwrap_or(false) {
|
if if_exists && !user_table_exists(conn, &name).unwrap_or(false) {
|
||||||
let result = (|| {
|
// ADR-0052: journaling moved to the dispatch layer.
|
||||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
let result: Result<DropOutcome, DbError> = Ok(DropOutcome::Skipped);
|
||||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
|
||||||
}
|
|
||||||
Ok(DropOutcome::Skipped)
|
|
||||||
})();
|
|
||||||
let _ = reply.send(result);
|
let _ = reply.send(result);
|
||||||
} else {
|
} else {
|
||||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
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
|
// ADR-0035 §4). Existence uses the same user-index lookup as
|
||||||
// `do_drop_index` (`sql IS NOT NULL`).
|
// `do_drop_index` (`sql IS NOT NULL`).
|
||||||
if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
|
if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
|
||||||
let result = (|| {
|
// ADR-0052: journaling moved to the dispatch layer.
|
||||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
let result: Result<DropIndexOutcome, DbError> =
|
||||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
Ok(DropIndexOutcome::Skipped);
|
||||||
}
|
|
||||||
Ok(DropIndexOutcome::Skipped)
|
|
||||||
})();
|
|
||||||
let _ = reply.send(result);
|
let _ = reply.send(result);
|
||||||
} else {
|
} else {
|
||||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
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).
|
// hits `do_add_index`'s redundant-set refusal (ADR-0025).
|
||||||
let resolved = resolve_index_name(name.as_deref(), &table, &columns);
|
let resolved = resolve_index_name(name.as_deref(), &table, &columns);
|
||||||
if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
|
if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
|
||||||
let result = (|| {
|
// ADR-0052: journaling moved to the dispatch layer.
|
||||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
let result: Result<CreateIndexOutcome, DbError> =
|
||||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
Ok(CreateIndexOutcome::Skipped(resolved));
|
||||||
}
|
|
||||||
Ok(CreateIndexOutcome::Skipped(resolved.clone()))
|
|
||||||
})();
|
|
||||||
let _ = reply.send(result);
|
let _ = reply.send(result);
|
||||||
} else {
|
} else {
|
||||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
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
|
/// Read-only requests (no schema change, no row writes, no
|
||||||
/// drops) still use this to append `history.log` if `source`
|
/// drops) still use this to append `history.log` if `source`
|
||||||
/// is set; they pass an empty `Changes`.
|
/// 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(
|
fn finalize_persistence(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
persistence: Option<&Persistence>,
|
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,
|
changes: &Changes,
|
||||||
) -> Result<(), DbError> {
|
) -> Result<(), DbError> {
|
||||||
let Some(p) = persistence else {
|
let Some(p) = persistence else {
|
||||||
@@ -3093,10 +3092,6 @@ fn finalize_persistence(
|
|||||||
p.delete_table_data(table)
|
p.delete_table_data(table)
|
||||||
.map_err(DbError::from_persistence)?;
|
.map_err(DbError::from_persistence)?;
|
||||||
}
|
}
|
||||||
if let Some(text) = source {
|
|
||||||
p.append_history(text)
|
|
||||||
.map_err(DbError::from_persistence)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8361,18 +8356,18 @@ fn do_drop_index(
|
|||||||
/// Read-only wrapper around `do_describe_table` that runs an
|
/// Read-only wrapper around `do_describe_table` that runs an
|
||||||
/// auxiliary `history.log` append for user-issued
|
/// auxiliary `history.log` append for user-issued
|
||||||
/// `show table` commands.
|
/// `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(
|
fn do_describe_table_request(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
persistence: Option<&Persistence>,
|
_persistence: Option<&Persistence>,
|
||||||
source: Option<&str>,
|
_source: Option<&str>,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<TableDescription, DbError> {
|
) -> Result<TableDescription, DbError> {
|
||||||
let description = do_describe_table(conn, name)?;
|
do_describe_table(conn, name)
|
||||||
if let (Some(p), Some(text)) = (persistence, source) {
|
|
||||||
p.append_history(text)
|
|
||||||
.map_err(DbError::from_persistence)?;
|
|
||||||
}
|
|
||||||
Ok(description)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
|
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
|
/// Read-only `show data` wrapper. ADR-0052: journaling moved to the
|
||||||
/// `show data` user commands.
|
/// dispatch layer (`_persistence` / `_source` vestigial).
|
||||||
fn do_query_data_request(
|
fn do_query_data_request(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
persistence: Option<&Persistence>,
|
_persistence: Option<&Persistence>,
|
||||||
source: Option<&str>,
|
_source: Option<&str>,
|
||||||
table: &str,
|
table: &str,
|
||||||
filter: Option<&Expr>,
|
filter: Option<&Expr>,
|
||||||
limit: Option<u64>,
|
limit: Option<u64>,
|
||||||
) -> Result<DataResult, DbError> {
|
) -> Result<DataResult, DbError> {
|
||||||
let data = do_query_data(conn, table, filter, limit)?;
|
// ADR-0052: journaling moved to the dispatch layer (`_persistence` /
|
||||||
if let (Some(p), Some(text)) = (persistence, source) {
|
// `_source` vestigial; the spawn journals the `Ok` outcome).
|
||||||
p.append_history(text)
|
do_query_data(conn, table, filter, limit)
|
||||||
.map_err(DbError::from_persistence)?;
|
|
||||||
}
|
|
||||||
Ok(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Worker handler for `Request::RunSelect` (ADR-0030 §6,
|
/// Worker handler for `Request::RunSelect` (ADR-0030 §6, ADR-0031).
|
||||||
/// ADR-0031). Mirrors `do_query_data_request`: run the
|
/// ADR-0052: journaling moved to the dispatch layer, so this no longer
|
||||||
/// statement, append the literal line to `history.log` so a
|
/// appends to `history.log` — the spawn journals the literal line so a
|
||||||
/// replay re-runs it (ADR-0030 §11).
|
/// replay re-runs it (ADR-0030 §11).
|
||||||
fn do_run_select_request(
|
fn do_run_select_request(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
persistence: Option<&Persistence>,
|
_persistence: Option<&Persistence>,
|
||||||
source: Option<&str>,
|
_source: Option<&str>,
|
||||||
sql: &str,
|
sql: &str,
|
||||||
) -> Result<DataResult, DbError> {
|
) -> Result<DataResult, DbError> {
|
||||||
let data = do_run_select(conn, sql)?;
|
do_run_select(conn, sql)
|
||||||
if let (Some(p), Some(text)) = (persistence, source) {
|
|
||||||
p.append_history(text)
|
|
||||||
.map_err(DbError::from_persistence)?;
|
|
||||||
}
|
|
||||||
Ok(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Currently-stored non-NULL values of one column, for shortid
|
/// Currently-stored non-NULL values of one column, for shortid
|
||||||
@@ -11119,8 +11106,10 @@ fn read_relationships_inbound(
|
|||||||
/// violation aborts with a fatal error.
|
/// violation aborts with a fatal error.
|
||||||
fn do_rebuild_from_text(
|
fn do_rebuild_from_text(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
persistence: Option<&Persistence>,
|
// Vestigial since ADR-0052: `rebuild` is journalled at the dispatch
|
||||||
source: Option<&str>,
|
// layer (`spawn_rebuild`), not here.
|
||||||
|
_persistence: Option<&Persistence>,
|
||||||
|
_source: Option<&str>,
|
||||||
project_path: &Path,
|
project_path: &Path,
|
||||||
) -> Result<(), DbError> {
|
) -> Result<(), DbError> {
|
||||||
debug!(path = %project_path.display(), "rebuild_from_text");
|
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
|
// 7. Append `history.log` if this rebuild was
|
||||||
// user-initiated (the silent on-load case has
|
// user-initiated (the silent on-load case has
|
||||||
// `source = None`).
|
// `source = None`).
|
||||||
if let (Some(p), Some(text)) = (persistence, source) {
|
// ADR-0052: `rebuild` is journalled at the dispatch layer
|
||||||
p.append_history(text)
|
// (`spawn_rebuild`), not here — journaling left the worker.
|
||||||
.map_err(DbError::from_persistence)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ pub enum AppEvent {
|
|||||||
/// commands, so an execution failure would otherwise be
|
/// commands, so an execution failure would otherwise be
|
||||||
/// lost across sessions.
|
/// lost across sessions.
|
||||||
source: String,
|
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.
|
/// Refreshed list of tables in the database.
|
||||||
TablesRefreshed(Vec<String>),
|
TablesRefreshed(Vec<String>),
|
||||||
|
|||||||
+112
-4
@@ -28,7 +28,35 @@ use super::PersistenceError;
|
|||||||
pub(super) const STATUS_OK: &str = "ok";
|
pub(super) const STATUS_OK: &str = "ok";
|
||||||
pub(super) const STATUS_ERR: &str = "err";
|
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 {
|
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
|
||||||
format_record_with_status(command_text, timestamp_iso, STATUS_OK)
|
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.
|
// characters) is preserved.
|
||||||
let mut parts = line.splitn(3, '|');
|
let mut parts = line.splitn(3, '|');
|
||||||
let _ts = parts.next()?;
|
let _ts = parts.next()?;
|
||||||
let _status = parts.next()?;
|
let status = parts.next()?;
|
||||||
let source = 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
|
/// 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) {
|
if !looks_like_iso8601(ts) {
|
||||||
return None;
|
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 {
|
Some(JournalRecord {
|
||||||
status_is_ok: status == STATUS_OK,
|
status_is_ok,
|
||||||
source: unescape_command(source),
|
source: unescape_command(source),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -436,4 +478,70 @@ mod tests {
|
|||||||
let body = fs::read_to_string(&path).unwrap();
|
let body = fs::read_to_string(&path).unwrap();
|
||||||
assert_eq!(body, "first|ok|a\nsecond|ok|b\n");
|
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`.
|
/// Append one successful-command record to `history.log`. `advanced`
|
||||||
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> {
|
/// (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 path = self.project_path.join(HISTORY_LOG);
|
||||||
let line = history::format_record(command_text, history::utc_iso8601_now());
|
let status = history::status_token(history::STATUS_OK, advanced);
|
||||||
debug!(len = command_text.len(), "persist: append ok record to history.log");
|
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)
|
history::append(&path, &line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,14 +425,22 @@ impl Persistence {
|
|||||||
/// transactional `ok` journal). Best-effort at the call site:
|
/// transactional `ok` journal). Best-effort at the call site:
|
||||||
/// a failure to record a failure must never escalate a user
|
/// a failure to record a failure must never escalate a user
|
||||||
/// error into a fatal (ADR-0034 §4).
|
/// 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 path = self.project_path.join(HISTORY_LOG);
|
||||||
|
let status = history::status_token(history::STATUS_ERR, advanced);
|
||||||
let line = history::format_record_with_status(
|
let line = history::format_record_with_status(
|
||||||
command_text,
|
command_text,
|
||||||
history::utc_iso8601_now(),
|
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)
|
history::append(&path, &line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,8 +600,8 @@ mod tests {
|
|||||||
fn append_history_creates_and_appends() {
|
fn append_history_creates_and_appends() {
|
||||||
let dir = tempdir();
|
let dir = tempdir();
|
||||||
let p = Persistence::new(dir.path().to_path_buf());
|
let p = Persistence::new(dir.path().to_path_buf());
|
||||||
p.append_history("create table Foo with pk id(serial)").unwrap();
|
p.append_history("create table Foo with pk id(serial)", false).unwrap();
|
||||||
p.append_history("insert into Foo (1)").unwrap();
|
p.append_history("insert into Foo (1)", false).unwrap();
|
||||||
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
|
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
|
||||||
let lines: Vec<&str> = body.trim_end().lines().collect();
|
let lines: Vec<&str> = body.trim_end().lines().collect();
|
||||||
assert_eq!(lines.len(), 2);
|
assert_eq!(lines.len(), 2);
|
||||||
|
|||||||
+48
-9
@@ -479,17 +479,19 @@ async fn run_loop(
|
|||||||
command,
|
command,
|
||||||
source,
|
source,
|
||||||
submission_mode,
|
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
|
// ADR-0034 §1/§4: record a failed command as an
|
||||||
// `err` record. Best-effort — a failure to record
|
// `err` record (ADR-0052: `err:adv` when advanced).
|
||||||
// a failure must never escalate a user error into
|
// Best-effort — a failure to record a failure must
|
||||||
// a fatal, so the result is logged and ignored.
|
// never escalate a user error into a fatal, so the
|
||||||
|
// result is logged and ignored.
|
||||||
if let Err(e) = crate::persistence::Persistence::new(
|
if let Err(e) = crate::persistence::Persistence::new(
|
||||||
session.project().path().to_path_buf(),
|
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)");
|
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
|
// history.log. The worker's persistence is wired but not
|
||||||
// directly addressable from here, so we use a fresh
|
// directly addressable from here, so we use a fresh
|
||||||
// Persistence handle for this single line.
|
// 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
|
// Update the resume pointer so the next `--resume` launch
|
||||||
// reopens the project we just switched to — unless it is a
|
// reopens the project we just switched to — unless it is a
|
||||||
@@ -1040,7 +1044,9 @@ fn spawn_export(
|
|||||||
source: String,
|
source: String,
|
||||||
event_tx: mpsc::Sender<AppEvent>,
|
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 {
|
tokio::spawn(async move {
|
||||||
let outcome = tokio::task::spawn_blocking(move || {
|
let outcome = tokio::task::spawn_blocking(move || {
|
||||||
do_export(&project_path, &project_name, &data_root, target.as_deref())
|
do_export(&project_path, &project_name, &data_root, target.as_deref())
|
||||||
@@ -1295,11 +1301,20 @@ fn spawn_rebuild(
|
|||||||
source: String,
|
source: String,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let source_for_journal = source.clone();
|
||||||
match database
|
match database
|
||||||
.rebuild_from_text(project_path.clone(), Some(source))
|
.rebuild_from_text(project_path.clone(), Some(source))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => {
|
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)
|
let summary = summarize_project(&project_path)
|
||||||
.unwrap_or_else(|_| "rebuild complete".to_string());
|
.unwrap_or_else(|_| "rebuild complete".to_string());
|
||||||
let _ = event_tx
|
let _ = event_tx
|
||||||
@@ -1407,10 +1422,11 @@ fn spawn_dsl_dispatch(
|
|||||||
command: Command,
|
command: Command,
|
||||||
source: String,
|
source: String,
|
||||||
submission_mode: crate::app::EffectiveMode,
|
submission_mode: crate::app::EffectiveMode,
|
||||||
|
project_path: std::path::PathBuf,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Retain the source for `DslFailed` so the App can journal a
|
// Retain the source for journaling (ADR-0034 §1/§2; ADR-0052
|
||||||
// rejected command as `err` (ADR-0034 §1/§2).
|
// moved success journaling here, next to the failure path).
|
||||||
let source_for_journal = source.clone();
|
let source_for_journal = source.clone();
|
||||||
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
|
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
|
||||||
// command submitted in an advanced effective mode (ADR-0037).
|
// 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 lookups = collect_echo_lookups(&database, &command, submission_mode).await;
|
||||||
let echo = crate::echo::echo_for(&command, submission_mode);
|
let echo = crate::echo::echo_for(&command, submission_mode);
|
||||||
let outcome = execute_command_typed(&database, command.clone(), source).await;
|
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 {
|
let event = match outcome {
|
||||||
Ok(CommandOutcome::Schema(description)) => {
|
Ok(CommandOutcome::Schema(description)) => {
|
||||||
let schema_echo = build_schema_echo(
|
let schema_echo = build_schema_echo(
|
||||||
@@ -1569,6 +1598,7 @@ fn spawn_dsl_dispatch(
|
|||||||
error,
|
error,
|
||||||
facts,
|
facts,
|
||||||
source: source_for_journal,
|
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;
|
execute_command_typed(database, command, command_text.clone()).await;
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(_) => {
|
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;
|
count += 1;
|
||||||
}
|
}
|
||||||
Err(DbError::PersistenceFatal {
|
Err(DbError::PersistenceFatal {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use rdbms_playground::db::Database;
|
|||||||
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
|
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
|
||||||
use rdbms_playground::persistence::Persistence;
|
use rdbms_playground::persistence::Persistence;
|
||||||
use rdbms_playground::project::{
|
use rdbms_playground::project::{
|
||||||
self, DATA_DIR, HISTORY_LOG, PROJECT_YAML,
|
self, DATA_DIR, PROJECT_YAML,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn tempdir() -> tempfile::TempDir {
|
fn tempdir() -> tempfile::TempDir {
|
||||||
@@ -44,11 +44,6 @@ fn open_project(
|
|||||||
(project, db, path)
|
(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 {
|
fn read_yaml(project_path: &Path) -> String {
|
||||||
fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml")
|
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: serial"), "yaml: {yaml}");
|
||||||
assert!(yaml.contains("type: text"), "yaml: {yaml}");
|
assert!(yaml.contains("type: text"), "yaml: {yaml}");
|
||||||
|
|
||||||
let history = read_history(&path);
|
// ADR-0052: journaling moved to the dispatch layer (the worker no
|
||||||
assert_eq!(history.len(), 1, "expected one history line; got {history:?}");
|
// longer writes history.log); this test verifies only the yaml state.
|
||||||
assert!(history[0].ends_with("|ok|create table Customers with pk id(serial)"));
|
// Journaling is covered by the history.rs/app.rs/replay tests.
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -119,11 +114,8 @@ fn insert_writes_csv_and_history() {
|
|||||||
assert_eq!(lines[0], "id,Name");
|
assert_eq!(lines[0], "id,Name");
|
||||||
assert_eq!(lines[1], "1,Alice");
|
assert_eq!(lines[1], "1,Alice");
|
||||||
|
|
||||||
let history = read_history(&path);
|
// ADR-0052: journaling moved off the worker; this test verifies the
|
||||||
assert!(
|
// csv state only (journaling covered elsewhere).
|
||||||
history.iter().any(|l| l.ends_with("|ok|insert into Customers ('Alice')")),
|
|
||||||
"history missing insert: {history:?}",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn failed_command_does_not_append_history_or_change_yaml() {
|
fn failed_command_does_not_append_history_or_change_yaml() {
|
||||||
let data = tempdir();
|
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");
|
assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml");
|
||||||
});
|
});
|
||||||
|
|
||||||
let history = read_history(&path);
|
// ADR-0052: journaling moved off the worker; this test now verifies
|
||||||
// Only the first (successful) create_table should have logged.
|
// only that a failed command does not change the yaml state.
|
||||||
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:?}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -178,10 +178,7 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
|
|||||||
assert_eq!(rows.rows.len(), 1);
|
assert_eq!(rows.rows.len(), 1);
|
||||||
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
|
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
|
||||||
|
|
||||||
// history.log should contain the rebuild entry.
|
// ADR-0052: `rebuild` journaling moved to the dispatch layer
|
||||||
let history = fs::read_to_string(project_path.join("history.log")).unwrap();
|
// (`spawn_rebuild`), so the direct worker call here no longer writes
|
||||||
assert!(
|
// history.log; this test verifies the wipe/reload behaviour only.
|
||||||
history.lines().any(|l| l.ends_with("|ok|rebuild")),
|
|
||||||
"history.log missing rebuild entry:\n{history}",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,9 +141,9 @@ fn read_recent_history_returns_appended_entries_in_order() {
|
|||||||
let tmp = tempdir();
|
let tmp = tempdir();
|
||||||
let project = Project::create_temp(tmp.path()).unwrap();
|
let project = Project::create_temp(tmp.path()).unwrap();
|
||||||
let p = Persistence::new(project.path().to_path_buf());
|
let p = Persistence::new(project.path().to_path_buf());
|
||||||
p.append_history("create table A with pk").unwrap();
|
p.append_history("create table A with pk", false).unwrap();
|
||||||
p.append_history("create table B with pk").unwrap();
|
p.append_history("create table B with pk", false).unwrap();
|
||||||
p.append_history("create table C with pk").unwrap();
|
p.append_history("create table C with pk", false).unwrap();
|
||||||
let entries = p.read_recent_history(10).unwrap();
|
let entries = p.read_recent_history(10).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
entries,
|
entries,
|
||||||
@@ -165,9 +165,9 @@ fn hydration_reads_both_ok_and_err_records() {
|
|||||||
let tmp = tempdir();
|
let tmp = tempdir();
|
||||||
let project = Project::create_temp(tmp.path()).unwrap();
|
let project = Project::create_temp(tmp.path()).unwrap();
|
||||||
let p = Persistence::new(project.path().to_path_buf());
|
let p = Persistence::new(project.path().to_path_buf());
|
||||||
p.append_history("create table A with pk").unwrap();
|
p.append_history("create table A with pk", false).unwrap();
|
||||||
p.append_history_failure("insert into A (1, 2, 3)").unwrap();
|
p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
|
||||||
p.append_history("show data A").unwrap();
|
p.append_history("show data A", false).unwrap();
|
||||||
let entries = p.read_recent_history(10).unwrap();
|
let entries = p.read_recent_history(10).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
entries,
|
entries,
|
||||||
@@ -194,6 +194,32 @@ fn seed_history_replaces_in_memory_history() {
|
|||||||
assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]);
|
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]
|
#[test]
|
||||||
fn seed_history_preserves_chronological_order_for_navigation() {
|
fn seed_history_preserves_chronological_order_for_navigation() {
|
||||||
let mut app = App::new();
|
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");
|
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) —
|
// — FK sampling, empty-parent error, block guard (ADR-0048 D14 / D1) —
|
||||||
|
|
||||||
/// `Users(id serial pk, name text)` + `Orders(id serial pk, user_id
|
/// `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]
|
#[test]
|
||||||
fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
|
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();
|
let r = rt();
|
||||||
make_t(&db, &r);
|
make_t(&db, &r);
|
||||||
r.block_on(db.sql_create_index(
|
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::Skipped(name) => assert_eq!(name, "ix"),
|
||||||
CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"),
|
CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"),
|
||||||
}
|
}
|
||||||
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
|
// ADR-0052: journaling moved to the dispatch layer; this test now
|
||||||
assert!(log.contains(line), "the skipped create should be journalled; log:\n{log}");
|
// asserts only the no-op `Skipped` outcome.
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -590,7 +590,7 @@ fn if_not_exists_noop_is_journalled() {
|
|||||||
// A successful no-op is still a submission and belongs in the
|
// A successful no-op is still a submission and belongs in the
|
||||||
// complete journal (ADR-0034) — like read-only `show table`, and
|
// complete journal (ADR-0034) — like read-only `show table`, and
|
||||||
// unlike a *failed* duplicate-create (journalled `err`).
|
// unlike a *failed* duplicate-create (journalled `err`).
|
||||||
let (p, db, _d) = open(false);
|
let (_p, db, _d) = open(false);
|
||||||
let r = rt();
|
let r = rt();
|
||||||
r.block_on(db.sql_create_table(
|
r.block_on(db.sql_create_table(
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
@@ -617,8 +617,8 @@ fn if_not_exists_noop_is_journalled() {
|
|||||||
))
|
))
|
||||||
.expect("no-op");
|
.expect("no-op");
|
||||||
assert!(matches!(out, CreateOutcome::Skipped(_)));
|
assert!(matches!(out, CreateOutcome::Skipped(_)));
|
||||||
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
|
// ADR-0052: journaling moved to the dispatch layer; this test now
|
||||||
assert!(log.contains(noop), "the no-op skip should be journalled; log:\n{log}");
|
// asserts only the no-op `Skipped` outcome.
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -261,19 +261,6 @@ fn r2_cascade_with_subquery_where() {
|
|||||||
"only Bob's order remains: {orders_csv:?}");
|
"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]
|
#[test]
|
||||||
fn cascade_to_two_children_reports_both() {
|
fn cascade_to_two_children_reports_both() {
|
||||||
// DA gate (untested branch): a parent with TWO cascade children
|
// 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]
|
#[test]
|
||||||
fn if_exists_on_an_absent_index_is_a_noop_and_journalled() {
|
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 r = rt();
|
||||||
let line = "drop index if exists ghost_idx";
|
let line = "drop index if exists ghost_idx";
|
||||||
let out = r
|
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");
|
.expect("IF EXISTS on an absent index succeeds as a no-op");
|
||||||
assert!(matches!(out, DropIndexOutcome::Skipped));
|
assert!(matches!(out, DropIndexOutcome::Skipped));
|
||||||
// The no-op is still journalled (ADR-0034), like the create-skip.
|
// 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");
|
// ADR-0052: journaling moved to the dispatch layer; this test now
|
||||||
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}");
|
// asserts only the no-op `Skipped` outcome.
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ fn drop_table_removes_an_existing_table() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
|
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 r = rt();
|
||||||
let line = "drop table if exists Ghost";
|
let line = "drop table if exists Ghost";
|
||||||
let out = r
|
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");
|
.expect("IF EXISTS on an absent table succeeds as a no-op");
|
||||||
assert!(matches!(out, DropOutcome::Skipped));
|
assert!(matches!(out, DropOutcome::Skipped));
|
||||||
// The no-op is still journalled (ADR-0034), like the create-skip.
|
// 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");
|
// ADR-0052: journaling moved to the dispatch layer; this test now
|
||||||
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}");
|
// asserts only the no-op `Skipped` outcome.
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -132,30 +132,6 @@ fn no_column_list_full_arity_insert_persists() {
|
|||||||
assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}");
|
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]
|
#[test]
|
||||||
fn failed_insert_rolls_back_and_does_not_repersist() {
|
fn failed_insert_rolls_back_and_does_not_repersist() {
|
||||||
let (project, db, _dir) = open_project_db();
|
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:?}");
|
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]
|
#[test]
|
||||||
fn shortid_autofill_respects_mixed_case_column_name() {
|
fn shortid_autofill_respects_mixed_case_column_name() {
|
||||||
// ADR-0009 / 3d DA gate: identifiers are case-preserving. The
|
// 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:?}");
|
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
|
// 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(),
|
facts: rdbms_playground::friendly::FailureContext::default(),
|
||||||
source: String::new(),
|
source: String::new(),
|
||||||
|
advanced: false,
|
||||||
});
|
});
|
||||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Reference in New Issue
Block a user