Merge branch 'main' into website (m:n, logging, UI nav, demo overlays, vi-nav)

Brings a large batch of app work onto the website branch so the docs (and
casts) can reflect it:

- #24 vi-style j/k/g/G navigation in the load picker (ADR-0047 era) — unblocks
  a scriptable projects cast (autocast can send j/k; not arrows)
- #22 demonstration overlay layer (ADR-0047): `--demo` mode, keystroke badges,
  and step-caption info banners — usable from casts to highlight key moments
- C4 m:n convenience command (ADR-0045): `add m:n relationship … via <junction>`
- ADR-0046 UI: width-derived schema sidebar + Ctrl-O nav mode, responsive
  two-row input + horizontal scroll, geometry-fixed hint panel
- X1 comprehensive logging sweep across worker/parser/app/persistence/runtime
- FK fixes: compound-FK violation message names every column pair; inline FK
  referencing a compound PK points at the table-level form

Merged clean — no conflicts (the docs/website/ ADR namespace split kept the new
main ADRs 0045–0047 from colliding). Tests on the merged tree: 2290 passed,
0 failed (1 ignored doctest, inherited from main).
This commit is contained in:
claude@clouddev1
2026-06-11 10:06:18 +00:00
66 changed files with 6174 additions and 316 deletions
+300
View File
@@ -0,0 +1,300 @@
# ADR-0045: `create m:n relationship` convenience command (C4)
## Status
Accepted (2026-06-10); **implemented 2026-06-10**. Closes
`requirements.md` **C4**.
**Implementation note — a corrected ADR premise.** The plan claimed
"the walker already dispatches multiple nodes per entry word" and used
that to justify a *separate* `CREATE_M2N` node. That is true only in
**advanced** mode. The build hit **two** places hard-coded to assume
**≤1 DSL form per entry word in simple mode**: (1) the dispatcher
(`decide`) committed `simple.first()` unconditionally, so `create
table` shadowed `create m:n`; (2) the completion continuation-merge was
gated `if mode == Advanced`, so simple mode never surfaced `m:n` as a
candidate. Both were generalized to support multiple DSL forms per
entry word — **behaviour-preserving for every existing single-form
command** (the dispatch reduces to the old single-candidate commit; the
completion merge is gated on `simple_count > 1`). Verified: zero ripple
beyond the new command's own surfaces. The teaching echo (advanced-mode
DSL→SQL, ADR-0038) was also wired: `render_create_m2n` emits the
generated `CREATE TABLE … FOREIGN KEY …` from the post-exec junction
description (round-trips as valid SQL).
A second `/runda` DA pass (pre-commit) closed five coverage gaps
(highlighting, persistence round-trip, junction rename, name-collision,
missing-parent — the first two had been wrongly claimed verified) and
found two more issues: (a) `create m:n … as __rdbms_*` was accepted —
a hidden-orphan hole the new `as` slot **exposed**, but rooted in the
simple-mode `TABLE_NAME_NEW` slot having no internal-name guard (so
plain `create table __rdbms_*` had it too). Fixed at the **root**
a `reject_internal_table_name` guard in the shared `do_create_table`,
closing every path (the advanced-SQL path already rejected at parse).
(b) the usage disambiguator (`usage_key_for_input`) handled the `1:n`
opener but not `m:n`, so `create m:n …` resolved to no usage form —
fixed with an explicit `m:n` branch. All four
design forks were escalated and user-confirmed at the recommended
option (compound-over-FKs junction PK; `CASCADE` actions; auto-name +
optional `as`; both modes). Two follow-up points were also confirmed
in a `/runda` DA pass: **self-referential m:n is refused outright**
(user: "refuse — full stop"; it is a beginner-facing convenience, not
the place for directional-naming complexity), and the FK column naming
is **`{parent_table}_{pk_column}`**. The DA pass additionally
established — against the user's initial assumption — that **PK-less
tables *are* reachable** (advanced-mode SQL `create table t (a int)`
declares no PK; `sql_create_table.rs` asserts `pk.is_empty()`), so the
parent-PK guard (D7) is retained as a correctness check.
Builds on ADR-0013 (named 1:n relationships, the relationship
metadata table, the rebuild-table primitive), ADR-0043 (compound,
list-based FK references — the junction may reference compound parent
PKs), ADR-0011 (`Type::fk_target_type()` for FK column typing), and
the existing `do_create_table` executor (which already accepts
`foreign_keys: Vec<SqlForeignKey>` and writes relationship metadata
per FK). Honours ADR-0003 (mode model), ADR-0009 (DSL conventions),
ADR-0002 (no engine name in user-facing strings), and ADR-0024
(unified grammar / `CommandNode` registration, completion, hints,
help-id, usage-id wiring).
## Context
A many-to-many relationship is modelled in a relational database by a
**junction table** (a.k.a. associative / bridge table) that holds one
foreign key to each of the two parents. Today a learner can build this
by hand: `create table` the junction, then `add 1:n relationship`
twice. That is three commands and requires the learner to already know
the junction-table pattern — exactly the concept C4 is meant to
*teach*.
C4 (`requirements.md`): *"`create m:n relationship from <T1> to <T2>`
produces an auto-named junction table the user can rename; pulls
primary keys and FK definitions automatically."*
The relationship machinery this builds on is freshly solid: ADR-0043
made the relationship model list-based (compound-aware) across six
layers, and ADR-0044 gave relationships a visual representation. C4 is
the natural convenience layer on top.
## Decision
Add a DSL command:
```
create m:n relationship from <T1> to <T2> [as <name>]
```
It generates a **junction table** with one FK column per primary-key
column of each parent, a **compound primary key** over all of those FK
columns, and **two 1:n relationships** (junction → T1, junction → T2),
all in a single transaction (= one undo step). The junction is a
normal table: `rename table`, `drop table`, `show table`, `insert`,
etc. all work on it afterward.
### D1 — Junction primary key: compound over the FK columns (fork, user-chosen)
The junction's PK is the **combination of all its FK columns**. For
`create m:n relationship from Students to Courses` (both PK `id`):
```
Students_Courses
Students_id int ┐ PRIMARY KEY (Students_id, Courses_id)
Courses_id int ┘
FOREIGN KEY (Students_id) REFERENCES Students(id)
FOREIGN KEY (Courses_id) REFERENCES Courses(id)
```
This is the textbook junction: the `(Students_id, Courses_id)` pair is
unique, so a student cannot be linked to the same course twice. It is
the most pedagogically correct model and needs no surrogate key.
*Rejected:* a surrogate `serial` PK + `UNIQUE` over the FK pair (adds a
key the learner did not ask for); two FK columns with no PK (allows
duplicate links — wrong lesson).
### D2 — Referential actions: `CASCADE` on delete and update (fork, user-chosen)
Both generated FKs default to `ON DELETE CASCADE ON UPDATE CASCADE`. A
junction row is meaningless without both ends, so deleting a parent
(a Student or a Course) removes its link rows automatically — the
natural junction semantics, and a clean teaching demonstration of
cascade. There is no syntax in this command to override the actions;
the learner who wants different actions builds the junction by hand
(or drops + re-adds a relationship). This keeps the convenience
command convenient.
### D3 — Naming: auto-name `<T1>_<T2>`, optional `as <name>` (fork, user-chosen)
The junction table is auto-named `{T1}_{T2}` (e.g. `Students_Courses`).
An optional `as <name>` clause overrides it — consistent with
`add 1:n relationship [as <name>]` and saving a follow-up
`rename table`. The two generated relationships are auto-named by the
existing relationship-name resolver (e.g.
`Students_id_to_Students_Courses_Students_id`), exactly as `create
table` with inline FKs already names them.
### D4 — FK column naming: `{parent_table}_{pk_column}`
Each FK column is named `{parent_table}_{pk_column}` — one per PK
column of each parent. This disambiguates the common case where both
parents share a PK column name (both `id``Students_id`,
`Courses_id`), and generalises to compound parent PKs: a parent
`Sections(course_id, term)` contributes `Sections_course_id` and
`Sections_term`. Column types come from each parent PK column's
`Type::fk_target_type()` (ADR-0011): `serial → int`, `shortid → text`,
others identity — so the junction columns are plain storable types,
never auto-generating.
### D5 — Mode availability: both simple and advanced
`create m:n relationship` is a `CommandCategory::Simple` DSL command,
reachable in **both** input modes — the same posture as the sibling
relationship commands (`drop relationship … Mode::Advanced` is a tested
path today). There is no SQL spelling for it; an advanced-mode user who
prefers raw SQL still has `CREATE TABLE` + `FOREIGN KEY`. The command
is purely additive teaching sugar, so making it available everywhere is
harmless and consistent.
### D6 — Implementation: one `do_create_table` call, not a batch
The junction table and both relationships are created by **building a
single `Command`/worker request that `do_create_table` already
handles**: `do_create_table` accepts `columns`, `primary_key`, and
`foreign_keys: Vec<SqlForeignKey>`, and already inserts relationship
metadata for each FK. So the executor:
1. Canonicalises `T1`, `T2` (`require_canonical_table`) and reads each
parent's PK (`read_schema().primary_key`). **D7 — parent-PK guard:**
if either parent's `primary_key` is empty, error with a friendly
"`<table>` has no primary key, so it cannot anchor an m:n
relationship" *before* building any FK. This is a real case — a
table created via advanced-mode SQL `create table t (a int)` has no
PK (`sql_create_table.rs` asserts `pk.is_empty()` for that form) —
not a theoretical one, so the guard is a correctness requirement,
not defensive padding.
2. Builds the junction `ColumnSpec`s (one per parent PK column, typed
via `fk_target_type`), the compound `primary_key` list, and two
`SqlForeignKey` values (`on_delete = on_update = Cascade`).
3. Calls the create-table path, which creates the table + both FKs +
all metadata in **one transaction** — naturally one undo step, no
`BeginBatch`/`EndBatch` bracketing needed.
This reuses the most-tested machinery and inherits its persistence,
metadata, and FK-validation behaviour for free.
A new typed command variant `Command::CreateM2nRelationship { t1, t2,
name }` carries the parsed form; the runtime/executor expands it to the
junction definition. (We do **not** lower it to `Command::CreateTable`
at parse time — keeping a distinct command preserves command identity
per the X5 "unique commands for every unique case" principle, and lets
the teaching echo speak in m:n terms.)
## Grammar, AST, and cross-cutting wiring
A new command is not done when it parses — it must light up every
surface a learner touches. Enumerated here so none is missed
(verification in **Testing**).
- **Grammar node** `CREATE_M2N` (`ddl.rs`): a separate `CommandNode`
with `entry: create`, shape `m:n relationship [as <name>] from <T1>
to <T2>`, registered in `REGISTRY` as `CommandCategory::Simple`. A
separate node (not a branch inside `CREATE`) keeps the tested
create-table builder untouched; the walker already dispatches
multiple nodes per entry word. The `m:n` opener mirrors `1:n`
(`Word("m")`, `Punct(':')`, `Word("n")`).
- **AST builder** `build_create_m2n``Command::CreateM2nRelationship`.
- **Command + worker plumbing:** `Command::CreateM2nRelationship`
variant; `Request::CreateM2nRelationship`; runtime
`execute_command_typed` arm; `Database::create_m2n_relationship`
public API; executor `do_create_m2n_relationship` (per D6).
- **Completion:** add `("m", "m:n")` to `COMPOSITE_CANDIDATES`
(`completion.rs`) so Tab on `create m` offers `m:n` as one fluent
piece (exactly as `("1", "1:n")` does for `add`). Identifier slots
for `<T1>`/`<T2>` inherit table-name completion from the walker's
`IdentSource::Tables` automatically.
- **Hints:** set `HintMode`s on the `CREATE_M2N` nodes so the ambient
hint panel guides `from` / table / `to` / table / optional `as`,
matching the `add 1:n relationship` hinting.
- **Highlighting:** automatic — `walker/highlight.rs` is grammar-driven
with no per-command special-casing; verify the line highlights.
- **Help:** `help_id: Some("ddl.create_m2n")` → the command appears in
`help` automatically (REGISTRY iteration) and under `help create`;
add the `help.ddl.create_m2n` catalog string.
- **Usage / parse errors:** `usage_ids: &["parse.usage.create_m2n"]`
→ a malformed `create m:n …` shows the form in the usage block; add
the `parse.usage.create_m2n` catalog string. Add the near-miss cases
to the `parse_error_pedagogy` matrix (ADR-0042) for the `create`
entry word.
- **Teaching echo** (`echo.rs` + `build_schema_echo`): a
`CreateM2nRelationship` arm that echoes the generated junction —
the `create table` it built plus the two `FOREIGN KEY` lines — so the
learner sees the pattern the convenience expanded to.
- **Structure render:** the executor returns the junction's
`TableDescription`; the ADR-0044 render path already draws its
relationships as diagrams on the create echo? No — incidental
`create table` echoes keep prose (ADR-0044 reach); the m:n echo shows
the junction structure with its outbound FKs in the standard prose
form. (A future enhancement could draw both relationships as
diagrams; out of scope here.)
## Genuine forks (escalated, all resolved 2026-06-10)
1. **Junction PK** — compound-over-FKs (chosen) vs surrogate serial +
UNIQUE vs no PK. → D1.
2. **Referential actions**`CASCADE` (chosen) vs `NO ACTION` vs
`RESTRICT`. → D2.
3. **Naming** — auto-name + optional `as` (chosen) vs auto-name only.
→ D3.
4. **Mode** — both (chosen by default, unobjected) vs simple-only. → D5.
## Testing
Integration (`tests/it/`), test-first:
- **Functional:** `create m:n relationship from A to B` creates table
`A_B` with the two FK columns, compound PK over them, and two
enforced FKs; inserting a junction row with a non-existent parent is
refused; the `(fk1, fk2)` pair is unique (duplicate link refused).
- **`as <name>`** overrides the junction name.
- **Compound parent PK:** a parent with a 2-column PK contributes two
FK columns; the junction PK spans all of them; FKs enforce per pair.
- **Cascade:** deleting a parent row removes its junction rows.
- **Undo:** one `create m:n` is exactly one undo step (table + both
relationships gone after `undo`).
- **Persistence round-trip:** the junction + both relationships survive
a save → rebuild-from-text.
- **Errors:** missing parent table; parent without a PK; junction-name
collision with an existing table; (self-m:n → OOS error, below).
Cross-cutting (the surfaces a new command must light up):
- **Completion:** Tab after `create ` surfaces `m:n relationship`;
table-name completion fires at the `<T1>`/`<T2>` slots.
- **Help:** `help` lists the command; `help create` includes the m:n
form; the catalog string renders.
- **Usage / parse pedagogy:** a bare/half `create m:n` shows the usage
block; near-miss matrix entries added (`parse_error_pedagogy`).
- **Hints + highlighting:** ambient hint progression through the form;
the line highlights (snapshot or assertion as the sibling commands
use).
All tiers green, zero skips; clippy clean (nursery).
## Out of scope
- **Self-referential m:n** (`from T to T`) — **refused outright**
(user-confirmed, "full stop"): the two FK column sets would collide
on `{T}_{pkcol}`, and directional disambiguation (`from_*`/`to_*`)
is more complexity than this beginner-facing convenience warrants.
The executor detects `t1 == t2` (on the canonical names) and errors
with a friendly pointer ("an m:n relationship needs two different
tables — to link a table to itself, add the junction by hand").
Not a deferred follow-up; a deliberate non-goal.
- **Per-relationship action overrides** in the command syntax (D2 fixes
`CASCADE`); use a hand-built junction for other actions.
- **Extra junction columns** (payload attributes on the link, e.g. an
enrolment date) — add them afterward with `add column`.
- **m:n visualization as diagrams** on the create echo (ADR-0044 reach
keeps incidental create echoes in prose).
- **Renaming the auto-generated *relationships*** (only the table is
`as`-nameable); drop + re-add covers it.
@@ -0,0 +1,556 @@
# ADR-0046: Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20 / #21 / #23)
## Status
Accepted (2026-06-10); **implemented 2026-06-10**, phased **A → B → C**
(see *Decision — phasing*) across commits `9f5f76b` (DA1/DA2) · `e0b9470`
(DA3) · `41bae99` (DA4) · `386627a` (DB1) · `94825d0` (DB2/DB4) ·
`c9da6ff` (DC1/DC2/DC4) · `22bec61` (DC3 + DC2 refinement). Closes Gitea
issues **#20** (hint-panel height jumpiness), **#21** (database-structure
/ left-column improvements), and **#23** (long command input). Issue
#23's own note ("handle after #21 is decided") is honoured: the input
work is split so the part that depends on the sidebar's width budget
lands with it.
Two decisions landed differently from the original draft and are
recorded inline: the relationship data lives on **`App`, not
`SchemaCache`** (DB2), and the navigation overlay clears **only the
sidebar strip + a one-column gutter** (panels stay visible behind),
not the whole area (DC2).
Builds on and honours: **ADR-0003** (the persistent Simple/Advanced
mode model — navigation mode is *not* a third input mode, see DC1),
**ADR-0027** (the input validity indicator's reserved 6 right columns —
horizontal scroll and 2-line display preserve that reserve),
**ADR-0044** (relationship visualization — the relationships panel
renders the same `RelationshipSchema` data the `show relationship`
diagram already consumes), **ADR-0013 / ADR-0043** (the
`RelationshipSchema` model: name, parent/child tables, list-based
compound columns, referential actions), **ADR-0015** (project file
format — sidebar visibility is **session-only**, so the format is
untouched), and **ADR-0002** (no engine name in user-facing strings).
Preserves the **pure-render-from-`App`-state** invariant (CLAUDE.md):
visual changes here are driven either by new `App` *state* fields
(mutated in `update()`) or by pure *render-time* functions of the frame
geometry (see State section); `update()` stays pure-sync.
**Requirements & issues touched (verified against `requirements.md`).**
Evolves **S1** (the always-present three-region layout — the left items
region becomes width-optional, DB1). **Overrides S2**, which planned
additional element kinds as *nested* items in the tables list;
relationships get their own panel instead (DB2/DB4 — see Genuine forks
§11). **Corrects S4**: its "keyboard-toggleable hint area" was never
implemented (no toggle keybinding exists in the code) and is not wanted
— the hint panel became indispensable once completion moved into it
(ADR-0022) — so the toggle phrase is struck from `requirements.md` and
no toggle is added here. Extends **I1a** (single-line cursor editing →
horizontal scroll, DA3) and honours **S6 / ADR-0027** (the 6-column
validity-indicator reserve, DA3/DA4). The PageUp/PageDown context-rebind
(DC3) does **not** regress **V4**'s output scroll, which stays live in
input mode. Adjacent but separate: Gitea **#22** (an in-app
overlay/annotation layer for casts and guided lessons — its own ADR)
shares the overlay-render and screencast context with DC2's `Clear`
overlay; the two are meant to coexist, not merge.
## Context
Three UI issues were raised together because they are coupled through
the terminal's width and height budget; treating them as one decision
avoids three conflicting partial fixes.
**Current layout (verified in `src/ui.rs`).** `render()` splits
vertically into `Min(8)` main / `Length(1)` project label / `Length(1)`
status. The main area splits horizontally into a **fixed
`Length(28)`** left column (`render_items_panel`, a "Tables" list with
indented index names) and a `Min(20)` right column. The left column's
block has `Borders::ALL`, so its **usable inner width is 26 columns**
(28 2 borders). The right column splits vertically into output
(`Min(5)`), input (`Length(3)`), and hint (`Length(hint_content)`).
**#20 — hint jumpiness.** `hint_content` is recomputed **every frame**
as `clamp(wrapped_lines, 1, MAX_HINT_ROWS=3) + 2`, i.e. 35 rows. As the
user types and hint strings appear, grow, and vanish, the hint panel
resizes and **shoves the input and output panels**, producing the
flicker visible in screencasts. The root cause is that height tracks
*content* rather than terminal *geometry*.
The hint catalog (`src/friendly/strings/en-US.yaml`) was measured: the
two longest strings are `value_literal_slot` (106 chars) and
`create_table_element` (102); four more are 5057; the rest ≤ 50. The
wrapping consequence is sharp: at a right-column inner width ≥ ~54
columns the worst string needs **at most 2 lines**; a **3rd line is
only ever required when the right column is narrower than ~54** (a
sub-~83-column terminal *with the sidebar shown*, or a sub-~55-column
terminal). On the project's screencasts (90 columns wide, sidebar
hidden — see DB1) two lines are provably sufficient.
**#21 — the left column.** A persistent 26-column Tables list is rarely
filled even half-way by a teaching database, yet it permanently costs
horizontal space the output and input panels want — acutely so on the
90-column screencasts. The pedagogical value of an *always-visible*
schema overview is real (CLAUDE.md "pedagogy wins ties"), so the column
is **kept but made optional and more useful**, not deleted.
**#23 — long input.** The command input is a **single logical `String`**
rendered by a `Paragraph` with no wrap and no horizontal scroll
(`render_input_panel`); text past the panel width **clips silently**.
The cursor is a byte offset on a char boundary; Up/Down drive history.
The fix needs the width the sidebar's removal frees, hence the coupling.
**Keybinding space (verified).** Taken: Tab/Shift-Tab (completion),
Enter (submit), Up/Down (history), Left/Right/Home/End (cursor),
PageUp/PageDown (output scroll), Backspace/Delete, Esc
(completion-undo / modal cancel), Ctrl-C (quit). Reserved-but-deferred
(I1b readline): Ctrl-A/E/W/K/U. Printable keys all route to the input.
Terminal-hijacked and therefore unusable: Ctrl-S/Q (flow control),
Ctrl-Z (suspend), Ctrl-H (backspace), Ctrl-I/M (Tab/Enter), Ctrl-G
(BEL). This leaves a narrow band of safe combinations for new controls.
## Decision — phasing
The work ships in three phases so the screencasts benefit from the
least-controversial part first and the riskiest part (the focus/scroll
model) is isolated:
- **Phase A — input & hint (DA1DA4):** self-contained, no sidebar
dependency; fixes #20 and the baseline of #23.
- **Phase B — optional, richer sidebar (DB1DB3):** visibility model +
relationships panel + schema-cache enrichment.
- **Phase C — navigation mode (DC1DC4):** the Ctrl-O focus/scroll/
expand model that makes the sidebar browsable.
Each phase is independently shippable and independently green.
## Decision — Phase A: responsive input & hint heights
### DA1 — Hint height is a function of terminal geometry, fixed between resizes
The hint panel's height is **decoupled from hint content**. It is
computed from the terminal's width and height **once per resize** and
held constant as the user types. Because the panel no longer resizes on
every keystroke, it never shoves the input/output panels — the #20 jump
is eliminated at the source, not damped. Content that exceeds the fixed
height is ellipsized (the existing `clamp_wrapped` truncation), which is
now a rare, width-driven event rather than a per-keystroke one.
### DA2 — Responsive height buckets
Heights are chosen by terminal **height** (rows), with the hint's
optional 3rd line gated on right-column **width** (per the Context
measurement):
| Terminal height | Input content rows | Hint content rows |
| --- | --- | --- |
| **Compact** (`H < 40` — covers the 25-row screencasts) | 1 (+ horizontal scroll, DA3) | 2 |
| **Comfortable** (`H ≥ 40` — fullscreen terminals) | 2 (soft-wrap, DA4) | 2 (→ 3 only if right-column inner < ~54) |
A safety degradation protects tiny terminals: the output panel's
`Min(5)` is honoured first; if rows are insufficient, the hint shrinks
to 1, then the input to 1. The `40`-row threshold is a tunable constant.
### DA3 — Input horizontal scroll (single logical line)
The input keeps its **single-`String`** model (no embedded newlines —
this is explicitly *not* multi-line input, see Out of scope). A new
`App` field `input_scroll_offset: usize` tracks the first visible
column; the renderer shows a window of the line and keeps the cursor in
view, mirroring the candidate-line horizontal-scroll markers already in
`render_candidate_line`. The ADR-0027 6-column indicator reserve is
preserved (the scroll window is the text area = `inner.width 6`, not
the full inner width). Because `update()` does not know the panel width,
the renderer feeds it back via a `note_input_viewport(text_width)` call
(the analogue of the existing `note_output_viewport`), against which the
offset is clamped to keep the cursor visible. `input_scroll_offset`
**resets to 0** whenever the buffer is replaced wholesale — on `submit`,
on history navigation (Up/Down), and on any clear. This is the baseline
#23 fix and is sufficient on its own for the compact (1-row) layout.
### DA4 — Two-line input display when tall (`H ≥ 40`)
On comfortable terminals the input renders across **2 visual rows** by
soft-wrapping the single logical line, with the cursor mapped to a
(row, col) within the two rows. Content longer than two rows scrolls
the two-row window horizontally (DA3) so the cursor stays visible. The
**ADR-0027 `[ERR]`/`[WRN]` indicator stays anchored to the right edge
of the *first* row** (its 6-column reserve applies to row 1; the soft-
wrap on row 1 stops 6 columns short, row 2 uses the full text width) —
S6 is preserved.
This is display-only over the same single-`String` model — distinct
from the deferred true multi-line-input feature (I1, which adds
*multiple logical lines* with Enter-inserts-newline). **Forward-compat
note:** I1, when built, should reuse DA4's row-rendering and cursor
(row, col) mapping rather than introduce a parallel one — DA4 is the
substrate, not a competitor.
## Decision — Phase B: optional, richer sidebar
### DB1 — Width-derived visibility plus transient peek (session-only)
Sidebar visibility is **derived, not stored**: the sidebar is visible
iff the terminal **width > 90** *or* navigation mode is currently
focused on a sidebar panel (the Ctrl-O peek, DC1). It is recomputed
every frame from terminal width and `NavFocus`; nothing persists to
`project.yaml` (ADR-0015 untouched), so it is session-only by
construction — and there is no stored visibility field to keep in sync.
At ≤ 90 columns the sidebar is hidden by default — so the 90-column
screencasts never show it and the output panel gets the full width it
needs there — but `Ctrl-O` temporarily reveals it for the duration of a
browse and re-hides it on exit (DC1).
**No persistent show/hide toggle (resolved 2026-06-10, user).** Issue
#21's original wording asked for "a keystroke to show and hide it"; the
Ctrl-O peek covers that need, so no separate toggle and no
force-shown/force-hidden override is added. Visibility stays a pure
function of `(terminal width, NavFocus)` — the simplest model that
satisfies the requirement. Should pinning ever prove necessary, a
persistent override is an additive follow-up (see Out of scope).
### DB2 — Add a relationships panel; enrich the schema cache
The left column gains a **second panel** below Tables: a list of the
project's relationships. This is a deliberate **override of S2**, whose
note proposed additional element kinds (relations, views) as *nested*
items inside the existing tables list. Relationships are *cross-table*,
not per-table, so nesting them under a single table reads wrong; a
sibling panel is the honest shape (user-confirmed 2026-06-10). S2's
"without restructuring" intent is still met — the items column simply
holds two stacked panels (DB4) instead of one.
The panel needs the full `RelationshipSchema` (name, parent/child
tables, list-based columns, on-delete/on-update actions) that the `show
relationship` path already fetches.
**Data home — `App`, not `SchemaCache` (revised at implementation,
2026-06-10).** The design first proposed an additive
`SchemaCache.relationship_details: Vec<RelationshipSchema>` field.
Implementation revised this to a **parallel `App.relationships:
Vec<RelationshipSchema>`** field for two reasons: (1) `SchemaCache` is
*walker/completion-facing* — it needs only relationship **names**
(unchanged in `SchemaCache.relationships`, still borrowed as
`&Vec<String>` by `IdentSource::Relationships`); the full records are
**UI-only**, so `App` is the architecturally correct home, mirroring
`app.tables` (which the items panel already reads alongside the cache).
(2) Adding a field to `SchemaCache` would force edits to ~23 full
struct literals across the test suite, whereas `App` gains one field.
The /runda guard it answered — *don't break completion by retyping
`relationships`* — is fully honoured either way. Delivery: a worker
`Request::ReadAllRelationships` (→ `Database::read_all_relationships`,
returning `Vec<RelationshipSchema>` via the existing
`read_all_relationships(conn)`); the runtime's `refresh_schema_cache`
posts a new `AppEvent::RelationshipsRefreshed` alongside
`SchemaCacheRefreshed`, and the `App` stores it. No behavioural
difference from the original design.
The panel has **two display states** keyed off focus (DC2):
- **Unfocused (26-col)** — an ambient glance. Per relationship: the
name (ellipsized past the inner width), and the endpoints broken at
the arrow to fit a narrow column:
```
Customers_Orders
Customers.id ->
Orders.customer_id
```
- **Focused + expanded (4050 col, DC2)** — a browse view. At the wider
width the endpoints fit on one line
(`Customers.id -> Orders.customer_id`); the arrow-break is used only
when even the expanded width cannot hold a (possibly compound)
endpoint pair. The wider width minimises horizontal truncation so the
panel needs **mainly vertical scrolling** (DC3).
### DB3 — Sidebar width unchanged when unfocused
The unfocused sidebar keeps `Length(28)` / 26 inner columns. Widening
happens only on focus (DC2), as an overlay, so the unfocused layout and
the right-column reflow are unchanged from today.
### DB4 — Vertical split of the two left-column panels
The items column stacks **Tables (top)** and **Relationships (bottom)**.
The Relationships panel's height is content-driven within bounds, so it
stays small when there is little to show and never dominates the column
(user-chosen 2026-06-10):
- **No relationships:** fixed at **5 rows** (3 content + 2 border),
rendering a single `None` line. This is the floor.
- **With relationships:** grows with content (`content_rows + 2`, where
the unfocused format is ~3 rows per relationship) up to a **cap of
50 % of the column height**; beyond the cap the panel **scrolls**
(DC3). Formally `rel_h = clamp(content_rows + 2, 5, ⌊col_h / 2⌋)`.
- **Tables** takes the remainder (`col_h rel_h`) and scrolls if it
overflows (it, too, is a focusable, scrollable panel — DC3).
- **Degradation:** on a column too short to honour the 5-row floor plus
a usable Tables panel (`col_h < ~10`), the floor yields first so
Tables keeps at least its border + one row; both panels stay
renderable. The `50 %` cap and `5`-row floor are tunable constants.
Heights are a pure render-time function of the column height and the
cached relationship count, so they are unit-testable without a terminal
(see Testing).
## Decision — Phase C: navigation mode
### DC1 — `Ctrl-O` navigation mode: a focus cycle, not an input mode
`Ctrl-O` enters a **navigation mode** that is orthogonal to the
Simple/Advanced input mode (ADR-0003) — it changes *where keystrokes
go*, not *how commands parse*. It drives a focus cycle:
1. **Press 1 →** focus the **Tables** panel (revealing the sidebar if
it is currently hidden — a temporary peek).
2. **Press 2 →** focus the **Relationships** panel.
3. **Press 3 →** leave navigation mode: restore the sidebar width,
re-hide it if the peek revealed it, and return focus to the command
input.
`Esc` exits navigation mode directly from any focused panel (a
short-cut for step 3); `Esc` is otherwise only completion-undo, which
does not apply while browsing.
**Why `Ctrl-O` and not `Ctrl-B`.** `Ctrl-B` is the *default tmux prefix*
and `Ctrl-A` is *screen's* — a multiplexer eats them before the app
sees them, so either would make navigation mode unreachable for the many
students who run inside tmux/screen. `Ctrl-O` is not a multiplexer
prefix; in the raw mode the TUI sets, its legacy line-discipline meaning
(discard-output) is disabled, so it reaches the app. It is free in the
app today (the main key handler's catch-all, `app.rs:1001`). The
mnemonic is weak ("**O**utline"); reachability won over mnemonic.
**Routing.** Navigation mode is handled inside the **main** key handler,
which runs only when no modal is open (`app.rs:919` gates on
`self.modal.is_some()`). So `Ctrl-O` and the nav keys are **inert while
a modal dialog is active** — modals keep full keyboard ownership. Within
the main handler, a `NavFocus != Input` branch precedes the normal
input-editing arms and routes keys per DC3/DC4.
### DC2 — Expand-on-focus as an overlay
A focused sidebar panel widens to a **45-column** overlay
(`NAV_EXPANDED_WIDTH`): the renderer `Clear`s the strip the expanded
panel occupies **plus a one-column gutter** (`NAV_OVERLAY_GUTTER`) and
paints the wide panel on top. The output/input/hint panels underneath
keep their exact layout — **unused and unchanging** while browsing,
**still visible to the right** of the overlay (just partially occluded
on the left) — and are restored fully by the next frame on exit. The
gutter keeps them from butting against the expanded panel's border so
the overlay edge reads cleanly. This is cheap because the renderer is a
pure function of `App` state: focus state selects the width and the
overlay path. (The input underneath is inactive in navigation mode.)
*Implementation note (2026-06-10):* a full-area clear (hiding the base
panels entirely during browse) was tried first and rejected — leaving
the base visible is truer to "underneath keep their layout," and the
one-column gutter resolves the only wrinkle (the panels' left edges
being cut by the overlay reading harshly without separation).
### DC3 — Scroll the focused panel; focus highlight
While a sidebar panel is focused it scrolls, reusing the output panel's
proven mechanism (a `usize` offset clamped against a renderer-reported
viewport via a `note_*_viewport` call):
- **Up / Down — line-by-line** scroll (the lazygit `j`/`k` feel;
user-chosen 2026-06-10).
- **PageUp / PageDown — page** scroll.
This is a context-sensitive rebind: Up/Down drive *history* and
PageUp/PageDown scroll the *output* in input mode, whereas in navigation
mode they scroll the *focused sidebar panel*. The two contexts never
apply simultaneously (`NavFocus` selects which). The focused panel shows
an **accent border** so it is obvious where keys are going (lazygit
convention).
### DC4 — Other keys are inert in navigation mode
The command input is visibly occluded by the overlay while browsing, so
keys that have no navigation meaning are **inert** rather than acting on
the hidden input. Specifically, **only** `Ctrl-O` (advance focus),
Up/Down + PageUp/PageDown (scroll, DC3), and `Esc` (exit) are live;
printable characters, Enter, Tab, Backspace/Delete, Left/Right, and
Home/End all do nothing until navigation mode is exited. The occlusion
signals "not typing," so swallowing these is clearer than letting them
silently edit an invisible buffer.
## State, keybindings, and cross-cutting wiring
**Stored `App` state** (mutated in `update()`, read by the renderer):
- `input_scroll_offset: usize` (DA3) — reset on submit / history-nav /
clear.
- `NavFocus { Input, SidebarTables, SidebarRelationships }` (DC1) — the
navigation-mode focus cursor; `Input` ≙ not in navigation mode.
- Per-panel scroll offsets for the Tables and Relationships panels, each
clamped against a renderer-reported viewport (DC3), mirroring
`output_scroll` / `note_output_viewport`.
- **`App.relationships: Vec<RelationshipSchema>`** (DB2) — the full
relationship records for the sidebar panel, delivered by
`AppEvent::RelationshipsRefreshed` from the runtime's schema refresh.
`SchemaCache.relationships: Vec<String>` (names, for completion) is
unchanged. (See DB2 for why this lives on `App`, not `SchemaCache`.)
**Render-time derived** (pure functions of `frame.area()` + cached
counts — *not* stored fields; this keeps the pure-render invariant and
makes the geometry logic unit-testable without a terminal):
- Sidebar visibility — `(width > 90) || NavFocus is a sidebar panel`
(DB1).
- Input/hint row counts — a pure helper `panel_heights(area) ->
(input_rows, hint_rows)` (DA1/DA2), the same helper the renderer and
the Tier-1 tests call.
- Left-column split `rel_h = clamp(content_rows + 2, 5, ⌊col_h/2⌋)`
(DB4).
- Input width fed back to `update()` via `note_input_viewport`
(DA3), since `update()` cannot read `frame.area()`.
Keybindings introduced/affected:
| Key | Input mode | Navigation mode |
| --- | --- | --- |
| `Ctrl-O` | enter nav mode, focus Tables (peek-reveal) | advance focus (Tables → Relationships → exit) |
| `Up` / `Down` | history (unchanged) | line-scroll focused panel |
| `PageUp` / `PageDown` | scroll output (unchanged) | page-scroll focused panel |
| `Esc` | completion-undo (unchanged) | exit nav mode directly |
| printable / Enter / Tab / Backspace / Left / Right / Home / End | edit/submit input (unchanged) | inert |
All nav keys are inert while a modal is open (the main handler is gated
on `!modal.is_some()`, `app.rs:919`).
Renderer changes (`src/ui.rs`): geometry-driven hint/input height
(DA1/DA2), input window + cursor windowing (DA3) and 2-row soft-wrap
with row-1 indicator (DA4), the relationships panel + two-panel split
(DB2/DB4), the focus accent border and expand-on-focus `Clear` overlay
(DC2/DC3); `note_input_viewport` feedback added alongside the existing
`note_output_viewport`.
## Genuine forks (escalated, resolved 2026-06-10)
1. **Left column fate** — remove entirely vs narrow vs **keep + make
optional and richer** (chosen, user). → DB1/DB2.
2. **Focus/scroll model** — a navigation mode (chosen, user) vs
modeless modifier-key scroll vs deferring scroll. → DC1.
3. **Navigation shortcut** — **`Ctrl-O`** (chosen, user); `Ctrl-B`
*rejected on review* (it is the default tmux prefix → unreachable
inside tmux); Ctrl-T also viable; terminal-hijacked combos excluded.
→ DC1.
4. **Expand-on-focus rendering** — **overlay with `Clear`** (chosen,
keeps the right panels unchanging) vs re-splitting the layout (would
reflow output). → DC2.
5. **Navigation-mode printables** — **ignore** (chosen, user) vs
drop-to-input-and-type. → DC4.
6. **Hint anti-jump** — **fix height to terminal geometry** (chosen)
vs damping/hysteresis vs always-reserve-max. → DA1.
7. **Height thresholds** — `H < 40` compact / `H ≥ 40` comfortable, with
1/2 and 2/2 splits (chosen, user). → DA2.
8. **Visibility persistence** — **session-only** (chosen, user) vs
per-project in `project.yaml`. → DB1.
9. **Persistent show/hide toggle** — **deferred** (chosen, user): the
Ctrl-O peek covers #21's "keystroke to show and hide", so visibility
stays width-derived with no override. → DB1.
10. **Nav-mode Up/Down** — **line-scroll the focused panel** (chosen,
user) vs leaving scroll to PageUp/PageDown only. → DC3.
11. **Relationships placement** — **a separate sibling panel** (chosen,
user — *overrides S2*) vs nesting relations inside the tables list
per S2's documented extension model. → DB2/DB4.
12. **Hint-area toggle (S4)** — **no toggle** (chosen, user): the hint
panel is indispensable since completion moved into it; S4's stale
"keyboard-toggleable" claim (never implemented) is struck from
`requirements.md`. → Status (Requirements & issues touched).
## Testing
Tier-1 (`app.rs` pure `update()` unit tests), **Tier-2 (`insta`
snapshots, `src/snapshots/`) for the visual surfaces** — this change is
heavily render-side, so the geometry/format/overlay assertions belong in
snapshots, not only behavioural tests — and Tier-3 integration.
Test-first per CLAUDE.md. The geometry helpers (`panel_heights`, the
DB4 split, visibility) are **pure functions** exercised directly in
Tier-1 without a terminal.
Phase A:
- **Hint anti-jump:** `panel_heights(area)` is invariant under changing
hint content at a fixed terminal size (assert it does not change as
`app.hint` varies); it *does* change across the `H < 40` / `H ≥ 40`
boundary and the width-< 54 boundary.
- **Height buckets:** compact → input 1 row / hint 2; comfortable →
input 2 / hint 2 (3 only when right-column inner < ~54); tiny-terminal
degradation honours output `Min(5)`.
- **Input horizontal scroll:** a line longer than the panel keeps the
cursor visible while moving Left/Right/Home/End; ADR-0027's 6-column
reserve is intact; no characters are lost (buffer = full string);
`input_scroll_offset` resets on submit / history-nav / clear.
- **Two-line input:** at `H ≥ 40` a line wrapping to two rows renders
both rows with correct cursor (row, col), the `[ERR]`/`[WRN]`
indicator on row 1's right edge (Tier-2 snapshot); a longer line
scrolls.
Phase B:
- **Relationship data path:** `Database::read_all_relationships`
returns full records through the worker thread (integration test, real
DB via an m:n junction); `AppEvent::RelationshipsRefreshed` populates
`App.relationships`; `SchemaCache.relationships` names are undisturbed
(completion still resolves them).
- **Relationships panel render (Tier-2):** empty → a single `None` line
at the 5-row floor; the unfocused narrow format (name + arrow-break,
ellipsis past inner width); a compound endpoint pair arrow-breaks
correctly.
- **Two-panel split (DB4):** `rel_h = clamp(content_rows + 2, 5,
⌊col_h/2⌋)` — 5 when empty; grows with content; capped at 50 %;
Tables takes the remainder; degrades sanely at `col_h < 10`.
- **Width-derived visibility:** width ≤ 90 hides, > 90 shows, recomputed
on resize (the peek interaction is covered under Phase C).
Phase C:
- **Focus cycle:** `Ctrl-O` cycles Input → Tables → Relationships →
Input; `Esc` exits directly; a peek-revealed sidebar re-hides on exit;
a width-shown (> 90) sidebar stays shown on exit; `Ctrl-O` is inert
while a modal is open.
- **Expand overlay (Tier-2):** focusing widens to the expanded width;
the underlying output/input/hint state is unchanged across enter/exit
(no reflow); the focus accent border marks the focused panel.
- **Scroll rebind:** in nav mode Up/Down line-scroll and PageUp/PageDown
page-scroll the focused panel (clamped to its viewport); in input mode
Up/Down still drive history and PageUp/PageDown still scroll output
(no V4 regression); inert keys (printable/Enter/Tab/Backspace) do
nothing in nav mode.
All tiers green, zero skips; clippy clean (nursery).
## Out of scope
- **True multi-line input (I1)** — Enter-inserts-newline / Ctrl-Enter-
submits over a multi-logical-line buffer. DA3/DA4 keep a single
logical line; this remains a separate, deferred feature.
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
not touched here.
- **Cross-session sidebar persistence** — visibility is session-only
(DB1); persisting it would amend ADR-0015.
- **The output panel as a third navigation focus target** — navigation
mode cycles the two sidebar panels only; output keeps its input-mode
PageUp/PageDown scroll.
- **Relationship search / filtering within the panel** — the panel is a
scrollable list; no query box.
- **Relationship rename / edit from the panel** — it is read-only;
mutation stays with the DSL/SQL commands.
- **A persistent show/hide toggle / force-shown override** (DB1,
resolved deferred) — visibility is width-derived + Ctrl-O peek; a
pin/force override is an additive follow-up if ever needed.
- **A hint-area toggle (old S4 wording)** — not implemented today and
not wanted (the hint panel is indispensable since completion moved in;
fork §12). The stale "keyboard-toggleable" phrase is removed from S4.
- **In-app overlay / keystroke-annotation layer (Gitea #22)** — a
separate feature with its own ADR; DC2's `Clear` overlay is built to
coexist with it, not to provide it.
## Accepted consequences
- **Width-threshold discontinuity.** Because `Auto` visibility flips at
width 90 and the sidebar costs 28 columns, widening a terminal across
the boundary (89 → 91) makes the *output* narrower (≈ 89 → 63 inner)
as the sidebar appears. This is inherent to any width-gated auto-hide
and is accepted: 90 is the screencast width, real terminals sit well
to one side of it, and `Ctrl-O` peek covers the in-between case. The
`90` threshold is a tunable constant.
@@ -0,0 +1,418 @@
# ADR-0047: Demonstration overlay layer — keystroke badges and step captions
## Status
Accepted (2026-06-10); **implemented 2026-06-11**, phased A→B→C (closes
Gitea **#22**). Addresses Gitea **#22**. Builds the in-app
overlay/annotation primitive that screencast recording (ADR-website-001
§2, the `autocast` pipeline) and a future guided-lesson system both
need. Adjacent to ADR-0046 (the nav-mode sidebar overlay it must
coexist with) and unblocks the polished version of the assistive-editor
and projects (`#24`) casts.
**Implementation (commits `f879d54` → `2d0f4b2`).** Phase A
(`f879d54`): `--demo` flag + `RDBMS_PLAYGROUND_DEMO` env →
`App.demo_mode`, mirroring the `--no-undo` plumbing; help text mentions
only the visible badges (the `Ctrl+]` caption trigger stays
low-profile, D6). Phase B (`2584e76`): automatic keystroke badges — pure
`demo_badge_label`, set in `App::update` before the modal gate, expired
by a ~1.5 s runtime timer via the new `nearest_deadline` helper that
extends the time-boxed-`recv` arm condition **without** regressing the
ADR-0027 indicator debounce (the rewrite tracks `Instant` deadlines;
verified equivalent). Phase C (`241f60c`): the stealth `Ctrl+]`
caption buffer in `App::update`, intercepted before the modal gate so
captions work over the load picker. Post-build (`2d0f4b2`, user
decision): the overlays render as **flat filled yellow rectangles** (no
border glyphs, one-cell text margin) to read as a distinct callout. A
whole-implementation `/runda` pass returned **PASS** with no blockers;
the only untested wiring is the `run_loop` badge timer (not unit-testable
in isolation — same posture as the existing `IndicatorDebounce`; the
pure pieces are all tested). One intentional, user-acknowledged
behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is,
by spec; exit capture with `Ctrl+]`). Tests: 2290 passing / 0 failing /
0 skipped (Tier-1 label fn + caption FSM + `nearest_deadline`, Tier-2
dark/light/stacked/wrapped/clamp snapshots + black-on-yellow style,
CLI parse/env); clippy clean.
All primary forks and the visual placement were **user-confirmed**
including the two follow-ups settled after the first draft: the trigger
key (**`Ctrl+]`**, the maximally-obscure valid single-byte code, over
`Ctrl+!` which autocast cannot send) and caption sizing (**wrap to 3
lines**). A `/runda` pass over this ADR ran before implementation and
tightened it — its findings are folded in below (caption/badge
interception placement, in-capture key disposition, badge suppression
during capture, the timer arm-condition, box clamping, the new
output-rect field, and the control-code decode note).
**Requirements traceability.** There is **no `requirements.md` item**
for this work — verified by sweep. It is tracked as Gitea issue **#22**
plus this ADR, consistent with the project's convention ("issues are
the lightweight tracker; ADRs are the decisions"). The website-side cast
scope lives in **ADR-website-001** (website branch), not main's
`requirements.md`.
## Context
The website records its demos as asciinema `.cast` files driven by
**`autocast`** (ADR-website-001 §2; STYLE.md): source step-lists in
`casts-src/casts.mjs` (`type` / `wait` / `key`) expand to **one key per
character, Enter = `^M`**, recorded against the real `target/debug`
binary. The hard constraint — the same one that drove `#24` — is that
autocast can only emit **typeable characters, ASCII control codes
(`^X`), and waits**. It cannot send arrow keys, function keys, or any
multi-byte escape sequence.
Two classes of on-screen event are therefore invisible or
unexplained in a cast:
1. **Keystrokes that cause a visible change but render no glyph of
their own** — most acutely **Tab** completion: the command line
jumps from `show data bo` to `show data books` with no sign a key
was pressed. Enter, the arrows, Ctrl-O, Esc are the same.
2. **Step structure / "what just happened" narration** — a cast is a
silent moving picture; there is no channel to separate or explain
steps for a visual learner.
asciinema-player has no inline keystroke overlay, and a website-side
HTML overlay layered on the player would be fragile (its timings would
have to track every recording and break on each re-record). The robust
place to solve this is **in the app**: if the app renders the overlay,
the cast captures it natively and it re-records for free. The same
primitive is exactly what a future **guided-lesson** system needs to
point at things and narrate steps — so it is built as a general
capability, not a cast-only hack (the issue's "pays off twice"). It is
also directly useful for a **teacher demonstrating the playground
live** — pressing Tab in front of a class has the same
invisible-keystroke problem as a cast.
The app's renderer is a pure function of `App` state and already draws
two kinds of last-pass overlay over the base render with **no layout
reflow**: modals and the ADR-0046 nav-mode sidebar overlay. The event
loop already **time-boxes `event_rx.recv()`** with a `tokio` timeout
(the ADR-0027 `IndicatorDebounce`) and redraws when the timer elapses —
the exact mechanism a self-expiring badge needs. These two existing
seams make the feature cheap.
## Decisions
### D1 — Activation: a `--demo` flag (+ env var), off by default
Demonstration mode is entered with a **`--demo`** CLI flag, or
equivalently the **`RDBMS_PLAYGROUND_DEMO`** environment variable (set
truthy) — mirroring the existing `--log-file` / `RDBMS_PLAYGROUND_LOG_FILE`
pair. It combines freely with every other flag (`--resume`, `--mode`, a
positional path); there are no exclusions.
When the flag is **off** (the default), none of the key handling or
rendering below is active and the app behaves exactly as today — **zero
footprint for real users** (R8). `autocast` sets the flag when it
launches the binary; a teacher sets it on their own command line.
It is framed as a general **demonstration mode**, not "cast mode" — the
honest name for what it does, and it reads sensibly in `--help`. The
flag is documented in the CLI banner (one line); obscurity is not a
security property here and a harmless opt-in flag is better surfaced
than hidden. What stays "low-profile" (per #22) is that there is **no
normal in-app command** for it and **no persistent on-screen indicator**
(see D7) — so a cast frame is never polluted by a `[DEMO]` marker.
### D2 — Keystroke badges: automatic, app-detected
In demo mode the app shows a transient badge **automatically** whenever
it handles one of a curated set of *otherwise-invisible* keys. The cast
does nothing special — it presses the key it was going to press anyway,
and the badge re-records for free. The set:
| Key | Badge | | Key | Badge |
|-----|-------|-|-----|-------|
| Tab | `[TAB]` | | Home | `[HOME]` |
| Shift-Tab | `[SHIFT-TAB]` | | End | `[END]` |
| Enter | `[ENTER]` | | PageUp | `[PGUP]` |
| Esc | `[ESC]` | | PageDown | `[PGDN]` |
| ↑ | `[UP]` | | Backspace | `[BKSP]` |
| ↓ | `[DOWN]` | | Delete | `[DEL]` |
| ← | `[LEFT]` | | Ctrl-O | `[CTRL-O]` |
| → | `[RIGHT]` | | | |
Plain character keys render a glyph on the input line already, so they
produce **no** badge (that is the definition of the set — "invisible"
keys). The badge fires on **key press**, regardless of whether the key
had an effect in the current state (e.g. `↑` with no history still shows
`[UP]`): simpler, and the demo author controls the script. Badge text is
bracketed ASCII (`[TAB]`) per the user's preference — renders on every
terminal and is cast-safe, unlike the `⇥` glyph mocked earlier.
The label mapping is a **pure function** `demo_badge_label(&KeyEvent) ->
Option<&'static str>` (Tier-1 testable). The badge **auto-expires on a
timer** (D5).
### D3 — Step captions: a stealth, control-code-delimited input buffer
Caption text must arrive through typeable input only (R4). A **single
toggle control code — `Ctrl+]`** (byte `0x1D`) drives a **stealth
capture buffer**. `Ctrl+]` was chosen (over the bound `Ctrl-O`/`Ctrl-C`,
the readline-reserve letters Ctrl-A/E/W/K/U, the tmux-prefix Ctrl-B, the
signal/flow-control codes Ctrl-\\=SIGQUIT and Ctrl-S/Q=XON/XOFF, and a
plain letter chord like Ctrl-G) because it is **maximally non-obvious**
— the classic telnet escape, almost never pressed by accident — while
still being a single ASCII control byte autocast can emit. It has **no
signal or flow-control baggage** and is **multiplexer-safe**. Note
collision risk is already near-zero in casts (a fresh `--demo` binary
sees only scripted keys); the obscurity mainly protects a live teacher
from a stray trigger.
- First `Ctrl+]` **opens** capture. The command input line and the
output are untouched. If a caption is already visible, opening clears
it (you are starting a new annotation).
- Subsequent typed characters **accumulate into the caption buffer
invisibly** — they do **not** appear on the prompt, do not execute,
and do not enter history. **`Backspace`** deletes the last buffered
character. **Every other key while capturing — Enter, the arrows,
Tab, … — is inert** (swallowed, no effect): only typing and `Ctrl+]`
do anything.
- A second `Ctrl+]` **commits** the buffer to the caption box (D4).
An **empty** commit (toggle-toggle with nothing typed) clears any
visible caption — the author's explicit dismiss.
Because nothing about the capture shows on the prompt, the caption
"pops" into its box with no ugly typing artifact, while the caption text
still lives **inline in `casts.mjs`** at the right spot (one source of
truth, no separate notes file to keep ordered).
This is all keyboard-stream interpretation, so it lives in the
pure-sync `App::update()` (Tier-1 testable) and is **only active in demo
mode** — when off, `Ctrl+]` is inert and characters reach the input
line normally.
**Placement in `handle_key` — before the modal gate (runda finding).**
The capture interception (`Ctrl+]` and the accumulating characters)
**and** the "clear a visible caption on the next keystroke" check sit at
the **very top of `handle_key`, before the `self.modal.is_some()`
gate** — *not* alongside the `Ctrl-O` handler, which is gated behind it.
This is required so captions can be authored **while a modal is open**
specifically the load-picker, which is exactly the **projects / `#24`
cast** (annotating "press j/k to move", with an `[ENTER]` badge as the
selection is made). While capturing, the modal is frozen (capture
swallows keys), which is the intended behaviour. `App` exposes
`demo_capturing` so the runtime can read it (see D5).
The control-code path is sound end to end, verified against our
crossterm (0.29, `event/sys/unix/parse.rs:110-113`): `autocast` emits
`^]` = byte `0x1D`; crossterm decodes `0x1C..=0x1F`
`KeyCode::Char('4'..='7') + CONTROL`, so **`Ctrl+]` (0x1D) arrives in
the app as `KeyCode::Char('5') + KeyModifiers::CONTROL`** — that is the
pattern `handle_key` matches. (The same routine decodes `0x09`/`0x0D`/
`0x1B`/`0x7F` to the named `Tab`/`Enter`/`Esc`/`Backspace` keys and
`0x01..=0x1A` to `Ctrl+a..z`, so `0x1D` is unambiguously distinct.) The
canonical way to produce it is **Ctrl+]**; on some layouts `Ctrl+5`
yields the same byte. *(This is the Unix/Linux decode path — the
cast-recording platform; crossterm's separate Windows backend would be
confirmed by test if live `--demo` on Windows is exercised.)*
### D4 — Both overlays are floating boxes at the output panel's inner bottom-right
The badge and the caption both render as **floating, flat filled
rectangles anchored to the inside of the output panel's bottom-right
corner** (inset one cell from the panel's inner edge), drawn **last over
the base render** — after modals, so they remain visible while the
load-picker (the `#24` cast) or any modal is up, and with **no layout
reflow** (consistent with the modal / nav-overlay precedent; honours
R8).
**Flat rectangle, not a bordered box (user decision, post-build).** The
overlays draw as a **solid yellow rectangle with no border glyphs** and
a one-cell margin around the text — deliberately *unlike* the app's
rounded-border panels, so they read as a distinct callout that "stands
out nicely" rather than as another panel. Implemented with a borderless
`Block` fill (the `paint_background` mechanism) plus a `Paragraph` inset
into a one-cell `Margin`.
The top-level `render()` does not currently know the output-panel rect
(it is computed inside `render_right_column`), so a **new field
`App.last_output_area: Rect`** is set in `render_output_panel` and read
at the top-level draw pass to anchor the overlay — the established
"renderer reports metrics back to `App`" pattern (sibling to
`note_output_viewport`, which stores row counts, not a rect).
When **both** are present, the **keystroke badge stacks directly above
the caption box** (both right-aligned in the corner) so they never
overlap.
**Styling — deliberately high-contrast:** **bold black text on a yellow
fill** — hard to overlook, identical in light and dark themes (a fixed
high-contrast pair centralised in `theme.rs`, not theme-derived).
**Caption sizing (user-confirmed).** The caption is **word-wrapped to at
most 3 lines** within a content width of `min(40, output_inner_width
4)` columns, ellipsised beyond the third line. So the caption rectangle
is **35 rows** tall (13 text rows + a one-cell margin top and bottom),
its height varying with the text — a full sentence fits without forcing
the author to split it, while the 3-line cap keeps it corner-sized. The
**badge** rectangle is always a single short token (`[TAB]`
`[SHIFT-TAB]`), so it is a fixed **3 rows** (1 text row + the margin),
narrow.
**Clamping (runda finding).** Stacked, the two boxes are up to 8 rows
(5 caption + 3 badge); the output panel's inner height is only `Min(5)`,
so on a short terminal they could exceed it. Both boxes are **clamped to
the output inner area**: width to `output_inner_width`, the caption's
wrap-line count reduced so the stack fits the available height (badge
first — it is the time-critical one), and if a box cannot fit at all
(pathologically small terminal) it is **not drawn** rather than
overflowing. Cast geometry (90×26) leaves ~18 output rows — ample; the
guard only protects a real user who runs `--demo` in a tiny window.
### D5 — Timing: badges expire on a ~1.5 s timer; captions persist until the next keystroke
- **Keystroke badge:** auto-expires on a **time-based TTL**, default
**1.5 s** (a single tunable constant; the user asked for 12 s). This
matters for both media: in a cast the badge fades on its own so a
trailing `wait` ends on a clean frame, and in live teaching the badge
clears without the presenter needing another key. A new badge replaces
the current one and resets the timer.
- **Caption:** persists **until the next keystroke**, which clears it
and is then processed normally (or until an explicit empty-`Ctrl+]`
dismiss, or replacement by a new caption).
The timer reuses the runtime's existing time-boxed-`recv` pattern: the
loop already arms a `tokio::time::timeout` for the indicator debounce.
**Arm-condition extension (runda finding).** Today the loop time-boxes
`recv` **only while `debounce.is_armed()`** — and the debounce settles
at `INDICATOR_DEBOUNCE` (1000 ms), shorter than the 1500 ms badge TTL.
So the arm condition becomes **`debounce.is_armed() || badge_pending`**,
and the loop waits on the **nearest deadline** of the two. On a wake it
checks each independently: at the 1000 ms debounce deadline it settles
the indicator **without clearing the badge**; at the 1500 ms badge
deadline it clears the badge; then redraws. The pure "nearest deadline"
computation is unit-testable on its own.
The badge's expiry `Instant` lives in the **runtime** (so `App` stays
clock-free and Tier-1-pure, exactly as `IndicatorDebounce` keeps timing
out of `App`); `App.demo_badge: Option<&'static str>` is the render
mirror, **set by the runtime** on a significant key and cleared on timer
elapse.
**Badge suppression during capture (runda finding).** Because the
runtime sets badges from the raw key independently of `App` state, it
must **not** badge a key that capture swallowed (e.g. an inert `Tab`
while a caption is being typed would otherwise flash `[TAB]` for a
no-op). The runtime sets a badge only when **`!app.demo_capturing`**.
**Ownership note.** `demo_caption` is mutated inside `update()`
(input-driven) while `demo_badge` is mutated by the runtime
(timing-driven). This split is deliberate and mirrors the existing
`input` (set in `update()`) vs `input_indicator` (set by the runtime
from `IndicatorDebounce`) pair — not an inconsistency.
### D6 — Help text and strings
The CLI banner (`help.cli_banner` in `en-US.yaml`) gains a `--demo`
line. User-facing wording obeys the house rules (no engine name, no
"DSL"): *"Demonstration mode — show on-screen badges for otherwise-
invisible keys (Tab, Enter, …), for screencasts and live teaching."*
The help text **deliberately mentions only the visible badges, not the
`Ctrl+]` step-caption mechanism** (user decision): the caption trigger
stays low-profile, true to #22's "secret trigger" framing — a cast
author or lesson script knows it; a casual `--help` reader is not
pointed at it. Badge labels and the `[…]` chrome are fixed ASCII, not
localised; caption content is author-supplied free text and likewise
not a catalog string.
## Alternatives considered
- **Scripted badges** (cast pushes each badge explicitly) — rejected:
the app already sees every key, so automatic detection (D2) is more
robust and re-records for free. *(User-confirmed.)*
- **Typed hidden command for captions** (a secret-prefixed line) —
rejected: the command is briefly visible being typed on the prompt.
**Preloaded notes file + advance key** — rejected: a separate file
that must stay ordered/in-sync with the cast. The **stealth buffer**
(D3) is self-contained in the cast script *and* leaves the prompt
clean. *(User-confirmed.)*
- **Fixed-corner HUD badge / badge by the input line** — rejected in
favour of a floating box at the output panel's bottom-right; **top
banner / subtitle band** for captions — rejected in favour of the
matching floating box. *(User-confirmed via mockups.)*
- **A persistent `[DEMO]` status-bar marker** — rejected: it would show
in every cast frame. Demo mode is silent except for the transient
overlays (D7).
- **Caption persists for a fixed time** (instead of until next
keystroke) — noted as a one-constant change if the next-keystroke rule
proves too eager in practice; the user chose next-keystroke.
- **Trigger via `Ctrl+!` / a Kitty-protocol chord** — rejected: not
representable as a single ASCII control byte, so autocast cannot send
it (fails R4, the same wall as arrow keys). **`Ctrl+G` / a letter
chord** — workable but less non-obvious; the user chose the
maximally-obscure `Ctrl+]` from the valid single-byte set.
- **Single-line ellipsised caption** — rejected in favour of wrap-to-3-
lines so a full sentence fits. *(User-confirmed via mockups.)*
## Consequences
- A general overlay primitive exists that the cast pipeline uses now and
the guided-lesson system can reuse later (`App.demo_caption` and the
badge channel are the seam).
- `autocast` casts gain a real Tab-completion moment, key indicators for
the projects/`#24` round-trip, and step captions — all by adding
`key: ^G` / `type:` / `key: ^G` and ordinary keys to `casts.mjs`, then
re-running `pnpm casts`. No website-side overlay machinery.
- Teachers get the same affordance live via `--demo`.
- One new control-code binding (`Ctrl+]`) is consumed, but only inside
demo mode — normal sessions are unaffected, so it does not encroach on
the reserved readline chords (I1b).
- The renderer must expose the output-panel rect to `App`; a small,
pattern-consistent addition.
## Scope / non-goals (OOS)
- **Manual/scripted badge push** and **badges for plain character
keys** — out; badges are automatic over the fixed invisible-key set.
- **Configurable overlay styling or placement** — out; fixed
black-on-yellow boxes at the output panel's bottom-right.
- **The guided-lesson / tutorial system itself** — out (its own ADR);
this ADR only builds the primitive it will reuse.
- **Persisting demo mode across project switches / sessions** — out;
it is a per-run flag.
- **Localising caption content** — out; captions are author-supplied
free text.
- **Output-pane scroll-in-casts** and other arrow-only interactions —
out (separate enhancement; same autocast limitation as noted in #24).
## Testing
Per ADR-0008 and the project's test discipline (test-first; green, no
skips):
- **Tier 1 (`app.rs` units):** `demo_badge_label` mapping over the full
key set **and** the no-badge cases (plain chars, `Ctrl+]`, `Ctrl-C`);
the stealth-caption state machine — open on `Ctrl+]`; characters
accumulate with the **input line unchanged**; `Backspace` edits the
buffer; **non-typing keys inert while capturing**; commit sets the
caption; empty commit clears; opening over a visible caption clears
it; next keystroke clears a visible caption **then processes
normally**; capture works **with a modal open** (caption set while the
load-picker modal is up, picker state untouched); the **demo-off
gate** (`Ctrl+]` inert, characters reach the input, no caption/badge
state ever set); the pure "nearest deadline" helper.
- **Tier 2 (insta snapshots, `ui.rs`):** badge box, caption box, both
stacked, at 90×26 in light and dark — verifying the bottom-right
anchor, the stack order, and the black-on-yellow styling; plus a
short-terminal case exercising the clamp/skip guard.
- **Tier 3 (integration):** `--demo` plumbs `app.demo_mode`; a
significant-key event sets `app.demo_badge` and a swallowed key during
capture does **not**; a `Ctrl+]` / type / `Ctrl+]` sequence sets
`app.demo_caption` without touching `app.input`.
- **CLI (`cli.rs` units):** `--demo` parses (mirrors `--no-undo`); the
`RDBMS_PLAYGROUND_DEMO` env fallback; default-off.
**Honest coverage limit.** The badge **timer-expiry wiring** runs inside
`run_loop` (terminal + db worker), which is not unit-testable in
isolation; it is a thin reuse of the already-proven `IndicatorDebounce`
time-boxed-`recv` path. We therefore test the **pure pieces**
exhaustively (label fn, capture state machine, nearest-deadline helper)
and assert plumbing via Tier-3, rather than over-claiming an integration
test of the `tokio` timeout itself.
</content>
</invoke>
+3
View File
@@ -57,3 +57,6 @@ This directory contains the project's ADRs, recorded per
- [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block - [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block
- [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~1520 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from <P>.(a, b) to <C>.(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec<String>`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~1520 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from <P>.(a, b) to <C>.(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec<String>`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change
- [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject*`show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject*`show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from <T1> to <T2> [as <name>]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships
- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b``22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String`**not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec<String>`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~4050 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears)
- [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54``2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (35 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle
+171
View File
@@ -0,0 +1,171 @@
# Session handoff — 2026-06-10 (61)
Sixty-first handover. Continues from handoff-60 (Gitea migration
cleanup + V1 relationship visualization, ADR-0044). This session was
a **list-trimming pass on "easy wins"**: it closed **X1**
(comprehensive logging, full sweep) and both **T3 residuals** (the two
ADR-0043 messaging-polish items). Four commits, all green, all
user-confirmed.
## §1. State at handoff
**Branch:** `main`. **HEAD `5a33f2a`.** 4 commits this session
(`a8ad0c6``5a33f2a`) on top of session-60's 5; push is the user's
step.
**Tests: 2211 passing / 0 failing / 1 ignored** (lib 1588, it 431,
typing_surface_matrix 192; the 1 ignored is the long-standing
doc-test). **Clippy clean** (nursery, all targets). +4 over the
handoff-60 baseline of 2207 (one test per residual at each of the
enrichment + render layers, plus the two grammar/worker tests).
This session's commits:
```
5a33f2a fix(fk): compound-FK violation message names every column pair
6985a43 fix(fk): inline FK referencing a compound PK points at the table-level form
0a7612e feat: comprehensive logging across parser, app, persistence, runtime (X1)
a8ad0c6 feat(db): comprehensive logging across worker + executors (X1)
```
## §2. X1 — comprehensive logging (closed, `[x]`)
The full-sweep instrumentation pass the "log liberally" standard
called for. **~75 → 135 `tracing` sites** under a documented level
discipline now living in the **`src/logging.rs` module doc** (read it
before adding logs — it is the durable convention).
**Levels:** `error` = unrecoverable; `warn` = recoverable / fallback
taken; `info` = low-volume lifecycle (worker start/exit, project
open); `debug` = the bulk, one line per *executed* command + its
decision points (off by default, opt-in `RDBMS_PLAYGROUND_LOG=debug`);
`trace` = hot paths only (per-keystroke parse, per-key input).
**Where logs go (was a point of confusion):** always a **file**
(stdout/stderr would corrupt the TUI). Path precedence: `--log-file`
> `RDBMS_PLAYGROUND_LOG_FILE` > default `~/.rdbms-playground/
playground.log` (append mode). Level filter is the *separate*
`RDBMS_PLAYGROUND_LOG` env var, default `info`.
**Coverage by commit:**
- `a8ad0c6` **db.rs** (26→67): entry-`debug!` on all 34 `do_*`
executors (DDL/DML/relationship/index/read), matching the existing
`do_sql_delete`/`do_run_select` style — so the route through
*delegating* executors (e.g. `add_column` →
`add_constrained_column_via_rebuild`) is visible in the log
*sequence*. Decision-point logs: `rebuild_table_with_copy`
begin/commit (+ FK-check-failure and `foreign_keys` re-enable
failure as `warn`), `do_insert` autofill summary, `do_delete`
cascade summary, `do_create_table` FK resolution. Worker
start/exit `debug!`→`info!`.
- `0a7612e` **rest**: `persistence/mod.rs` logs every yaml/CSV/history
write (the silent-failure disk paths); `runtime.rs`
`execute_command_typed` dispatch; `app.rs` submit /
`dispatch_app_command` / ADR-0044 diagram-vs-prose render choice;
`dsl/parser.rs` parse begin/outcome at **`trace`** (the
`parse_command_inner` choke point — `completion.rs` re-parses
per-keystroke, probing candidates in a loop, so `debug` would
flood).
**Verification:** emission proven end-to-end through the *real* worker
thread + real `logging::init` via two throwaway smoke tests (db path
and persistence path), both since deleted. The DA-honest gap: a few
internal read-only helpers (`do_find_rows_matching`,
`do_read_relationships`, `do_list_names_for`) and the thin `*_request`
wrappers are not *individually* instrumented — the wrappers delegate
to logged executors (skipped to avoid double-logging), the helpers are
low-value. Effective coverage is complete via logged entry points; it
is not literally 44/44.
## §3. T3 residuals — both closed (ADR-0043)
Two messaging-only items carried since handoff-59 §4; FK
correctness/enforcement was never affected.
**#1 — inline-FK arity wording (`6985a43`).** `col REFERENCES P(a,b)`
referencing a compound PK gave the generic arity error. An inline
column-level FK is single-column by construction, so it now points at
the table-level form: *"an inline column reference can only name one
column … Use the table-level form instead: `FOREIGN KEY (<columns>)
REFERENCES P (a, b)`."* Mechanism: new **`inline: bool` on
`SqlForeignKey`**, set by the single shared grammar builder
`consume_fk_reference` (true for the inline path at `ddl.rs:1560`,
false for table-level `1590` and `build_alter_fk`); threaded into
`resolve_fk_parent_columns`, which tailors the arity-mismatch message
when `inline && parent_key.len() > 1`. 6 construction sites total (2
grammar + 1 ALTER delegate + 3 test literals) — hand-edited, **not**
the scripted sweep handoff-59 §4 warned about. The bare inline form
(`col REFERENCES P`, no parens) hits the same arity branch, so it is
covered by the same code (tested via the explicit-parens form).
**#2 — compound-FK violation names every pair (`5a33f2a`).**
`enrich_fk_violation` (`runtime.rs`) picked only `local_columns
.first()` / `other_columns.next()`. It now gathers all pairs of the
matched relationship and carries them **comma-joined in the existing
single-column facts slots** (`column`, `parent_column`, `value`), so
the headline reads *"no parent row in `Region` has `country, code` =
`7, 8`."* No facts-model or catalog change — joined strings flow
through the existing `{parent_column}`/`{value}` placeholders.
Single-column behaviour is byte-identical (a one-element join is the
element). **Known minor awkwardness:** the *verbose hint* interpolates
`{parent_table}.{parent_column}` → `Region.country, code`, which reads
a touch oddly; the headline is clean. A perfectly-formatted compound
hint would need catalog work, out of scope for a messaging-polish
residual — flagged, not fixed.
## §4. Remaining open landscape (unchanged except X1)
**Closed this session:** X1 → `[x]`; both T3 residuals (ADR-0043 fully
wrapped — no residuals left).
**Still `[/]` / `[~]` / larger (design-first, own ADR):**
- **V2 / S3** multi-result tabs — output-model redesign.
- **V3** whole-DB ER export; **V4** scrollable journal + Markdown
(also the home for diagram live-reflow, ADR-0044 OOS-1).
- **A1** app-commands — blocked on `seed` (SD1) + `hint` (H2).
- **H1a** parse-error syntax help (partial; ADR-0021).
- **DOC1** reference docs.
**`[ ]` not started:** H2 `hint`, SD1 `seed`, C4 m:n convenience, B3
query-timeout, I1 multi-line input, I1b readline shortcuts, I5
cancellation, **TT5 CI** (now Gitea Actions / Woodpecker — a fresh
decision tied to the migration + ADR-0001's reopened distribution
question), TT4 PTY (spec-only), D1D3 distribution, NFR-1…7.
**ADR-0044 OOS for later:** OOS-7 user-configurable relationship-
display setting (always-prose / always-diagram / auto-by-width).
## §5. Next job — candidates (by readiness)
No forced next step. Recommended order:
1. **TT5 CI** — test infra is solid (2211 green) and now there is real
logging to surface failures; no pipeline yet. A fresh **Gitea
Actions / Woodpecker** decision (earns a short ADR; ties into
ADR-0001's reopened distribution question). Highest leverage:
protects everything else.
2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1**
app-commands; both are net-new, self-contained features (each its
own ADR).
3. **C4 m:n convenience** — auto-generate a junction table; depends on
relationships, which are now solid (ADR-0043/0044 done).
4. **V2/S3 tabs** or **V4 journal** — larger output-model redesign;
design-first, own ADR. V4 also unlocks diagram live-reflow.
## §6. How to take over
1. Read handoffs 59 → 60 → 61, then `CLAUDE.md` (Gitea/`tea` section),
`docs/requirements.md` (X1 now `[x]`), `docs/adr/README.md`.
2. **Before adding any logging:** read the level-discipline block in
the `src/logging.rs` module doc (the X1 convention).
3. **For FK/relationship work:** ADR-0043 (compound FKs) + ADR-0044
(visualization) are both fully landed; `SqlForeignKey` now carries
`inline`.
4. Codebase on `main` at `5a33f2a`, clean, 9 commits unpushed (5 from
session 60 + 4 this session).
5. Process pins that paid off: **verify log emission end-to-end, not
just that it compiles** (throwaway smoke tests through the real
worker thread caught nothing broken but proved the stack);
**hot-path logging belongs at `trace`, not `debug`** (the parser);
**test-first on both residuals** (red → green at every layer);
**hand-edit struct-field ripples, never script them** (handoff-59
§4's scare avoided). Commits user-confirmed, append-only, no AI
attribution.
+185
View File
@@ -0,0 +1,185 @@
# Session handoff — 2026-06-10 (62)
Sixty-second handover. Continues from handoff-61 (X1 logging full sweep
+ T3 residuals). This session was a **list-trimming + one-feature run**:
it closed **C4** (the `create m:n relationship` convenience command,
**ADR-0045**) and, in passing, resolved **Gitea issue #19** (drop-PK
guard). Handoff-61 itself was written mid-session, so the X1 / T3 work
it describes is also part of this session's commit range.
## §1. State at handoff
**Branch:** `main`. **HEAD `8bd43cc`.** Push is the user's step.
**Tests: 2237 passing / 0 failing / 1 ignored** (the 1 ignored is the
long-standing doc-test). **Clippy clean** (nursery, all targets). +30
over the handoff-60 baseline of 2207.
**This session's commits** (8, on top of session-60's 5):
```
8bd43cc feat: create m:n relationship convenience command (C4, ADR-0045)
e598008 docs: ADR-0045 m:n convenience command (C4); accepted
e44d298 test+docs: lock drop-PK-refused on advanced surface; document no-PK advanced mode (#19)
b803468 docs: session handoff 61 — X1 logging full sweep + T3 residuals closed
5a33f2a fix(fk): compound-FK violation message names every column pair
6985a43 fix(fk): inline FK referencing a compound PK points at the table-level form
0a7612e feat: comprehensive logging across parser, app, persistence, runtime (X1)
a8ad0c6 feat(db): comprehensive logging across worker + executors (X1)
```
**Requirements closed this session:** **X1** `[x]` (logging), **T3**
residuals (both ADR-0043 messaging items), **C4** `[x]` (m:n). Gitea
**#19 closed**.
## §2. X1 — comprehensive logging (closed) — see handoff-61 §2
Full detail in handoff-61. In brief: ~75 → **137** `tracing` sites under
a documented level discipline (read the **`src/logging.rs` module doc**
before adding logs). Logs go to a **file** (`--log-file` >
`RDBMS_PLAYGROUND_LOG_FILE` > `~/.rdbms-playground/playground.log`);
level via the separate `RDBMS_PLAYGROUND_LOG` env (default `info`).
`debug` = per-command detail (off by default), `trace` = hot paths
(per-keystroke parse).
## §3. T3 residuals (both closed) — see handoff-61 §3
`6985a43` inline-FK arity wording (points at the table-level form;
added `inline: bool` to `SqlForeignKey`). `5a33f2a` compound-FK
violation names every column pair (comma-joined in the single-column
facts slots; `enrich_fk_violation`). ADR-0043 now has no residuals.
## §4. Issue #19 — drop-PK guard (closed, `e44d298`)
A parallel check the user requested. **Finding: dropping a PK column is
already refused in both modes** via the shared `do_drop_column` guard
(*"cannot drop primary-key column …"*) — simple `drop column` and
advanced `ALTER … DROP COLUMN` both route through it. Added end-to-end
coverage (`tests/it/sql_alter_table.rs`: single + compound PK, refusal
for the right reason). **Corrected a long-standing misconception:** the
issue's premise ("we don't support creating a table with no PK") is true
only in **simple** mode — advanced SQL `create table t (a int)` makes a
real **PK-less** table (SQLite's implicit `rowid` keys it; only
`WITHOUT ROWID` lacks one, which this app never creates). The simple-mode
`with pk` requirement is **pedagogical** (ADR-0029), not an engine
constraint. Documented in `docs/simple-mode-limitations.md`.
## §5. C4 — `create m:n relationship` (the feature, ADR-0045)
`create m:n relationship from <T1> to <T2> [as <name>]` generates a
**junction table**: one FK column per parent PK column
(`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a **compound
PK** over all of them, and **two `CASCADE` 1:n relationships** — all in
**one `do_create_table` call = one undo step** (no batch needed;
`do_create_table` already takes `foreign_keys` + writes per-FK
relationship metadata). Auto-named `{T1}_{T2}` (optional `as`), available
in **both modes**, compound-parent PKs supported (ADR-0043).
**Forks (all user-confirmed):** compound-over-FKs PK (vs surrogate /
none); `CASCADE` actions; auto-name + optional `as`; both modes; FK
columns `{table}_{pkcol}`. **Refused:** self-referential m:n (`from T to
T` — full stop, OOS); PK-less parent; internal `__rdbms_*` junction
name; name collision.
**Where the code lives:**
- Grammar: a **separate `CREATE_M2N` `CommandNode`** in
`dsl/grammar/ddl.rs` (entry `create`, opener `Node::Literal("m")`
not a keyword, so it never shadows an identifier), registered Simple
in `grammar/mod.rs` `REGISTRY`. `build_create_m2n`
`Command::CreateM2nRelationship { t1, t2, name }`.
- Worker: `Request::CreateM2nRelationship`,
`Database::create_m2n_relationship`, executor
`do_create_m2n_relationship` (reads each PK, guards self-ref /
PK-less, builds columns + compound PK + 2 `SqlForeignKey`s, calls
`do_create_table`).
- Runtime: `execute_command_typed` arm. Echo:
`echo::render_create_m2n` (advanced-mode DSL→SQL teaching echo, ADR-
0038 — the generated `CREATE TABLE … FOREIGN KEY …`, round-trips as
valid SQL), wired in `build_schema_echo`.
- Surfaces: completion `("m","m:n")` composite; `help.ddl.create_m2n` +
`parse.usage.create_m2n` catalog (+ `keys.rs` declarations);
highlighting is grammar-driven (automatic).
**Tests:** 14 integration (`tests/it/m2n.rs`), 7 typing-surface matrix
(`tests/typing_surface/create_m2n.rs` — completion/hint/highlight/parse),
plus echo / highlight / usage-disambiguator / internal-name units.
## §6. Framework fixes the C4 build + two `/runda` passes surfaced
C4's "separate node" design rested on an ADR premise that proved **only
half true**: *"the walker already dispatches multiple nodes per entry
word"* held in **advanced** mode but not **simple**. Three latent
simple-mode assumptions ("≤1 DSL form per entry word") were generalized,
**all behaviour-preserving for existing single-form commands**:
1. **Dispatch** (`walker/mod.rs` `decide`) committed `simple.first()`
unconditionally → now tries simple candidates (so `create table` no
longer shadows `create m:n`). Reduces to the old single-candidate
commit when there is one.
2. **Completion continuation-merge** (`walker/mod.rs`) was gated
`if mode == Advanced` → now runs in simple mode too, **gated on
`simple_count > 1`** so single-form entry words are untouched.
3. **Usage disambiguator** (`grammar/mod.rs` `usage_key_for_input`)
knew the `1:n` opener but not `m:n` → added an explicit branch.
Plus a **root-cause bug fix** (user-chosen scope): `do_create_table`
now rejects internal `__rdbms_*` names. This closed both the C4 `as
__rdbms_*` hole **and a pre-existing hole** — simple-mode DSL `create
table __rdbms_*` was accepted at parse (the `TABLE_NAME_NEW` slot had no
guard; only the advanced-SQL path rejected internal names). The shared
executor is the single choke point; the SQL path still rejects earlier
at parse.
**Process note:** the two `/runda` passes were worth it. The first
(pre-build) corrected the inverted "no PK-less tables" assumption and
confirmed the `do_create_table` reuse against code. The second
(pre-commit) closed **five** test-coverage gaps — two of which
(highlighting, persistence round-trip) had been **wrongly claimed
verified** (the typing-surface `Assessment` has no highlight field;
"transitively covered" was a hand-wave) — and found the two bugs above.
Lesson re-confirmed: verify a claimed-tested surface actually has an
assertion; "transitively covered" is a DA red flag.
## §7. Remaining open landscape
**Closed since handoff-60:** X1, both T3 residuals, C4, #19. ADR-0043 and
ADR-0045 fully landed.
**Still open (by readiness, unchanged otherwise):**
1. **TT5 CI** — test infra solid (2237 green); no pipeline. **Gitea
Actions / Woodpecker** (a fresh decision tied to the migration +
ADR-0001's reopened distribution question). **Friction:** the
requirement is Linux/macOS/Windows on stable — self-hosted Gitea can
do Linux easily, but mac/Windows runners need machines that may not
exist; likely needs a Linux-first scope decision.
2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1**
app-commands; both net-new, own ADR (SD2 is the seed-generator design
ADR). SD1 should now seed **m:n junctions** too (valid FK refs from
parent rows) — C4 makes that concrete.
3. **V2/S3 multi-result tabs** or **V4 journal** — larger output-model
redesign, design-first, own ADR. V4 also unlocks diagram live-reflow.
4. **C3a modify relationship** — small follow-up (drop+add covers it
today; ADR pending).
**ADR-0045 OOS for later:** self-referential m:n (deliberate non-goal);
per-relationship action overrides; extra junction payload columns;
m:n-as-diagram echo. **Pre-existing, now-fixed:** the internal-name hole
(§6) — no separate issue needed, it's closed.
## §8. How to take over
1. Read handoffs 60 → 61 → 62, then `CLAUDE.md`, `docs/requirements.md`
(X1/C4 now `[x]`), `docs/adr/README.md`.
2. **Before adding logging:** the level discipline in the
`src/logging.rs` module doc.
3. **For grammar/command work:** an entry word can now carry **multiple
DSL forms** in simple mode (C4 generalized the dispatch + completion +
usage paths). `create` is the first such entry word (table + m:n).
4. **For relationship/FK work:** ADR-0013/0043/0044/0045 are all landed;
`SqlForeignKey` carries `inline`; `do_create_table` now guards
internal names.
5. Codebase on `main` at `8bd43cc`, clean. Commits user-confirmed,
append-only, no AI attribution. Process pins that paid off: **two
`/runda` passes per feature** (design + pre-commit) — both found real
bugs and gaps every time; **verify a claimed-tested surface has an
actual assertion**; **escalate genuine forks** (every C4 design choice
+ the internal-name fix scope was the user's).
+159
View File
@@ -0,0 +1,159 @@
# Session handoff — 2026-06-10 (63)
Sixty-third handover. Continues from handoff-62 (C4 m:n + #19). This
was a **single-ADR, full-build session**: it designed and implemented
**ADR-0046** end to end — the UI work for the three sidebar/input
issues **#20 / #21 / #23**, all now **closed** on Gitea.
## §1. State at handoff
**Branch:** `main`. **HEAD `22bec61`** (plus an uncommitted docs
finalization — ADR status flip + this handoff — see §7). Push is the
user's step.
**Tests: 2263 passing / 0 failing / 1 ignored** (the 1 ignored is the
long-standing doc-test). **Clippy clean** (nursery, all targets). +26
over the handoff-62 baseline of 2237.
**This session's commits** (8 + the docs finalization):
```
22bec61 feat(ui): scroll the focused sidebar panel + refine the nav overlay (DC3 + DC2)
c9da6ff feat(ui): Ctrl-O navigation mode — peek + expand the schema sidebar (DC1/DC2/DC4)
94825d0 feat(ui): relationships sidebar panel + schema data (DB2/DB4)
386627a feat(ui): width-derived sidebar visibility — hide at <=90 cols (DB1)
41bae99 feat(ui): two-row input display on tall terminals (DA4)
e0b9470 feat(ui): horizontal-scroll long input so the cursor stays visible (DA3)
9f5f76b fix(ui): geometry-fixed hint-panel height kills the typing jump (DA1/DA2)
93266b9 docs: ADR-0046 UI sidebar nav-mode + responsive input/hint
```
**Issues closed:** **#20**, **#21**, **#23** (all via ADR-0046).
**#22** (in-app overlay/keystroke-annotation layer for casts/lessons)
remains **open** — its own future ADR; adjacent but out of scope here.
## §2. What shipped — ADR-0046 (read it; it's the source of truth)
Three coupled UI issues, treated as one decision because they share the
terminal width/height budget. Phased A → B → C.
**Phase A — input & hint (#20, #23).**
- **DA1/DA2 (#20):** the Hint panel height is now a pure function of
terminal geometry (`hint_rows` → later `panel_heights`), **fixed
between resizes** — it no longer resizes as you type, killing the
jump. Compact (`<40` rows) = hint 2; comfortable = hint 2, or 3 only
when the column is narrow (`inner < 54`). This **reverses issue #12's**
shrink-to-content sizing (its two tests were replaced by an anti-jump
invariant). Long hints ellipsize at the fixed budget.
- **DA3 (#23):** long input **horizontally scrolls** to keep the cursor
visible (`input_scroll_offset`, pure `input_scroll_offset()` helper),
with muted `<` / `>` edge markers; resets on submit / history.
Preserves ADR-0027's 6-col indicator reserve.
- **DA4 (#23):** on a tall terminal (`>=40` rows) the input renders
across **two visual rows** (soft-wrap of the single logical line;
indicator stays on row 1). Distinct from deferred multi-line **I1**;
`expand_runs_to_cells` is the substrate I1 should reuse.
**Phase B — the sidebar (#21).**
- **DB1:** the left column is **width-optional** — `sidebar_visible() =
width > 90`, so it's hidden at <=90 (the 90-col screencasts) and the
right column takes the full width. (Resize a terminal below ~90 to see
it; in a normal wide terminal it shows, by design.)
- **DB2/DB4:** a **Relationships panel** stacks below Tables — each
relationship is name + endpoints broken at the arrow
(`Customers.id ->` / indented `Orders.customer_id`), ellipsized. The
panel floors at 5 rows ("(none)") and grows to a 50%-of-column cap
(`relationships_panel_height`). **Overrides S2** (relations were to be
*nested* in the tables list; a sibling panel is the honest shape).
**Phase C — navigation mode (#21).**
- **DC1/DC4:** **`Ctrl-O`** enters a navigation mode orthogonal to the
input mode, cycling focus **Input → Tables → Relationships → Input**
(`Esc` exits). It's routed in the main key handler *after* the modal
gate, so it's inert behind a modal; in nav mode every non-nav key is
inert (the input is occluded). `NavFocus` enum on `App`.
- **DC2:** the focused panel is revealed (peek, even when width-hidden)
and drawn as a **45-col expanded overlay**, clearing the sidebar strip
**+ a one-column gutter** and leaving the base output/input/hint
visible (unchanged) to the right. *(Two variants were eyeballed; this
partial-clear-with-gutter was chosen over a full-area clear.)*
- **DC3:** the focused panel **scrolls** — Up/Down by a line,
PageUp/PageDown by its visible rows; per-panel offsets clamped to
content at render time, mirroring the output-panel scroll.
**`Ctrl-B` was rejected** for nav mode (it's the tmux prefix →
unreachable inside tmux); `Ctrl-O` is multiplexer-safe.
## §3. Two decisions that landed differently from the draft
Both recorded inline in the ADR (and called out in its Status):
1. **Relationship data on `App`, not `SchemaCache`** (DB2). `SchemaCache`
is walker/completion-facing and needs only relationship *names*
(untouched); the full records are UI-only, so `App.relationships`
mirrors `app.tables`, and it avoided editing ~23 `SchemaCache`
literals. Delivered via `Database::read_all_relationships` (new worker
request) + `AppEvent::RelationshipsRefreshed` from the runtime's
schema refresh.
2. **Nav overlay = partial clear + 1-col gutter** (DC2), not a full-area
clear — truer to "underneath keeps its layout."
## §4. Process notes
- **The pre-build `/runda` pass earned its keep again.** It caught the
`Ctrl-B`/tmux collision, a `SchemaCache` retype that would have broken
completion, the 2-row-input/indicator placement, the missing nav-mode
key disposition + modal gate, and **three unreferenced requirements**
(S1 evolved, S2 overridden, S4 corrected — `requirements.md` updated).
- **Snapshot discipline:** DB1's 90-col threshold collided with the
test-suite's 80-col convention — many snapshots/tests were retuned
(sidebar-dependent ones now render at 110; input tests at narrower
widths so the now-wider input still overflows). One masked-intent
integration check (matched "Customers" in output, not the panel) was
corrected.
- Each phase was committed green + clippy-clean, user-confirmed message,
no AI attribution, append-only.
## §5. Requirements / S-items touched
`requirements.md` annotated: **S1** (three-region layout → left region
width-optional), **S2** (*overridden* — relationships get a sibling
panel, not nested), **S4** (*corrected* — the "keyboard-toggleable" hint
claim was never implemented and is struck; the panel is always-on).
## §6. Remaining open landscape (unchanged from handoff-62, minus the closed items)
1. **TT5 CI** — test infra solid (2263 green); no pipeline. Gitea Actions
/ Woodpecker decision + likely a Linux-first scope call.
2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1**
app-commands; both net-new, own ADR. SD1 should seed m:n junctions.
3. **V2/S3 multi-result tabs** or **V4 journal** — larger output-model
redesign, design-first, own ADR.
4. **C3a modify relationship** — small follow-up (drop+add covers it).
5. **#22 overlay/annotation layer** — own ADR; shares the cast + overlay
space with DC2 (designed to coexist).
6. **Tutorial/lesson system** — acknowledged in scope; needs its own ADR.
**ADR-0046 OOS (deferred):** true multi-line input (I1); readline
shortcuts (I1b); cross-session sidebar persistence; a persistent
show/hide toggle (Ctrl-O peek covers it); output as a third nav focus;
relationship search/edit from the panel; a hint-area toggle.
## §7. How to take over
1. Read handoffs 61 → 62 → 63, then `CLAUDE.md`, `docs/requirements.md`,
`docs/adr/README.md`, and **ADR-0046** (fully landed).
2. **Pending:** an uncommitted docs finalization (ADR-0046 status →
*implemented*; README index status; this handoff). Commit it as
`docs: session handoff 63` (the user confirms commit messages).
3. **For UI/layout work:** `src/ui.rs` now has `panel_heights`,
`sidebar_visible`, `relationships_panel_height`, the nav overlay, and
`&mut App` sidebar panels (they report scroll viewports). `App` gained
`input_scroll_offset`, `nav_focus`, `relationships`, and the
`tables_scroll` / `relationships_scroll` (+ `last_*_visible`) fields.
4. **For relationship/schema-cache work:** relationship *names* are in
`SchemaCache.relationships` (completion); full records are on
`App.relationships` via `Database::read_all_relationships` +
`RelationshipsRefreshed`.
5. **Eyeball reminder honoured:** the user reviewed the nav overlay
appearance and chose the partial-clear + 1-col-gutter variant.
6. Run a `cargo sweep` at some point — `target/` has grown across this
build-heavy session.
+140
View File
@@ -0,0 +1,140 @@
# Session handoff — 2026-06-11 (64)
Sixty-fourth handover. Continues from handoff-63 (ADR-0046 sidebar/nav).
This session closed **two unrelated, website-screencast-enabling Gitea
issues**: **#24** (vi-style load-picker navigation) and **#22**
(in-app demonstration overlay layer — its own **ADR-0047**, built end
to end across three phases + a restyle).
## §1. State at handoff
**Branch:** `main`. **HEAD `2d0f4b2`** plus an **uncommitted docs
finalization** (ADR-0047 status → implemented, README index, this
handoff — see §6). Push is the user's step.
**Tests: 2290 passing / 0 failing / 0 skipped / 1 ignored** (the 1
ignored is the long-standing `friendly` doctest). **Clippy clean**
(nursery, all targets). +27 over the handoff-63 baseline of 2263.
**This session's commits:**
```
2d0f4b2 feat(ui): flat filled rectangles for demo overlays (#22, ADR-0047 D4)
241f60c feat(ui): demo-mode step-caption stealth buffer (#22, ADR-0047 D3/D4)
2584e76 feat(ui): demo-mode keystroke badges (#22, ADR-0047 D2/D4/D5)
f879d54 feat(cli): --demo demonstration mode flag + app plumbing (#22, ADR-0047 D1)
e9eb1b1 docs: ADR-0047 — demonstration overlay layer for casts/teaching (#22)
638b4c9 feat(app): vi-style j/k/g/G navigation in the load picker (#24)
```
**Issues closed:** **#24** (vi nav) and **#22** (demo overlays) — close
#22 once the docs finalization commit lands.
## §2. #24 — vi-style load-picker navigation (commit `638b4c9`)
Purely additive to the ADR-0015 load picker (`handle_load_picker_key`,
`LoadPickerSubMode::List`): **`j`/`k`** mirror Down/Up (bounds-
respecting, no wrap), **`g`/`G`** jump to first/last. Existing keys
(`↑↓`/`Enter`/`Esc`/`b`) unchanged; the footer hint is **left as-is** at
the user's request (the new keys are not advertised). No ADR (additive).
Motivation: `autocast` (the website cast driver) can only send typeable
characters — not arrow keys — so the projects demo needs `j`/`k` to
drive the picker. Tests: `load_picker_jk_navigates_like_arrows`,
`load_picker_g_jumps_to_first_and_last` (test-first).
## §3. #22 — ADR-0047 demonstration overlay layer (read the ADR)
An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO`
env, **off by default, zero footprint when off**) that renders two
transient overlays so `autocast` screencasts — and live teaching, and a
future guided-lesson system — can show otherwise-invisible interactions.
- **Phase A (`f879d54`):** `--demo` + env → `App.demo_mode`, threaded
through `run_loop` like `--no-undo`. `--help` line mentions **only the
visible badges**; the `Ctrl+]` caption trigger is kept low-profile
(user decision, D6).
- **Phase B (`2584e76`):** **automatic keystroke badges**
(`[TAB]`/`[ENTER]`/`[UP]`/…) over a fixed set of glyph-less keys —
pure `demo_badge_label(&KeyEvent)`, set in `App::update` **before the
modal gate** (so they fire over the load picker), expired by a **~1.5 s
runtime timer**. The timer extends the event loop's time-boxed-`recv`
via a new pure `nearest_deadline` helper; the rewrite tracks `Instant`
deadlines and was **verified not to regress the ADR-0027 indicator
debounce**. New `App.last_output_area: Rect` (set in
`render_output_panel`) anchors the overlays.
- **Phase C (`241f60c`):** the **stealth `Ctrl+]` caption buffer**
`Ctrl+]` (byte `0x1D``Char('5')+CONTROL`, verified vs crossterm
0.29) toggles an invisible buffer; typed chars accumulate without
touching input/output, `Backspace` edits, other keys inert, a second
`Ctrl+]` commits (empty commit dismisses). In pure-sync `App::update`,
intercepted **before the modal gate**; an ordinary keystroke clears a
visible caption.
- **Restyle (`2d0f4b2`):** the overlays render as **flat filled yellow
rectangles** (no border glyphs, one-cell text margin) — user decision,
deliberately unlike the bordered panels so they pop. Shared
`fill_overlay_rect` (borderless `Block` fill + inset `Paragraph`).
**Placement:** both float at the output panel's inner bottom-right,
drawn **last over modals**, badge **stacked directly above** the caption
when both show; caption **wraps to ≤ 3 lines** then ellipsises; clamp/
skip guard for tiny terminals.
**Process:** ADR-first (user chose), pre-build `/runda` (10 findings,
folded in) + whole-implementation `/runda` (**PASS, no blockers**). Every
fork user-confirmed via mockups/questions, incl. the two post-draft
follow-ups: `Ctrl+]` trigger (over `Ctrl+!`, which `autocast` cannot
send — not a single ASCII byte) and wrap-to-3-line captions.
## §4. Two things to know about the implementation
1. **Ownership split (intentional, mirrors `input`/`input_indicator`):**
`demo_caption`/`demo_caption_capturing`/`demo_caption_buffer` are
driven by `App::update` (input); `demo_badge` is **set** by
`App::update` but its expiry is **timed by the runtime**
(`demo_badge_seq` bumps so a repeated key restarts the timer).
2. **`Ctrl-C` is inert while capturing** — by spec ("every other key is
inert"); exit capture with `Ctrl+]`. User-acknowledged; flagged in
the ADR. The only behaviour worth a second look if it ever annoys.
## §5. Honest coverage note
Everything *testable* is tested (label fn, full caption FSM incl.
over-modal + demo-off, `nearest_deadline`, all rendering, CLI parse/env).
The **only** untested wiring is inside `run_loop` (the badge-timer
arm/clear and `app.demo_mode = demo_mode`) — `run_loop` is not
unit-testable (terminal + DB + channels), exactly the posture the
existing `IndicatorDebounce` already takes. A future Tier-4 PTY harness
(ADR-0008 TT4, still unwired) would close it.
## §6. How to take over
1. Read handoffs 62 → 63 → 64, `CLAUDE.md`, `docs/requirements.md`,
`docs/adr/README.md`, and **ADR-0047** (fully landed).
2. **Pending:** the docs finalization commit (ADR-0047 status →
implemented; README index; this handoff). Commit as
`docs: session handoff 64 + ADR-0047 implemented (#22/#24)` (the user
confirms commit messages). Then close **#22** on Gitea.
3. **For demo-overlay work:** `App` has `demo_mode`, `demo_badge`,
`demo_badge_seq`, `demo_caption`, `demo_caption_capturing`,
`demo_caption_buffer`, `last_output_area`. Rendering:
`render_demo_overlays` / `render_badge_box` / `render_caption_box` /
`fill_overlay_rect` in `ui.rs`; colours `DEMO_OVERLAY_FG/BG` in
`theme.rs`; key handling `handle_demo_caption_key` + the top-of-
`handle_key` gate; timer in `runtime.rs` (`nearest_deadline`,
`DEMO_BADGE_TTL`).
## §7. Remaining open landscape (from handoff-63, minus the closed items)
1. **Wire the overlays into the website casts**`casts.mjs` on the
`website` branch can now emit `^]`/text/`^]` for captions and rely on
automatic badges. Website-branch follow-up (OOS for #22's app scope).
2. **TT5 CI** — 2290 green, no pipeline yet.
3. **SD1 `seed`** then **H2 `hint`** — the unblockers for **A1**
app-commands; own ADRs.
4. **V2/S3 multi-result tabs** / **V4 journal** — larger output-model
redesign, own ADR.
5. **C3a modify relationship** — small (drop+add covers it).
6. **Tutorial/lesson system** — acknowledged in scope; needs its own
ADR; ADR-0047's overlay primitive is what it will reuse.
Run a `cargo sweep` at some point — `target/` grew across this
build-heavy session.
+53 -15
View File
@@ -73,24 +73,38 @@ since ADR-0027.)
panel (right), input field (bottom). panel (right), input field (bottom).
*(Verified 2026-06-07: `ui.rs:26-58` lays out a horizontal *(Verified 2026-06-07: `ui.rs:26-58` lays out a horizontal
split — items panel left, right column subdivided into output split — items panel left, right column subdivided into output
panel / input field / hint panel; rendered every frame.)* panel / input field / hint panel; rendered every frame.
**ADR-0046 evolves this:** the left items region becomes
width-optional — hidden by default at ≤ 90 columns, peek-revealed
via `Ctrl-O` navigation mode — so the three-region layout is the
wide-terminal default, not an invariant.)*
- [x] **S2** Items list shows tables and per-table indexes; - [x] **S2** Items list shows tables and per-table indexes;
designed to extend to additional element kinds (relations, designed to extend to additional element kinds (relations,
views, etc.) without restructuring. views, etc.) without restructuring.
*(ADR-0025: the items panel renders a nested list — each *(ADR-0025: the items panel renders a nested list — each
table with its index names indented beneath it. The nested table with its index names indented beneath it. The nested
model is the extension point for future element kinds.)* model is the extension point for future element kinds.
**ADR-0046 overrides the nesting approach for relationships:**
because relationships are cross-table rather than per-table, they
get their own sibling panel stacked below the tables list, not
nested items within it — user-confirmed 2026-06-10.)*
- [/] **S3** Output panel renders a visualization of the - [/] **S3** Output panel renders a visualization of the
currently selected item and supports multiple tabs. currently selected item and supports multiple tabs.
*(Partial, verified 2026-06-07: single-element structure *(Partial, verified 2026-06-07: single-element structure
visualisation renders (`output_render.rs:82-180`); **multiple visualisation renders (`output_render.rs:82-180`); **multiple
tabs are not implemented** — the output is one line buffer, no tabs are not implemented** — the output is one line buffer, no
tab abstraction. Same multi-tab gap as V2.)* tab abstraction. Same multi-tab gap as V2.)*
- [x] **S4** Hint area below the input field; keyboard-toggleable - [x] **S4** Hint area below the input field, showing hints about
for inspecting hints about the current input or last error. the current input or last error.
*(Verified 2026-06-07: `ui.rs:1088-1110` `render_hint_panel` / *(Verified 2026-06-07: `ui.rs:1088-1110` `render_hint_panel` /
`resolve_hint_lines` — a dynamic 1`MAX_HINT_ROWS` panel below `resolve_hint_lines` — a dynamic 1`MAX_HINT_ROWS` panel below
the input showing ambient hints, candidates, or the last error.)* the input showing ambient hints, candidates, or the last error.
**Correction (2026-06-10, ADR-0046):** the original wording said
the area was "keyboard-toggleable"; that was never implemented and
is deliberately dropped — the panel became indispensable once
completion moved into it (ADR-0022), so it is always on. ADR-0046
replaces its content-driven height with a geometry-driven one to
stop the resize jump (#20); no toggle is added.)*
- [x] **S5** Mode label and distinct border style on the input - [x] **S5** Mode label and distinct border style on the input
field communicate the current input mode at all times. field communicate the current input mode at all times.
*(Verified 2026-06-07: `ui.rs:896-934` `render_input_panel` — *(Verified 2026-06-07: `ui.rs:896-934` `render_input_panel` —
@@ -276,9 +290,23 @@ since ADR-0027.)
the same via drop + add today; one-step modify is a small the same via drop + add today; one-step modify is a small
follow-up using the existing rebuild-table machinery. ADR follow-up using the existing rebuild-table machinery. ADR
pending. pending.
- [ ] **C4** Convenience: `create m:n relationship from <T1> to - [x] **C4** Convenience: `create m:n relationship from <T1> to
<T2>` produces an auto-named junction table the user can rename; <T2>` produces an auto-named junction table the user can rename;
pulls primary keys and FK definitions automatically. pulls primary keys and FK definitions automatically.
*(Done 2026-06-10 via **ADR-0045**. `create m:n relationship from
<T1> to <T2> [as <name>]` builds a junction table with one FK column
per parent PK column (`{table}_{pkcol}`, typed via `fk_target_type`),
a **compound PK** over them, and two **`CASCADE`** 1:n relationships
— all in one `do_create_table` call = one undo step. Auto-named
`{T1}_{T2}` (optional `as`), available in both modes, compound-parent
PKs supported (ADR-0043). Self-referential m:n refused; PK-less parent
refused. Wired across every surface — completion (`m:n` composite),
hints, highlighting, `help`/usage, and the advanced-mode DSL→SQL
teaching echo (the generated `CREATE TABLE … FOREIGN KEY …`). 9
integration + 7 typing-surface + echo/parse unit tests. The build
surfaced — and fixed — two latent simple-mode dispatch/completion
assumptions ("≤1 DSL form per entry word"), now generalized
behaviour-preservingly.)*
- [x] **C5** Data operations: insert / update / delete via DSL. - [x] **C5** Data operations: insert / update / delete via DSL.
*(ADR-0014. INSERT short and long forms, UPDATE/DELETE with *(ADR-0014. INSERT short and long forms, UPDATE/DELETE with
required WHERE plus `--all-rows` opt-in, `show data <T>`, required WHERE plus `--all-rows` opt-in, `show data <T>`,
@@ -804,17 +832,27 @@ since ADR-0027.)
## Cross-cutting ## Cross-cutting
- [/] **X1** Comprehensive logging via the project's logging - [x] **X1** Comprehensive logging via the project's logging
infrastructure per `CLAUDE.md` (decision points, parameter infrastructure per `CLAUDE.md` (decision points, parameter
values, fallback paths). values, fallback paths).
*(Partial, verified 2026-06-07: the logging **harness** is *(Done 2026-06-10 via a full-sweep instrumentation pass. The
wired — `src/logging.rs` sets up file-backed `tracing` with an prior state (verified 2026-06-07) was a wired harness
env filter — but instrumentation is **sparse**: ~25 `tracing::` (`src/logging.rs`) but sparse instrumentation — failure-path
call sites across the tree, concentrated in `runtime.rs` and heavy, nothing in `db.rs`/parser/executors. The sweep brought
`undo.rs` and mostly error/warning on failure paths. The every layer to the "log liberally" bar under a documented level
decision-point / parameter-value / fallback-path coverage the discipline (see the `logging.rs` module doc): **`db.rs`** gained
`CLAUDE.md` "log liberally" standard calls for — especially in entry-level `debug!` on all 34 `do_*` executors plus decision-point
`db.rs`, the parser, and the executors — is largely absent.)* logs (rebuild-table primitive, insert auto-fill, delete cascade,
FK resolution) — so the route through delegating executors is
visible in the log sequence; **persistence** logs every
yaml/CSV/history write (the silent-failure paths); **runtime**
logs `execute_command_typed` dispatch; **`app.rs`** logs
submit / app-command dispatch / render-mode choice; the **parser**
logs parse begin/outcome at `trace` (it is a per-keystroke hot
path). Levels: `debug` for per-command detail (off by default,
`RDBMS_PLAYGROUND_LOG=debug`), `info` for lifecycle, `warn` for
fallbacks, `trace` for hot paths. Emission verified end-to-end
through the real worker thread + `logging::init`. ~75 → ~135 sites.)*
- [~] **X2** Language: English-only for v1; multi-language is an - [~] **X2** Language: English-only for v1; multi-language is an
open question to revisit later. open question to revisit later.
- [~] **X3** Accessibility: TUI screen-reader support is - [~] **X3** Accessibility: TUI screen-reader support is
+16
View File
@@ -41,6 +41,22 @@ entry names the ADR that drew the boundary.
## Table creation (ADR-0029) ## Table creation (ADR-0029)
- **A simple-mode table always has a primary key; an advanced-mode
table need not.** `create table … with pk …` is mandatory in simple
mode (ADR-0029) — the bare `with pk` even defaults to `id serial`.
Advanced-mode SQL follows standard SQL and permits a *PK-less* table:
`create table t (a int)` declares no primary key. This is **not** a
storage problem — every ordinary table (STRICT included) carries
SQLite's implicit `rowid`, which keys it internally; only a
`WITHOUT ROWID` table (which this app never creates) would lack one.
So the simple-mode requirement is a *pedagogical* boundary (teach that
tables should have a key), not an engine constraint. Consequences in a
PK-less table, all handled: `show data … limit` falls back to rowid
order (no stable user-facing key to order by); `update` / `delete`
still target the affected rows by rowid; and there is no "PK column"
to drop — dropping a *declared* PK column is refused in **both** modes
(the shared `do_drop_column` guard: *"cannot drop primary-key column
…"*).
- **`create table` declares only primary-key columns.** - **`create table` declares only primary-key columns.**
`create table T with pk …` makes every listed column part `create table T with pk …` makes every listed column part
of the primary key; there is no simple-mode syntax for a of the primary key; there is no simple-mode syntax for a
+650 -12
View File
@@ -9,6 +9,7 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::Rect;
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
use crate::action::Action; use crate::action::Action;
@@ -226,6 +227,28 @@ impl EffectiveMode {
} }
} }
/// Navigation-mode focus cursor (ADR-0046 DC1).
///
/// `Input` means not in navigation mode — keystrokes edit the command
/// input as usual. `Ctrl-O` cycles Input → SidebarTables →
/// SidebarRelationships → Input; while a sidebar panel is focused the
/// sidebar is revealed (peek) and expanded as an overlay, and scroll
/// keys drive it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NavFocus {
#[default]
Input,
SidebarTables,
SidebarRelationships,
}
impl NavFocus {
/// True while a sidebar panel is focused (navigation mode is active).
pub const fn in_sidebar(self) -> bool {
matches!(self, Self::SidebarTables | Self::SidebarRelationships)
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct App { pub struct App {
pub mode: Mode, pub mode: Mode,
@@ -237,6 +260,15 @@ pub struct App {
/// Byte offset into `input` where the next character will be /// Byte offset into `input` where the next character will be
/// inserted. Always lies on a UTF-8 character boundary. /// inserted. Always lies on a UTF-8 character boundary.
pub input_cursor: usize, pub input_cursor: usize,
/// First visible display column of the input line when it is too
/// long to fit the input panel (ADR-0046 DA3). The renderer keeps
/// the cursor in view by adjusting this; it resets to 0 whenever the
/// buffer is replaced wholesale (submit / history navigation).
pub input_scroll_offset: usize,
/// Navigation-mode focus cursor (ADR-0046 DC1). `Input` when not in
/// navigation mode. Driven by `Ctrl-O` / `Esc`; the renderer reveals
/// + expands the focused sidebar panel as an overlay.
pub nav_focus: NavFocus,
pub output: VecDeque<OutputLine>, pub output: VecDeque<OutputLine>,
pub hint: Option<String>, pub hint: Option<String>,
/// The validity indicator's currently-visible verdict /// The validity indicator's currently-visible verdict
@@ -247,6 +279,12 @@ pub struct App {
/// [`App::input_validity_verdict`] once typing pauses. /// [`App::input_validity_verdict`] once typing pauses.
pub input_indicator: Option<crate::dsl::walker::Severity>, pub input_indicator: Option<crate::dsl::walker::Severity>,
pub tables: Vec<String>, pub tables: Vec<String>,
/// All relationships as full schema records, for the sidebar
/// relationships panel (ADR-0046 DB2). Refreshed by the runtime
/// alongside `tables`. Kept on the App (not `SchemaCache`) because
/// only the UI needs the details — the walker/completion need just
/// the names, which stay in `SchemaCache::relationships`.
pub relationships: Vec<crate::persistence::RelationshipSchema>,
/// Last successfully described table, shown in the output /// Last successfully described table, shown in the output
/// pane until the next DDL operation. /// pane until the next DDL operation.
pub current_table: Option<TableDescription>, pub current_table: Option<TableDescription>,
@@ -286,6 +324,20 @@ pub struct App {
/// diagram's side-by-side vs vertical layout choice. Defaults to /// diagram's side-by-side vs vertical layout choice. Defaults to
/// `80` until the first render measures the real width. /// `80` until the first render measures the real width.
pub last_output_width: u16, pub last_output_width: u16,
/// The most recent **inner area** (inside the border) of the output
/// panel, recorded by the renderer (ADR-0047 D4). The demo overlays
/// anchor to its bottom-right corner; read at the top-level draw
/// pass, which otherwise does not know where the output panel sits.
/// Zero-sized until the first render measures it.
pub last_output_area: Rect,
/// Top visible row of the Tables / Relationships sidebar panels
/// while scrolled in navigation mode (ADR-0046 DC3), with the most
/// recent visible-row count the renderer reported for each (used to
/// page-scroll and to clamp the offset). `0` = showing from the top.
pub tables_scroll: usize,
pub relationships_scroll: usize,
pub last_tables_visible: usize,
pub last_relationships_visible: usize,
/// Prettified display name of the currently-open project, /// Prettified display name of the currently-open project,
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None` /// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
/// during very-early startup before the runtime has opened a /// during very-early startup before the runtime has opened a
@@ -323,6 +375,38 @@ pub struct App {
/// flag; the `undo` / `redo` commands then report undo is off /// flag; the `undo` / `redo` commands then report undo is off
/// rather than emitting a prepare action. /// rather than emitting a prepare action.
pub undo_enabled: bool, pub undo_enabled: bool,
/// Whether **demonstration mode** is active this session (ADR-0047,
/// issue #22). `true` under `--demo` / `RDBMS_PLAYGROUND_DEMO`. When
/// off (the default) none of the demo key handling or overlay
/// rendering runs — zero footprint. When on, otherwise-invisible
/// keys raise a transient badge (`demo_badge`) and `Ctrl+]` drives
/// the stealth step-caption buffer (`demo_caption` / `demo_capturing`,
/// Phase C).
pub demo_mode: bool,
/// The keystroke badge currently displayed in demo mode (ADR-0047
/// D2), e.g. `"[TAB]"`. Set in `update()` when an otherwise-invisible
/// key is handled; cleared by the runtime when its ~1.5 s timer
/// elapses (the timing lives in the runtime, mirroring how
/// `input_indicator` is driven from `IndicatorDebounce`). `None` when
/// no badge is showing.
pub demo_badge: Option<&'static str>,
/// Monotonic counter bumped every time `demo_badge` is (re)set
/// (ADR-0047 D5). The runtime watches it so a *new* badge — even the
/// same label twice in a row (Tab, Tab) — restarts the expiry timer.
pub demo_badge_seq: u64,
/// The step-caption currently displayed in demo mode (ADR-0047 D3),
/// or `None`. Committed from the stealth buffer on the closing
/// `Ctrl+]`; cleared by the next ordinary keystroke (or an empty
/// commit). Rendered as a wrapped box stacked above the badge.
pub demo_caption: Option<String>,
/// Whether the stealth caption buffer is open (ADR-0047 D3): between
/// the opening and closing `Ctrl+]`, typed characters accumulate into
/// `demo_caption_buffer` invisibly and every other key is inert.
pub demo_caption_capturing: bool,
/// The invisible accumulator for the caption being typed while
/// `demo_caption_capturing` (ADR-0047 D3). Never rendered directly;
/// its trimmed contents become `demo_caption` on commit.
pub demo_caption_buffer: String,
/// The DSL → SQL teaching echo (ADR-0038) for the command currently /// The DSL → SQL teaching echo (ADR-0038) for the command currently
/// being rendered: set from the success event just before its handler /// being rendered: set from the success event just before its handler
/// runs, consumed by `note_ok_summary` (which pushes it beneath /// runs, consumed by `note_ok_summary` (which pushes it beneath
@@ -425,6 +509,36 @@ const PAGE_SCROLL_LINES: usize = 5;
const HISTORY_CAPACITY: usize = 1000; const HISTORY_CAPACITY: usize = 1000;
/// The demo-mode keystroke badge for `key`, or `None` if the key
/// produces a glyph of its own (and so needs no badge) — ADR-0047 D2.
///
/// The set is exactly the *otherwise-invisible* keys: motion, editing,
/// submission, and the `Ctrl-O` navigation toggle. Plain character keys
/// already appear on the input line, and `Ctrl-C` (quit) / `Ctrl+]`
/// (the caption toggle) are deliberately excluded. Pure and total, so
/// it is exhaustively unit-testable without a running app.
pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
match (key.code, key.modifiers) {
(KeyCode::Tab, _) => Some("[TAB]"),
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
(KeyCode::Enter, _) => Some("[ENTER]"),
(KeyCode::Esc, _) => Some("[ESC]"),
(KeyCode::Up, _) => Some("[UP]"),
(KeyCode::Down, _) => Some("[DOWN]"),
(KeyCode::Left, _) => Some("[LEFT]"),
(KeyCode::Right, _) => Some("[RIGHT]"),
(KeyCode::Home, _) => Some("[HOME]"),
(KeyCode::End, _) => Some("[END]"),
(KeyCode::PageUp, _) => Some("[PGUP]"),
(KeyCode::PageDown, _) => Some("[PGDN]"),
(KeyCode::Backspace, _) => Some("[BKSP]"),
(KeyCode::Delete, _) => Some("[DEL]"),
// The only badged control chord: the ADR-0046 navigation toggle.
(KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => Some("[CTRL-O]"),
_ => None,
}
}
impl Default for App { impl Default for App {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@@ -439,10 +553,13 @@ impl App {
messages_verbosity: crate::friendly::Verbosity::default(), messages_verbosity: crate::friendly::Verbosity::default(),
input: String::new(), input: String::new(),
input_cursor: 0, input_cursor: 0,
input_scroll_offset: 0,
nav_focus: NavFocus::Input,
output: VecDeque::with_capacity(OUTPUT_CAPACITY), output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None, hint: None,
input_indicator: None, input_indicator: None,
tables: Vec::new(), tables: Vec::new(),
relationships: Vec::new(),
current_table: None, current_table: None,
history: Vec::new(), history: Vec::new(),
history_cursor: None, history_cursor: None,
@@ -451,6 +568,11 @@ impl App {
last_output_visible: 0, last_output_visible: 0,
last_output_total_wrapped: 0, last_output_total_wrapped: 0,
last_output_width: 80, last_output_width: 80,
last_output_area: Rect::new(0, 0, 0, 0),
tables_scroll: 0,
relationships_scroll: 0,
last_tables_visible: 0,
last_relationships_visible: 0,
project_name: None, project_name: None,
project_is_temp: false, project_is_temp: false,
fatal_message: None, fatal_message: None,
@@ -460,6 +582,14 @@ impl App {
// Undo is on by default; the runtime flips this off for // Undo is on by default; the runtime flips this off for
// a `--no-undo` session (ADR-0006 Amendment 1). // a `--no-undo` session (ADR-0006 Amendment 1).
undo_enabled: true, undo_enabled: true,
// Demo mode is off by default; the runtime flips it on for
// a `--demo` session (ADR-0047).
demo_mode: false,
demo_badge: None,
demo_badge_seq: 0,
demo_caption: None,
demo_caption_capturing: false,
demo_caption_buffer: String::new(),
pending_echo: None, pending_echo: None,
} }
} }
@@ -715,6 +845,11 @@ impl App {
self.schema_cache = cache; self.schema_cache = cache;
Vec::new() Vec::new()
} }
AppEvent::RelationshipsRefreshed(relationships) => {
trace!(count = relationships.len(), "relationships refreshed");
self.relationships = relationships;
Vec::new()
}
AppEvent::PersistenceFatal { AppEvent::PersistenceFatal {
operation, operation,
path, path,
@@ -904,6 +1039,64 @@ impl App {
} }
} }
/// ADR-0046 DC1: advance the navigation focus cycle. From `Input`
/// it enters navigation mode on the Tables panel (revealing +
/// expanding the sidebar via the renderer); the third press returns
/// to the command input.
fn nav_advance(&mut self) {
self.nav_focus = match self.nav_focus {
NavFocus::Input => NavFocus::SidebarTables,
NavFocus::SidebarTables => NavFocus::SidebarRelationships,
NavFocus::SidebarRelationships => NavFocus::Input,
};
trace!(nav_focus = ?self.nav_focus, "navigation focus advanced");
}
/// Leave navigation mode, returning focus to the command input
/// (ADR-0046 DC1 — the `Esc` shortcut for the cycle's last step).
const fn nav_exit(&mut self) {
self.nav_focus = NavFocus::Input;
}
/// ADR-0046 DC3/DC4: key handling while a sidebar panel is focused.
/// `Esc` exits navigation mode; scroll keys drive the focused panel
/// (wired in DC3); every other key is inert because the command
/// input is occluded by the expanded sidebar overlay.
fn handle_nav_key(&mut self, key: KeyEvent) -> Vec<Action> {
match key.code {
KeyCode::Esc => self.nav_exit(),
KeyCode::Up => self.nav_scroll(-1),
KeyCode::Down => self.nav_scroll(1),
KeyCode::PageUp => self.nav_scroll_page(-1),
KeyCode::PageDown => self.nav_scroll_page(1),
_ => {}
}
Vec::new()
}
/// Scroll the focused sidebar panel by `lines` (ADR-0046 DC3); the
/// renderer clamps the offset to the panel's content on the next
/// frame, so over-scrolling is harmless.
const fn nav_scroll(&mut self, lines: i32) {
let slot = match self.nav_focus {
NavFocus::SidebarTables => &mut self.tables_scroll,
NavFocus::SidebarRelationships => &mut self.relationships_scroll,
NavFocus::Input => return,
};
*slot = slot.saturating_add_signed(lines as isize);
}
/// Page-scroll the focused panel by its last reported visible-row
/// count (ADR-0046 DC3).
fn nav_scroll_page(&mut self, dir: i32) {
let visible = match self.nav_focus {
NavFocus::SidebarTables => self.last_tables_visible,
NavFocus::SidebarRelationships => self.last_relationships_visible,
NavFocus::Input => return,
};
self.nav_scroll(dir * (visible.max(1) as i32));
}
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> { fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
// On Windows, key events fire for both Press and Release; // On Windows, key events fire for both Press and Release;
// honour only Press to avoid double-handling. Other // honour only Press to avoid double-handling. Other
@@ -913,6 +1106,32 @@ impl App {
} }
trace!(?key, "handle_key"); trace!(?key, "handle_key");
// ADR-0047 D3: the demo step-caption stealth buffer runs before
// every other gate — even ahead of the badge and the modal gate —
// so it can be authored over the load picker (the `#24` cast) and
// so captured keystrokes never leak into the input, a badge, or a
// command. `Ctrl+]` toggles capture; while capturing, the key is
// consumed here.
if self.demo_mode {
if let Some(actions) = self.handle_demo_caption_key(key) {
return actions;
}
// Not a caption key: any ordinary keystroke dismisses a
// visible caption (it then falls through to normal handling).
self.demo_caption = None;
}
// ADR-0047 D2: in demo mode raise a transient badge for an
// otherwise-invisible key. Done before the modal / nav gates so
// it fires even while a modal is open (the `#24` projects cast)
// or in navigation mode. The runtime times its expiry (D5).
if self.demo_mode
&& let Some(label) = demo_badge_label(&key)
{
self.demo_badge = Some(label);
self.demo_badge_seq = self.demo_badge_seq.wrapping_add(1);
}
// While a modal is open it owns the keyboard. Normal // While a modal is open it owns the keyboard. Normal
// input editing, history navigation, and command // input editing, history navigation, and command
// submission are all gated behind closing the modal. // submission are all gated behind closing the modal.
@@ -920,6 +1139,20 @@ impl App {
return self.handle_modal_key(key); return self.handle_modal_key(key);
} }
// ADR-0046 DC1: `Ctrl-O` cycles navigation focus from any state
// (Input → Tables → Relationships → Input), inert only behind a
// modal (handled above).
if (key.code, key.modifiers) == (KeyCode::Char('o'), KeyModifiers::CONTROL) {
self.nav_advance();
return Vec::new();
}
// DC3/DC4: in navigation mode, keys drive the focused sidebar
// panel (scroll) or are inert; the command input is occluded.
if self.nav_focus.in_sidebar() {
return self.handle_nav_key(key);
}
// ADR-0022 stage 8 — non-modal completion. Tab / // ADR-0022 stage 8 — non-modal completion. Tab /
// Shift-Tab cycle; Esc / Backspace undo the whole // Shift-Tab cycle; Esc / Backspace undo the whole
// last-Tab insertion in one keystroke while the memo // last-Tab insertion in one keystroke while the memo
@@ -1002,6 +1235,59 @@ impl App {
} }
} }
/// Drive the demo step-caption stealth buffer (ADR-0047 D3).
///
/// Returns `Some(_)` when the key belongs to the caption mechanism
/// (the `Ctrl+]` toggle, or any key while capturing) — the caller
/// then returns it and processes nothing else. Returns `None` when
/// the key is not consumed, so normal handling continues.
///
/// `Ctrl+]` decodes to `Char('5') + CONTROL` (ADR-0047 D3, verified
/// against crossterm 0.29). Only active in demo mode (the caller
/// gates on `self.demo_mode`).
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
let is_toggle = key.code == KeyCode::Char('5')
&& key.modifiers.contains(KeyModifiers::CONTROL);
if self.demo_caption_capturing {
if is_toggle {
// Commit: a trimmed, non-empty buffer becomes the caption;
// an empty commit dismisses any caption (explicit clear).
self.demo_caption_capturing = false;
let text = std::mem::take(&mut self.demo_caption_buffer);
let trimmed = text.trim();
self.demo_caption =
(!trimmed.is_empty()).then(|| trimmed.to_string());
} else {
match key.code {
// Plain characters accumulate invisibly; the prompt
// and output are untouched.
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
self.demo_caption_buffer.push(c);
}
KeyCode::Backspace => {
self.demo_caption_buffer.pop();
}
// Every other key (Enter, arrows, Tab, …) is inert
// while capturing.
_ => {}
}
}
return Some(Vec::new());
}
if is_toggle {
// Open capture. Starting a new annotation clears any caption
// currently on screen.
self.demo_caption_capturing = true;
self.demo_caption_buffer.clear();
self.demo_caption = None;
return Some(Vec::new());
}
None
}
fn cursor_left(&mut self) { fn cursor_left(&mut self) {
let mut idx = self.input_cursor; let mut idx = self.input_cursor;
while idx > 0 { while idx > 0 {
@@ -1232,6 +1518,7 @@ impl App {
self.history_cursor = Some(next_index); self.history_cursor = Some(next_index);
self.input = self.history[next_index].clone(); self.input = self.history[next_index].clone();
self.input_cursor = self.input.len(); self.input_cursor = self.input.len();
self.input_scroll_offset = 0;
} }
/// Move forwards in history (towards newer entries; eventually /// Move forwards in history (towards newer entries; eventually
@@ -1250,6 +1537,7 @@ impl App {
self.input = self.history_draft.take().unwrap_or_default(); self.input = self.history_draft.take().unwrap_or_default();
} }
self.input_cursor = self.input.len(); self.input_cursor = self.input.len();
self.input_scroll_offset = 0;
} }
fn cancel_history_navigation(&mut self) { fn cancel_history_navigation(&mut self) {
@@ -1284,6 +1572,7 @@ impl App {
fn submit(&mut self) -> Vec<Action> { fn submit(&mut self) -> Vec<Action> {
let raw = std::mem::take(&mut self.input); let raw = std::mem::take(&mut self.input);
self.input_cursor = 0; self.input_cursor = 0;
self.input_scroll_offset = 0;
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Vec::new(); return Vec::new();
@@ -1311,6 +1600,13 @@ impl App {
return Vec::new(); return Vec::new();
} }
debug!(
persistent_mode = ?self.mode,
submission_mode = ?submission_mode,
len = effective_input.len(),
"submit"
);
// Parse-first: app-level commands and DSL commands now // Parse-first: app-level commands and DSL commands now
// share the chumsky parser (per the round-5 refactor). // share the chumsky parser (per the round-5 refactor).
// App commands work in both modes — they're not gated by // App commands work in both modes — they're not gated by
@@ -1342,6 +1638,7 @@ impl App {
source: &str, source: &str,
) -> Vec<Action> { ) -> Vec<Action> {
use crate::dsl::{AppCommand, MessagesValue, ModeValue}; use crate::dsl::{AppCommand, MessagesValue, ModeValue};
debug!(command = ?cmd, "dispatch app command");
match cmd { match cmd {
AppCommand::Quit => vec![Action::Quit], AppCommand::Quit => vec![Action::Quit],
AppCommand::Help { topic } => { AppCommand::Help { topic } => {
@@ -1700,6 +1997,7 @@ impl App {
| Command::AddRelationship { .. } | Command::AddRelationship { .. }
| Command::DropRelationship { .. } | Command::DropRelationship { .. }
) { ) {
debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)");
for line in crate::output_render::render_structure_with_diagrams( for line in crate::output_render::render_structure_with_diagrams(
desc, desc,
self.last_output_width, self.last_output_width,
@@ -2049,6 +2347,10 @@ impl App {
// column for a compound FK (ADR-0043). // column for a compound FK (ADR-0043).
parent_columns.first().map(String::as_str), parent_columns.first().map(String::as_str),
), ),
// m:n builds a junction table; its errors (missing parent,
// no PK, self-reference, name collision) name the relevant
// table in the message, so no fallback table/column here.
C::CreateM2nRelationship { .. } => (Operation::CreateTable, None, None),
C::DropRelationship { selector } => match selector { C::DropRelationship { selector } => match selector {
RelationshipSelector::Endpoints { RelationshipSelector::Endpoints {
parent_table, parent_table,
@@ -2343,20 +2645,34 @@ impl App {
self.note_system(crate::t!("modal.load_cancelled")); self.note_system(crate::t!("modal.load_cancelled"));
Vec::new() Vec::new()
} }
KeyCode::Up => { // `k` mirrors Up; vi-style keys keep the picker drivable by
// autocast, which can only emit typeable characters (#24).
KeyCode::Up | KeyCode::Char('k') => {
if state.selected > 0 { if state.selected > 0 {
state.selected -= 1; state.selected -= 1;
} }
self.modal = Some(Modal::LoadPicker(state)); self.modal = Some(Modal::LoadPicker(state));
Vec::new() Vec::new()
} }
KeyCode::Down => { // `j` mirrors Down (see the Up arm above).
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.entries.len() { if state.selected + 1 < state.entries.len() {
state.selected += 1; state.selected += 1;
} }
self.modal = Some(Modal::LoadPicker(state)); self.modal = Some(Modal::LoadPicker(state));
Vec::new() Vec::new()
} }
// `g` jumps to the first entry, `G` to the last (vi convention).
KeyCode::Char('g') => {
state.selected = 0;
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Char('G') => {
state.selected = state.entries.len().saturating_sub(1);
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Enter => { KeyCode::Enter => {
if let Some(entry) = state.entries.get(state.selected).cloned() { if let Some(entry) = state.entries.get(state.selected).cloned() {
self.modal = None; self.modal = None;
@@ -2764,6 +3080,209 @@ mod tests {
AppEvent::Key(KeyEvent::new(code, mods)) AppEvent::Key(KeyEvent::new(code, mods))
} }
// ---- ADR-0047 (issue #22): demo-mode keystroke badges ----
fn ke(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
#[test]
fn demo_badge_label_maps_the_invisible_keys() {
let none = KeyModifiers::NONE;
assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]"));
assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Down, none)), Some("[DOWN]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Left, none)), Some("[LEFT]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Right, none)), Some("[RIGHT]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]"));
assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
Some("[CTRL-O]")
);
}
#[test]
fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
// Plain characters render their own glyph — no badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None);
assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None);
// Quit and the (Phase C) caption toggle are deliberately excluded.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None);
// Ctrl+] decodes to Char('5')+CONTROL — must not badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None);
}
#[test]
fn demo_mode_off_never_sets_a_badge() {
let mut app = App::new();
assert!(!app.demo_mode);
app.update(key(KeyCode::Tab));
assert_eq!(app.demo_badge, None);
assert_eq!(app.demo_badge_seq, 0);
}
#[test]
fn demo_mode_on_sets_badge_and_bumps_seq() {
let mut app = App::new();
app.demo_mode = true;
app.update(key(KeyCode::Tab));
assert_eq!(app.demo_badge, Some("[TAB]"));
assert_eq!(app.demo_badge_seq, 1);
app.update(key(KeyCode::Enter));
assert_eq!(app.demo_badge, Some("[ENTER]"));
assert_eq!(app.demo_badge_seq, 2);
// The same label twice still bumps the seq so the runtime
// restarts the expiry timer.
app.update(key(KeyCode::Enter));
assert_eq!(app.demo_badge, Some("[ENTER]"));
assert_eq!(app.demo_badge_seq, 3);
// A glyph key leaves the badge (and seq) untouched — the
// runtime's timer is what clears it, not the next key.
app.update(key(KeyCode::Char('x')));
assert_eq!(app.demo_badge, Some("[ENTER]"));
assert_eq!(app.demo_badge_seq, 3);
}
#[test]
fn demo_badge_fires_over_an_open_modal() {
// Badges are set before the modal gate, so the `#24` projects
// cast can show [ENTER]/[DOWN] while the load picker is up.
let mut app = App::new();
app.demo_mode = true;
app.modal = Some(Modal::LoadPicker(LoadPickerModal {
entries: Vec::new(),
selected: 0,
sub_mode: LoadPickerSubMode::List,
}));
app.update(key(KeyCode::Down));
assert_eq!(app.demo_badge, Some("[DOWN]"));
assert_eq!(app.demo_badge_seq, 1);
}
// ---- ADR-0047 (issue #22): demo-mode step-caption stealth buffer ----
/// `Ctrl+]` — the caption toggle (decodes to Char('5')+CONTROL).
fn caption_toggle() -> AppEvent {
key_mod(KeyCode::Char('5'), KeyModifiers::CONTROL)
}
#[test]
fn demo_caption_toggle_captures_then_commits() {
let mut app = App::new();
app.demo_mode = true;
app.update(caption_toggle());
assert!(app.demo_caption_capturing, "first Ctrl+] opens capture");
assert_eq!(app.demo_caption, None);
type_str(&mut app, "Press Tab");
// The text accumulates invisibly — nothing on the input line.
assert_eq!(app.input, "");
assert_eq!(app.demo_caption_buffer, "Press Tab");
assert_eq!(app.demo_caption, None, "not shown until committed");
app.update(caption_toggle());
assert!(!app.demo_caption_capturing, "second Ctrl+] commits");
assert_eq!(app.demo_caption.as_deref(), Some("Press Tab"));
assert_eq!(app.demo_caption_buffer, "", "buffer drained on commit");
assert_eq!(app.input, "", "input never touched");
}
#[test]
fn demo_caption_backspace_edits_the_buffer() {
let mut app = App::new();
app.demo_mode = true;
app.update(caption_toggle());
type_str(&mut app, "Helloo");
app.update(key(KeyCode::Backspace));
assert_eq!(app.demo_caption_buffer, "Hello");
assert_eq!(app.input, "");
}
#[test]
fn demo_caption_other_keys_are_inert_while_capturing() {
let mut app = App::new();
app.demo_mode = true;
app.update(caption_toggle());
type_str(&mut app, "note");
// Enter must not submit, Tab must not complete, arrows do nothing.
let a1 = app.update(key(KeyCode::Enter));
let a2 = app.update(key(KeyCode::Tab));
let a3 = app.update(key(KeyCode::Up));
assert!(a1.is_empty() && a2.is_empty() && a3.is_empty());
assert!(app.demo_caption_capturing, "still capturing");
assert_eq!(app.demo_caption_buffer, "note");
assert_eq!(app.input, "");
assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing");
}
#[test]
fn demo_caption_empty_commit_dismisses() {
let mut app = App::new();
app.demo_mode = true;
app.demo_caption = Some("old".to_string());
// Open (clears the visible caption) then commit empty.
app.update(caption_toggle());
assert_eq!(app.demo_caption, None, "opening clears the visible caption");
app.update(caption_toggle());
assert_eq!(app.demo_caption, None, "empty commit leaves nothing");
assert!(!app.demo_caption_capturing);
}
#[test]
fn demo_caption_cleared_by_next_ordinary_keystroke() {
let mut app = App::new();
app.demo_mode = true;
app.demo_caption = Some("step 1".to_string());
// An ordinary key clears the caption, then is processed normally.
app.update(key(KeyCode::Char('a')));
assert_eq!(app.demo_caption, None);
assert_eq!(app.input, "a", "the key still reaches the input");
}
#[test]
fn demo_caption_captures_over_an_open_modal() {
// The stealth buffer sits before the modal gate, so captions can
// be authored while the load picker is up (the `#24` cast).
let mut app = App::new();
app.demo_mode = true;
app.modal = Some(Modal::LoadPicker(LoadPickerModal {
entries: Vec::new(),
selected: 0,
sub_mode: LoadPickerSubMode::List,
}));
app.update(caption_toggle());
type_str(&mut app, "pick one");
app.update(caption_toggle());
assert_eq!(app.demo_caption.as_deref(), Some("pick one"));
// The modal is untouched by the capture.
assert!(matches!(app.modal, Some(Modal::LoadPicker(_))));
}
#[test]
fn demo_mode_off_makes_ctrl_rbracket_inert() {
let mut app = App::new();
assert!(!app.demo_mode);
app.update(caption_toggle());
type_str(&mut app, "x");
assert!(!app.demo_caption_capturing);
assert_eq!(app.demo_caption, None);
// Ctrl+] did nothing; the later 'x' is an ordinary character.
assert_eq!(app.input, "x");
}
fn type_str(app: &mut App, s: &str) { fn type_str(app: &mut App, s: &str) {
for c in s.chars() { for c in s.chars() {
app.update(key(KeyCode::Char(c))); app.update(key(KeyCode::Char(c)));
@@ -2918,13 +3437,15 @@ mod tests {
#[test] #[test]
fn tab_at_word_boundary_inserts_next_expected_keyword() { fn tab_at_word_boundary_inserts_next_expected_keyword() {
// `create ` → expects only `table`. Single candidate; // `change ` → expects only `column`. Single candidate;
// insert "table " with space, no memo. // insert "column " with space, no memo. (Uses `change`, not
// `create`: ADR-0045 made `create ` ambiguous — `table` vs
// `m:n` — so it is no longer a single-candidate boundary.)
let mut app = App::new(); let mut app = App::new();
type_str(&mut app, "create "); type_str(&mut app, "change ");
let actions = app.update(key(KeyCode::Tab)); let actions = app.update(key(KeyCode::Tab));
assert!(actions.is_empty()); assert!(actions.is_empty());
assert_eq!(app.input, "create table "); assert_eq!(app.input, "change column ");
assert!(app.last_completion.is_none()); assert!(app.last_completion.is_none());
} }
@@ -3071,17 +3592,19 @@ mod tests {
// Stage-8 follow-up #2 (testing-round-2): the // Stage-8 follow-up #2 (testing-round-2): the
// single-candidate-no-memo design lets the user chain // single-candidate-no-memo design lets the user chain
// Tabs through unique completions without getting // Tabs through unique completions without getting
// stuck. From "cr", Tab → "create ", Tab → "create // stuck. From "ch", Tab → "change ", Tab → "change
// table ". (Round 5 added the app-lifecycle commands — // column ". (Round 5 added the app-lifecycle commands —
// single-letter prefixes like `i` are now ambiguous // single-letter prefixes like `i` are now ambiguous
// (`insert` vs. `import`), so the test starts from a // (`insert` vs. `import`), so the test starts from a
// disambiguated two-letter prefix.) // disambiguated two-letter prefix. `change` is used rather
// than `create`: ADR-0045 made `create ` ambiguous (`table`
// vs `m:n`), so it no longer chains as a unique completion.)
let mut app = App::new(); let mut app = App::new();
type_str(&mut app, "cr"); type_str(&mut app, "ch");
app.update(key(KeyCode::Tab)); app.update(key(KeyCode::Tab));
assert_eq!(app.input, "create "); assert_eq!(app.input, "change ");
app.update(key(KeyCode::Tab)); app.update(key(KeyCode::Tab));
assert_eq!(app.input, "create table "); assert_eq!(app.input, "change column ");
assert!(app.last_completion.is_none()); assert!(app.last_completion.is_none());
} }
@@ -5072,6 +5595,121 @@ mod tests {
assert_eq!(app.input_cursor, 0); assert_eq!(app.input_cursor, 0);
} }
#[test]
fn relationships_refreshed_event_updates_the_field() {
// ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the
// App stores it for the sidebar relationships panel to render.
use crate::dsl::action::ReferentialAction;
let mut app = App::new();
assert!(app.relationships.is_empty());
app.update(AppEvent::RelationshipsRefreshed(vec![
crate::persistence::RelationshipSchema {
name: "Customers_Orders".to_string(),
parent_table: "Customers".to_string(),
parent_columns: vec!["id".to_string()],
child_table: "Orders".to_string(),
child_columns: vec!["customer_id".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
},
]));
assert_eq!(app.relationships.len(), 1);
assert_eq!(app.relationships[0].name, "Customers_Orders");
}
#[test]
fn ctrl_o_cycles_navigation_focus() {
// ADR-0046 DC1: Input → Tables → Relationships → Input.
let mut app = App::new();
assert_eq!(app.nav_focus, NavFocus::Input);
let ctrl_o = || key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL);
app.update(ctrl_o());
assert_eq!(app.nav_focus, NavFocus::SidebarTables);
app.update(ctrl_o());
assert_eq!(app.nav_focus, NavFocus::SidebarRelationships);
app.update(ctrl_o());
assert_eq!(app.nav_focus, NavFocus::Input);
}
#[test]
fn esc_exits_navigation_mode() {
let mut app = App::new();
app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
assert!(app.nav_focus.in_sidebar());
app.update(key(KeyCode::Esc));
assert_eq!(app.nav_focus, NavFocus::Input);
}
#[test]
fn navigation_mode_ignores_input_keys() {
// ADR-0046 DC4: the input is occluded; printable/Enter/Backspace
// are inert while a sidebar panel is focused.
let mut app = App::new();
type_str(&mut app, "select");
app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
app.update(key(KeyCode::Char('x')));
app.update(key(KeyCode::Backspace));
let actions = app.update(key(KeyCode::Enter));
assert_eq!(app.input, "select", "input untouched in navigation mode");
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
}
#[test]
fn nav_scroll_keys_move_only_the_focused_panel() {
// ADR-0046 DC3: Up/Down line-scroll the focused sidebar panel.
let mut app = App::new();
app.nav_focus = NavFocus::SidebarTables;
app.update(key(KeyCode::Down));
app.update(key(KeyCode::Down));
assert_eq!(app.tables_scroll, 2);
assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls");
app.update(key(KeyCode::Up));
assert_eq!(app.tables_scroll, 1);
// Up saturates at the top.
app.update(key(KeyCode::Up));
app.update(key(KeyCode::Up));
assert_eq!(app.tables_scroll, 0);
// Switching focus moves the other panel instead.
app.nav_focus = NavFocus::SidebarRelationships;
app.update(key(KeyCode::Down));
assert_eq!(app.relationships_scroll, 1);
assert_eq!(app.tables_scroll, 0);
}
#[test]
fn nav_page_scroll_uses_the_panels_visible_rows() {
// ADR-0046 DC3: PageUp/PageDown move by the last reported
// visible-row count.
let mut app = App::new();
app.nav_focus = NavFocus::SidebarTables;
app.last_tables_visible = 10;
app.update(key(KeyCode::PageDown));
assert_eq!(app.tables_scroll, 10);
app.update(key(KeyCode::PageUp));
assert_eq!(app.tables_scroll, 0);
}
#[test]
fn input_scroll_offset_resets_when_the_buffer_is_replaced() {
// ADR-0046 DA3: the horizontal scroll offset must not leak from
// one command to the next. Submitting and recalling from history
// both replace the buffer wholesale, so both reset it.
let mut app = App::new();
type_str(&mut app, "a long command line that would have scrolled");
app.input_scroll_offset = 25;
submit(&mut app);
assert_eq!(app.input_scroll_offset, 0, "submit resets the input scroll");
// Recall the submitted line from history — also a reset.
type_str(&mut app, "another draft line entirely");
app.input_scroll_offset = 25;
app.update(key(KeyCode::Up));
assert_eq!(
app.input_scroll_offset, 0,
"history recall resets the input scroll"
);
}
#[test] #[test]
fn page_up_scrolls_output_back() { fn page_up_scrolls_output_back() {
let mut app = App::new(); let mut app = App::new();
+77
View File
@@ -42,6 +42,13 @@ pub struct Args {
/// mode > the default (`simple`). Combines with `--resume` and /// mode > the default (`simple`). Combines with `--resume` and
/// a positional path; on collision the flag wins. /// a positional path; on collision the flag wins.
pub mode: Option<Mode>, pub mode: Option<Mode>,
/// `--demo` (or `RDBMS_PLAYGROUND_DEMO` set truthy): enter
/// **demonstration mode** (ADR-0047, issue #22). Off by default,
/// zero footprint when off. When on, the app shows transient
/// on-screen badges for otherwise-invisible keys (Tab, Enter, …)
/// and enables the `Ctrl+]` stealth step-caption buffer — for
/// screencasts and live teaching. The flag wins over the env var.
pub demo: bool,
} }
/// Usage banner printed by `--help`. /// Usage banner printed by `--help`.
@@ -124,6 +131,12 @@ impl Args {
let mut help = false; let mut help = false;
let mut no_undo = false; let mut no_undo = false;
let mut mode: Option<Mode> = None; let mut mode: Option<Mode> = None;
// Demonstration mode (ADR-0047): the env var is the default,
// the `--demo` flag overrides it to on. Mirrors the
// env-then-flag layering used for the log file above.
let mut demo = env::var("RDBMS_PLAYGROUND_DEMO")
.ok()
.is_some_and(|v| demo_value_is_truthy(&v));
let mut iter = iter.into_iter().map(Into::into); let mut iter = iter.into_iter().map(Into::into);
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
match arg.as_str() { match arg.as_str() {
@@ -136,6 +149,9 @@ impl Args {
"--no-undo" => { "--no-undo" => {
no_undo = true; no_undo = true;
} }
"--demo" => {
demo = true;
}
"--theme" => { "--theme" => {
let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?; let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?;
theme = match value.as_str() { theme = match value.as_str() {
@@ -194,10 +210,25 @@ impl Args {
help, help,
no_undo, no_undo,
mode, mode,
demo,
}) })
} }
} }
/// Whether a `RDBMS_PLAYGROUND_DEMO` value enables demo mode.
///
/// Truthy for any value except the conventional "off" set
/// (`0`/`false`/`no`/`off`, case-insensitively, and the empty
/// string). So `RDBMS_PLAYGROUND_DEMO=1` and `=true` enable, while
/// `=0` / `=false` explicitly disable — letting a value of `0` turn
/// it off even if something upstream exported the variable.
fn demo_value_is_truthy(value: &str) -> bool {
!matches!(
value.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
)
}
fn default_theme() -> Theme { fn default_theme() -> Theme {
// NFR-7: support both backgrounds. For the walking skeleton we // NFR-7: support both backgrounds. For the walking skeleton we
// honour an explicit `--theme` flag and the COLORFGBG env var // honour an explicit `--theme` flag and the COLORFGBG env var
@@ -391,6 +422,52 @@ mod tests {
); );
} }
// ---- ADR-0047 (issue #22): --demo demonstration mode ----
#[test]
fn demo_flag_parses() {
let args = Args::parse(["--demo"]).unwrap();
assert!(args.demo);
}
#[test]
fn demo_defaults_off() {
// Absent `--demo` (and absent env var in the test runner),
// demo mode is off — zero footprint for real users.
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
assert!(!args.demo, "demo is off unless --demo or the env var is given");
}
#[test]
fn demo_flag_coexists_with_positional_path() {
let args = Args::parse(["--demo", "/home/me/MyProject"]).unwrap();
assert!(args.demo);
assert_eq!(
args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/MyProject"))
);
}
#[test]
fn demo_flag_combines_with_resume_and_mode() {
let args = Args::parse(["--resume", "--demo", "--mode", "advanced"]).unwrap();
assert!(args.demo);
assert!(args.resume);
assert_eq!(args.mode, Some(Mode::Advanced));
}
#[test]
fn demo_env_value_truthiness() {
// Enabling values.
for v in ["1", "true", "TRUE", "yes", "on", "anything", " 1 "] {
assert!(demo_value_is_truthy(v), "{v:?} should enable demo mode");
}
// Disabling values.
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode");
}
}
#[test] #[test]
fn unknown_double_dash_flag_errors_even_with_positional() { fn unknown_double_dash_flag_errors_even_with_positional() {
// Make sure the path-vs-flag distinction is robust: // Make sure the path-vs-flag distinction is robust:
+11 -3
View File
@@ -31,6 +31,7 @@ use crate::mode::Mode;
/// fragments the user thinks of as a single phrase: /// fragments the user thinks of as a single phrase:
/// ///
/// - `1:n` — the opener for `add 1:n relationship`. /// - `1:n` — the opener for `add 1:n relationship`.
/// - `m:n` — the opener for `create m:n relationship` (ADR-0045).
/// - `double precision` — the lone two-word SQL type alias /// - `double precision` — the lone two-word SQL type alias
/// (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word /// (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word
/// `Ident` validator never has to make sense of `double` alone). /// `Ident` validator never has to make sense of `double` alone).
@@ -40,7 +41,7 @@ use crate::mode::Mode;
/// composite replaces the bare opener rather than appearing /// composite replaces the bare opener rather than appearing
/// alongside it. /// alongside it.
const COMPOSITE_CANDIDATES: &[(&str, &str)] = const COMPOSITE_CANDIDATES: &[(&str, &str)] =
&[("1", "1:n"), ("double", "double precision")]; &[("1", "1:n"), ("m", "m:n"), ("double", "double precision")];
/// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D). /// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D).
/// ///
@@ -1346,12 +1347,19 @@ mod tests {
fn at_token_boundary_offers_next_expected_keyword() { fn at_token_boundary_offers_next_expected_keyword() {
// After `create ` advanced mode offers `table` (valid in both // After `create ` advanced mode offers `table` (valid in both
// modes) plus the SQL-only `unique` (`create unique index`) and // modes) plus the SQL-only `unique` (`create unique index`) and
// `index` — the shared-entry-word merge (ADR-0035 §4i d). // `index` — the shared-entry-word merge (ADR-0035 §4i d) — and
// `m:n` (`create m:n relationship`, ADR-0045), surfaced as the
// composite (the bare `m` opener is filtered).
// `table` (Both) blocks before the Advanced-only `unique`/`index`. // `table` (Both) blocks before the Advanced-only `unique`/`index`.
let cs = cands("create ", 7); let cs = cands("create ", 7);
assert_eq!( assert_eq!(
cs, cs,
vec!["table".to_string(), "unique".to_string(), "index".to_string()] vec![
"table".to_string(),
"unique".to_string(),
"index".to_string(),
"m:n".to_string()
]
); );
} }
+268 -2
View File
@@ -605,6 +605,13 @@ enum Request {
source: Option<String>, source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>, reply: oneshot::Sender<Result<TableDescription, DbError>>,
}, },
CreateM2nRelationship {
t1: String,
t2: String,
name: Option<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
DropRelationship { DropRelationship {
selector: RelationshipSelector, selector: RelationshipSelector,
source: Option<String>, source: Option<String>,
@@ -830,6 +837,13 @@ enum Request {
source: crate::dsl::grammar::IdentSource, source: crate::dsl::grammar::IdentSource,
reply: oneshot::Sender<Result<Vec<String>, DbError>>, reply: oneshot::Sender<Result<Vec<String>, DbError>>,
}, },
/// All relationships as full schema records (name, parent/child
/// tables + columns, referential actions). Feeds the sidebar
/// relationships panel (ADR-0046 DB2); the walker only needs the
/// names, which `ListNamesFor` already provides.
ReadAllRelationships {
reply: oneshot::Sender<Result<Vec<RelationshipSchema>, DbError>>,
},
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1). /// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
/// Replies with the metadata of the command that was undone, or /// Replies with the metadata of the command that was undone, or
/// `None` if there is nothing to undo (or undo is disabled). /// `None` if there is nothing to undo (or undo is disabled).
@@ -1420,6 +1434,29 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)? recv.await.map_err(|_| DbError::WorkerGone)?
} }
/// Generate a junction table for an m:n relationship between
/// `t1` and `t2` (ADR-0045 / C4). One worker request = one undo
/// step (the junction + both relationships are built in a single
/// `do_create_table`).
pub async fn create_m2n_relationship(
&self,
t1: String,
t2: String,
name: Option<String>,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::CreateM2nRelationship {
t1,
t2,
name,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_relationship( pub async fn drop_relationship(
&self, &self,
selector: RelationshipSelector, selector: RelationshipSelector,
@@ -1757,6 +1794,14 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)? recv.await.map_err(|_| DbError::WorkerGone)?
} }
/// All relationships as full schema records, for the sidebar
/// relationships panel (ADR-0046 DB2).
pub async fn read_all_relationships(&self) -> Result<Vec<RelationshipSchema>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::ReadAllRelationships { reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1). /// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
/// `Ok(Some(meta))` reports the command that was undone; /// `Ok(Some(meta))` reports the command that was undone;
/// `Ok(None)` means nothing to undo (or undo is disabled). /// `Ok(None)` means nothing to undo (or undo is disabled).
@@ -1921,7 +1966,7 @@ fn worker_loop(
snapshots: Option<SnapshotStore>, snapshots: Option<SnapshotStore>,
mut rx: mpsc::Receiver<Request>, mut rx: mpsc::Receiver<Request>,
) { ) {
debug!("db worker started"); info!("db worker started");
// `conn` must be mutable: restoring a snapshot (undo/redo) writes // `conn` must be mutable: restoring a snapshot (undo/redo) writes
// into the live connection via the backup API (`&mut`). // into the live connection via the backup API (`&mut`).
let mut conn = conn; let mut conn = conn;
@@ -1968,7 +2013,7 @@ fn worker_loop(
other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other), other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other),
} }
} }
debug!("db worker exiting"); info!("db worker exiting");
} }
/// Worker-side undo bracketing state for the request stream. /// Worker-side undo bracketing state for the request stream.
@@ -2347,6 +2392,24 @@ fn handle_request(
create_fk, create_fk,
)); ));
} }
Request::CreateM2nRelationship {
t1,
t2,
name,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_create_m2n_relationship(
conn,
persistence,
source.as_deref(),
&t1,
&t2,
name.as_deref(),
)
});
}
Request::DropRelationship { Request::DropRelationship {
selector, selector,
source, source,
@@ -2726,6 +2789,9 @@ fn handle_request(
let result = do_list_names_for(conn, source); let result = do_list_names_for(conn, source);
let _ = reply.send(result); let _ = reply.send(result);
} }
Request::ReadAllRelationships { reply } => {
let _ = reply.send(read_all_relationships(conn));
}
// Undo/redo/peek/batch are intercepted in `worker_loop` (they // Undo/redo/peek/batch are intercepted in `worker_loop` (they
// need `&mut conn` or persistent batch state) and never reach // need `&mut conn` or persistent batch state) and never reach
// here. Listed explicitly so a new variant still forces a // here. Listed explicitly so a new variant still forces a
@@ -3393,6 +3459,15 @@ fn do_create_table(
check_constraints: &[String], check_constraints: &[String],
foreign_keys: &[SqlForeignKey], foreign_keys: &[SqlForeignKey],
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table");
// A new table may not take an internal `__rdbms_*` name (it would be
// filtered out of `list_tables` — a hidden orphan). The advanced-SQL
// create path rejects this at parse, but the simple-mode DSL
// `TABLE_NAME_NEW` slot has no validator, and `create m:n … as
// <name>` (ADR-0045) reaches here too — so the shared executor is the
// single place that closes every path (issue raised by the ADR-0045
// /runda pass).
reject_internal_table_name(name)?;
if columns.is_empty() { if columns.is_empty() {
// SQLite requires at least one column. The DSL grammar // SQLite requires at least one column. The DSL grammar
// already prevents this, but defending here too keeps // already prevents this, but defending here too keeps
@@ -3407,6 +3482,9 @@ fn do_create_table(
// §5, sub-phase 4b). Self-references validate against the columns // §5, sub-phase 4b). Self-references validate against the columns
// being defined; other parents must already exist. // being defined; other parents must already exist.
let resolved_fks = resolve_create_table_fks(conn, name, columns, primary_key, foreign_keys)?; let resolved_fks = resolve_create_table_fks(conn, name, columns, primary_key, foreign_keys)?;
if !resolved_fks.is_empty() {
debug!(table = %name, fks = resolved_fks.len(), "create_table: foreign keys resolved + validated");
}
// Inline `PRIMARY KEY` on the column when the table has a single // Inline `PRIMARY KEY` on the column when the table has a single
// primary-key column and it is the **first** column — the exact // primary-key column and it is the **first** column — the exact
@@ -3568,6 +3646,7 @@ fn do_drop_table(
source: Option<&str>, source: Option<&str>,
name: &str, name: &str,
) -> Result<(), DbError> { ) -> Result<(), DbError> {
debug!(table = %name, "drop_table");
// Canonicalize the user-typed name to its stored case (and refuse a // Canonicalize the user-typed name to its stored case (and refuse a
// non-existent / internal table), so the metadata DELETEs and the CSV // non-existent / internal table), so the metadata DELETEs and the CSV
// removal target the right name regardless of capitalization. // removal target the right name regardless of capitalization.
@@ -3647,6 +3726,7 @@ fn do_add_column(
table: &str, table: &str,
column: &ColumnSpec, column: &ColumnSpec,
) -> Result<AddColumnResult, DbError> { ) -> Result<AddColumnResult, DbError> {
debug!(table = %table, column = %column.name, "add_column");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
if matches!(column.ty, Type::Serial | Type::ShortId) { if matches!(column.ty, Type::Serial | Type::ShortId) {
@@ -3700,6 +3780,7 @@ fn do_add_plain_column(
table: &str, table: &str,
spec: &ColumnSpec, spec: &ColumnSpec,
) -> Result<AddColumnResult, DbError> { ) -> Result<AddColumnResult, DbError> {
debug!(table = %table, column = %spec.name, "add_plain_column");
// The plain `ALTER TABLE ADD COLUMN` path. `do_add_column` // The plain `ALTER TABLE ADD COLUMN` path. `do_add_column`
// only routes here when the constraints are ALTER-expressible // only routes here when the constraints are ALTER-expressible
// (no UNIQUE; NOT NULL only alongside a default), so the // (no UNIQUE; NOT NULL only alongside a default), so the
@@ -3752,6 +3833,7 @@ fn do_add_auto_generated_column(
table: &str, table: &str,
spec: &ColumnSpec, spec: &ColumnSpec,
) -> Result<AddColumnResult, DbError> { ) -> Result<AddColumnResult, DbError> {
debug!(table = %table, column = %spec.name, "add_auto_generated_column");
use rusqlite::types::Value as RV; use rusqlite::types::Value as RV;
let ty = spec.ty; let ty = spec.ty;
@@ -3883,6 +3965,7 @@ fn do_add_constrained_column_via_rebuild(
table: &str, table: &str,
spec: &ColumnSpec, spec: &ColumnSpec,
) -> Result<AddColumnResult, DbError> { ) -> Result<AddColumnResult, DbError> {
debug!(table = %table, column = %spec.name, "add_constrained_column_via_rebuild");
let old_schema = read_schema(conn, table)?; let old_schema = read_schema(conn, table)?;
if old_schema.columns.iter().any(|c| c.name == spec.name) { if old_schema.columns.iter().any(|c| c.name == spec.name) {
return Err(DbError::Unsupported(format!( return Err(DbError::Unsupported(format!(
@@ -3984,6 +4067,7 @@ fn do_add_constraint(
column: &str, column: &str,
constraint: &Constraint, constraint: &Constraint,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(table = %table, column = %column, "add_constraint");
// Canonicalize to the stored case (and refuse a non-existent / // Canonicalize to the stored case (and refuse a non-existent /
// internal `__rdbms_*` table as "no such table"), like the sibling // internal `__rdbms_*` table as "no such table"), like the sibling
// schema-mutation executors. Closes the simple `add constraint` // schema-mutation executors. Closes the simple `add constraint`
@@ -4126,6 +4210,7 @@ fn do_drop_constraint(
column: &str, column: &str,
kind: ConstraintKind, kind: ConstraintKind,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(table = %table, column = %column, "drop_constraint");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?; let old_schema = read_schema(conn, table)?;
@@ -4228,6 +4313,7 @@ fn do_set_column_default(
column: &str, column: &str,
default_sql: &str, default_sql: &str,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(table = %table, column = %column, "set_column_default");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?; let old_schema = read_schema(conn, table)?;
@@ -4617,6 +4703,7 @@ fn do_drop_column(
column: &str, column: &str,
cascade: bool, cascade: bool,
) -> Result<DropColumnResult, DbError> { ) -> Result<DropColumnResult, DbError> {
debug!(table = %table, column = %column, cascade, "drop_column");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
let schema = read_schema(conn, table)?; let schema = read_schema(conn, table)?;
@@ -4776,6 +4863,7 @@ fn do_rename_column(
old: &str, old: &str,
new: &str, new: &str,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(table = %table, old = %old, new = %new, "rename_column");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
let schema = read_schema(conn, table)?; let schema = read_schema(conn, table)?;
@@ -4898,6 +4986,7 @@ fn do_rename_table(
old: &str, old: &str,
new: &str, new: &str,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(old = %old, new = %new, "rename_table");
reject_internal_table_name(new)?; reject_internal_table_name(new)?;
// Canonicalize the source to its stored case (and refuse a // Canonicalize the source to its stored case (and refuse a
// non-existent / internal source as "no such table") — so a // non-existent / internal source as "no such table") — so a
@@ -5086,6 +5175,7 @@ fn do_change_column_type(
ty: Type, ty: Type,
mode: ChangeColumnMode, mode: ChangeColumnMode,
) -> Result<ChangeColumnTypeResult, DbError> { ) -> Result<ChangeColumnTypeResult, DbError> {
debug!(table = %table, column = %column, ty = %ty, mode = ?mode, "change_column_type");
// Canonicalize to the stored case (and refuse a non-existent / // Canonicalize to the stored case (and refuse a non-existent /
// internal `__rdbms_*` table as "no such table"), like the sibling // internal `__rdbms_*` table as "no such table"), like the sibling
// column executors. Closes the simple `change column` exposure and // column executors. Closes the simple `change column` exposure and
@@ -5888,6 +5978,7 @@ fn more_row(width: usize, more: usize) -> Vec<String> {
} }
fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> { fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
debug!("list_tables");
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT name FROM sqlite_schema \ "SELECT name FROM sqlite_schema \
@@ -5915,6 +6006,7 @@ fn do_show_relationship(
conn: &Connection, conn: &Connection,
name: &str, name: &str,
) -> Result<Option<RelationshipDiagramData>, DbError> { ) -> Result<Option<RelationshipDiagramData>, DbError> {
debug!(name = %name, "show_relationship");
let Some(rel) = read_all_relationships(conn)? let Some(rel) = read_all_relationships(conn)?
.into_iter() .into_iter()
.find(|r| r.name == name) .find(|r| r.name == name)
@@ -5937,6 +6029,7 @@ fn do_show_list(
kind: crate::dsl::command::ShowListKind, kind: crate::dsl::command::ShowListKind,
name: Option<&str>, name: Option<&str>,
) -> Result<Vec<String>, DbError> { ) -> Result<Vec<String>, DbError> {
debug!(kind = ?kind, name = ?name, "show_list");
use crate::dsl::command::ShowListKind; use crate::dsl::command::ShowListKind;
// V5a: a named item shows one relationship/index's detail. // V5a: a named item shows one relationship/index's detail.
if let Some(name) = name { if let Some(name) = name {
@@ -6024,6 +6117,7 @@ fn do_show_one(
kind: crate::dsl::command::ShowListKind, kind: crate::dsl::command::ShowListKind,
name: &str, name: &str,
) -> Result<Vec<String>, DbError> { ) -> Result<Vec<String>, DbError> {
debug!(kind = ?kind, name = %name, "show_one");
use crate::dsl::command::ShowListKind; use crate::dsl::command::ShowListKind;
let mut lines = Vec::new(); let mut lines = Vec::new();
match kind { match kind {
@@ -6802,6 +6896,7 @@ where
C: FnOnce(&rusqlite::Transaction<'_>, &str, &str) -> Result<(), DbError>, C: FnOnce(&rusqlite::Transaction<'_>, &str, &str) -> Result<(), DbError>,
M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>, M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>,
{ {
debug!(table = %table, cols = new_schema.columns.len(), "rebuild_table: begin (foreign_keys OFF, temp-copy primitive)");
// foreign_keys=OFF must be set *outside* a transaction. // foreign_keys=OFF must be set *outside* a transaction.
conn.execute_batch("PRAGMA foreign_keys = OFF;") conn.execute_batch("PRAGMA foreign_keys = OFF;")
.map_err(DbError::from_rusqlite)?; .map_err(DbError::from_rusqlite)?;
@@ -6870,6 +6965,7 @@ where
.map_err(DbError::from_rusqlite)?; .map_err(DbError::from_rusqlite)?;
let mut rows = check.query([]).map_err(DbError::from_rusqlite)?; let mut rows = check.query([]).map_err(DbError::from_rusqlite)?;
if let Some(_row) = rows.next().map_err(DbError::from_rusqlite)? { if let Some(_row) = rows.next().map_err(DbError::from_rusqlite)? {
warn!(table = %table, "rebuild_table: foreign_key_check failed; existing data violates new constraint, rolling back");
return Err(DbError::Sqlite { return Err(DbError::Sqlite {
message: format!( message: format!(
"foreign-key check failed after rebuild of `{table}`; \ "foreign-key check failed after rebuild of `{table}`; \
@@ -6882,6 +6978,7 @@ where
drop(check); drop(check);
tx.commit().map_err(DbError::from_rusqlite)?; tx.commit().map_err(DbError::from_rusqlite)?;
debug!(table = %table, indexes = captured_indexes.len(), "rebuild_table: committed (indexes recreated)");
Ok(()) Ok(())
})(); })();
@@ -6889,6 +6986,9 @@ where
let pragma_result = conn let pragma_result = conn
.execute_batch("PRAGMA foreign_keys = ON;") .execute_batch("PRAGMA foreign_keys = ON;")
.map_err(DbError::from_rusqlite); .map_err(DbError::from_rusqlite);
if let Err(e) = &pragma_result {
warn!(table = %table, error = %e, "rebuild_table: failed to re-enable foreign_keys after rebuild");
}
result.and(pragma_result) result.and(pragma_result)
} }
@@ -7084,6 +7184,7 @@ fn resolve_fk_parent_columns(
parent_pk: &[String], parent_pk: &[String],
explicit: Option<&[String]>, explicit: Option<&[String]>,
child_arity: usize, child_arity: usize,
inline: bool,
) -> Result<Vec<String>, DbError> { ) -> Result<Vec<String>, DbError> {
if child_arity == 0 { if child_arity == 0 {
return Err(DbError::Unsupported( return Err(DbError::Unsupported(
@@ -7116,6 +7217,20 @@ fn resolve_fk_parent_columns(
} }
}; };
if parent_columns.len() != child_arity { if parent_columns.len() != child_arity {
// An inline column-level FK (`<col> REFERENCES …`) can only carry
// the one column it sits on, so it can never satisfy a compound
// key — point the user at the table-level form rather than the
// generic arity message (ADR-0043 D4).
if inline && parent_columns.len() > 1 {
return Err(DbError::Unsupported(format!(
"an inline column reference can only name one column, but \
`{parent_table}`'s key has {n}. Use the table-level form \
instead: `FOREIGN KEY (<columns>) REFERENCES \
{parent_table} ({pk})`.",
n = parent_columns.len(),
pk = parent_columns.join(", "),
)));
}
return Err(DbError::Unsupported(format!( return Err(DbError::Unsupported(format!(
"{child_arity} foreign-key column(s) on the child side, but \ "{child_arity} foreign-key column(s) on the child side, but \
`{parent_table}`'s key has {n}. A foreign key references every \ `{parent_table}`'s key has {n}. A foreign key references every \
@@ -7184,6 +7299,7 @@ fn resolve_create_table_fks(
&parent_pk, &parent_pk,
fk.parent_columns.as_deref(), fk.parent_columns.as_deref(),
fk.child_columns.len(), fk.child_columns.len(),
fk.inline,
)?; )?;
// Each child column must be one of the columns being defined, // Each child column must be one of the columns being defined,
@@ -7235,6 +7351,101 @@ fn resolve_create_table_fks(
Ok(out) Ok(out)
} }
/// Generate a junction table for an m:n relationship between `t1` and
/// `t2` (ADR-0045 / C4). Builds one FK column per parent PK column
/// (`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a
/// compound PK over all of them, and two `CASCADE` foreign keys, then
/// hands the whole thing to [`do_create_table`] — so the junction table
/// and both relationships are created in one transaction = one undo
/// step. Self-referential m:n is refused (column-name collision); a
/// PK-less parent is refused (nothing to reference).
fn do_create_m2n_relationship(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
t1: &str,
t2: &str,
name: Option<&str>,
) -> Result<TableDescription, DbError> {
debug!(t1 = %t1, t2 = %t2, name = ?name, "create_m2n_relationship");
// Canonicalize both parents (refuse non-existent / internal tables).
let canon_t1 = require_canonical_table(conn, t1)?;
let t1 = canon_t1.as_str();
let canon_t2 = require_canonical_table(conn, t2)?;
let t2 = canon_t2.as_str();
// Self-referential m:n is OOS (ADR-0045): the two FK column sets
// would collide on `{T}_{pkcol}`, needing directional names this
// beginner convenience deliberately avoids.
if t1.eq_ignore_ascii_case(t2) {
return Err(DbError::Unsupported(format!(
"an m:n relationship needs two different tables (got `{t1}` twice). \
To link a table to itself, build the junction table by hand."
)));
}
let schema1 = read_schema(conn, t1)?;
let schema2 = read_schema(conn, t2)?;
// Build one FK column per parent PK column (compound parents
// contribute one each, ADR-0043) + the compound PK + the two FKs.
let mut columns: Vec<ColumnSpec> = Vec::new();
let mut primary_key: Vec<String> = Vec::new();
let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
for (tbl, schema) in [(t1, &schema1), (t2, &schema2)] {
// D7 parent-PK guard: advanced-mode SQL can create a PK-less
// table; it cannot anchor an m:n relationship.
if schema.primary_key.is_empty() {
return Err(DbError::Unsupported(format!(
"`{tbl}` has no primary key, so it cannot anchor an m:n relationship."
)));
}
let mut child_columns: Vec<String> = Vec::new();
for pkcol in &schema.primary_key {
let pcol = schema
.columns
.iter()
.find(|c| &c.name == pkcol)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {tbl}.{pkcol}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
let pty = pcol.user_type.ok_or_else(|| {
DbError::Unsupported("primary-key column has no user type metadata".to_string())
})?;
let col_name = format!("{tbl}_{pkcol}");
columns.push(ColumnSpec::new(col_name.clone(), pty.fk_target_type()));
primary_key.push(col_name.clone());
child_columns.push(col_name);
}
foreign_keys.push(SqlForeignKey {
name: None,
child_columns,
parent_table: tbl.to_string(),
parent_columns: Some(schema.primary_key.clone()),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::Cascade,
inline: false,
});
}
// Junction name: explicit `as <name>` or the auto-name `{t1}_{t2}`.
let junction = name.map_or_else(|| format!("{t1}_{t2}"), str::to_string);
debug!(junction = %junction, cols = columns.len(), "create_m2n_relationship: building junction table");
do_create_table(
conn,
persistence,
source,
&junction,
&columns,
&primary_key,
&[],
&[],
&foreign_keys,
)
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn do_add_relationship( fn do_add_relationship(
conn: &Connection, conn: &Connection,
@@ -7249,6 +7460,7 @@ fn do_add_relationship(
on_update: ReferentialAction, on_update: ReferentialAction,
create_fk: bool, create_fk: bool,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(name = ?name, parent = %parent_table, child = %child_table, "add_relationship");
// Canonicalize both endpoints to their stored case (and refuse a // Canonicalize both endpoints to their stored case (and refuse a
// non-existent / internal `__rdbms_*` table as "no such table"), like // non-existent / internal `__rdbms_*` table as "no such table"), like
// the sibling schema-mutation executors — so the relationship metadata // the sibling schema-mutation executors — so the relationship metadata
@@ -7268,6 +7480,7 @@ fn do_add_relationship(
&parent_schema.primary_key, &parent_schema.primary_key,
Some(parent_columns), Some(parent_columns),
child_columns.len(), child_columns.len(),
false, // DSL `add relationship` is never an inline column FK
)?; )?;
// 2. Read child schema; refuse missing columns unless --create-fk. // 2. Read child schema; refuse missing columns unless --create-fk.
@@ -7409,6 +7622,7 @@ fn do_drop_relationship(
source: Option<&str>, source: Option<&str>,
selector: &RelationshipSelector, selector: &RelationshipSelector,
) -> Result<Option<TableDescription>, DbError> { ) -> Result<Option<TableDescription>, DbError> {
debug!(selector = ?selector, "drop_relationship");
// Resolve to a single relationship row. // Resolve to a single relationship row.
let resolved: Option<(String, String, String, String, String)> = match selector { let resolved: Option<(String, String, String, String, String)> = match selector {
RelationshipSelector::Named { name } => conn RelationshipSelector::Named { name } => conn
@@ -7488,6 +7702,7 @@ fn do_alter_add_table_check(
name: Option<&str>, name: Option<&str>,
expr_sql: &str, expr_sql: &str,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(table = %table, name = ?name, "alter_add_table_check");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?; let old_schema = read_schema(conn, table)?;
@@ -7593,6 +7808,7 @@ fn do_alter_add_unique(
table: &str, table: &str,
columns: &[String], columns: &[String],
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(table = %table, cols = ?columns, "alter_add_unique");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
let old_schema = read_schema(conn, table)?; let old_schema = read_schema(conn, table)?;
@@ -7660,6 +7876,7 @@ fn do_drop_constraint_by_name(
table: &str, table: &str,
name: &str, name: &str,
) -> Result<Option<TableDescription>, DbError> { ) -> Result<Option<TableDescription>, DbError> {
debug!(table = %table, name = %name, "drop_constraint_by_name");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
@@ -7781,6 +7998,7 @@ fn do_alter_add_foreign_key(
name: Option<&str>, name: Option<&str>,
fk: &SqlForeignKey, fk: &SqlForeignKey,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(child = %child_table, name = ?name, "alter_add_foreign_key");
reject_internal_table_name(child_table)?; reject_internal_table_name(child_table)?;
reject_internal_table_name(&fk.parent_table)?; reject_internal_table_name(&fk.parent_table)?;
// Resolve the parent columns: explicit must be the full PK (F-A); // Resolve the parent columns: explicit must be the full PK (F-A);
@@ -7792,6 +8010,7 @@ fn do_alter_add_foreign_key(
&parent_pk, &parent_pk,
fk.parent_columns.as_deref(), fk.parent_columns.as_deref(),
fk.child_columns.len(), fk.child_columns.len(),
fk.inline, // false for `ALTER … ADD FOREIGN KEY` (table-level)
)?; )?;
// Every child column must already exist for `ALTER … ADD FOREIGN // Every child column must already exist for `ALTER … ADD FOREIGN
// KEY` — there is no SQL spelling to auto-create one (`--create-fk` // KEY` — there is no SQL spelling to auto-create one (`--create-fk`
@@ -7891,6 +8110,7 @@ fn do_add_index(
columns: &[String], columns: &[String],
unique: bool, unique: bool,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(name = ?name, table = %table, cols = ?columns, unique, "add_index");
// 0. Canonicalize to the stored case (and refuse a non-existent / // 0. Canonicalize to the stored case (and refuse a non-existent /
// internal `__rdbms_*` table) — both the simple `add index` and SQL // internal `__rdbms_*` table) — both the simple `add index` and SQL
// `CREATE INDEX` surfaces reach here, and the auto-index name embeds // `CREATE INDEX` surfaces reach here, and the auto-index name embeds
@@ -7979,6 +8199,7 @@ fn do_drop_index(
source: Option<&str>, source: Option<&str>,
selector: &IndexSelector, selector: &IndexSelector,
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(selector = ?selector, "drop_index");
let (index_name, table_name) = match selector { let (index_name, table_name) = match selector {
IndexSelector::Named { name } => { IndexSelector::Named { name } => {
let lookup = conn.query_row( let lookup = conn.query_row(
@@ -8067,6 +8288,7 @@ fn do_describe_table_request(
} }
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> { fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
debug!(name = %name, "describe_table");
// Column info — including the ADR-0029 constraints — comes // Column info — including the ADR-0029 constraints — comes
// from `read_schema`, the single source of per-column truth // from `read_schema`, the single source of per-column truth
// (it joins `pragma_table_info` with our type metadata and // (it joins `pragma_table_info` with our type metadata and
@@ -8422,6 +8644,7 @@ fn do_insert(
user_columns: Option<&[String]>, user_columns: Option<&[String]>,
user_values: &[Value], user_values: &[Value],
) -> Result<InsertResult, DbError> { ) -> Result<InsertResult, DbError> {
debug!(table = %table, "insert");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
let schema = read_schema(conn, table)?; let schema = read_schema(conn, table)?;
@@ -8500,6 +8723,14 @@ fn do_insert(
)); ));
} }
debug!(
table = %table,
user_cols = user_cols.len(),
total_cols = bindings.len(),
autofilled = bindings.len() - user_cols.len(),
"insert: column bindings resolved (serial/shortid auto-fill applied)"
);
let cols_csv = bindings let cols_csv = bindings
.iter() .iter()
.map(|(c, _)| quote_ident(c)) .map(|(c, _)| quote_ident(c))
@@ -8579,6 +8810,7 @@ fn do_update(
assignments: &[(String, Value)], assignments: &[(String, Value)],
filter: &RowFilter, filter: &RowFilter,
) -> Result<UpdateResult, DbError> { ) -> Result<UpdateResult, DbError> {
debug!(table = %table, assignments = assignments.len(), "update");
if assignments.is_empty() { if assignments.is_empty() {
return Err(DbError::InvalidValue( return Err(DbError::InvalidValue(
"UPDATE requires at least one assignment".to_string(), "UPDATE requires at least one assignment".to_string(),
@@ -8678,6 +8910,7 @@ fn do_delete(
table: &str, table: &str,
filter: &RowFilter, filter: &RowFilter,
) -> Result<DeleteResult, DbError> { ) -> Result<DeleteResult, DbError> {
debug!(table = %table, "delete");
let canonical_table = require_canonical_table(conn, table)?; let canonical_table = require_canonical_table(conn, table)?;
let table = canonical_table.as_str(); let table = canonical_table.as_str();
let schema = read_schema(conn, table)?; let schema = read_schema(conn, table)?;
@@ -8732,6 +8965,14 @@ fn do_delete(
} }
} }
debug!(
table = %table,
rows_affected,
cascaded_relationships = cascade.len(),
rewritten_tables = rewritten_tables.len(),
"delete: complete (cascade effects detected by child-count diff)"
);
let changes = Changes { let changes = Changes {
schema_dirty: false, schema_dirty: false,
rewritten_tables, rewritten_tables,
@@ -9045,6 +9286,7 @@ fn do_sql_insert(
returning: bool, returning: bool,
literal_rows: &[Vec<Option<Value>>], literal_rows: &[Vec<Option<Value>>],
) -> Result<InsertResult, DbError> { ) -> Result<InsertResult, DbError> {
debug!(table = %target_table, returning, "sql_insert");
debug!(sql = %sql, table = %target_table, returning, "sql_insert"); debug!(sql = %sql, table = %target_table, returning, "sql_insert");
let canonical_table = require_canonical_table(conn, target_table)?; let canonical_table = require_canonical_table(conn, target_table)?;
let target_table = canonical_table.as_str(); let target_table = canonical_table.as_str();
@@ -9161,6 +9403,7 @@ fn do_sql_update(
returning: bool, returning: bool,
set_literals: &[(String, Option<Value>)], set_literals: &[(String, Option<Value>)],
) -> Result<UpdateResult, DbError> { ) -> Result<UpdateResult, DbError> {
debug!(table = %target_table, returning, "sql_update");
debug!(sql = %sql, table = %target_table, returning, "sql_update"); debug!(sql = %sql, table = %target_table, returning, "sql_update");
let canonical_table = require_canonical_table(conn, target_table)?; let canonical_table = require_canonical_table(conn, target_table)?;
let target_table = canonical_table.as_str(); let target_table = canonical_table.as_str();
@@ -9544,6 +9787,7 @@ fn do_query_data(
filter: Option<&Expr>, filter: Option<&Expr>,
limit: Option<u64>, limit: Option<u64>,
) -> Result<DataResult, DbError> { ) -> Result<DataResult, DbError> {
debug!(table = %table, limit = ?limit, "query_data");
let schema = read_schema(conn, table)?; let schema = read_schema(conn, table)?;
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect(); let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
let column_types: Vec<Option<Type>> = let column_types: Vec<Option<Type>> =
@@ -9602,6 +9846,7 @@ fn format_cell(value: rusqlite::types::Value, ty: Option<Type>) -> Option<String
/// executes the statement), and pairs the plan rows with a /// executes the statement), and pairs the plan rows with a
/// standard-SQL display form of the statement. /// standard-SQL display form of the statement.
fn do_explain_plan(conn: &Connection, query: &Command) -> Result<QueryPlan, DbError> { fn do_explain_plan(conn: &Connection, query: &Command) -> Result<QueryPlan, DbError> {
debug!("explain_plan");
let (exec_sql, params) = match query { let (exec_sql, params) = match query {
Command::ShowData { Command::ShowData {
name, name,
@@ -9855,6 +10100,7 @@ fn do_rebuild_from_text(
source: Option<&str>, source: Option<&str>,
project_path: &Path, project_path: &Path,
) -> Result<(), DbError> { ) -> Result<(), DbError> {
debug!(path = %project_path.display(), "rebuild_from_text");
let yaml_path = project_path.join(PROJECT_YAML); let yaml_path = project_path.join(PROJECT_YAML);
let data_dir = project_path.join(DATA_DIR); let data_dir = project_path.join(DATA_DIR);
@@ -10320,6 +10566,26 @@ mod tests {
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
} }
#[tokio::test]
async fn create_table_rejects_an_internal_name() {
// A new table may not take an internal `__rdbms_*` name — it would
// be hidden from `list_tables`. The advanced-SQL path rejects this
// at parse; the shared executor guards every other path (the
// simple-mode DSL slot and `create m:n … as`, ADR-0045).
let db = db();
let err = db
.create_table(
"__rdbms_sneaky".to_string(),
vec![col("id", Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchTable, .. }), "got {err:?}");
assert!(db.list_tables().await.unwrap().is_empty());
}
#[tokio::test] #[tokio::test]
async fn drop_table_removes_it_from_list() { async fn drop_table_removes_it_from_list() {
let db = db(); let db = db();
+23
View File
@@ -45,6 +45,13 @@ pub struct SqlForeignKey {
pub parent_columns: Option<Vec<String>>, pub parent_columns: Option<Vec<String>>,
pub on_delete: ReferentialAction, pub on_delete: ReferentialAction,
pub on_update: ReferentialAction, pub on_update: ReferentialAction,
/// `true` for an inline column-level FK (`<col> REFERENCES …`),
/// `false` for the table-level `FOREIGN KEY (…)` and `ALTER …`
/// forms. An inline FK is single-column by construction, so when
/// it references a compound key the resolver points the user at
/// the table-level form rather than emitting the generic arity
/// error (ADR-0043 D4).
pub inline: bool,
} }
/// A column at table-creation time: a name, a user-facing /// A column at table-creation time: a name, a user-facing
@@ -270,6 +277,18 @@ pub enum Command {
on_update: ReferentialAction, on_update: ReferentialAction,
create_fk: bool, create_fk: bool,
}, },
/// Convenience: generate a junction table for a many-to-many
/// relationship between `t1` and `t2` (ADR-0045 / C4). The
/// executor builds a table with one FK column per parent PK
/// column (named `{table}_{pkcol}`, typed via `fk_target_type`),
/// a compound PK over all of them, and two `CASCADE` 1:n
/// relationships — all in one `create table` (one undo step).
/// `name` overrides the auto-generated junction name `{t1}_{t2}`.
CreateM2nRelationship {
t1: String,
t2: String,
name: Option<String>,
},
/// Drop a relationship by either user-given/auto-generated /// Drop a relationship by either user-given/auto-generated
/// name, or by positional reference to the FK endpoints. /// name, or by positional reference to the FK endpoints.
DropRelationship { DropRelationship {
@@ -908,6 +927,7 @@ impl Command {
Self::RenameColumn { .. } => "rename column", Self::RenameColumn { .. } => "rename column",
Self::ChangeColumnType { .. } => "change column", Self::ChangeColumnType { .. } => "change column",
Self::AddRelationship { .. } => "add relationship", Self::AddRelationship { .. } => "add relationship",
Self::CreateM2nRelationship { .. } => "create m:n relationship",
Self::DropRelationship { .. } => "drop relationship", Self::DropRelationship { .. } => "drop relationship",
Self::AddIndex { .. } => "add index", Self::AddIndex { .. } => "add index",
Self::DropIndex { .. } => "drop index", Self::DropIndex { .. } => "drop index",
@@ -984,6 +1004,9 @@ impl Command {
// table's "Referenced by" entry, which is what the // table's "Referenced by" entry, which is what the
// user looks at to confirm the relationship. // user looks at to confirm the relationship.
Self::AddRelationship { parent_table, .. } => parent_table, Self::AddRelationship { parent_table, .. } => parent_table,
// For m:n we focus on the first table; the executor builds
// and returns the junction's structure regardless.
Self::CreateM2nRelationship { t1, .. } => t1,
Self::DropRelationship { selector } => match selector { Self::DropRelationship { selector } => match selector {
RelationshipSelector::Endpoints { parent_table, .. } => parent_table, RelationshipSelector::Endpoints { parent_table, .. } => parent_table,
// For a named drop we don't know the parent table // For a named drop we don't know the parent table
+76 -3
View File
@@ -1362,6 +1362,75 @@ pub static CREATE: CommandNode = CommandNode {
help_id: Some("ddl.create"), help_id: Some("ddl.create"),
usage_ids: &["parse.usage.create_table"],}; usage_ids: &["parse.usage.create_table"],};
// =================================================================
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
// (ADR-0045 / C4). Generates an auto-named junction table with two FKs
// + two 1:n relationships. A *separate* `CommandNode` under the shared
// `create` entry word (the walker dispatches both); the `m` opener is a
// `Literal` (not a keyword) so it never shadows an identifier, mirroring
// the `1` in `add 1:n relationship`.
// =================================================================
const M2N_T1: Node = Node::Ident {
source: IdentSource::Tables,
role: "m2n_t1",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const M2N_T2: Node = Node::Ident {
source: IdentSource::Tables,
role: "m2n_t2",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// Optional `as <junction name>` — a *new* table name (the junction),
// so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source +
// hint). The only `table_name` role in this path, so the builder reads
// it directly as the junction name.
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
const CREATE_M2N_NODES: &[Node] = &[
Node::Literal("m"),
Node::Punct(':'),
Node::Word(Word::keyword("n")),
Node::Word(Word::keyword("relationship")),
Node::Word(Word::keyword("from")),
M2N_T1,
Node::Word(Word::keyword("to")),
M2N_T2,
M2N_AS_NAME_OPT,
];
const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES);
fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::CreateM2nRelationship {
t1: require_ident(path, "m2n_t1")?,
t2: require_ident(path, "m2n_t2")?,
name: ident(path, "table_name").map(str::to_string),
})
}
pub static CREATE_M2N: CommandNode = CommandNode {
entry: Word::keyword("create"),
shape: CREATE_M2N_SHAPE,
ast_builder: build_create_m2n,
help_id: Some("ddl.create_m2n"),
usage_ids: &["parse.usage.create_m2n"],
};
/// The friendly error for a column type without a preceding name — /// The friendly error for a column type without a preceding name —
/// a structural impossibility given the grammar, defended anyway. /// a structural impossibility given the grammar, defended anyway.
fn sql_col_type_without_name() -> ValidationError { fn sql_col_type_without_name() -> ValidationError {
@@ -1557,7 +1626,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// Inline FK is single-column (the column it sits on); // Inline FK is single-column (the column it sits on);
// a compound FK uses the table-level form (ADR-0043 D4). // a compound FK uses the table-level form (ADR-0043 D4).
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone()); let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column])); foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true));
} }
// Table-level `[constraint <name>] foreign key (<col>) // Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b). // references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
@@ -1587,7 +1656,8 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
items.next(); items.next();
} }
let fk = consume_fk_reference(&mut items, pending_fk_name.take(), child_columns); let fk =
consume_fk_reference(&mut items, pending_fk_name.take(), child_columns, false);
foreign_keys.push(fk); foreign_keys.push(fk);
} }
// Track paren depth for element-boundary detection. The // Track paren depth for element-boundary detection. The
@@ -1704,6 +1774,7 @@ fn consume_fk_reference<'a, I>(
items: &mut std::iter::Peekable<I>, items: &mut std::iter::Peekable<I>,
name: Option<String>, name: Option<String>,
child_columns: Vec<String>, child_columns: Vec<String>,
inline: bool,
) -> SqlForeignKey ) -> SqlForeignKey
where where
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>, I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
@@ -1752,6 +1823,7 @@ where
parent_columns, parent_columns,
on_delete, on_delete,
on_update, on_update,
inline,
} }
} }
@@ -2454,7 +2526,8 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
items.next(); items.next();
} }
consume_fk_reference(&mut items, None, child_columns) // `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
consume_fk_reference(&mut items, None, child_columns, false)
} }
pub static SQL_ALTER_TABLE: CommandNode = CommandNode { pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
+14
View File
@@ -657,6 +657,12 @@ pub fn usage_key_for_input_in_mode(
if source.as_bytes().get(after).is_some_and(u8::is_ascii_digit) { if source.as_bytes().get(after).is_some_and(u8::is_ascii_digit) {
return keys.iter().copied().find(|k| k.ends_with("relationship")); return keys.iter().copied().find(|k| k.ends_with("relationship"));
} }
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it, and its usage key ends
// `…create_m2n` (not `relationship`).
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
return keys.iter().copied().find(|k| k.ends_with("m2n"));
}
// Otherwise the form word is an identifier — `column`, // Otherwise the form word is an identifier — `column`,
// `index`, `table`, `relationship` — matched against the // `index`, `table`, `relationship` — matched against the
// usage key's suffix. // usage key's suffix.
@@ -706,6 +712,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&ddl::RENAME, CommandCategory::Simple), (&ddl::RENAME, CommandCategory::Simple),
(&ddl::CHANGE, CommandCategory::Simple), (&ddl::CHANGE, CommandCategory::Simple),
(&ddl::CREATE, CommandCategory::Simple), (&ddl::CREATE, CommandCategory::Simple),
(&ddl::CREATE_M2N, CommandCategory::Simple),
(&data::SHOW, CommandCategory::Simple), (&data::SHOW, CommandCategory::Simple),
(&data::INSERT, CommandCategory::Simple), (&data::INSERT, CommandCategory::Simple),
(&data::UPDATE, CommandCategory::Simple), (&data::UPDATE, CommandCategory::Simple),
@@ -852,6 +859,13 @@ mod usage_key_tests {
), ),
("show data T", "parse.usage.show_data"), ("show data T", "parse.usage.show_data"),
("show table T", "parse.usage.show_table"), ("show table T", "parse.usage.show_table"),
// `create` is multi-form (table vs m:n, ADR-0045): each typed
// form resolves to its own usage key.
("create table T with pk id(int)", "parse.usage.create_table"),
(
"create m:n relationship from A to B",
"parse.usage.create_m2n",
),
]; ];
for (input, expected) in cases { for (input, expected) in cases {
assert_eq!( assert_eq!(
+10
View File
@@ -1004,6 +1004,16 @@ mod builder_tests {
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()])); assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
assert_eq!(fk.on_delete, ReferentialAction::NoAction); assert_eq!(fk.on_delete, ReferentialAction::NoAction);
assert_eq!(fk.on_update, ReferentialAction::NoAction); assert_eq!(fk.on_update, ReferentialAction::NoAction);
assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)");
}
#[test]
fn table_level_fk_is_not_inline() {
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
// carry a multi-column reference and never triggers the inline
// "use the table-level form" hint (ADR-0043 D4).
let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
} }
#[test] #[test]
+19 -3
View File
@@ -12,6 +12,8 @@
//! synthetic "unknown command" error when the input's first //! synthetic "unknown command" error when the input's first
//! identifier-shape token isn't a registered entry word. //! identifier-shape token isn't a registered entry word.
use tracing::trace;
use crate::dsl::command::Command; use crate::dsl::command::Command;
use crate::mode::Mode; use crate::mode::Mode;
@@ -150,13 +152,27 @@ fn parse_command_inner(
schema: Option<&crate::completion::SchemaCache>, schema: Option<&crate::completion::SchemaCache>,
mode: Mode, mode: Mode,
) -> Result<Command, ParseError> { ) -> Result<Command, ParseError> {
// `trace`, not `debug`: parsing is a hot path — the live overlay /
// completion (completion.rs) re-parse per keystroke, probing
// candidates in a loop, so a per-parse `debug` line would flood. The
// executed-command story lives at `debug` in db.rs (one per submit).
trace!(
len = input.len(),
mode = ?mode,
schema_aware = schema.is_some(),
"parse: begin"
);
if input.trim().is_empty() { if input.trim().is_empty() {
trace!("parse: empty input");
return Err(ParseError::Empty); return Err(ParseError::Empty);
} }
if let Some(result) = try_walker_route(input, schema, mode) { let result =
return result; try_walker_route(input, schema, mode).unwrap_or_else(|| Err(unknown_command_error(input)));
match &result {
Ok(cmd) => trace!(command = cmd.verb(), "parse: ok"),
Err(e) => trace!(error = %e, "parse: rejected"),
} }
Err(unknown_command_error(input)) result
} }
/// Synthetic ParseError for inputs whose first identifier-shape /// Synthetic ParseError for inputs whose first identifier-shape
+15
View File
@@ -211,6 +211,21 @@ mod tests {
assert_eq!(run("quit"), vec![(0, 4, HighlightClass::Keyword)]); assert_eq!(run("quit"), vec![(0, 4, HighlightClass::Keyword)]);
} }
#[test]
fn create_m2n_relationship_highlights_cleanly() {
// ADR-0045: a valid `create m:n relationship` line classifies
// with no Error runs; keywords are keywords and the table names
// are identifiers (the `m:n` opener is a Literal, keyword-classed).
let runs = run("create m:n relationship from A to B");
assert!(
!runs.iter().any(|(_, _, c)| *c == HighlightClass::Error),
"no Error highlight on a valid m:n line: {runs:?}"
);
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
}
#[test] #[test]
fn keyword_plus_identifier_via_walker() { fn keyword_plus_identifier_via_walker() {
// `show data Customers` walks end-to-end. // `show data Customers` walks end-to-end.
+61 -6
View File
@@ -406,13 +406,28 @@ pub fn completion_probe_in_mode(
// Mismatch and is naturally skipped — the viability check is the // Mismatch and is naturally skipped — the viability check is the
// gate, not the cursor depth. // gate, not the cursor depth.
let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()]; let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()];
if mode == crate::mode::Mode::Advanced { {
let s = skip_whitespace(source, 0); let s = skip_whitespace(source, 0);
if let Some((kw_start, kw_end)) = consume_ident(source, s) { if let Some((kw_start, kw_end)) = consume_ident(source, s) {
let entry = &source[kw_start..kw_end]; let entry = &source[kw_start..kw_end];
let candidates = grammar::commands_for_entry_word(entry); let candidates = grammar::commands_for_entry_word(entry);
if candidates.len() > 1 {
use crate::dsl::grammar::CommandCategory; use crate::dsl::grammar::CommandCategory;
// Advanced mode merges DSL + SQL continuations across all
// candidate nodes; Simple mode merges only when an entry word
// has more than one DSL form (e.g. `create table` vs
// `create m:n relationship`, ADR-0045). With a single DSL form
// the committed node already carries every continuation, so
// that case is left untouched (its `Both` mode-class too) —
// keeping this zero-ripple for every existing command.
let simple_count = candidates
.iter()
.filter(|(_, _, c)| *c == CommandCategory::Simple)
.count();
let run_merge = match mode {
crate::mode::Mode::Advanced => candidates.len() > 1,
crate::mode::Mode::Simple => simple_count > 1,
};
if run_merge {
// (continuation word, produced-by-simple, produced-by-advanced) // (continuation word, produced-by-simple, produced-by-advanced)
let mut tally: Vec<(&'static str, bool, bool)> = Vec::new(); let mut tally: Vec<(&'static str, bool, bool)> = Vec::new();
// Continuations that aren't keyword/literal-shaped // Continuations that aren't keyword/literal-shaped
@@ -422,6 +437,13 @@ pub fn completion_probe_in_mode(
// for punctuation defaults to `Both`. // for punctuation defaults to `Both`.
let mut punct_tally: Vec<char> = Vec::new(); let mut punct_tally: Vec<char> = Vec::new();
for (_, node, category) in candidates { for (_, node, category) in candidates {
// Simple mode never offers advanced SQL continuations
// (ADR-0030 §2); only DSL forms contribute.
if mode == crate::mode::Mode::Simple
&& category == CommandCategory::Advanced
{
continue;
}
let mut sctx = context::WalkContext::with_schema(schema); let mut sctx = context::WalkContext::with_schema(schema);
sctx.mode = mode; sctx.mode = mode;
let (res, _) = let (res, _) =
@@ -2720,12 +2742,45 @@ fn decide(
// appended at the rendering layer (see // appended at the rendering layer (see
// `advanced_alternative_note`), combining the DSL fix with // `advanced_alternative_note`), combining the DSL fix with
// the mode hint. // the mode hint.
match simple.first() { if simple.is_empty() {
Some(&(sidx, snode)) => Decision::Commit { idx: sidx, node: snode },
None => {
let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary); let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
Decision::ThisIsSql { primary } return Decision::ThisIsSql { primary };
} }
// An entry word may register more than one DSL form
// (e.g. `create table` and `create m:n relationship`,
// ADR-0045). Commit the first that fully matches or is
// content-rejected (a `ValidationFailed` means the shape
// fits but the content is invalid — that error must
// surface), mirroring the advanced branch below. With a
// single DSL form this reduces to "commit it": a lone
// non-matching candidate falls through to the
// furthest-progress step and is committed anyway, so its
// positioned DSL error still surfaces (unchanged behaviour).
for &(idx, node) in &simple {
if matches!(
scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema),
WalkOutcome::Match { .. } | WalkOutcome::ValidationFailed { .. }
) {
return Decision::Commit { idx, node };
}
}
// None matched — commit the furthest-progress candidate
// (first on ties) so the surfaced DSL error is the most
// informative.
let mut best = simple[0];
let mut best_progress =
scratch_progress(effective_source, kw_start, kw_end, best.1, mode, schema);
for &(idx, node) in &simple[1..] {
let progress =
scratch_progress(effective_source, kw_start, kw_end, node, mode, schema);
if progress > best_progress {
best = (idx, node);
best_progress = progress;
}
}
Decision::Commit {
idx: best.0,
node: best.1,
} }
} }
crate::mode::Mode::Advanced => { crate::mode::Mode::Advanced => {
+54
View File
@@ -15,6 +15,7 @@
use crate::app::EffectiveMode; use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction; use crate::dsl::ReferentialAction;
use crate::dsl::types::Type;
use crate::dsl::Command; use crate::dsl::Command;
use crate::dsl::command::{ use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter, ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
@@ -286,6 +287,31 @@ pub(crate) fn render_add_relationship(
s s
} }
/// The advanced-mode DSL→SQL teaching echo (ADR-0038) for `create m:n
/// relationship` (ADR-0045): the single `CREATE TABLE` the junction
/// expands to — every FK column, the compound primary key over them,
/// and the two `CASCADE` foreign keys (m:n always cascades, D2). Built
/// from the post-exec junction description (the resolved columns don't
/// exist on the command), so it shows exactly what was created.
pub(crate) fn render_create_m2n(
junction: &str,
columns: &[(String, Type)],
primary_key: &[String],
foreign_keys: &[(Vec<String>, String, Vec<String>)],
) -> String {
let mut parts: Vec<String> =
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
for (child_columns, parent_table, parent_columns) in foreign_keys {
parts.push(format!(
"FOREIGN KEY ({}) REFERENCES {parent_table} ({}) ON DELETE CASCADE ON UPDATE CASCADE",
child_columns.join(", "),
parent_columns.join(", "),
));
}
format!("CREATE TABLE {junction} ({})", parts.join(", "))
}
/// `ALTER TABLE <C> DROP CONSTRAINT <name>` — the `drop relationship` /// `ALTER TABLE <C> DROP CONSTRAINT <name>` — the `drop relationship`
/// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an /// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an
/// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre- /// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre-
@@ -1077,6 +1103,34 @@ mod tests {
); );
} }
#[test]
fn create_m2n_echo_renders_junction_and_round_trips() {
// The advanced-mode teaching echo for `create m:n relationship`
// (ADR-0045): the single CREATE TABLE the junction expands to,
// compound PK + the two CASCADE FKs — and it is valid SQL.
let sql = render_create_m2n(
"Students_Courses",
&[
("Students_id".to_string(), Type::Int),
("Courses_id".to_string(), Type::Int),
],
&["Students_id".to_string(), "Courses_id".to_string()],
&[
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
],
);
assert_eq!(
sql,
"CREATE TABLE Students_Courses (Students_id int, Courses_id int, \
PRIMARY KEY (Students_id, Courses_id), \
FOREIGN KEY (Students_id) REFERENCES Students (id) ON DELETE CASCADE ON UPDATE CASCADE, \
FOREIGN KEY (Courses_id) REFERENCES Courses (id) ON DELETE CASCADE ON UPDATE CASCADE)"
);
// The echoed SQL is valid advanced-mode SQL (round-trips).
assert!(matches!(reparse(&sql), Ok(Command::SqlCreateTable { .. })));
}
// --- expr / literal rendering ------------------------------------ // --- expr / literal rendering ------------------------------------
#[test] #[test]
+4
View File
@@ -165,6 +165,10 @@ pub enum AppEvent {
/// posts this alongside `TablesRefreshed` after project /// posts this alongside `TablesRefreshed` after project
/// load and after every successful DDL. /// load and after every successful DDL.
SchemaCacheRefreshed(crate::completion::SchemaCache), SchemaCacheRefreshed(crate::completion::SchemaCache),
/// Refreshed list of relationships as full schema records, for the
/// sidebar relationships panel (ADR-0046 DB2). Posted by the runtime
/// alongside `SchemaCacheRefreshed` after every schema refresh.
RelationshipsRefreshed(Vec<crate::persistence::RelationshipSchema>),
/// A persistence failure occurred (ADR-0015 §8). The /// A persistence failure occurred (ADR-0015 §8). The
/// application surfaces a fatal banner and exits cleanly so /// application surfaces a fatal banner and exits cleanly so
/// the message remains above the shell prompt. /// the message remains above the shell prompt.
+4
View File
@@ -190,6 +190,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.app.redo", &[]), ("help.app.redo", &[]),
("help.app.copy", &[]), ("help.app.copy", &[]),
("help.ddl.create", &[]), ("help.ddl.create", &[]),
("help.ddl.create_m2n", &[]),
("help.ddl.sql_create_table", &[]), ("help.ddl.sql_create_table", &[]),
("help.ddl.sql_drop_table", &[]), ("help.ddl.sql_drop_table", &[]),
("help.ddl.sql_create_index", &[]), ("help.ddl.sql_create_index", &[]),
@@ -277,6 +278,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.add_relationship", &[]), ("parse.usage.add_relationship", &[]),
("parse.usage.change_column", &[]), ("parse.usage.change_column", &[]),
("parse.usage.create_table", &[]), ("parse.usage.create_table", &[]),
("parse.usage.create_m2n", &[]),
("parse.usage.sql_create_table", &[]), ("parse.usage.sql_create_table", &[]),
("parse.usage.sql_drop_table", &[]), ("parse.usage.sql_drop_table", &[]),
("parse.usage.sql_create_index", &[]), ("parse.usage.sql_create_index", &[]),
@@ -441,6 +443,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("panel.hint_empty", &[]), ("panel.hint_empty", &[]),
("panel.hint_title", &[]), ("panel.hint_title", &[]),
("panel.output_title", &[]), ("panel.output_title", &[]),
("panel.relationships_empty", &[]),
("panel.relationships_title", &[]),
("panel.tables_empty", &[]), ("panel.tables_empty", &[]),
("panel.tables_title", &[]), ("panel.tables_title", &[]),
("status.no_project", &[]), ("status.no_project", &[]),
+9
View File
@@ -204,6 +204,9 @@ help:
project's stored mode. Without it, the project's stored mode. Without it, the
project's last-used mode is restored project's last-used mode is restored
(default: simple). (default: simple).
--demo Demonstration mode: show on-screen badges
for otherwise-invisible keys (Tab, Enter,
...) — for screencasts and live teaching.
App-level commands (typed inside the app, available in both modes): App-level commands (typed inside the app, available in both modes):
quit Exit cleanly. quit Exit cleanly.
@@ -279,6 +282,9 @@ help:
ddl: ddl:
create: |- create: |-
create table <T> with pk [<col>(<type>), ...] — create a table create table <T> with pk [<col>(<type>), ...] — create a table
create_m2n: |-
create m:n relationship from <T1> to <T2> [as <name>]
— build a junction table linking two tables
sql_create_table: |- sql_create_table: |-
create table [if not exists] <T> ( create table [if not exists] <T> (
<col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <P>[(<col>)]], ... <col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <P>[(<col>)]], ...
@@ -523,6 +529,7 @@ parse:
# placeholders. ADR-0009's surface conventions apply. # placeholders. ADR-0009's surface conventions apply.
usage: usage:
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]" create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
create_m2n: "create m:n relationship from <Table1> to <Table2> [as <Name>]"
# Terse one-line synopsis (issue #12): the full grammar — every # Terse one-line synopsis (issue #12): the full grammar — every
# column- and table-level constraint — lives in `help.ddl.sql_create_table`. # column- and table-level constraint — lives in `help.ddl.sql_create_table`.
sql_create_table: "create table [if not exists] <Name> (<col> <type> [constraints], ...)" sql_create_table: "create table [if not exists] <Name> (<col> <type> [constraints], ...)"
@@ -849,6 +856,8 @@ status:
panel: panel:
tables_title: "Tables" tables_title: "Tables"
tables_empty: "(none yet)" tables_empty: "(none yet)"
relationships_title: "Relationships"
relationships_empty: "(none)"
hint_empty: "Type a command — press Tab for options, `help` for a list" hint_empty: "Type a command — press Tab for options, `help` for a list"
# Panel titles for the output and hint panels (rendered inside # Panel titles for the output and hint panels (rendered inside
# the rounded border, hence the leading/trailing space). # the rounded border, hence the leading/trailing space).
+24
View File
@@ -882,6 +882,30 @@ mod tests {
assert!(f.headline.contains("`99`")); assert!(f.headline.contains("`99`"));
} }
#[test]
fn fk_child_side_renders_every_column_of_a_compound_key() {
// ADR-0043 residual: a compound-FK violation carries the
// comma-joined column + value lists in the single-column facts
// slots, so the headline names every pair, not just the first.
let err = sqlite(
"FOREIGN KEY constraint failed",
SqliteErrorKind::UniqueViolation,
);
let mut ctx = ctx_with(Operation::Insert);
ctx.parent_table = Some("Region".to_string());
ctx.parent_column = Some("country, code".to_string());
ctx.value = Some("7, 8".to_string());
let f = translate(&err, &ctx);
assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline);
assert!(f.headline.contains("Region"));
assert!(
f.headline.contains("country, code"),
"both parent columns must appear: {}",
f.headline
);
assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline);
}
#[test] #[test]
fn fk_with_delete_op_renders_parent_side_wording() { fn fk_with_delete_op_renders_parent_side_wording() {
let err = sqlite( let err = sqlite(
+32
View File
@@ -6,6 +6,38 @@
//! environment variable; if neither is set we default to //! environment variable; if neither is set we default to
//! `~/.rdbms-playground/playground.log` and create directories as //! `~/.rdbms-playground/playground.log` and create directories as
//! needed. //! needed.
//!
//! ## Level conventions (X1 — `requirements.md`)
//!
//! Instrumentation across the tree follows a consistent level
//! discipline so the default `info` filter stays quiet and
//! `RDBMS_PLAYGROUND_LOG=debug` (or `=trace`) is a rich, layered
//! diagnostic stream. The env filter (`RDBMS_PLAYGROUND_LOG`,
//! full `EnvFilter` syntax) controls this independently of the
//! file path above; the default is `info`.
//!
//! - **`error!`** — unrecoverable failure (fatal persistence, a
//! panic-equivalent). The process is going down or a command is
//! hard-failing.
//! - **`warn!`** — recoverable failure or a fallback taken (a
//! snapshot couldn't be staged, a `PRAGMA` couldn't be restored,
//! an integrity check rolled a rebuild back).
//! - **`info!`** — low-volume lifecycle, visible by default: db
//! worker start/exit, project create/open, "logging initialised".
//! - **`debug!`** — the bulk of instrumentation, one line per
//! *executed* command and the decision points within it (executor
//! entry with key params, autofill/cascade summaries, the
//! rebuild-table primitive, persistence writes, render-mode
//! choice). Off by default.
//! - **`trace!`** — hot paths only: per-keystroke parsing
//! (`dsl::parser`), per-key input handling (`app`), per-refresh
//! table reads. A firehose; never on except when debugging that
//! specific layer.
//!
//! Rule of thumb for new code: a loop logs a single summary count,
//! never per-iteration at `debug`/`info`. Logs are developer-facing,
//! so naming the engine (SQLite/PRAGMA) is fine here even though the
//! "no engine name" rule (ADR-0002) forbids it in user-facing strings.
use std::fs::{File, OpenOptions, create_dir_all}; use std::fs::{File, OpenOptions, create_dir_all};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
+7
View File
@@ -19,6 +19,8 @@ use std::fs;
use std::io::Write as _; use std::io::Write as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::debug;
use crate::dsl::action::ReferentialAction; use crate::dsl::action::ReferentialAction;
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::mode::Mode; use crate::mode::Mode;
@@ -338,6 +340,7 @@ impl Persistence {
/// renames over the destination. /// renames over the destination.
pub fn write_schema(&self, schema: &SchemaSnapshot) -> Result<(), PersistenceError> { pub fn write_schema(&self, schema: &SchemaSnapshot) -> Result<(), PersistenceError> {
let body = yaml::serialize_schema(schema); let body = yaml::serialize_schema(schema);
debug!(bytes = body.len(), "persist: write project.yaml (atomic)");
atomic_write(&self.project_path.join(PROJECT_YAML), body.as_bytes()) atomic_write(&self.project_path.join(PROJECT_YAML), body.as_bytes())
} }
@@ -355,8 +358,10 @@ impl Persistence {
/// with files they didn't ask for. /// with files they didn't ask for.
pub fn write_table_data(&self, table: &TableSnapshot) -> Result<(), PersistenceError> { pub fn write_table_data(&self, table: &TableSnapshot) -> Result<(), PersistenceError> {
if table.rows.is_empty() { if table.rows.is_empty() {
debug!(table = %table.name, "persist: table empty -> removing CSV (no data, no CSV)");
return self.delete_table_data(&table.name); return self.delete_table_data(&table.name);
} }
debug!(table = %table.name, rows = table.rows.len(), "persist: write data/<table>.csv (atomic)");
let data_dir = self.project_path.join(DATA_DIR); let data_dir = self.project_path.join(DATA_DIR);
fs::create_dir_all(&data_dir).map_err(|source| PersistenceError::Io { fs::create_dir_all(&data_dir).map_err(|source| PersistenceError::Io {
operation: "create", operation: "create",
@@ -394,6 +399,7 @@ impl Persistence {
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> { pub fn append_history(&self, command_text: &str) -> 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 line = history::format_record(command_text, history::utc_iso8601_now());
debug!(len = command_text.len(), "persist: append ok record to history.log");
history::append(&path, &line) history::append(&path, &line)
} }
@@ -411,6 +417,7 @@ impl Persistence {
history::utc_iso8601_now(), history::utc_iso8601_now(),
history::STATUS_ERR, history::STATUS_ERR,
); );
debug!(len = command_text.len(), "persist: append err record to history.log");
history::append(&path, &line) history::append(&path, &line)
} }
+144 -23
View File
@@ -11,7 +11,7 @@
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crossterm::event::{Event as CtEvent, EventStream}; use crossterm::event::{Event as CtEvent, EventStream};
@@ -53,6 +53,24 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// reappears once typing stops (ADR-0027 §3). /// reappears once typing stops (ADR-0027 §3).
const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000); const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000);
/// How long a demo-mode keystroke badge stays on screen before it
/// fades on its own (ADR-0047 D5). Long enough to read in a screencast
/// or in front of a class; short enough that a trailing `wait` in a
/// cast ends on a clean frame.
const DEMO_BADGE_TTL: Duration = Duration::from_millis(1500);
/// The nearest (soonest) of two optional deadlines (ADR-0047 D5) — the
/// instant the event loop should next wake to service a timer. `None`
/// when neither is set (the loop then blocks on `recv`). Pure, so the
/// scheduling decision is unit-testable without the loop.
fn nearest_deadline(a: Option<Instant>, b: Option<Instant>) -> Option<Instant> {
match (a, b) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, b) => b,
}
}
/// The input-validity indicator's debounce state machine /// The input-validity indicator's debounce state machine
/// (ADR-0027 §3, step E). /// (ADR-0027 §3, step E).
/// ///
@@ -216,6 +234,9 @@ pub async fn run(args: Args) -> Result<()> {
let db_existed = db_path.exists(); let db_existed = db_path.exists();
// Undo is on unless `--no-undo` (ADR-0006 Amendment 1). // Undo is on unless `--no-undo` (ADR-0006 Amendment 1).
let undo_enabled = !args.no_undo; let undo_enabled = !args.no_undo;
// Demonstration mode under `--demo` / `RDBMS_PLAYGROUND_DEMO`
// (ADR-0047). Off by default; threaded onto the `App` in run_loop.
let demo_mode = args.demo;
let database = let database =
Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled) Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled)
.context("open database")?; .context("open database")?;
@@ -273,6 +294,7 @@ pub async fn run(args: Args) -> Result<()> {
initial_events, initial_events,
undo_enabled, undo_enabled,
resolved_mode, resolved_mode,
demo_mode,
) )
.await; .await;
if let Err(e) = teardown_terminal(&mut terminal) { if let Err(e) = teardown_terminal(&mut terminal) {
@@ -331,6 +353,7 @@ async fn run_loop(
initial_events: Vec<AppEvent>, initial_events: Vec<AppEvent>,
undo_enabled: bool, undo_enabled: bool,
initial_mode: crate::mode::Mode, initial_mode: crate::mode::Mode,
demo_mode: bool,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY); let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx.clone()); let reader_handle = spawn_event_reader(event_tx.clone());
@@ -339,6 +362,8 @@ async fn run_loop(
app.project_name = Some(project_display_name); app.project_name = Some(project_display_name);
app.project_is_temp = project_is_temp; app.project_is_temp = project_is_temp;
app.undo_enabled = undo_enabled; app.undo_enabled = undo_enabled;
// ADR-0047: enable the demo overlays for this session under `--demo`.
app.demo_mode = demo_mode;
// Start in the resolved input mode (ADR-0015 mode-restore // Start in the resolved input mode (ADR-0015 mode-restore
// amendment, issue #14): `--mode` > stored project mode > // amendment, issue #14): `--mode` > stored project mode >
// default. `Persistence` already carries the same value, so the // default. `Persistence` already carries the same value, so the
@@ -376,6 +401,17 @@ async fn run_loop(
// no wake-ups. See `IndicatorDebounce` for the decision // no wake-ups. See `IndicatorDebounce` for the decision
// logic; `app.input_indicator` mirrors it for the renderer. // logic; `app.input_indicator` mirrors it for the renderer.
let mut debounce = IndicatorDebounce::default(); let mut debounce = IndicatorDebounce::default();
// ADR-0027 §3 + ADR-0047 D5: absolute deadlines for the two timed
// wake-ups — the indicator debounce and the demo keystroke-badge
// expiry. The loop time-boxes `recv` on the *nearest* of them and,
// on elapse, services whichever actually fired. Tracking them as
// `Instant`s (rather than one fixed `timeout` duration) lets the
// shorter badge timer fire without prematurely settling the longer
// debounce, and vice-versa. Both `None` ⇒ block on `recv` (no idle
// wake-ups).
let mut debounce_deadline: Option<Instant> = None;
let mut badge_deadline: Option<Instant> = None;
let mut last_badge_seq: u64 = app.demo_badge_seq;
// Long-lived native clipboard for the `copy` command (ADR-0041). // Long-lived native clipboard for the `copy` command (ADR-0041).
// Created lazily on first copy (so an OSC-52-only session never // Created lazily on first copy (so an OSC-52-only session never
// opens an X11 connection) and kept alive for the session — the // opens an X11 connection) and kept alive for the session — the
@@ -383,25 +419,36 @@ async fn run_loop(
// handle, so it must outlive each write. // handle, so it must outlive each write.
let mut native_clipboard = crate::clipboard::SystemClipboard::new(); let mut native_clipboard = crate::clipboard::SystemClipboard::new();
loop { loop {
let event = if debounce.is_armed() { let event = match nearest_deadline(debounce_deadline, badge_deadline) {
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await { None => match event_rx.recv().await {
Some(event) => event,
None => break,
},
Some(deadline) => {
let wait = deadline.saturating_duration_since(Instant::now());
match tokio::time::timeout(wait, event_rx.recv()).await {
Ok(Some(event)) => event, Ok(Some(event)) => event,
Ok(None) => break, Ok(None) => break,
Err(_elapsed) => { Err(_elapsed) => {
// Typing has been quiet for the debounce let now = Instant::now();
// interval — settle the indicator. // ADR-0047 D5: the keystroke badge has aged out.
if badge_deadline.is_some_and(|d| d <= now) {
app.demo_badge = None;
badge_deadline = None;
}
// ADR-0027 §3: typing has paused for the debounce
// interval — settle the validity indicator.
if debounce_deadline.is_some_and(|d| d <= now) {
debounce.settle(app.input_validity_verdict()); debounce.settle(app.input_validity_verdict());
app.input_indicator = debounce.visible(); app.input_indicator = debounce.visible();
debounce_deadline = None;
}
terminal terminal
.draw(|f| ui::render(&mut app, &theme, f)) .draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?; .context("redraw")?;
continue; continue;
} }
} }
} else {
match event_rx.recv().await {
Some(event) => event,
None => break,
} }
}; };
let is_key = matches!(event, AppEvent::Key(_)); let is_key = matches!(event, AppEvent::Key(_));
@@ -584,6 +631,23 @@ async fn run_loop(
// pauses; non-key events leave it untouched. // pauses; non-key events leave it untouched.
debounce.note_event(is_key); debounce.note_event(is_key);
app.input_indicator = debounce.visible(); app.input_indicator = debounce.visible();
// Keep the debounce deadline in lock-step with `is_armed()`,
// restarting it on every event while armed (preserving the prior
// behaviour) and clearing it once the indicator is visible again.
debounce_deadline = debounce
.is_armed()
.then(|| Instant::now() + INDICATOR_DEBOUNCE);
// ADR-0047 D5: (re)arm the badge timer whenever `update()` set a
// fresh badge. `demo_badge_seq` bumps even for the same label
// twice, so a repeated key restarts the timer rather than letting
// a stale deadline expire it early.
if app.demo_badge_seq != last_badge_seq {
last_badge_seq = app.demo_badge_seq;
badge_deadline = app
.demo_badge
.is_some()
.then(|| Instant::now() + DEMO_BADGE_TTL);
}
terminal terminal
.draw(|f| ui::render(&mut app, &theme, f)) .draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?; .context("redraw")?;
@@ -1079,6 +1143,13 @@ async fn refresh_schema_cache(
) { ) {
let cache = build_schema_cache(database).await; let cache = build_schema_cache(database).await;
let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await; let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await;
// ADR-0046 DB2: full relationship records for the sidebar panel.
// Best-effort — a failed read leaves the panel empty.
if let Ok(relationships) = database.read_all_relationships().await {
let _ = event_tx
.send(AppEvent::RelationshipsRefreshed(relationships))
.await;
}
} }
/// Build a `SchemaCache` snapshot from the live database. /// Build a `SchemaCache` snapshot from the live database.
@@ -1832,6 +1903,24 @@ fn build_schema_echo(
.map(|(name, child_table)| { .map(|(name, child_table)| {
vec![crate::echo::render_drop_relationship(name, child_table)] vec![crate::echo::render_drop_relationship(name, child_table)]
}), }),
// `create m:n relationship` (ADR-0045): the resolved junction
// columns/FKs only exist on the post-exec description, so the
// teaching echo is rendered from it (not `command_to_sql`).
Command::CreateM2nRelationship { .. } => description.map(|desc| {
let columns: Vec<(String, crate::dsl::types::Type)> = desc
.columns
.iter()
.filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
.collect();
let primary_key: Vec<String> =
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
.outbound_relationships
.iter()
.map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone()))
.collect();
vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)]
}),
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C // Everything else (Bucket A pure-Command, plus the no-echo Bucket C
// variants like `Sql*` / `ShowTable`) routes through the existing // variants like `Sql*` / `ShowTable`) routes through the existing
// `echo::command_to_sql` — wrapping its `Option<String>` to fit the // `echo::command_to_sql` — wrapping its `Option<String>` to fit the
@@ -2017,23 +2106,34 @@ async fn enrich_fk_violation(
}; };
facts.table = Some(table.clone()); facts.table = Some(table.clone());
for rel in outbound { for rel in outbound {
// The friendly FK-error facts model is single-column // Identify the violated FK by the first local column the
// (ADR-0019); for a compound FK (ADR-0043) we enrich // user supplied a value for (SQLite names no column in the
// from the first column pair — the error still surfaces, // error). The single-column facts slots then carry the
// richer multi-column enrichment is a later refinement. // comma-joined lists so a compound FK (ADR-0043) names
let Some(local_col) = rel.local_columns.first().cloned() else { // *every* child->parent column pair, not just the first.
let Some(first_local) = rel.local_columns.first().cloned() else {
continue; continue;
}; };
let value = let Some(first_val) =
user_value_for_column_with_schema(database, command, table, &local_col).await; user_value_for_column_with_schema(database, command, table, &first_local).await
if let Some(v) = value { else {
facts.column = Some(local_col); continue;
facts.parent_table = Some(rel.other_table); };
facts.parent_column = rel.other_columns.into_iter().next(); // Matched. Gather the remaining pairs' values in order.
facts.value = Some(v.to_string()); let mut values = vec![first_val.to_string()];
break; for local_col in rel.local_columns.iter().skip(1) {
if let Some(v) =
user_value_for_column_with_schema(database, command, table, local_col).await
{
values.push(v.to_string());
} }
} }
facts.column = Some(rel.local_columns.join(", "));
facts.parent_table = Some(rel.other_table);
facts.parent_column = Some(rel.other_columns.join(", "));
facts.value = Some(values.join(", "));
break;
}
// For UPDATE, if no outbound match was found we may // For UPDATE, if no outbound match was found we may
// be in the parent-side case (updating a column // be in the parent-side case (updating a column
// children reference). Check inbound as a fallback. // children reference). Check inbound as a fallback.
@@ -2531,6 +2631,7 @@ async fn execute_command_typed(
command: Command, command: Command,
source: String, source: String,
) -> Result<CommandOutcome, DbError> { ) -> Result<CommandOutcome, DbError> {
debug!(verb = command.verb(), "execute command (routing to worker)");
let src = Some(source); let src = Some(source);
match command { match command {
Command::CreateTable { Command::CreateTable {
@@ -2645,6 +2746,10 @@ async fn execute_command_typed(
) )
.await .await
.map(|d| CommandOutcome::Schema(Some(d))), .map(|d| CommandOutcome::Schema(Some(d))),
Command::CreateM2nRelationship { t1, t2, name } => database
.create_m2n_relationship(t1, t2, name, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::DropRelationship { selector } => database Command::DropRelationship { selector } => database
.drop_relationship(selector, src) .drop_relationship(selector, src)
.await .await
@@ -2964,8 +3069,24 @@ fn teardown_terminal(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::IndicatorDebounce; use super::{IndicatorDebounce, nearest_deadline};
use crate::dsl::walker::Severity; use crate::dsl::walker::Severity;
use std::time::{Duration, Instant};
#[test]
fn nearest_deadline_picks_the_soonest_or_none() {
let now = Instant::now();
let soon = now + Duration::from_millis(100);
let later = now + Duration::from_millis(500);
// Neither armed ⇒ block (None).
assert_eq!(nearest_deadline(None, None), None);
// One armed ⇒ that one, regardless of order.
assert_eq!(nearest_deadline(Some(soon), None), Some(soon));
assert_eq!(nearest_deadline(None, Some(soon)), Some(soon));
// Both armed ⇒ the soonest, regardless of order.
assert_eq!(nearest_deadline(Some(soon), Some(later)), Some(soon));
assert_eq!(nearest_deadline(Some(later), Some(soon)), Some(soon));
}
#[test] #[test]
fn starts_hidden_and_disarmed() { fn starts_hidden_and_disarmed() {
@@ -1,29 +1,29 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1540 assertion_line: 2326
expression: snapshot expression: snapshot
--- ---
Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ Output ──────────────────────────────────────────────────────────────────────╮
(none yet) ││
││
││
││
││
││
││
││
││
││
││
││
││
│ │╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
│ │╭ ADVANCED ────────────────────────────────────────╮ ╭ ADVANCED ────────────────────────────────────────────────────────────────────╮
││
│ │╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ ╭ Hint ────────────────────────────────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` │ │Type a command — press Tab for options, `help` for a list
││for a list
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · mode simple switch · Ctrl-C quit Enter submit · mode simple switch · Ctrl-C quit
@@ -1,29 +1,29 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1523 assertion_line: 2309
expression: snapshot expression: snapshot
--- ---
Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ Output ──────────────────────────────────────────────────────────────────────╮
(none yet) ││
││
││
││
││
││
││
││
││
││
││
││
││
│ │╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮ ╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮
││
│ │╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ ╭ Hint ────────────────────────────────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` │ │Type a command — press Tab for options, `help` for a list
││for a list
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,29 +1,29 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1531 assertion_line: 2317
expression: snapshot expression: snapshot
--- ---
Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ Output ──────────────────────────────────────────────────────────────────────╮
(none yet) ││
││
││
││
││
││
││
││
││
││
││
││
││
│ │╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮ ╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮
││
│ │╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ ╭ Hint ────────────────────────────────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` │ │Type a command — press Tab for options, `help` for a list
││for a list
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,30 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ [TAB] │
│ │
│ │
│ Completing the name │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,30 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ [ENTER] │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,30 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ [TAB] │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,30 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ Now press Tab to complete the table name │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,30 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ This is a deliberately long step caption │
│ that must wrap onto several lines and │
│ then be clipped to three with an… │
│ │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,29 +1,29 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1583 assertion_line: 2369
expression: snapshot expression: snapshot
--- ---
Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ Output ──────────────────────────────────────────────────────────────────────╮
(none yet) ││
││
││
││
││
││
││
││
││
││
││
││
│╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ────────────────────────────────────────── ╰──────────────────────────────────────────────────────────────────────────────
│ ││insert into T values (1, 'hi', null) --all-r │ ╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮
│╰──────────────────────────────────────────────────╯ insert into T values (1, 'hi', null) --all-rows $ │
│ │╭ Hint ──────────────────────────────────────────── ╰──────────────────────────────────────────────────────────────────────────────
│ ││after `insert into T values (1, 'hi', null)`, │ ╭ Hint ────────────────────────────────────────────────────────────────────────╮
││expected end of input — usage: insert into after `insert into T values (1, 'hi', null)`, expected end of input — usage: │
││<Table> [(<col>[, ...])] [values] (<value>[, ...])│ insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,29 @@
---
source: src/ui.rs
assertion_line: 2967
expression: snapshot
---
╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮
│Customers │ │
│Orders │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ ─────────────────────────────────╯
│ │ ─────────────────────────────────╮
╰───────────────────────────────────────────╯ │
╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯
│Customers_Orders │ ─────────────────────────────────╮
│ Customers.id -> │ ` for a list │
│ Orders.customer_id │ │
╰───────────────────────────────────────────╯ ─────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,28 +1,29 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 2385
expression: snapshot expression: snapshot
--- ---
Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ Output ──────────────────────────────────────────────────────────────────────╮
(none yet) ││
││
││
││
││
││
││
││
││
││
││
││
││
│ ││ │ ╰──────────────────────────────────────────────────────────────────────────────╯
│ │╰────────────────────────────────────────────────── ╭ Advanced: ───────────────────────────────────────────────────────────────────
│╭ Advanced: ───────────────────────────────────────╮ : sel
│ ││: sel │ ╰──────────────────────────────────────────────────────────────────────────────╯
│ │╰────────────────────────────────────────────────── ╭ Hint ────────────────────────────────────────────────────────────────────────
│╭ Hint ────────────────────────────────────────────╮ select
││select
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · Backspace cancel one-shot · Ctrl-C quit Enter submit · Backspace cancel one-shot · Ctrl-C quit
@@ -1,9 +1,9 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1841 assertion_line: 2679
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────
│Customers ││[simple] create table Customers ✓ │ │Customers ││[simple] create table Customers ✓ │
│Orders ││[system] Customers │ │Orders ││[system] Customers │
│ ││[system] id serial [PK] │ │ ││[system] id serial [PK] │
@@ -17,13 +17,13 @@ expression: snapshot
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ ││ │ │ ││ │
│ │╰──────────────────────────────────────────────────╯ │ │╰────────────────────────────────────────────────────────────────────────────────
│ │╭ SIMPLE ──────────────────────────────────────────╮ │ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────
╰──────────────────────────╯│ │
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` for a list │
│ ││ │ │ ││ │
│ │╰──────────────────────────────────────────────────╯ ╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` │
│ ││for a list │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,15 +1,15 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 1613 assertion_line: 2399
expression: snapshot expression: snapshot
--- ---
Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ Output ──────────────────────────────────────────────────────────────────────╮
(none yet) ││
││
││
││
││
││
│ ╭ Rebuild project ─────────────────────────────────────────╮ │ │ ╭ Rebuild project ─────────────────────────────────────────╮ │
│ │ │ │ │ │ │ │
│ │3 tables and 47 rows will be reconstructed; the existing │ │ │ │3 tables and 47 rows will be reconstructed; the existing │ │
@@ -17,13 +17,13 @@ expression: snapshot
│ │ │ │ │ │ │ │
│ │Continue? │ │ │ │Continue? │ │
│ │ │ │ │ │ │ │
│[Y] Yes [N] No Esc cancel │─────────╯ ╰─────────│[Y] Yes [N] No Esc cancel │─────────╯
╰──────────────────────────────────────────────────────────╯─────────╮ ╭ SIMPLE ─╰──────────────────────────────────────────────────────────╯─────────╮
││
│ │╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮ ╭ Hint ────────────────────────────────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` │ │Type a command — press Tab for options, `help` for a list
││for a list
╰──────────────────────────╯╰──────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,29 @@
---
source: src/ui.rs
assertion_line: 2789
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
│Customers ││ │
│Orders ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ │╰────────────────────────────────────────────────────────────────────────────────╯
│ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮
╰──────────────────────────╯│ │
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │
│ Orders.customer_id ││ │
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -0,0 +1,49 @@
---
source: src/ui.rs
assertion_line: 2265
expression: snapshot
---
╭ Output ──────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────╯
╭ SIMPLE ──────────────────────────────────────────────────╮
│select * from Customers where id = 12345 and name = │
│'Alice Wonderland' │
╰──────────────────────────────────────────────────────────╯
╭ Hint ────────────────────────────────────────────────────╮
│`select` is SQL — available in advanced mode. Switch │
│with `mode advanced`, or prefix the line with `:` to run… │
╰──────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch ·
+10
View File
@@ -20,6 +20,16 @@ use ratatui::style::Color;
use crate::dsl::grammar::HighlightClass; use crate::dsl::grammar::HighlightClass;
/// Foreground of the demonstration-mode overlays (ADR-0047 D4).
///
/// Deliberately a fixed, theme-independent high-contrast pair — black
/// on yellow — so the badge / caption boxes are hard to overlook in a
/// screencast on any background.
pub const DEMO_OVERLAY_FG: Color = Color::Black;
/// Background of the demonstration-mode overlays (ADR-0047 D4); see
/// [`DEMO_OVERLAY_FG`].
pub const DEMO_OVERLAY_BG: Color = Color::Rgb(0xFF, 0xD7, 0x00);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Background { pub enum Background {
Light, Light,
+1159 -74
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -137,6 +137,7 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
parent_columns: Some(vec!["country".to_string(), "code".to_string()]), parent_columns: Some(vec!["country".to_string(), "code".to_string()]),
on_delete: ReferentialAction::NoAction, on_delete: ReferentialAction::NoAction,
on_update: ReferentialAction::NoAction, on_update: ReferentialAction::NoAction,
inline: false,
}], }],
false, false,
None, None,
@@ -363,6 +364,65 @@ fn compound_fk_arity_mismatch_is_refused() {
}); });
} }
#[test]
fn inline_fk_referencing_compound_pk_points_at_table_level_form() {
// ADR-0043 D4 residual: an *inline* single-column FK cannot express a
// multi-column reference, so referencing a parent's compound PK must
// refuse with a pointer to the table-level `FOREIGN KEY (...)` form —
// not the generic arity message. The grammar marks the FK `inline`.
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"Region".to_string(),
vec![
ColumnSpec::new("country", Type::Int),
ColumnSpec::new("code", Type::Int),
],
vec!["country".to_string(), "code".to_string()],
None,
)
.await
.expect("create Region");
// Parse the inline form so the `inline` flag is set by the grammar.
let cmd = parse_command(
"create table City (country int references Region(country, code))",
)
.expect("parses");
let Command::SqlCreateTable {
name,
columns,
primary_key,
unique_constraints,
check_constraints,
foreign_keys,
if_not_exists,
} = cmd
else {
panic!("expected SqlCreateTable");
};
let err = db
.sql_create_table(
name,
columns,
primary_key,
unique_constraints,
check_constraints,
foreign_keys,
if_not_exists,
None,
)
.await
.expect_err("inline FK referencing a compound PK must be refused");
let msg = format!("{err}");
assert!(
msg.contains("FOREIGN KEY"),
"expected a pointer to the table-level `FOREIGN KEY (...)` form, got: {msg}"
);
});
}
#[test] #[test]
fn compound_fk_type_mismatch_per_pair_is_refused() { fn compound_fk_type_mismatch_per_pair_is_refused() {
let (_p, db, _dir) = open_project_db(); let (_p, db, _dir) = open_project_db();
+75
View File
@@ -464,6 +464,81 @@ fn enrich_fk_insert_resolves_parent_table_column_and_value() {
}); });
} }
#[test]
fn enrich_fk_insert_compound_names_every_column_pair() {
// ADR-0043 residual: a compound-FK violation must name *every*
// child->parent column pair, not just the first. The single-column
// facts slots carry the comma-joined lists.
let db = db();
rt().block_on(async {
db.create_table(
"Region".to_string(),
vec![
ColumnSpec::new("country".to_string(), Type::Int),
ColumnSpec::new("code".to_string(), Type::Int),
],
vec!["country".to_string(), "code".to_string()],
None,
)
.await
.unwrap();
db.create_table(
"City".to_string(),
vec![
ColumnSpec::new("country".to_string(), Type::Int),
ColumnSpec::new("region_code".to_string(), Type::Int),
],
vec![],
None,
)
.await
.unwrap();
db.add_relationship(
None,
"Region".to_string(),
vec!["country".to_string(), "code".to_string()],
"City".to_string(),
vec!["country".to_string(), "region_code".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.unwrap();
// Insert a City whose (country, region_code) has no parent Region.
let cmd = Command::Insert {
table: "City".to_string(),
columns: Some(vec!["country".to_string(), "region_code".to_string()]),
values: vec![
Value::Number("7".to_string()),
Value::Number("8".to_string()),
],
};
let err = db
.insert(
"City".to_string(),
Some(vec!["country".to_string(), "region_code".to_string()]),
vec![
Value::Number("7".to_string()),
Value::Number("8".to_string()),
],
None,
)
.await
.unwrap_err();
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert_eq!(facts.table.as_deref(), Some("City"));
assert_eq!(facts.parent_table.as_deref(), Some("Region"));
// Both pairs named, not just the first.
assert_eq!(facts.column.as_deref(), Some("country, region_code"));
assert_eq!(facts.parent_column.as_deref(), Some("country, code"));
assert_eq!(facts.value.as_deref(), Some("7, 8"));
});
}
#[test] #[test]
fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() { fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() {
// Regression: `insert into Orders values (4, 11.99)` — // Regression: `insert into Orders values (4, 11.99)` —
@@ -252,6 +252,91 @@ fn load_picker_renders_entries_and_navigates() {
assert_eq!(source, "load"); assert_eq!(source, "load");
} }
/// Build a load picker with three entries for the vi-navigation tests.
fn three_entry_picker() -> App {
let mut app = App::new();
app.update(AppEvent::LoadPickerReady {
entries: vec![
LoadPickerEntry {
display_name: "First".to_string(),
modified: "2026-05-07 14:30".to_string(),
path: std::path::PathBuf::from("/tmp/first"),
is_temp: true,
},
LoadPickerEntry {
display_name: "Second".to_string(),
modified: "2026-05-05 10:00".to_string(),
path: std::path::PathBuf::from("/tmp/second"),
is_temp: false,
},
LoadPickerEntry {
display_name: "Third".to_string(),
modified: "2026-05-01 09:15".to_string(),
path: std::path::PathBuf::from("/tmp/third"),
is_temp: false,
},
],
});
app
}
fn picker_selected(app: &App) -> usize {
let Some(Modal::LoadPicker(picker)) = app.modal.as_ref() else {
panic!("expected LoadPicker modal");
};
picker.selected
}
#[test]
fn load_picker_jk_navigates_like_arrows() {
// vi-style j/k mirror Down/Up so autocast (typeable keys only) can drive
// the load picker in documentation casts (#24).
let mut app = three_entry_picker();
assert_eq!(picker_selected(&app), 0);
// j moves the selection down.
app.update(key(KeyCode::Char('j')));
assert_eq!(picker_selected(&app), 1);
app.update(key(KeyCode::Char('j')));
assert_eq!(picker_selected(&app), 2);
// j at the last entry does not wrap past the end.
app.update(key(KeyCode::Char('j')));
assert_eq!(picker_selected(&app), 2);
// k moves the selection up.
app.update(key(KeyCode::Char('k')));
assert_eq!(picker_selected(&app), 1);
// k at the first entry does not wrap past the start.
app.update(key(KeyCode::Char('k')));
assert_eq!(picker_selected(&app), 0);
app.update(key(KeyCode::Char('k')));
assert_eq!(picker_selected(&app), 0);
}
#[test]
fn load_picker_g_jumps_to_first_and_last() {
// g → first entry, G → last entry (vi convention).
let mut app = three_entry_picker();
// G jumps to the last entry from the top.
app.update(key(KeyCode::Char('G')));
assert_eq!(picker_selected(&app), 2);
// G again is idempotent at the end.
app.update(key(KeyCode::Char('G')));
assert_eq!(picker_selected(&app), 2);
// g jumps back to the first entry.
app.update(key(KeyCode::Char('g')));
assert_eq!(picker_selected(&app), 0);
// g again is idempotent at the start.
app.update(key(KeyCode::Char('g')));
assert_eq!(picker_selected(&app), 0);
}
#[test] #[test]
fn load_picker_b_enters_path_entry_submode() { fn load_picker_b_enters_path_entry_submode() {
let mut app = App::new(); let mut app = App::new();
+455
View File
@@ -0,0 +1,455 @@
//! Integration tests for the m:n convenience command (C4 / ADR-0045):
//! `create m:n relationship from <T1> to <T2> [as <name>]`.
//!
//! Covers parse, junction generation (columns / compound PK / two
//! enforced FKs), the `as <name>` override, a compound-PK parent,
//! CASCADE delete, one-undo-step, self-m:n refusal, and the PK-less
//! parent guard.
use rdbms_playground::db::Database;
use rdbms_playground::dsl::command::RowFilter;
use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{self, PLAYGROUND_DB};
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project");
let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.expect("db");
(project, db, dir)
}
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project");
let db = Database::open_with_persistence_and_undo(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
true,
)
.expect("db");
(project, db, dir)
}
/// A parent table `(id serial PK, label text)` — the `label` gives an
/// insertable non-PK column (a serial-PK-only table has nothing to put
/// in a short-form INSERT).
async fn serial_pk_table(db: &Database, name: &str) {
db.create_table(
name.to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
vec!["id".to_string()],
None,
)
.await
.unwrap_or_else(|e| panic!("create {name}: {e}"));
}
/// Insert one row into a `serial_pk_table`, returning its auto-assigned id.
async fn add_row(db: &Database, table: &str, label: &str) {
db.insert(
table.to_string(),
Some(vec!["label".to_string()]),
vec![Value::Text(label.to_string())],
None,
)
.await
.unwrap_or_else(|e| panic!("insert into {table}: {e}"));
}
// ---- parse layer -----------------------------------------------
#[test]
fn parses_to_create_m2n_relationship() {
match parse_command("create m:n relationship from Students to Courses").expect("parses") {
Command::CreateM2nRelationship { t1, t2, name } => {
assert_eq!(t1, "Students");
assert_eq!(t2, "Courses");
assert_eq!(name, None);
}
other => panic!("expected CreateM2nRelationship, got {other:?}"),
}
}
#[test]
fn parses_with_as_name() {
match parse_command("create m:n relationship from Students to Courses as Enrollments")
.expect("parses")
{
Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")),
other => panic!("expected CreateM2nRelationship, got {other:?}"),
}
}
// ---- junction generation ---------------------------------------
#[test]
fn generates_junction_with_compound_pk_and_two_enforced_fks() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect("create m:n");
// Auto-named `Students_Courses` exists.
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
// Two FK columns, both part of the compound PK.
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
let cols: Vec<(&str, bool)> =
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
assert_eq!(
cols,
vec![("Students_id", true), ("Courses_id", true)],
"expected two FK columns forming the compound PK"
);
// Two outbound relationships (one per parent).
assert_eq!(desc.outbound_relationships.len(), 2, "expected two FKs");
// FK enforcement: a junction row needs existing parents.
add_row(&db, "Students", "s1").await;
add_row(&db, "Courses", "c1").await;
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await
.expect("valid link");
// Duplicate link refused by the compound PK.
let dup = db
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await;
assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused");
// A link to a non-existent parent is refused by the FK.
let orphan = db
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("99".to_string())],
None,
)
.await;
assert!(orphan.is_err(), "link to a non-existent Course must be refused");
});
}
#[test]
fn as_name_overrides_the_junction_table_name() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
Some("Enrollments".to_string()),
None,
)
.await
.expect("create m:n as Enrollments");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(!tables.contains(&"Students_Courses".to_string()));
});
}
#[test]
fn compound_parent_pk_contributes_one_fk_column_each() {
let (_p, db, _d) = open();
rt().block_on(async {
// Sections has a 2-column PK (course_id, term).
db.create_table(
"Sections".to_string(),
vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)],
vec!["course_id".to_string(), "term".to_string()],
None,
)
.await
.unwrap();
serial_pk_table(&db, "Students").await;
db.create_m2n_relationship("Students".to_string(), "Sections".to_string(), None, None)
.await
.expect("create m:n");
let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap();
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
// All three form the compound PK.
assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}");
});
}
#[test]
fn deleting_a_parent_cascades_to_the_junction() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.unwrap();
add_row(&db, "Students", "s1").await;
add_row(&db, "Courses", "c1").await;
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await
.unwrap();
// Deleting the student cascades to the junction (ON DELETE CASCADE).
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap();
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
});
}
#[test]
fn create_m2n_is_one_undo_step() {
let (_p, db, _d) = open_with_undo();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
// A real source makes the command undoable (a source-less call is
// treated as an internal, non-undoable op).
db.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
None,
Some("create m:n relationship from Students to Courses".to_string()),
)
.await
.unwrap();
assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string()));
// One undo removes the junction table AND both relationships.
db.undo().await.unwrap();
let tables = db.list_tables().await.unwrap();
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
// The parents' relationships are gone too (the junction held them).
let students = db.describe_table("Students".to_string(), None).await.unwrap();
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
});
}
// ---- guards ----------------------------------------------------
#[test]
fn self_referential_m2n_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Users").await;
let err = db
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
.await
.expect_err("self m:n must be refused");
assert!(format!("{err}").contains("two different tables"), "got: {err}");
});
}
#[test]
fn missing_parent_table_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
let err = db
.create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None)
.await
.expect_err("a missing parent table must be refused");
// The standard "no such table" guard (require_canonical_table).
assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}");
});
}
#[test]
fn junction_name_collision_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect("first m:n");
// A second identical m:n collides on the auto-name `Students_Courses`.
let err = db
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect_err("a junction-name collision must be refused");
assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}");
});
}
// ---- the junction is a normal table ----------------------------
#[test]
fn the_junction_can_be_renamed() {
// C4 requirement text: "an auto-named junction table the user can
// rename." It is a normal table, so `rename table` works.
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.unwrap();
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None)
.await
.expect("rename the junction");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(!tables.contains(&"Students_Courses".to_string()));
// Both relationships survive the rename (rebuild-preserving).
let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
});
}
#[test]
fn junction_survives_save_and_rebuild() {
// Persistence round-trip: the junction + both relationships are
// reconstructed from project.yaml after the .db is discarded.
let dir = tempfile::tempdir().expect("tempdir");
let project_path = {
let project = project::open_or_create(None, Some(dir.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
None,
Some("create m:n relationship from Students to Courses".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
// Discard the derived .db so the next open rebuilds from text.
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db =
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
});
}
#[test]
fn as_an_internal_name_is_refused() {
// The junction must be a real, listable table — an `as __rdbms_*`
// name would be filtered out of `list_tables` (a hidden orphan).
// Guarded in the shared `do_create_table` (ADR-0045 /runda finding).
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
let err = db
.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
Some("__rdbms_evil".to_string()),
None,
)
.await
.expect_err("an internal junction name must be refused");
assert!(format!("{err}").contains("no such table"), "got: {err}");
assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string()));
});
}
#[test]
fn pk_less_parent_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
// A PK-less table via the advanced SQL path.
db.sql_create_table(
"Loose".to_string(),
vec![ColumnSpec::new("a", Type::Int)],
vec![],
vec![],
vec![],
vec![],
false,
None,
)
.await
.unwrap();
let err = db
.create_m2n_relationship("Students".to_string(), "Loose".to_string(), None, None)
.await
.expect_err("a PK-less parent must be refused");
assert!(format!("{err}").contains("no primary key"), "got: {err}");
});
}
/// ADR-0046 DB2: the worker's `read_all_relationships` returns full
/// schema records (name, parent/child tables + columns, actions) — the
/// data source for the sidebar relationships panel. Exercised through
/// the real worker thread after an m:n junction creates two of them.
#[test]
fn read_all_relationships_returns_the_junction_relationships() {
let (_project, db, _dir) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect("create m:n");
let rels = db
.read_all_relationships()
.await
.expect("read all relationships");
assert_eq!(
rels.len(),
2,
"the m:n junction creates two relationships: {rels:?}"
);
// Both have the junction (Students_Courses) as their child.
for r in &rels {
assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}");
}
// One points back to each parent.
let parents: std::collections::BTreeSet<&str> =
rels.iter().map(|r| r.parent_table.as_str()).collect();
assert!(
parents.contains("Students") && parents.contains("Courses"),
"one relationship per parent: {rels:?}"
);
});
}
+1
View File
@@ -19,6 +19,7 @@ mod iteration4a_rebuild_command;
mod iteration4b_lifecycle_commands; mod iteration4b_lifecycle_commands;
mod iteration5_export_import; mod iteration5_export_import;
mod iteration6_resume_history; mod iteration6_resume_history;
mod m2n;
mod parse_error_pedagogy; mod parse_error_pedagogy;
mod project_lifecycle; mod project_lifecycle;
mod replay_command; mod replay_command;
+44
View File
@@ -65,6 +65,50 @@ fn replay_is_refused(script: &str) -> bool {
matches!(events.last(), Some(AppEvent::ReplayFailed { .. })) matches!(events.last(), Some(AppEvent::ReplayFailed { .. }))
} }
/// Like [`replay_is_refused`] but returns the failure message, so a test
/// can assert the command was refused *for the expected reason* rather
/// than e.g. a parse error.
fn replay_failure_message(script: &str) -> Option<String> {
let (project, db, _d) = open();
let r = rt();
std::fs::write(project.path().join("conv.commands"), script).expect("write script");
let events = r.block_on(run_replay(&db, project.path(), "conv.commands"));
match events.last() {
Some(AppEvent::ReplayFailed { error, .. }) => Some(error.clone()),
_ => None,
}
}
#[test]
fn e2e_alter_drop_primary_key_column_is_refused() {
// Issue #19: dropping a PK column must be refused on the advanced
// ALTER surface too (it reaches the shared `do_drop_column` guard).
let msg = replay_failure_message(
"create table T (id int primary key, v text)\n\
alter table T drop column id\n",
)
.expect("dropping a PK column must be refused");
assert!(
msg.to_lowercase().contains("primary"),
"refused for the wrong reason: {msg}"
);
}
#[test]
fn e2e_alter_drop_compound_primary_key_member_is_refused() {
// A member of a *compound* PK is still a PK column, so dropping it is
// refused identically (each member reports primary_key = true).
let msg = replay_failure_message(
"create table T (a int, b int, v text, primary key (a, b))\n\
alter table T drop column a\n",
)
.expect("dropping a compound-PK member must be refused");
assert!(
msg.to_lowercase().contains("primary"),
"refused for the wrong reason: {msg}"
);
}
/// The current user-facing type of column `name` in table `T`. /// The current user-facing type of column `name` in table `T`.
fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> { fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> {
r.block_on(db.describe_table("T".to_string(), None)) r.block_on(db.describe_table("T".to_string(), None))
+1
View File
@@ -839,6 +839,7 @@ fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> Sq
parent_columns: parent_column.map(|c| vec![c.to_string()]), parent_columns: parent_column.map(|c| vec![c.to_string()]),
on_delete: ReferentialAction::NoAction, on_delete: ReferentialAction::NoAction,
on_update: ReferentialAction::NoAction, on_update: ReferentialAction::NoAction,
inline: false,
} }
} }
+1
View File
@@ -109,6 +109,7 @@ fn dropping_a_referenced_parent_is_refused() {
parent_columns: Some(vec!["id".to_string()]), parent_columns: Some(vec!["id".to_string()]),
on_delete: rdbms_playground::dsl::ReferentialAction::NoAction, on_delete: rdbms_playground::dsl::ReferentialAction::NoAction,
on_update: rdbms_playground::dsl::ReferentialAction::NoAction, on_update: rdbms_playground::dsl::ReferentialAction::NoAction,
inline: true,
}], }],
false, false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()), Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
+4 -2
View File
@@ -301,7 +301,8 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
assert_eq!(app.tables, vec!["Customers".to_string()]); assert_eq!(app.tables, vec!["Customers".to_string()]);
assert_eq!(app.current_table, Some(desc)); assert_eq!(app.current_table, Some(desc));
let rendered = rendered_text(&mut app, &theme, 80, 24); // Width > 90 so the sidebar (items panel) is shown (ADR-0046 DB1).
let rendered = rendered_text(&mut app, &theme, 110, 24);
assert!( assert!(
rendered.contains("Customers"), rendered.contains("Customers"),
"items panel should list Customers:\n{rendered}" "items panel should list Customers:\n{rendered}"
@@ -397,7 +398,8 @@ fn drop_table_flow_clears_items_list() {
assert!(app.tables.is_empty()); assert!(app.tables.is_empty());
assert!(app.current_table.is_none()); assert!(app.current_table.is_none());
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); // Width > 90 so the (now-empty) sidebar is shown (ADR-0046 DB1).
let rendered = rendered_text(&mut app, &Theme::dark(), 110, 24);
assert!(rendered.contains("(none yet)")); assert!(rendered.contains("(none yet)"));
// ADR-0040: `drop table` is content-less, so the echo's ✓ marker // ADR-0040: `drop table` is content-less, so the echo's ✓ marker
// is the entire success signal (replacing `[ok] drop table …`). // is the entire success signal (replacing `[ok] drop table …`).
+73
View File
@@ -0,0 +1,73 @@
//! Matrix coverage for `create m:n relationship from <T1> to <T2>
//! [as <name>]` (C4 / ADR-0045). Exercises the full typing surface —
//! completion candidates, ambient hint, highlighting, and parse state —
//! at each stage, so a regression in any of those surfaces is caught.
use crate::typing_surface::*;
use rdbms_playground::input_render::InputState;
#[test]
fn after_create_offers_table_and_m2n() {
let schema = schema_multi_table();
let a = assess_at_end("create ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
// `create` branches to `table` (create table) or the `m:n` composite.
assert_candidate_present(&a, &["table", "m:n"]);
crate::snap!("after_create", a);
}
#[test]
fn m2n_relationship_keyword_sequence_is_incomplete() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["from"]);
crate::snap!("after_relationship_keyword", a);
}
#[test]
fn after_from_offers_table_names() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_from", a);
}
#[test]
fn after_to_offers_table_names() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_to", a);
}
#[test]
fn complete_create_m2n_parses() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to Orders", &schema);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
crate::snap!("complete", a);
}
#[test]
fn create_m2n_with_as_name_parses() {
let schema = schema_multi_table();
let a = assess_at_end(
"create m:n relationship from Customers to Orders as CustomerOrders",
&schema,
);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
crate::snap!("with_as_name", a);
}
#[test]
fn after_as_keyword_is_incomplete() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("after_as", a);
}
+2
View File
@@ -35,6 +35,7 @@ pub mod create_table;
pub mod drop_column; pub mod drop_column;
pub mod drop_relationship; pub mod drop_relationship;
pub mod add_relationship; pub mod add_relationship;
pub mod create_m2n;
pub mod index_ops; pub mod index_ops;
pub mod constraints; pub mod constraints;
pub mod rename_change_column; pub mod rename_change_column;
@@ -224,6 +225,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
RenameColumn { .. } => "RenameColumn".into(), RenameColumn { .. } => "RenameColumn".into(),
ChangeColumnType { .. } => "ChangeColumnType".into(), ChangeColumnType { .. } => "ChangeColumnType".into(),
AddRelationship { .. } => "AddRelationship".into(), AddRelationship { .. } => "AddRelationship".into(),
CreateM2nRelationship { .. } => "CreateM2nRelationship".into(),
DropRelationship { .. } => "DropRelationship".into(), DropRelationship { .. } => "DropRelationship".into(),
AddIndex { .. } => "AddIndex".into(), AddIndex { .. } => "AddIndex".into(),
DropIndex { .. } => "DropIndex".into(), DropIndex { .. } => "DropIndex".into(),
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 72
description: "input=\"create m:n relationship from Customers to Orders as \" cursor=52"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders as ",
cursor: 52,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a name",
),
),
completion: None,
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 16
description: "input=\"create \" cursor=7"
expression: "& a"
---
Assessment {
input: "create ",
cursor: 7,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
7,
7,
),
partial_prefix: "",
candidates: [
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 34
description: "input=\"create m:n relationship from \" cursor=29"
expression: "& a"
---
Assessment {
input: "create m:n relationship from ",
cursor: 29,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
29,
29,
),
partial_prefix: "",
candidates: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 43
description: "input=\"create m:n relationship from Customers to \" cursor=42"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to ",
cursor: 42,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
42,
42,
),
partial_prefix: "",
candidates: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 52
description: "input=\"create m:n relationship from Customers to Orders\" cursor=48"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders",
cursor: 48,
state: Valid,
hint: Some(
Prose(
"Submit with Enter",
),
),
completion: None,
parse_result: Ok(
"CreateM2nRelationship",
),
}
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 64
description: "input=\"create m:n relationship from Customers to Orders as CustomerOrders\" cursor=66"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders as CustomerOrders",
cursor: 66,
state: Valid,
hint: Some(
Prose(
"Type a name",
),
),
completion: None,
parse_result: Ok(
"CreateM2nRelationship",
),
}
@@ -0,0 +1,42 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 25
description: "input=\"create m:n relationship \" cursor=24"
expression: "& a"
---
Assessment {
input: "create m:n relationship ",
cursor: 24,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "from",
kind: Keyword,
mode: Simple,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
24,
24,
),
partial_prefix: "",
candidates: [
Candidate {
text: "from",
kind: Keyword,
mode: Simple,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -1,5 +1,6 @@
--- ---
source: tests/typing_surface/create_table.rs source: tests/typing_surface/create_table.rs
assertion_line: 13
description: "input=\"create \" cursor=7" description: "input=\"create \" cursor=7"
expression: "& a" expression: "& a"
--- ---
@@ -13,6 +14,11 @@ Assessment {
Candidate { Candidate {
text: "table", text: "table",
kind: Keyword, kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both, mode: Both,
}, },
], ],
@@ -30,6 +36,11 @@ Assessment {
Candidate { Candidate {
text: "table", text: "table",
kind: Keyword, kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both, mode: Both,
}, },
], ],
@@ -1,5 +1,6 @@
--- ---
source: tests/typing_surface/create_table.rs source: tests/typing_surface/create_table.rs
assertion_line: 48
description: "input=\"create table Customers with \" cursor=28" description: "input=\"create table Customers with \" cursor=28"
expression: "& a" expression: "& a"
--- ---
@@ -13,7 +14,7 @@ Assessment {
Candidate { Candidate {
text: "pk", text: "pk",
kind: Keyword, kind: Keyword,
mode: Both, mode: Simple,
}, },
], ],
selected: None, selected: None,
@@ -30,7 +31,7 @@ Assessment {
Candidate { Candidate {
text: "pk", text: "pk",
kind: Keyword, kind: Keyword,
mode: Both, mode: Simple,
}, },
], ],
}, },