13 Commits
Author SHA1 Message Date
claude@clouddev1 64818c08f6 docs(handoff-78): record the #36 help fix and the value.rs DSL finding
ci / gate (push) Successful in 2m5s
ci / manifests (push) Successful in 4s
2026-06-22 19:01:27 +00:00
claude@clouddev1 3ad4affef2 feat(help): distinct help for advanced-mode SQL forms; split list by mode (#36)
The six advanced SQL DML/query forms (SELECT, WITH, SQL_INSERT, SQL_UPDATE,
SQL_DELETE, EXPLAIN_SQL) carried help_id: None, so `help select`/`help with`
resolved to nothing and `help insert` showed only the simple form. Give each
its own distinct help_id (data.select, data.sql_insert, …) with a hand-curated
help.data.* page. Distinct strings keep the dedup invariant intact, and
note_help_topic needs no change — `help insert` now shows the simple block and
the sql_insert block (like `help create` already did), and the advanced-only
forms resolve.

note_help now groups the list by CommandCategory: app-lifecycle commands first
(unlabelled), then "Simple-mode commands:" and "Advanced-mode (SQL) commands:"
sections — replacing the old single "DSL data commands (in simple mode):"
header, which used the banned "DSL" term and mis-labelled the advanced SQL
forms it already contained.

Four new help_command tests (red→green). Recorded as ADR-0024 Amendment 1;
CHANGELOG updated.
2026-06-22 19:01:27 +00:00
claude@clouddev1 e88fa79f09 docs: handoff-77, changelog-discipline rule, and #39 changelog entry
- docs/handoff/20260622-handoff-77.md — session handoff for the #39
  schema-cache-gate fix; records the four open issues (incl. the new #40).
- CLAUDE.md — add the changelog-discipline rule: update [Unreleased] in
  the same change as any user-facing behaviour change (scripted/pasted
  paths count), under the two copy rules; release-time sweep of
  commits/handoffs as the backstop.
- CHANGELOG.md — [Unreleased] → Fixed bullet for the #39 paste/scripted-
  input fix.
2026-06-22 17:13:12 +00:00
claude@clouddev1 07575da983 fix(app): hold input until the schema-cache refresh lands (#39)
A simple-mode Form-B insert (`insert into T values (…)`) submitted faster
than the post-DDL schema-cache refresh was validated against the stale
cache and wrongly rejected as "trying to write SQL?". The cache is
refreshed asynchronously after the worker applies a command, posting
`SchemaCacheRefreshed` on the same FIFO channel as key events; under
faster-than-human input the next Enter is processed before the refresh.

Add a submission gate: `dispatch_dsl` arms `awaiting_schema_refresh` on
every `ExecuteDsl` dispatch, holds further DSL submissions in a
`held_submissions` FIFO while armed, and the `SchemaCacheRefreshed`
handler drains them against the fresh cache in submission order.
App-lifecycle commands bypass the gate. Arm-on-every-dispatch keeps
exactly one DSL command in flight, so the boolean is provably correct.
Interactive-only: the replay/batch path already re-snapshots the schema
synchronously per line. No interactive-user impact; held input is never
lost (a refresh always follows a dispatch).

Tests (both verified red→green): a Tier-1 deterministic ordering test and
a Tier-4 PTY back-to-back regression (the unpaced inverse of flow 3).
Records the design as ADR-0022 Amendment 8.
2026-06-22 16:59:40 +00:00
claude@clouddev1 010dbf8e9e docs(handoff-76): tee up the open issues as the next session's focus
Add §7 — the four open Gitea issues (#36 help, #37/#38 hints, #39 the
schema-cache bug) with per-issue scope reads, key code/ADR pointers, a
suggested order, and the tea commands to read them — so a new session
starts there before resuming features (user direction). Flag #38 as
needing a do/defer/close call. Refresh §1's commit list (5 commits, all
on main, unpushed) and the macOS full-run note; point §4 at §7.
2026-06-22 15:20:03 +00:00
claude@clouddev1 35ca108fa1 docs(tt5): record macOS Tier-4 confirmation + Windows verification stance
ci / gate (push) Successful in 2m5s
ci / manifests (push) Successful in 4s
- TT5: macOS is now fully covered — the full suite incl. Tier 4 ran green
  natively on macOS (4 flows + startup; Linux-only RSS test cfg-skipped).
- TT5 Windows wording: automated execution is the one open piece. A standing
  Windows CI runner is more involved than the Linux/macOS runners already in
  use; kept open with no timing promise. Interim: Windows builds ship every
  release and are verified by running the suite on Windows by hand periodically
  — the same builds, manually verified rather than per-push. TT5 stays [/] by
  deliberate scope. Framed in project terms, no host specifics.
- handoff-76 §6: macOS confirmation, the `pid` dead-code fix (1ffe11c), normal
  off-target crate downloads, and the Windows decision.
2026-06-22 15:12:47 +00:00
claude@clouddev1 1ffe11cb1f fix(tt4): drop the dead pid helper so e2e_pty is warning-free off Linux
`rss_kib` is Linux-only (#[cfg(target_os = "linux")]) and was the sole
caller of `pid()`, so on macOS/Windows `pid` was dead code and emitted a
`method `pid` is never used` warning. The Linux CI clippy gate never saw
it — `pid` is used on Linux — so it only surfaced on a manual macOS run.
Read `child.process_id()` inline in `rss_kib` and delete the helper.

Verified: macOS `cargo test` is fully green (5 e2e_pty tests there; the
Linux-only RSS test is cfg'd out). clippy --all-targets + fmt clean.
2026-06-22 15:07:41 +00:00
claude@clouddev1 c1f24599da Merge branch 'website'
website / deploy (push) Successful in 39s
2026-06-22 14:27:55 +00:00
claude@clouddev1 a8ebbfba26 docs(website): serve Umami first-party from umami.relplay.org
Brave Shields (and similar blockers) block Umami only as a third-party
request, so point the tracker at umami.relplay.org — a proxied CNAME to
the Umami server that's first-party to relplay.org — instead of the
server's own host. Same Website ID; the data endpoint follows the script
host, so no other change. Privacy page updated to name the new host, and
a comment in astro.config.mjs records why it's a relplay.org subdomain.
2026-06-22 14:27:24 +00:00
claude@clouddev1 f06cbbc788 Merge branch 'website'
website / deploy (push) Successful in 54s
2026-06-22 13:32:43 +00:00
claude@clouddev1 88204f25c5 docs(tt4,nfr): record Tier-4 + NFR verification (ADR-0008 A1, ADR-0057)
Document the regression-hardening work from this session:

- ADR-0008 Amendment 1 — Tier 4 realized: the portable-pty + vt100 harness,
  expectrl dropped, serial/fail-fast design, the four flows, sidebar-region
  reads, command pacing (issue #39), CI default-target (advances TT5).
- ADR-0057 (new) — NFR verification strategy: contrast + ΔE2000 gates
  (NFR-5/7), startup/RSS measured (NFR-1/3: release ~29 ms / ~10 MB),
  NFR-2 by architecture, NFR-4/6 reviewer note. Records the two shipped
  light-theme contrast defects this work caught and fixed.
- requirements.md: TT4 [~]->[x], TT5 [/] (only a Windows exec runner left),
  NFR-1/2/3/5/7 ->[x], NFR-4/6 ->[/], each with evidence.
- CHANGELOG.md (new): Keep a Changelog + SemVer, [Unreleased] + [0.2.0].
- README ADR index updated; handoff-76; the working plan.

Docs-only (CI gate skips via paths-ignore).
2026-06-22 13:15:45 +00:00
claude@clouddev1 fd63de3441 test(tt4): Tier-4 PTY end-to-end tests for the four critical flows
ci / manifests (push) Successful in 3s
ci / gate (push) Successful in 1m58s
Wire the Tier-4 tier from ADR-0008 — the first tests that drive the actual
built binary in a real pseudo-terminal. tests/e2e_pty.rs spawns the binary
under portable-pty at a fixed 100x30, parses output with vt100, and asserts
on the rendered screen with tight (3s) fail-fast waits. Serial (one PTY at a
time) for predictable timing on the low-parallelism runner; each test gets
its own temp --data-dir so it never touches real projects.

The four flows mirror ADR-0008's Tier-4 scope:
- cold launch -> create table -> graceful quit (Ctrl-C)
- create + add column -> quit -> reopen (--resume) -> schema (incl. column)
  restored
- export -> import into a fresh project -> schema + data rebuilt
- undo after DROP TABLE, through the confirm modal (y)

Table presence is read from the Tables sidebar region (not whole-screen)
since the Output panel echoes commands; commands are paced to completion
(real-user cadence) — a command sent mid-rebuild can be misread against a
stale schema cache (issue #39).

Also two NFR perf measurements via the harness (NFR-1 startup, NFR-3 idle
RSS) against generous debug-binary bounds for gross-regression detection;
the real release figures (median 29 ms / 10 MB) are recorded in the NFR doc.

Deps (dev): portable-pty 0.9, vt100 0.16 (both current; expectrl from
ADR-0008 dropped — it bundles a conflicting PTY layer). Runs by default in
`cargo test`, so the existing Linux CI gate exercises it (advances TT5).
PTY-in-container verified (openpty+spawn works under Docker).

Full suite: 2519 passed / 0 failed / 1 ignored. clippy + fmt clean.
Relates to TT4 / ADR-0008.
2026-06-22 13:06:09 +00:00
claude@clouddev1 65eab71439 fix(theme): meet WCAG-AA contrast in light theme; gate contrast + token distinctness
ci / gate (push) Successful in 2m1s
ci / manifests (push) Successful in 4s
Computing actual WCAG ratios surfaced two real accessibility defects in
the shipped v0.2.0 light theme: tok_string (4.42:1) and tok_flag
(3.15:1) fell below the 4.5:1 the scheme promises for normal text
(NFR-5). Darken both to clear it with headroom (5.18 / 5.98).

Also widen two dark-theme token pairs that were perceptually close
despite distinct hex (hard to separate on good screens): tok_type and
tok_function move from ΔE2000 ~14 to ~18-19 vs their neighbours.

Add four gated tests in theme.rs:
- all_text_colours_meet_wcag_aa_contrast: every text fg >= 4.5:1 on bg,
  both themes (the regression gate that would have caught this).
- advanced_mode_border_meets_ui_contrast: 3:1 for the mode-alert border;
  plain border is decorative chrome, exempt.
- delta_e_2000_matches_reference_vectors: validates the CIEDE2000 metric
  against Sharma et al. reference data.
- syntax_token_colours_are_perceptually_distinct: ΔE2000 >= 15 for every
  token-class pair, both themes.

Persist scripts/palette-preview.py: the true-colour swatch + contrast +
ΔE2000 audit tool used to drive these changes (reads the live palette
from theme.rs), for future palette work.

Tests: 2513 passed / 0 failed / 1 ignored (was 2509; +4). clippy and fmt
clean. Relates to NFR-5/NFR-7 (ADR-0057).
2026-06-22 12:26:34 +00:00
26 changed files with 2356 additions and 82 deletions
+60
View File
@@ -0,0 +1,60 @@
# Changelog
All notable, user-facing changes to RDBMS Playground are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/).
## [Unreleased]
### Added
- `help` now covers the advanced-mode SQL commands: `help select`, `help with`,
and the SQL forms of `insert` / `update` / `delete` / `explain` show their own
syntax, and the full command list is grouped into "Simple-mode commands" and
"Advanced-mode (SQL) commands" sections.
- Install via **Scoop**, **Homebrew**, and **winget** in addition to the
existing channels.
- Tier-4 end-to-end test suite that exercises the real application in a
terminal (cold launch, save/reopen, export/import, undo) — guards against
regressions the unit and render tests can't see.
- Automated colour-accessibility checks: every theme now has its
foreground/background contrast and its syntax-colour distinctness verified
on each build.
### Fixed
- Pasting or scripting several commands at once no longer occasionally
rejects a valid simple-mode `insert` — submitted right after adding a
column — as though it were advanced-mode SQL.
- **Light theme:** string-literal and flag colours in syntax highlighting
were below the WCAG-AA contrast bar; both are now legible. Two dark-theme
token colours that were hard to tell apart have been separated.
- Windows `install.ps1` now works on the in-box Windows PowerShell 5.1 and
updates `PATH` for the current session.
## [0.2.0] - 2026-06-17
### Added
- **Version surfaces:** a `--version` / `-V` command-line flag and an in-app
`version` command, both reporting the build's exact version.
- **Installation options:** publish to crates.io (`cargo install
rdbms-playground`), `cargo binstall` support, a one-line `curl | sh`
installer, and a Windows `install.ps1` — so you no longer have to hand-pick
a release asset.
- **Documentation & landing site** at <https://relplay.org> — the canonical
user guide plus screencast demos.
- A demonstration-mode key alias (Ctrl-G acting as F1) so screencasts can
show the in-app help/hint key.
### Fixed
- Corrected several errors in the contextual-hint help text.
## [0.1.0] - 2026-06-15
First public release: the cross-platform terminal sandbox for learning
relational-database concepts — tables, keys, relationships, indexes, queries
and query plans — in a guided simple mode or a full advanced (SQL) mode, with
projects, undo, and export/import.
[Unreleased]: https://git.lazyeval.net/oli/rdbms-playground/compare/v0.2.0...HEAD
[0.2.0]: https://git.lazyeval.net/oli/rdbms-playground/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.lazyeval.net/oli/rdbms-playground/releases/tag/v0.1.0
+14
View File
@@ -246,6 +246,20 @@ Key invariants in the code:
the specific product (SQLite, STRICT, rusqlite, PRAGMA).
ADR-internal prose and code comments may name it where
technically necessary for precision.
- **Changelog discipline.** `CHANGELOG.md` (repo root) tracks
notable **user-facing** changes (Keep a Changelog + SemVer).
Update it in the **same change** that introduces a user-facing
behaviour change: add or amend a bullet under `[Unreleased]`
in the right category (Added / Changed / Deprecated / Removed /
Fixed / Security), phrased for end users under the two copy
rules above (no engine name; no "DSL" — say "simple mode" /
"advanced mode"). A change with no user-visible effect (pure
refactor, internal tests, CI plumbing) gets **no** entry — and
the judgement of "user-facing" includes scripted / pasted /
power-user paths, not just the interactive happy path. At
release time, rename `[Unreleased]` to the new version + date,
add the compare link, and **sweep the commits/handoffs since
the last tag** as a backstop for anything missed.
- **Confirm commits.** Per the user's global rules, every
`git commit` is preceded by an explicit message proposal
and user approval. No AI attribution in commit messages.
Generated
+113 -3
View File
@@ -64,6 +64,12 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arrayvec"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]]
name = "atomic"
version = "0.6.1"
@@ -164,6 +170,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -481,6 +493,12 @@ dependencies = [
"litrs",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "either"
version = "1.15.0"
@@ -976,7 +994,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [
"nix",
"nix 0.29.0",
"winapi",
]
@@ -1038,6 +1056,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -1046,7 +1076,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
"memoffset",
]
@@ -1360,6 +1390,27 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix 0.28.0",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -1548,6 +1599,7 @@ dependencies = [
"futures-util",
"gethostname",
"insta",
"portable-pty",
"pretty_assertions",
"rand 0.10.1",
"ratatui",
@@ -1559,6 +1611,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"vt100",
"zip",
]
@@ -1738,6 +1791,17 @@ dependencies = [
"unsafe-libyaml-norway",
]
[[package]]
name = "serial2"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
dependencies = [
"cfg-if",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -1758,6 +1822,22 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1968,7 +2048,7 @@ dependencies = [
"libc",
"log",
"memmem",
"nix",
"nix 0.29.0",
"num-derive",
"num-traits",
"ordered-float",
@@ -2240,6 +2320,27 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vt100"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
dependencies = [
"itoa",
"unicode-width",
"vte",
]
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"memchr",
]
[[package]]
name = "vtparse"
version = "0.6.2"
@@ -2639,6 +2740,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
+2
View File
@@ -59,8 +59,10 @@ zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
[dev-dependencies]
insta = { version = "1.47.2", features = ["yaml"] }
portable-pty = "0.9"
pretty_assertions = "1.4.1"
tempfile = "3.27.0"
vt100 = "0.16"
# Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test`
# links ~25 separate integration-test binaries, each statically
+54 -1
View File
@@ -146,4 +146,57 @@ and on a nightly schedule for any extended coverage.
- CI cost is real but bounded: Tiers 1–3 are fast; Tier 4 is the
only slow tier and is kept narrow.
- Adding a feature implies adding tests at the appropriate tier
(or tiers); coverage is not retrofitted later.
(or tiers); coverage is not retrofitted later.
## Amendment 1 — 2026-06-22: Tier 4 realized (TT4)
Tier 4 was a specification only (no PTY deps, no tests) until this
amendment. It is now implemented in `tests/e2e_pty.rs`, and the realized
shape refines the original decision in three ways.
**Tooling — `expectrl` dropped.** The decision named
`portable-pty` + `expectrl` + `vt100`. We kept **`portable-pty`** (0.9, to
spawn the real binary in a PTY at a fixed size) and **`vt100`** (0.16, to
parse the output stream into an inspectable cell grid), both current and
maintained. We **dropped `expectrl`**: it bundles its own PTY abstraction
(which conflicts with `portable-pty`) and matches line-by-line, a poor fit
for a full-screen TUI. A small hand-rolled `wait_for(predicate, timeout)`
that polls the vt100 screen replaces it — fewer dependencies, and assertions
read the actual rendered grid.
**Harness shape.** Each test spawns the binary
(`env!("CARGO_BIN_EXE_rdbms-playground")`) under a fresh PTY at **100×30**
(the wide three-region layout, ADR-0046), with its **own temp `--data-dir`**
so it never touches real projects or resume state, and `--theme dark` for
deterministic styling. Tests run **serially** (one PTY + child at a time)
via a poison-tolerant global lock, so timing stays predictable on the
low-parallelism self-hosted runner. Waits are **tight and fail-fast** (3 s):
a slow wait is a genuine hang, and a timeout panics with a full screen dump
for debuggability.
Two non-obvious robustness rules the harness encodes:
- **Read table presence from the Tables *sidebar* region, not the whole
screen** — the Output panel echoes every command, so a table name typed
in a command would pollute a whole-screen match.
- **Pace each command to completion before the next** (real-user / cast
cadence). A command submitted while the previous one's worker rebuild is
still in flight can be misread against a stale schema cache (issue #39,
discovered here — interactive use is unaffected).
**The four flows** mirror this ADR's Tier-4 scope: (1) cold launch → first
DDL → graceful quit (Ctrl-C); (2) create → quit → reopen via `--resume`,
asserting the *column* (not just the table name) round-trips; (3) export →
import into a fresh project → schema **and** data rebuilt; (4) `undo` after
`DROP TABLE`, through the Y/N confirm modal.
**CI.** The target runs by default in `cargo test`, so the existing Linux
gate exercises it on every push — this **advances TT5** (Tier 4 now runs in
CI on Linux). PTY-in-container was validated (openpty + child spawn work
under the Docker runtime the Gitea runner uses); the first real CI run is
the final confirmation. **Windows execution remains out of scope** (no
Windows runner), and the original Tier-4 note about a *nightly broader
coverage* schedule is **not** implemented — the focused four flows are the
committed TT4 scope; broader coverage is deferred.
**Piggybacked NFR measurement.** The same harness measures NFR-1 (startup
to first frame) and NFR-3 (idle RSS, Linux) — see ADR-0057.
@@ -824,6 +824,63 @@ of issue #4; no `AmbientHint` / renderer change. Covered by
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
seed_count_hint_also_fires_after_a_column_fill_target}`.
## Amendment 8 — submission gate: hold input until the schema-cache refresh lands (2026-06-22)
§9 established the schema cache as the source of truth for the
walker's schema-aware dispatch, "refreshed by the runtime … after
successful DDL". That refresh is **asynchronous**: the runtime applies
a command on the worker thread (ADR-0010) and only afterwards posts a
`SchemaCacheRefreshed` event back through the **same FIFO channel that
carries key events**. Between dispatching a DDL command and that event
landing, `schema_cache` is stale w.r.t. the command just run.
Validation in `App::update` is pure-sync (a core invariant — it cannot
do a DB round-trip), so it can only consult that cache. Under
**faster-than-human input** (paste, scripted input, an unpaced PTY
driver), the next submission's Enter is already queued *ahead* of the
refresh event, so it is validated against the stale cache. A simple-mode
**Form-B insert** (`insert into T values (…)`, columns derived from the
cache) submitted right after `add column` then sees the pre-DDL columns,
its value arity can't match, and the friendly layer tags it *"trying to
write SQL?"* — even though the identical line succeeds at human speed
(issue **#39**).
**Decision — gate submission on the pending refresh.** `App` carries
`awaiting_schema_refresh: bool` + a `held_submissions` FIFO queue.
`dispatch_dsl` arms the flag on **every** `ExecuteDsl` dispatch; while
armed, a new DSL submission is **held** (queued in submission order)
rather than validated. The `SchemaCacheRefreshed` handler clears the
flag and **drains** the queue against the now-fresh cache, stopping as
soon as a drained command re-arms the gate (so the remainder wait for
*its* refresh — order preserved). A held command that doesn't dispatch
(parse error / pre-flight rejection) leaves the gate open and the loop
continues. App-lifecycle commands (`quit` / `help` / `load` / `undo` /
`rebuild`) route through `dispatch_app_command` *before* `dispatch_dsl`
and so are never held.
**Why arm on every dispatch, not just DDL.** It keeps at most one DSL
command in flight, so refreshes are strictly one-per-dispatch and in
order — making the gate a provably-correct boolean. Arming only on
schema-mutating commands would let a *preceding* non-DDL command's
refresh clear the gate early and drain a held Form-B insert against a
cache that predates the DDL — reintroducing the bug in a corner case.
(App-lifecycle commands also refresh but bypass the gate; they are
modal/picker-gated and so cannot overlap a rapid DSL paste, the only
thing this guards.)
**Scope.** Interactive input only. The `replay` / history-log / startup
rebuild-from-text batch path already re-snapshots the schema
**synchronously, inline, before every line** (`run_replay`,
`build_schema_cache`), so it has always had this ordering guarantee;
this amendment brings the interactive path in line with it. No
interactive-user impact (the gate clears in milliseconds); held input is
never lost because the runtime sends a `SchemaCacheRefreshed` after
*every* dispatch, success or failure. Covered by
`app::tests::form_b_insert_after_ddl_is_held_until_refresh_then_dispatched`
(Tier-1, deterministic event ordering) and the Tier-4 PTY regression
`e2e_pty::back_to_back_insert_after_ddl_still_succeeds` (the unpaced
inverse of flow 3).
## Out of scope
Deliberately deferred to keep this ADR shippable as a single
@@ -706,6 +706,56 @@ documentation is still hand-curated for round 1.
table-ident from new-name-ident visually is a future
enhancement.
## Amendment 1 — advanced-mode SQL forms get their own `help` pages, and the list groups by mode (2026-06-22, issue #36)
`help_id` (§Node taxonomy) drives two surfaces: the `help` **list**
(`note_help` emits one `help.<id>` block per `Some` id) and the
`help <topic>` **lookup** (`note_help_topic` shows the block of every
node whose entry word matches the topic). The dedup rule is that
`help_id` *strings* are unique (one block ⇒ printed once).
Originally the six advanced-mode SQL **DML/query** forms — `SELECT`,
`WITH`, `SQL_INSERT`, `SQL_UPDATE`, `SQL_DELETE`, `EXPLAIN_SQL` —
carried `help_id: None`. That was a list-formatting shortcut (avoid a
second `insert` entry), but it had a side effect: `help select` /
`help with` resolved to **nothing** (the unknown-topic note), and
`help insert` showed only the simple form — even though the SQL surface
is genuinely different and advanced mode exists precisely for learners
moving to raw SQL. (The advanced SQL **DDL** forms — `sql_create_table`
etc. — already had their own `help.ddl.sql_*` pages, so the gap was
inconsistent as well as a pedagogy hole.)
**Decision.** Give every advanced SQL form its **own** `help_id`
(`data.select`, `data.with`, `data.sql_insert`, `data.sql_update`,
`data.sql_delete`, `data.explain_sql`) with a hand-curated
`help.data.*` page (the catalog body stays hand-written, per "What's
out of scope" above — this amendment doesn't change that). Because the
ids are **distinct strings**, the dedup invariant
(`no_two_registered_commands_share_a_help_id`) is untouched, and:
- **`help <topic>` shows every form sharing the entry word** — so
`help insert` shows the simple block *and* the `sql_insert` block
(exactly as `help create` already showed the simple + SQL create
forms). Advanced-only `help select` / `help with` now resolve.
- **The list groups by mode.** `note_help` now splits the REGISTRY by
[`CommandCategory`]: app-lifecycle commands (ids in the `app.*`
namespace, usable in either mode) list first, unlabelled, under the
intro; then a **`help.simple_section`** ("Simple-mode commands:")
group and a **`help.advanced_section`** ("Advanced-mode (SQL)
commands:") group. This replaces the single `help.dsl_section` header
("DSL data commands (in simple mode):"), which both used the banned
"DSL" term (ADR-0002 user-facing posture) and wrongly claimed simple
mode for the advanced SQL forms the section already held.
This partially realises ADR-0030 §6's "Polish" item (a `help sql`
page): rather than one combined page, each form has its own, reached
through the normal `help <topic>` surface and discoverable in the
advanced-mode list section. `note_help_topic` needed **no** change —
the new `help_id`s make the forms resolve automatically. Covered by
`help_command::{help_select_renders_the_sql_select_block,
help_with_renders_the_cte_block, help_insert_shows_both_simple_and_sql_forms,
help_list_splits_simple_and_advanced_sections}`.
## References
- ADR-0023 — Unified declarative grammar tree (Proposed direction). Superseded by this ADR for execution detail.
+122
View File
@@ -0,0 +1,122 @@
# ADR-0057: Non-functional-requirement verification strategy
## Status
Accepted (2026-06-22).
## Context
The non-functional requirements (`requirements.md` NFR-1..7 — startup,
input latency, memory, distinctive design, colour use, cross-platform
parity, light/dark legibility) were quality bars that had **never been
formally verified**, even though v0.2.0 ships public binaries. With users
now pulling updates, we want each NFR either **gated by an automated test**
(so a regression fails CI) or **measured-and-documented** with a recorded
figure and a reviewer judgement — not left as an aspiration.
The decision below was taken with the user: contrast is a hard gate;
startup and memory are measured against generous bounds (not the literal
target — a tight timing/RSS gate would flake on a shared runner); the
qualitative bars are verified by argument and reviewer note.
## Decision
### NFR-5 / NFR-7 — colour contrast & legibility: **gated**
Unit tests in `src/theme.rs` (Tier 1, run by the existing gate):
- **`all_text_colours_meet_wcag_aa_contrast`** — every text-bearing
foreground (`fg`, `muted`, mode labels, `system`/`error`/`warning`,
`plan_efficient`, and all `tok_*`) must clear **WCAG-AA 4.5:1** against
`bg`, in **both** themes.
- **`advanced_mode_border_meets_ui_contrast`** — the advanced-mode border
carries a mode-warning signal, so it meets the **3:1** non-text /
UI-component threshold. The plain `border` is decorative structural
chrome and is **deliberately exempt** (it sits at 2.20:1 dark / 1.82:1
light by design, so panel borders recede behind content — recorded here
rather than silently tolerated).
- **`syntax_token_colours_are_perceptually_distinct`** — contrast against
the background is necessary but not sufficient: two token colours can
each clear 4.5:1 yet be hard to tell *apart* on a good screen. Every pair
of syntax-token classes that can share a line must clear **CIEDE2000
(ΔE2000) ≥ 15** (post-tuning minimum is ~18). The metric is itself
validated against the Sharma et al. reference vectors
(`delta_e_2000_matches_reference_vectors`).
Computing the real ratios while writing these tests **uncovered two
shipped defects** in the v0.2.0 light theme — `tok_string` at 4.42:1 and
`tok_flag` at 3.15:1, both below the 4.5:1 the scheme promises — plus two
dark-theme token pairs that were perceptually close despite distinct hex
(`tok_type`↔`tok_keyword`, `tok_function`↔`tok_identifier`, ΔE2000 ~14).
All four were fixed in the same change (commit `65eab71`); the gates now
hold the palette to both bars permanently. A dev tool,
`scripts/palette-preview.py`, renders the live palette with contrast +
ΔE2000 for future palette work.
### NFR-1 / NFR-3 — startup & memory: **measured, generously bounded**
The Tier-4 PTY harness (ADR-0008 Amendment 1) measures both:
- **`startup_to_first_frame_is_reasonable`** — spawn → first rendered frame.
- **`idle_memory_footprint_is_reasonable`** — child `/proc/<pid>` VmRSS
after the app is idle (Linux only).
These run against the **debug** binary, so they assert *generous* bounds
(startup < 2000 ms, RSS < 150 MB) for **gross-regression detection**, not
the literal NFR targets — a tight 500 ms / 50 MB gate on a debug binary
under a loaded shared runner would flake. The **real release figures**
(measured 2026-06-22, 5 trials each, this dev machine) are the documented
verification:
| NFR | Target | Release median | Margin |
| --- | --- | --- | --- |
| NFR-1 startup → first frame | < 500 ms | **~29 ms** | ~17× under |
| NFR-3 idle RSS | < 50 MB | **~10 MB** | ~5× under |
(Measurement granularity is the harness poll interval, ~20 ms — figures
are "tens of ms", not sub-ms precise.) Per the user decision these are
**not** wired as tight CI gates; the generous debug-bound tests are the
ongoing regression signal.
### NFR-2 — input responsiveness: **verified by architecture**
"Long-running queries execute off the UI thread so the interface stays
responsive." This is structural and already true: database work runs on a
dedicated **worker thread** (ADR-0010), and terminal input is read on a
**separate Tokio task** (`spawn_event_reader` in `src/runtime.rs`), so a
running query cannot block keystroke handling or rendering. There is no
automated keystroke-latency test — a deterministic sub-16 ms measurement
in a PTY harness would be flaky and low-value — so NFR-2 is verified by
this architectural argument rather than a gate.
### NFR-4 / NFR-6 — distinctive design & cross-platform parity: **reviewer note**
Qualitative bars. **NFR-4** (deliberate, identifiable palette/layout using
box-drawing) is satisfied by the bespoke two-theme palette, the
three-region framed layout, and the annotated plan/relationship rendering —
reviewer judgement, not automatable. **NFR-6** is partly evidenced by the
CI matrix (Linux + macOS **execute** the suite; Windows is **build-only**,
no runner) and is otherwise a reviewer judgement; one documented divergence:
on terminals/multiplexers without true-colour passthrough (e.g. a tmux
session missing `Tc`/`RGB`), the 24-bit palette is quantised to the
256-colour cube and near hues can collide — a terminal-capability issue,
not a palette one.
## Consequences
- Contrast and perceptual distinctness are now **permanently gated** — the
palette cannot regress below WCAG-AA or into look-alike token colours
without failing the build.
- Startup and memory have **recorded figures** and a generous
gross-regression test; the literal targets are met with large margins.
- The qualitative and architectural NFRs are **documented with evidence**
rather than asserted.
- A latent behaviour found during this work — fast DDL→insert misparsing
against a stale schema cache — is tracked as **issue #39** (deferred; no
interactive-user impact).
## Requirements bookkeeping
NFR-1, NFR-3, NFR-5, NFR-7 → `[x]` (gated test and/or measured with
evidence). NFR-2 → `[x]` (architectural). NFR-4, NFR-6 → `[/]` (reviewer
note; not fully automatable).
+4 -3
View File
File diff suppressed because one or more lines are too long
+150
View File
@@ -0,0 +1,150 @@
# Session handoff — 2026-06-22 (76)
Continues from handoff-75 (D3 package managers). This session took up the
**regression-hardening** trio identified as "what's next" now that public
binaries ship: **TT4** (Tier-4 PTY tests), **NFR verification**, and a
**CHANGELOG** — plus a real palette accessibility fix the work uncovered.
## §1. State
**Branch `main`.** Commits this session (all on `main`, **not pushed** — push
is the user's step):
- `65eab71` `fix(theme)` — WCAG-AA palette fix + contrast/ΔE2000 gates +
`scripts/palette-preview.py`.
- `fd63de3` `test(tt4)` — Tier-4 PTY end-to-end suite (`tests/e2e_pty.rs`).
- `88204f2` `docs(tt4,nfr)` — ADR-0008 Amendment 1, ADR-0057, README index,
`requirements.md`, `CHANGELOG.md`, handoff-76, plan doc.
- `1ffe11c` `fix(tt4)` — drop the dead `pid` helper (macOS-only dead-code
warning the Linux gate can't see; found via the user's macOS run).
- `35ca108` `docs(tt5)` — macOS Tier-4 confirmation + Windows verification
stance (and the §7 below was added in a follow-up docs commit).
**Test baseline: 2519 passed / 0 failed / 1 ignored** (was 2509; +4 theme
gates, +6 e2e_pty). `clippy --all-targets -D warnings` + `fmt --check` clean.
Full suite verified green 3× under parallel load on Linux, and a full native
run on **macOS** (5 e2e_pty there — Linux-only RSS test cfg-skipped).
## §2. What shipped
**Palette accessibility (ADR-0057, commit `65eab71`).** Computing real WCAG
ratios while writing the gate **found two shipped defects** in v0.2.0's light
theme — `tok_string` (4.42:1) and `tok_flag` (3.15:1), below the 4.5:1 the
scheme promises — and two dark token pairs that were perceptually close
despite distinct hex. All four fixed (only `theme.rs` colours changed, 4
values). New gated tests in `src/theme.rs`:
- `all_text_colours_meet_wcag_aa_contrast` (≥4.5:1, both themes),
- `advanced_mode_border_meets_ui_contrast` (≥3:1; plain border exempt),
- `delta_e_2000_matches_reference_vectors` (CIEDE2000 validated vs Sharma data),
- `syntax_token_colours_are_perceptually_distinct` (ΔE2000 ≥15).
Dev tool **`scripts/palette-preview.py`** renders the live palette (truecolor
swatch + contrast + ΔE2000), supports `theme:key=HEX` overrides for previewing
changes.
**TT4 — Tier-4 PTY tests (ADR-0008 Amendment 1, commit `fd63de3`).**
`tests/e2e_pty.rs` drives the **real built binary** in a pseudo-terminal:
`portable-pty` 0.9 + `vt100` 0.16 (**`expectrl` dropped** — conflicting PTY
layer; replaced by a hand-rolled `wait_for` on the vt100 grid). 100×30, temp
`--data-dir`, serial, tight 3 s fail-fast waits. The four ADR-0008 flows all
green. Runs by default in `cargo test` → the Linux gate now exercises Tier 4
(**advances TT5**). PTY-in-container validated (openpty+spawn under Docker).
- **Gotchas baked into the harness:** read table presence from the Tables
**sidebar** region (the Output panel echoes commands, polluting whole-screen
matches); **pace commands to completion** (a command sent mid-rebuild
misparses — issue #39); simple-mode insert needs the `values` keyword and a
single-value insert needs `add column` (a 2nd col in `with pk …` makes a
**compound PK**).
**NFR verification (ADR-0057).** All seven NFRs formally verified:
- NFR-1/3 **measured** via the PTY harness — release figures **~29 ms** startup
/ **~10 MB** idle RSS (well under 500 ms / 50 MB). Harness tests gate generous
*debug*-binary bounds for gross-regression detection (tight gates declined as
flaky — user decision).
- NFR-5/7 **gated** (the contrast/ΔE2000 tests above).
- NFR-2 by architecture (worker thread ADR-0010 + separate input task).
- NFR-4/6 reviewer note (`[/]`).
**CHANGELOG.md** — Keep a Changelog + SemVer; `[Unreleased]` + `[0.2.0]`
(0.1.0 = pre-history). v0.2.0 tag = `bd5be5e`; the palette fix + package
managers + TT4 are post-tag → Unreleased.
## §3. Decisions taken with the user
- Palette: fix the two light contrast bugs **and** the two dark near-duplicates
(4 hex changes, approved after a rendered preview).
- Borders <3:1 → **accept + document** (decorative chrome).
- Work directly on `main`; ADR-0057 numbered now (not draft).
- NFR perf: measured + generously bounded, **not** tightly CI-gated.
- CHANGELOG depth: 0.2.0 + Unreleased only.
- Schema-cache finding → **file a Gitea issue, defer the fix** (see §4).
## §4. Open / follow-ups
- **Issue #39** (filed this session): simple-mode **Form-B insert**
(`insert into T values (…)`) submitted faster than the post-DDL **schema-cache
refresh** validates against stale schema and is misparsed as SQL. No
interactive-user impact (cast driver + tests pace); deferred fix. Repro +
diagnosis in the issue.
- **TT5 remaining:** only a **Windows execution runner** now (macOS + Tier-4
gaps closed this session). First real CI run is the final confirmation that
the PTY tests pass in the Gitea container (validated locally; very low risk).
- **Next focus is the open issues — see §7** (user direction 2026-06-22: clear
the open issues before resuming feature work). Larger features (V4 session
journal, TU1 tutorial) come after. The CHANGELOG can later feed
`--release-notes-url` into the CI winget job (handoff-75 §6).
## §5. Process pins
- Commits user-confirmed, no AI attribution, append-only, **push is the user's
step**.
- `/runda` + DA pass was run on both the plan and the implementation (it caught
the two light-theme contrast bugs and the #39 schema-cache behaviour — both
before finalizing).
- Consider a `cargo sweep` at this milestone (release build added ~ a GB).
## §6. Post-finalization (same session)
- **macOS fully verified.** A native `cargo test` on macOS was fully green —
1813 lib + **5 `e2e_pty`** + 500 integration + 200 typing. This **confirms
Tier 4 on macOS** (the 4 flows + startup; the Linux-only RSS probe is
`#[cfg]`-skipped, hence 5 not 6). Closes the runda "Tier-4 unverified on macOS"
item.
- **`fix(tt4)` `1ffe11c`** — the macOS run surfaced a `method `pid` is never
used` warning: `rss_kib` is Linux-only and was `pid`'s only caller, so off
Linux it was dead code. The Linux clippy gate can't catch this (pid *is* used
on Linux). Read `child.process_id()` inline; deleted the helper.
- **Cross-platform crate downloads off-target are normal** — `cargo` fetches the
whole locked graph (Windows ConPTY bindings, Unix libc/nix from `portable-pty`)
but only compiles host-target crates; nothing off-target is actually built.
- **TT5 / Windows decision (2026-06-22):** a permanent Windows CI runner stays
**open, no promises** — standing one up is more involved than the Linux/macOS
runners already in use. Interim: Windows builds ship every release (D1/D2) and
are **verified by running the suite on Windows by hand periodically** — the
same builds Windows users get, manually verified rather than per-push. The TT5
note was reworded to say this plainly and welcomingly; it stays `[/]` by
deliberate scope. (Virtualization options explored for a future runner are not
recorded here — they hinge on host specifics rather than project constraints.)
## §7. Next session — start with the open issues
Per user direction (2026-06-22), the next session should **work the open Gitea
issues before resuming feature work** (V4 journal, TU1 tutorial, etc.). There
are **four open** (`tea issues list --state open --limit 100`; read a body with
`tea issue <n> --fields body --output json < /dev/null | jq -r '.body'`, and
comments with `tea issue <n> --comments < /dev/null`):
| # | Label | One-line | This-session read on scope |
| --- | --- | --- | --- |
| **#36** | enhancement | `help <sql-form>` shows no distinct content — the seven advanced-mode SQL nodes carry `help_id: None` (a dedup hack for the `help` list; `src/dsl/grammar/mod.rs:915-918`), so e.g. `help select` resolves to nothing. The parse-error/usage layer (ADR-0042/H1a) already distinguishes them; only the `help` command lags. | **Contained.** Good first pick — a real, bounded gap. Touches H3/`help`; an ADR amendment may apply. |
| **#37** | enhancement | Clause-concept hints — deeper teaching when the cursor sits inside a recognized *clause* (`on delete ⟨cascade\|set null\|restrict⟩`, the `create table` constraint slots, `with pk`, `1:n`/`m:n`), between tier-2's candidate list and the whole-form tier-3 block. Deferred extension of **ADR-0053** (H2). | **Medium scope, richest teaching value** — squarely on the pedagogy mission. Likely an ADR-0053 amendment. |
| **#38** | enhancement | Pre-submit-diagnostic F1 route + ~33 `diagnostic.*` tier-3 blocks. Needs a `class`/`message_key` field threaded through **every** diagnostic-creation site (walker + validators). Deferred from ADR-0053 Phase C. | **Broad mechanism change for the most marginal value** (tier-2 already surfaces these). The issue itself says so. **Decision needed:** do, defer, or close as wontfix — escalate to the user. |
| **#39** | bug | Simple-mode **Form-B insert** (`insert into T values (…)`) submitted faster than the post-DDL **schema-cache refresh** validates against stale schema → misparsed as SQL. Filed this session; repro + diagnosis (incl. `SchemaCache` at `src/completion.rs:53`, Form-B handling `src/dsl/walker/context.rs:155-157`) in the issue. | **Low impact** (no interactive-user hit; cast driver + tests pace). Fix is its own focused change (sequence the cache refresh with command execution, or validate against the authoritative schema). |
**Suggested order (a recommendation, not a mandate — confirm with the user):**
#36 (contained warm-up) → #37 (highest on-mission value) → #39 (bug; size the fix
first) → #38 (get the user's do/defer/close call before investing in the broad
threading change). All four are hint/help/parse-adjacent except #39; reading
**ADR-0053** (the contextual-hint design) first will orient #37 and #38.
No new labels needed (`bug`/`enhancement` cover them; ask the user before
creating any). Issue-tracker etiquette + `tea` gotchas are in the project
`CLAUDE.md` ("Issue tracking — Gitea via `tea`").
+125
View File
@@ -0,0 +1,125 @@
# Session handoff — 2026-06-22 (77)
Continues from handoff-76. Per the user's direction (handoff-76 §7: clear the
open Gitea issues before resuming feature work), this session took the **bug**
of the four open issues — **#39** (Form-B insert misparse against a stale schema
cache after fast DDL) — and fixed it end-to-end. Three open issues remain
(#36/#37/#38, all enhancements); see §3.
## §1. State
**Branch `main`.** Commits this session (on `main`, **not pushed** — push is the
user's step):
- `07575da` `fix(app)` — schema-refresh submission gate (#39) + Tier-1 and
Tier-4 PTY regression tests + ADR-0022 Amendment 8 + README index.
- this `docs(handoff-77)` commit — folds in the `CHANGELOG.md` `[Unreleased]`
Fixed bullet for #39 **and** a new **changelog-discipline rule** in project
`CLAUDE.md` (see §4).
**Test baseline: 2521 passed / 0 failed / 1 ignored** (was 2519; +1 lib test,
+1 e2e_pty test → e2e_pty now 7 on Linux). `clippy --all-targets -D warnings` +
`fmt --check` clean. The new PTY test was confirmed to **time out without the
fix** (genuine guard), green with it.
**Issue #39 is closed** (resolution comment + diagnosis recorded on the issue).
## §2. What shipped — issue #39
**Root cause.** `App::update` is pure-sync and validates submissions against
`App::schema_cache`. The runtime refreshes that cache **asynchronously** after
the worker applies a command — a `SchemaCacheRefreshed` event posted on the
**same FIFO channel as key events** (`runtime.rs:1591`, applied `app.rs`
`SchemaCacheRefreshed` arm). Under faster-than-human input (paste / script /
unpaced PTY) the next Enter is processed *before* the refresh lands, so a
simple-mode **Form-B insert** (`insert into T values (…)`, columns derived from
the cache) submitted right after `add column` sees the pre-DDL columns, its
arity can't match, and the friendly layer tags it *"trying to write SQL?"*. The
worker itself is never wrong (it runs commands serially); the bug was purely in
client-side pre-validation racing the refresh.
**Fix — submission gate (`src/app.rs`).** Two new private `App` fields:
`awaiting_schema_refresh: bool` + `held_submissions: VecDeque<(String,
EffectiveMode)>`. `dispatch_dsl` arms the flag on **every** `ExecuteDsl`
dispatch; while armed, a new DSL submission is **held** (queued in submission
order) rather than validated. The `SchemaCacheRefreshed` handler clears the flag
and **drains** the queue against the now-fresh cache, stopping as soon as a
drained command re-arms the gate (the rest then wait for *its* refresh — order
preserved). App-lifecycle commands route through `dispatch_app_command` *before*
`dispatch_dsl`, so `quit`/`help`/`load`/`undo`/`rebuild` are never held.
**Why arm on every dispatch, not just DDL** — it keeps at most one DSL command
in flight, so refreshes are strictly one-per-dispatch and in order, making the
boolean provably correct. Arming only on DDL would let a *preceding* non-DDL
command's refresh clear the gate early and drain a held insert against a
pre-DDL cache (a real corner case). Cost was one existing test
(`walking_skeleton::colon_escape_in_simple_mode_is_one_shot`) updated to model
the post-dispatch refresh — faithful, since it had been assuming it.
**Scope = interactive only.** The `replay` / history-log / startup
rebuild-from-text batch path already re-snapshots the schema **synchronously,
inline, before every line** (`run_replay` → `build_schema_cache`,
`runtime.rs:2514`), so it always had this ordering guarantee; the fix brings the
interactive path in line with it. No interactive-user impact (the gate clears in
ms); held input is never lost because the runtime sends a `SchemaCacheRefreshed`
after **every** dispatch, success or failure (the unconditional post-match block
in `spawn_dsl_dispatch`).
**Tests** (both verified red→green):
- `app::tests::form_b_insert_after_ddl_is_held_until_refresh_then_dispatched` —
Tier-1, deterministic: stale cache → submit DDL (arms) → submit insert (held,
no error) → deliver fresh `SchemaCacheRefreshed` → insert dispatches.
- `e2e_pty::back_to_back_insert_after_ddl_still_succeeds` — Tier-4 PTY, the
unpaced inverse of flow 3; asserts the insert's own `('Alice') ✓` echo.
**Docs.** ADR-0022 **Amendment 8** records the gate (the §9 schema-cache
refresh-vs-validation timing contract); README index updated in the same edit
(ADR-0000 rule).
## §3. Open / follow-ups
Per the user's direction the open issues come before feature work. After closing
#39, **four** issues are open. The hint/help trio (#36/#37/#38) are all
**enhancements** on the pedagogy mission (read handoff-76 §7's table for fuller
scope; read **ADR-0053** first — it orients #37/#38). **#40** is a CI/packaging
follow-up filed this session.
| # | One-line | read |
| --- | --- | --- |
| **#36** | `help <sql-form>` shows no distinct content — the 7 advanced-mode SQL nodes share `help_id: None` (`src/dsl/grammar/mod.rs:915-918`) | **Contained.** Good next pick; touches H3/`help`, maybe an ADR amendment. |
| **#37** | Clause-concept hints (cursor inside `on delete …`, `with pk`, `1:n`/`m:n`, create-table constraint slots) — deferred ADR-0053 extension | **Medium scope, richest teaching value.** Likely an ADR-0053 amendment. |
| **#38** | Pre-submit-diagnostic F1 route + ~33 `diagnostic.*` tier-3 blocks; needs a `class`/`message_key` threaded through every diagnostic site | **Broad mechanism, most marginal value.** Get the user's do/defer/close call before investing. |
| **#40** | Wire `CHANGELOG.md` into the winget release notes (`komac --release-notes-url`); ADR-0056 area | Filed this session (the changelog→winget thread, see §4). Small, deferred-by-decision originally. |
**Suggested order (confirm with the user):** #36 (contained) → #37 (highest
on-mission value) → #38 (decision first: do / defer / close). #40 is independent
(release pipeline) and can slot in whenever. #38 in particular should be
escalated for a do-or-close call rather than silently built.
## §4. Changelog discipline — new rule + open winget thread
The session surfaced that **no rule existed** for keeping `CHANGELOG.md` current
(it was created in handoff-76's plan but never given a maintenance process), so
the #39 fix wasn't logged until the user asked. Decided with the user:
- **New `CLAUDE.md` rule (this commit):** update `[Unreleased]` in the **same
change** that alters user-facing behaviour (incl. scripted/pasted/power-user
paths, not just the interactive happy path), under the two copy rules; no entry
for refactor/test/CI-only changes; at release time rename `[Unreleased]` and
**sweep commits/handoffs since the last tag** as a backstop.
- **#39 entry added** under `[Unreleased] → Fixed`.
- **winget release notes → issue #40.** komac supports `--release-notes-url` /
`--release-notes`; the `winget` job (`publish.yaml` ≈ L276) passes neither.
Tracked, not done (it's a release-pipeline / ADR-0056 change, kept out of this
bug-fix session by the user's call).
## §5. Process pins
- Commit user-confirmed, no AI attribution, append-only, on `main`; **push is
the user's step** (this commit is unpushed).
- Test-first honored: both regression tests were confirmed RED before the fix
(the PTY one via a `git stash` of `src/app.rs`), GREEN after. A written
Devil's-Advocate pass on the implementation surfaced one comment imprecision
(fixed) and no behavioral findings.
- `cargo sweep` not run this session; the build grew modestly (one extra PTY
test binary). Consider a sweep at the next milestone.
+101
View File
@@ -0,0 +1,101 @@
# Session handoff — 2026-06-22 (78)
Continues from handoff-77 (issue #39 + changelog rule). This chunk cleared
**issue #36** — advanced-mode SQL forms now have distinct `help` content — the
first of the enhancement issues the user is working through. Three issues remain
open (#37, #38, #40).
## §1. State
**Branch `main`.** Work for #36 is staged but **not yet committed** at the time
of writing (two commits proposed: a `feat(help)` for the change + a
`docs(handoff-78)`). **Not pushed** — push is the user's step.
**Test baseline: 2525 passed / 0 failed / 1 ignored** (was 2521; +4 new
`help_command` integration tests). `clippy --all-targets -D warnings` +
`fmt --check` clean. Issue #36 to be **closed** on commit.
## §2. What shipped — issue #36
**Problem.** The in-app `help` command gave no distinct content for the six
advanced-mode SQL **DML/query** forms (`SELECT`, `WITH`, `SQL_INSERT`,
`SQL_UPDATE`, `SQL_DELETE`, `EXPLAIN_SQL`): they carried `help_id: None` (a
list-dedup shortcut), so `help select` / `help with` resolved to the
unknown-topic note and `help insert` showed only the simple form. (The advanced
SQL **DDL** forms already had `help.ddl.sql_*` pages, so the gap was
inconsistent too.)
**Decisions taken with the user** (three forks, all confirmed):
1. `help <topic>` shows **both forms** for a shared entry word, mode-blind.
2. `select` / `with` get `help_id`s and are **listed** too (consistent with the
already-listed SQL DDL forms).
3. The `help` **list** is **split by mode** into "Simple-mode commands:" and
"Advanced-mode (SQL) commands:" sections — fixing a pre-existing header bug
(next item).
**Fix (near-zero logic):**
- Gave all six advanced forms distinct `help_id`s (`data.select`, `data.with`,
`data.sql_insert`, `data.sql_update`, `data.sql_delete`, `data.explain_sql`)
with hand-curated terse `help.data.*` catalog pages (`src/dsl/grammar/data.rs`,
`src/friendly/strings/en-US.yaml`). Distinct strings ⇒ the dedup invariant
(`no_two_registered_commands_share_a_help_id`) is untouched. `note_help_topic`
needed **no** change — the forms now resolve automatically (so `help insert`
shows the simple block + the `sql_insert` block, like `help create` already
did).
- `note_help` (`src/app.rs`) now **groups by `CommandCategory`**: `app.*`
commands first (unlabelled, under the intro — they work in either mode), then
a simple-mode group and an advanced-mode (SQL) group. New catalog keys
`help.simple_section` / `help.advanced_section` replace the old
`help.dsl_section`.
- Trimmed `help.data.explain`'s advanced lines (the `explain_sql` page now owns
that). Updated `src/friendly/keys.rs` (catalog-key registry) and the stale
`help_id`-rationale comments in `src/dsl/grammar/{mod,data}.rs`.
**Copy-rule fix (bonus, in scope).** The old list header
`"DSL data commands (in simple mode):"` violated the project copy rule (the
banned word **"DSL"**) *and* mis-labelled the advanced SQL forms it already
contained as "simple mode". The split headers fix both. Verified the full `help`
output no longer contains "DSL".
**Tests** (all four red→green): `help_command::{help_select_renders_the_sql_select_block,
help_with_renders_the_cte_block, help_insert_shows_both_simple_and_sql_forms,
help_list_splits_simple_and_advanced_sections}`. Rendered output eyeballed
(alignment, both-forms, the three list groups).
**Docs.** ADR-0024 **Amendment 1** (owns `help_id`); README index updated same
edit; CHANGELOG `[Unreleased] → Added` entry (per handoff-77's new changelog
rule — landed *with* the change this time).
## §3. Copy-rule audit finding (flagged, NOT fixed) — needs a triage call
The user asked for a "DSL" sweep of help/hint strings. Result: the only
user-facing help/hint violation was the list header (fixed above). The other
catalog "DSL" hits are comments / YAML keys (internal — the rule allows those).
**One bonus finding outside help/hint, deliberately left for the user to
triage:** `src/dsl/value.rs:106` returns the error message *"literal `blob`
values are not supported in **DSL** yet"* — a likely user-facing copy-rule
violation (and it also surfaces an internal term). Not touched (out of #36
scope). **Decide: fix now / file an issue / leave.** Worth a quick check of
whether that message reaches the user raw or is wrapped by the friendly layer.
## §4. Open / follow-ups — three issues remain
| # | One-line | read |
| --- | --- | --- |
| **#37** | Clause-concept hints (cursor inside `on delete …`, `with pk`, `1:n`/`m:n`, create-table constraint slots) — deferred ADR-0053 extension | **Medium scope, richest teaching value.** Likely an ADR-0053 amendment; read ADR-0053 first. |
| **#38** | Pre-submit-diagnostic F1 route + ~33 `diagnostic.*` tier-3 blocks; needs a `class`/`message_key` threaded through every diagnostic site | **Broad mechanism, most marginal value.** Get the user's do/defer/close call before building. |
| **#40** | Wire `CHANGELOG.md` into the winget release notes (`komac --release-notes-url`); ADR-0056 area | Filed handoff-77; independent release-pipeline work. |
| (new?) | `value.rs:106` "DSL" copy-rule violation — see §3 | Not filed yet; awaiting the user's triage call. |
**Suggested next:** #37 (highest on-mission value) → #38 (escalate do/defer/close
first) → #40 (independent). Plus the §3 `value.rs` triage.
## §5. Process pins
- Test-first honoured: 4 help tests confirmed RED before the fix, GREEN after.
- Written Devil's-Advocate pass on the implementation: no blocking findings
(the dedup invariant holds with distinct ids; `note_help_topic` unchanged;
category split verified in the rendered output).
- Commits user-confirmed, no AI attribution, append-only, on `main`; push is the
user's step.
+189
View File
@@ -0,0 +1,189 @@
# Plan — TT4 (Tier-4 PTY tests) + NFR verification + CHANGELOG
Status: **approved 2026-06-22**, implementation pending. Supersedes the
transient harness plan-mode copy. This is the persistent, version-controlled
plan per project convention (`docs/plans/`).
## Context
RDBMS Playground now ships public binaries (v0.2.0 on crates.io, plus
Scoop/Homebrew/winget). With real users pulling updates, the priority is
**regression protection before the next release**. Three tracked gaps are taken
up together:
- **TT4** (`requirements.md`, ADR-0008 Tier 4): the four critical end-to-end
flows are specified but **no PTY harness exists** — no deps, no tests. Tiers
1–3 never exercise the *real built binary* through a terminal.
- **NFR-1..7**: quality bars (startup, latency, memory, contrast, distinctive
design, parity) that were **never formally verified** despite shipping.
- **CHANGELOG**: none exists; useful now that releases are public (and lets the
CI `winget` job pass `--release-notes-url` later).
Baseline (Phase 1): `cargo test` = **2509 passed / 0 failed / 1 ignored**;
clippy + fmt clean. This is the number Phase 5 verifies against (new tests add
to it; nothing may regress).
User decisions (Phase 3):
- **TT4 in CI:** own `e2e_pty` test target, runs by default in `cargo test`
(so the Linux gate exercises it every push). **Tight, fail-fast timeouts** —
the runner is self-hosted, low-parallelism, low expected flakiness (so a
failure surfaces fast rather than hanging on a generous timeout).
- **NFR:** WCAG contrast = hard gated test; startup + idle-memory = measured via
the PTY harness against *generous* bounds + numbers recorded; NFR-2/4/6 =
verify-by-argument + reviewer note. Each requirement → `[x]`/`[/]` with
evidence.
- **CHANGELOG:** Keep a Changelog + SemVer; `[Unreleased]` + a single
consolidated `[0.2.0]` only (0.1.0 treated as pre-history).
---
## Workstream 1 — TT4: Tier-4 PTY harness + 4 flows
### Tooling (refines ADR-0008's stated `portable-pty`+`expectrl`+`vt100`)
- **`portable-pty`** (wezterm, actively maintained) — spawn the real binary in a
PTY with a fixed window size.
- **`vt100`** — parse the PTY output stream into an inspectable cell grid.
- **Drop `expectrl`** — it bundles its *own* PTY abstraction (conflicts with
portable-pty) and is line-oriented, a poor fit for a full-screen TUI. Replace
it with a small hand-rolled `wait_for(predicate, timeout)` polling the vt100
screen. This deviation is recorded in the ADR-0008 amendment.
- **Checkpoint at implementation:** verify `vt100` is still maintained/current
(global currency rule). If stale, evaluate `avt` as the screen parser before
committing the dep. `cargo add` picks latest; pin sensibly.
### Harness (`tests/e2e_pty.rs` — new `[[test]]` target)
A `support` module providing:
- `struct PtyApp` — opens a PTY (e.g. **100×30**, the wide three-region
layout), spawns `env!("CARGO_BIN_EXE_rdbms-playground")` with a **per-test
temp `--data-dir`** (`tempfile::TempDir`), `--theme dark`, `TERM=xterm-256color`;
a reader thread feeds bytes into a `vt100::Parser`.
- `send(bytes)` (raw; `\r` = Enter, `\x03` = Ctrl-C), `send_line(s)` =
`s` + `\r`.
- `wait_for(&str, Duration)` — poll the vt100 screen text until the substring
appears or the (tight, ~**3 s**) timeout fires; on timeout **panic with a full
screen dump** (fail-fast + debuggable). A `screen_text()` helper for assertions.
- `quit()` — `send("\x03")`, wait for child exit, assert success.
- Run **serially** (`serial_test` dep, or a shared `Mutex`/single-threaded
target) — PTYs + temp dirs shouldn't race; also keeps timing stable.
- **Instrument** with `eprintln!`/screen dumps on every wait so a CI failure is
diagnosable from logs (per the project's logging discipline).
### The four flows (one `#[test]` each, mirroring ADR-0008 §Tier-4)
1. **Cold launch → DDL → quit.** Launch fresh; `wait_for("SIMPLE")` +
`wait_for("(none yet)")`; `send_line("create table Customers with pk
id(serial)")`; `wait_for("Customers")` in the Tables panel; `quit()`.
2. **Save → restart → reopen.** Launch project path P under the temp data-dir;
create a table; `quit()` (autosave already persisted per-command); relaunch
**same P**; `wait_for("Customers")` — schema restored from text/rebuild.
3. **Export → import → rebuild.** Project A: create table + insert a row;
`send_line("export A.zip")` (explicit path under the temp dir);
`wait_for` export-success note; `quit()`. Fresh project B:
`send_line("import <path>/A.zip")`; `wait_for` import note (rebuild auto-runs
on missing `.db`); assert the table + row are present (`show data` /
Tables panel).
4. **Undo after DROP.** Create table; `send_line("drop table Customers")`;
confirm gone; `send_line("undo")`; **`wait_for("Restore that earlier
state?")`** (the real modal); `send("y")`; `wait_for("Customers")` restored.
### CI
No workflow edit needed for execution: `e2e_pty` is a default target, so the
existing `gate` job's `cargo test --no-fail-fast` runs it on the Linux
container. **Validate locally first** that a PTY opens inside the
`rdbms-playground-ci` image (container `/dev/ptmx`); if not, add `--init`/pts
mount notes to the ADR. Advances **TT5** (Tier-4-in-CI on Linux); Windows
execution runner stays out of scope (no runner).
---
## Workstream 2 — NFR verification
### Gated: WCAG contrast (NFR-5, NFR-7) — `src/theme.rs` `#[cfg(test)]`
- Add a `relative_luminance(Color) -> f64` + `contrast_ratio(a,b) -> f64`
helper (WCAG 2.x sRGB formula) in the test module; a `match Color::Rgb` channel
extractor (panics on non-RGB — also a guard against a future named-colour
regression).
- For **both** `Theme::light()` and `Theme::dark()`, assert **≥ 4.5:1** for
normal-text foregrounds on `bg`: `fg`, `error`, `warning`, `system`,
`plan_efficient`, `mode_simple`, `mode_advanced`, and the info-carrying syntax
tokens (`tok_keyword/identifier/type/number/string/flag/function/error`).
- **`muted`/`tok_punct`** (deliberately dim secondary text) and **borders**
(`border`/`border_advanced`, non-text UI): assert the WCAG **non-text 3:1**
threshold, with a comment citing the WCAG large-text/non-text allowance. If
any *fails even 3:1*, that's a real finding → surface to the user, don't
silently relax. (Supersedes the existing inequality-only theme tests.)
- This runs in the existing gate — permanent regression protection on the palette.
### Measured + documented: startup (NFR-1) + idle memory (NFR-3)
Two measurement tests in `e2e_pty.rs`:
- **Startup:** time from spawn to `wait_for("SIMPLE")` (first rendered frame);
assert a **generous** bound (e.g. < 1500 ms locally) and `eprintln!` the actual.
- **Idle memory:** after the app is idle, read the child's `/proc/<pid>/status`
`VmRSS` (Linux); assert a generous bound and print actual. (Linux-gated via
`#[cfg(target_os = "linux")]`.)
The point is gross-regression detection, not a tight SLA gate (user decision).
### Verify-by-argument + reviewer note (NFR-2, NFR-4, NFR-6)
- **NFR-2** (input off-thread): already architecturally true — DB runs on the
worker thread (ADR-0010) and input on a separate Tokio task (confirmed in
`runtime.rs`). Record the evidence; optionally a test that a query doesn't
block a subsequent keystroke render.
- **NFR-4** (distinctive design) / **NFR-6** (cross-platform parity): qualitative
/ partly-CI. Written reviewer verification (DA-hat) in the NFR verification doc;
parity partly evidenced by the CI matrix (Linux+macOS execute, Windows builds).
### Requirements bookkeeping
Move NFR-5/7 → `[x]` (gated test), NFR-1/3 → `[x]` (measured + bounded test +
recorded numbers), NFR-2 → `[x]` (architectural evidence), NFR-4/6 → `[/]` with
the reviewer note (honest: not fully automatable). Each with a commit/test ref.
---
## Workstream 3 — CHANGELOG
- New **`CHANGELOG.md`** at repo root, **Keep a Changelog** + SemVer.
- `[Unreleased]` (empty/seeded) + one consolidated **`[0.2.0] - <release date>`**
with Added/Changed/Fixed of the notable user-facing changes (install methods,
packaging, `--version`/`version`, seed improvements, hint feature, readline
keys, cross-mode history) mined from ADRs 0053–0056 + handoffs 73–75. 0.1.0 =
pre-history (a one-line note).
- Optional follow-up (flag, don't auto-do): wire `--release-notes-url` into the
CI `winget`/release path — out of scope unless you want it now.
---
## Docs / ADR updates (project discipline)
- **Amend ADR-0008** (Tier-4 section): record the realized tooling
(`portable-pty` + `vt100` + hand-rolled `wait_for`, `expectrl` dropped),
serial execution, tight-timeout/fail-fast stance, the default-target CI wiring,
and the four implemented flows. Update `docs/adr/README.md` in the same edit.
- **New ADR-0057 — NFR verification strategy** (next free number; assigned at
merge per ADR-0000): the contrast-gate + thresholds (incl. the 3:1
muted/border allowance), the measured-not-tightly-gated stance for
startup/memory, and the verify-by-argument treatment of NFR-2/4/6. Add a
short **NFR verification record** (the measured numbers + reviewer notes) —
either in the ADR or a `docs/` companion. Update the README index.
- Update `docs/requirements.md`: TT4 `[~]`→`[x]`, TT5 `[/]` note (Tier-4 now in
Linux CI; Windows-exec still pending), NFR rows as above.
- A handoff note (next sequence number) per project convention.
---
## Verification (Phase 5)
1. `cargo test` — full suite green; new e2e_pty + contrast tests pass; **2509 +
new, 0 failed, ≤1 ignored**; no regressions vs baseline.
2. `cargo clippy --all-targets -- -D warnings` + `cargo fmt --check` clean.
3. Run `e2e_pty` repeatedly (e.g. 5–10×) locally to confirm non-flaky under the
tight timeouts before relying on the gate.
4. Confirm a PTY opens inside the CI image (or document the fix).
5. DA-hat pass over each workstream + the NFR reviewer judgements, written down.
6. `cargo sweep` at the milestone (CLAUDE.md build hygiene).
## Risks / checkpoints
- **vt100 currency** — verify before adding; fall back to `avt` if stale.
- **PTY-in-container** — validate early; the whole TT4 CI value depends on it.
- **muted/border contrast** — may fail even 3:1; if so it's a finding to escalate,
not silently relax (could mean a small palette tweak — touches a decided area,
so escalate).
- **Commits** — confirm each message; no AI attribution; append-only; never push.
+82 -27
View File
@@ -918,28 +918,44 @@ since ADR-0027.)
for representative views.
- [x] **TT3** Tier 3: synthetic event-loop integration tests
covering the user-facing flows in this checklist.
- [~] **TT4** Tier 4: PTY-based end-to-end for the four critical
- [x] **TT4** Tier 4: PTY-based end-to-end for the four critical
flows named in ADR-0008 (cold launch → DDL → quit; save →
reopen; export → import → rebuild; undo after DROP).
*(Verified 2026-06-07: **nothing is wired** — no
`portable-pty` / `expectrl` / `vt100` dependencies, no PTY test
files; ADR-0008 §Tier-4 is a specification only. The Tier-3
`tests/it/*_e2e.rs` files are synthetic event-loop tests, not
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is
wired only for the listed critical flows" — it was not wired at
all. Genuinely deferred.)*
*(Implemented 2026-06-22 — ADR-0008 **Amendment 1**;
`tests/e2e_pty.rs` (commit `fd63de3`). Drives the actual built
binary in a real pseudo-terminal: `portable-pty` + `vt100`
(**`expectrl` dropped** — conflicting PTY layer, replaced by a
hand-rolled `wait_for` on the vt100 grid). Fixed 100×30, per-test
temp `--data-dir`, serial, tight fail-fast 3 s waits; table
presence read from the Tables sidebar region (Output echoes
commands); commands paced to completion (stale-schema-cache
misparse on faster-than-human input → issue #39). All four flows
green (flow 2 asserts a **column** round-trips, not just the
table name). Was previously a spec-only deferral; the earlier
2026-06-07 "nothing is wired" note is now resolved.)*
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
stable Rust.
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` +
`cargo test` (Tiers 1–3) on the **Linux** runner for every branch
push / PR, and `release-macos` runs the suite natively on the
**macOS** runner. **Windows is build-only** — cross-compiled, not
executed (no Windows runner). **Tier 4** (PTY, TT4) is still
unwired, so "all tiers" is not yet fully met. "Stable Rust" is
satisfied by the flake's pinned `1.95.0` (a stable release, not
nightly). Remaining for full TT5: a Windows execution runner and
Tier-4 PTY in CI.)*
*(Partial, updated 2026-06-22. **CI is live** on the self-hosted
Gitea Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings`
+ `cargo test` (now Tiers **1–4** — the `e2e_pty` target runs by
default) on the **Linux** runner for every branch push / PR.
**macOS is fully covered**: `release-macos` runs the whole suite
natively on the macOS runner, and Tier 4 was confirmed green on
macOS (2026-06-22 — the 4 flows + startup; the Linux-only RSS probe
is `#[cfg]`-skipped). **Tier 4 in CI on Linux is met** (TT4);
PTY-in-container was validated (openpty + spawn under Docker), with
the first real gate run the final confirmation. "Stable Rust" is
satisfied by the flake's pinned `1.95.0`.
**Windows — automated execution is the one open piece.** Windows
binaries are first-class: cross-built and shipped for every release
(D1/D2), and we verify them by **running the suite on Windows by
hand from time to time** so the Windows experience stays sound
between releases. A standing Windows CI runner is more involved to
operate than the Linux and macOS runners already in use; we intend
to keep looking for a clean solution but make no promises on timing.
Until then, Windows users get exactly the same builds, verified
manually rather than on every push. So TT5 stays `[/]` by deliberate
scope, not neglect.)*
## Cross-cutting
@@ -1015,41 +1031,80 @@ target is measurable, it is stated numerically; where it is
necessarily qualitative, the criterion is named and the bar is
"reviewer judgement against the criterion."
- [ ] **NFR-1 Performance — startup.** Cold launch to first
All seven were formally verified 2026-06-22 (ADR-0057). Approach:
contrast/distinctness **gated** by tests, startup/memory **measured**
against the targets, the rest **verified by argument / reviewer note**.
- [x] **NFR-1 Performance — startup.** Cold launch to first
rendered frame under 500ms on commodity hardware (developer
laptop, mid-range desktop). Measured in CI on the Linux runner
as a regression gate.
- [ ] **NFR-2 Performance — input latency.** Keystroke-to-render
laptop, mid-range desktop).
*(Verified 2026-06-22, ADR-0057 — measured via the Tier-4 PTY
harness; release-binary median **~29 ms** to first frame (5
trials, this dev machine), ~17× under target. The harness test
`startup_to_first_frame_is_reasonable` gates a generous
debug-binary bound for gross-regression detection; a tight 500 ms
CI gate was declined as flaky on a shared runner — user decision.)*
- [x] **NFR-2 Performance — input latency.** Keystroke-to-render
latency under 16ms during normal editing; long-running queries
must execute off the UI thread so the interface remains
responsive (typing, scrolling, mode switching) while a query is
running.
- [ ] **NFR-3 Performance — resource footprint.** Idle memory
*(Verified 2026-06-22, ADR-0057 — by architecture: database work
runs on the dedicated worker thread (ADR-0010) and terminal input
on a separate Tokio task (`spawn_event_reader`, `runtime.rs`), so
a running query cannot block input/render. No automated sub-16 ms
latency test — it would be flaky and low-value.)*
- [x] **NFR-3 Performance — resource footprint.** Idle memory
under 50MB on the smallest target platform; no busy-loops; CPU
near zero when waiting for input.
- [ ] **NFR-4 Visual quality — distinctive design.** Colour
*(Verified 2026-06-22, ADR-0057 — release-binary idle RSS
**~10 MB** (5 trials, Linux), ~5× under target. The event loop
blocks on `recv` (no busy-loop). The harness test
`idle_memory_footprint_is_reasonable` gates a generous
debug-binary RSS bound on Linux.)*
- [/] **NFR-4 Visual quality — distinctive design.** Colour
palette and typography are deliberate and consistent across
views; layout uses Unicode box-drawing and symbols where they
add clarity; rendering avoids the generic flat-default look
that ships with most TUI frameworks. Criterion: a reviewer can
identify the app from a screenshot of any view.
- [ ] **NFR-5 Visual quality — colour use.** Colour conveys
*(Reviewer note 2026-06-22, ADR-0057 — satisfied by the bespoke
two-theme palette, the three-region framed layout, and the
annotated plan/relationship rendering; qualitative, not
automatable.)*
- [x] **NFR-5 Visual quality — colour use.** Colour conveys
information rather than decoration: mode indication, query
result types (numeric vs text vs null), error severity,
syntax highlighting categories. Foreground/background
combinations meet WCAG-AA contrast (4.5:1 for normal text)
even though we have not committed to broader accessibility.
- [ ] **NFR-6 Cross-platform parity.** Behaviour and visual
*(Verified 2026-06-22, ADR-0057 — **gated** in `src/theme.rs`:
every text foreground clears 4.5:1 on both themes; the
advanced-mode border clears 3:1 (plain border decorative-exempt);
token pairs clear ΔE2000 ≥ 15. Writing the gate caught + fixed two
shipped light-theme defects (`tok_string` 4.42:1, `tok_flag`
3.15:1) and two near-duplicate dark pairs, `65eab71`.)*
- [/] **NFR-6 Cross-platform parity.** Behaviour and visual
quality are equivalent across Linux, macOS, and Windows on
crossterm-supported terminals. Platform-specific divergence
(e.g. font fallbacks) is documented, not silently tolerated.
- [ ] **NFR-7 Light and dark background support.** The colour
*(Reviewer note 2026-06-22, ADR-0057 — partly evidenced by the CI
matrix (Linux + macOS execute the suite incl. Tier 4; Windows is
build-only). Documented divergence: on terminals/multiplexers
without true-colour passthrough the 24-bit palette is quantised to
256 colours and near hues can collide (a terminal-capability
issue). Full parity awaits a Windows execution runner — see TT5.)*
- [x] **NFR-7 Light and dark background support.** The colour
scheme remains legible and visually coherent on both light and
dark terminal backgrounds. The mechanism (auto-detect via
terminal query, explicit user setting, or both) is an
implementation choice, but the outcome is non-negotiable: no
dark-on-dark or light-on-light readability failures on either
background.
*(Verified 2026-06-22, ADR-0057 — the WCAG-AA contrast gate
(NFR-5) runs over **both** themes, so the legibility outcome is
enforced on light and dark. Selection mechanism is `--theme` +
`COLORFGBG` auto-detect (OSC-11 querying deferred).)*
---
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Palette preview + WCAG contrast + CIEDE2000 perceptual audit.
A dev tool for working on the colour palette (`src/theme.rs`). It reads
the live `dark()` / `light()` constructors so it never drifts from the
code, renders a true-colour swatch + sample for every palette entry on
the theme background, prints the WCAG-AA contrast ratio, and reports the
pairwise CIEDE2000 (ΔE2000) distance between the syntax-token colours so
near-duplicates (distinct hex, indistinguishable on screen) are obvious.
Usage:
scripts/palette-preview.py
scripts/palette-preview.py dark:tok_type=F58AAE light:tok_flag=7A5C00
Each `theme:key=HEX` argument previews a change without editing the
source — handy for trying candidate colours. The gates mirrored here are
enforced for real by the tests in `src/theme.rs` (NFR-5/NFR-7, ADR-0057):
text foregrounds must clear 4.5:1; token pairs must clear ΔE2000 15.
"""
import math
import os
import re
import sys
THEME_RS = os.path.join(os.path.dirname(__file__), "..", "src", "theme.rs")
# --- WCAG contrast -----------------------------------------------------
def _lin(c):
c = c / 255.0
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
def _luminance(rgb):
r, g, b = rgb
return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)
def contrast(fg, bg):
a, b = _luminance(fg), _luminance(bg)
hi, lo = max(a, b), min(a, b)
return (hi + 0.05) / (lo + 0.05)
# --- sRGB -> CIELAB (D65) + CIEDE2000 ----------------------------------
def _f(t):
return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116
def rgb_to_lab(rgb):
r, g, b = (_lin(c) for c in rgb)
x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047
y = r * 0.2126 + g * 0.7152 + b * 0.0722
z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883
fx, fy, fz = _f(x), _f(y), _f(z)
return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
def de2000(lab1, lab2):
L1, a1, b1 = lab1
L2, a2, b2 = lab2
avg_Lp = (L1 + L2) / 2
C1, C2 = math.hypot(a1, b1), math.hypot(a2, b2)
avg_C = (C1 + C2) / 2
G = 0.5 * (1 - math.sqrt(avg_C ** 7 / (avg_C ** 7 + 25 ** 7))) if avg_C > 0 else 0
a1p, a2p = (1 + G) * a1, (1 + G) * a2
C1p, C2p = math.hypot(a1p, b1), math.hypot(a2p, b2)
avg_Cp = (C1p + C2p) / 2
def hp(ap, b):
if ap == 0 and b == 0:
return 0
ang = math.degrees(math.atan2(b, ap))
return ang + 360 if ang < 0 else ang
h1p, h2p = hp(a1p, b1), hp(a2p, b2)
dLp, dCp = L2 - L1, C2p - C1p
if C1p * C2p == 0:
dhp = 0
elif abs(h2p - h1p) <= 180:
dhp = h2p - h1p
elif h2p - h1p > 180:
dhp = h2p - h1p - 360
else:
dhp = h2p - h1p + 360
dHp = 2 * math.sqrt(C1p * C2p) * math.sin(math.radians(dhp) / 2)
if C1p * C2p == 0:
avg_hp = h1p + h2p
elif abs(h1p - h2p) <= 180:
avg_hp = (h1p + h2p) / 2
elif h1p + h2p < 360:
avg_hp = (h1p + h2p + 360) / 2
else:
avg_hp = (h1p + h2p - 360) / 2
T = (1 - 0.17 * math.cos(math.radians(avg_hp - 30))
+ 0.24 * math.cos(math.radians(2 * avg_hp))
+ 0.32 * math.cos(math.radians(3 * avg_hp + 6))
- 0.20 * math.cos(math.radians(4 * avg_hp - 63)))
d_ro = 30 * math.exp(-((avg_hp - 275) / 25) ** 2)
Rc = 2 * math.sqrt(avg_Cp ** 7 / (avg_Cp ** 7 + 25 ** 7)) if avg_Cp > 0 else 0
Sl = 1 + (0.015 * (avg_Lp - 50) ** 2) / math.sqrt(20 + (avg_Lp - 50) ** 2)
Sc, Sh = 1 + 0.045 * avg_Cp, 1 + 0.015 * avg_Cp * T
Rt = -math.sin(math.radians(2 * d_ro)) * Rc
return math.sqrt((dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2
+ Rt * (dCp / Sc) * (dHp / Sh))
# --- parse the live palette from src/theme.rs --------------------------
def parse_palette(path):
text = open(path, encoding="utf-8").read()
field = re.compile(
r"(\w+):\s*Color::Rgb\(0x([0-9A-Fa-f]{2}),\s*0x([0-9A-Fa-f]{2}),\s*0x([0-9A-Fa-f]{2})\)"
)
def block(start, end):
s = text.index(start)
e = text.index(end, s)
return {m.group(1): (m.group(2) + m.group(3) + m.group(4)).upper()
for m in field.finditer(text[s:e])}
return {"dark": block("fn dark()", "fn light()"),
"light": block("fn light()", "fn highlight_class_color")}
def h(s):
return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))
def fg(rgb):
r, g, b = rgb
return f"\x1b[38;2;{r};{g};{b}m"
def bg(rgb):
r, g, b = rgb
return f"\x1b[48;2;{r};{g};{b}m"
R = "\x1b[0m"
TOKENS = ["tok_keyword", "tok_identifier", "tok_type", "tok_number",
"tok_string", "tok_flag", "tok_function"]
NONTEXT = {"border", "border_advanced"}
SAMPLE = {
"tok_keyword": "create table", "tok_identifier": "Customers",
"tok_type": "serial", "tok_number": "42", "tok_string": "'hello'",
"tok_flag": "--all-rows", "tok_function": "count(", "tok_punct": ", ;",
"tok_error": "bad", "fg": "body text", "muted": "(none yet)",
"system": "done.", "error": "error: nope", "warning": "[WRN] slow",
"plan_efficient": "SEARCH idx", "mode_simple": "SIMPLE",
"mode_advanced": "ADVANCED", "border": "──────", "border_advanced": "──────",
}
def main():
themes = parse_palette(THEME_RS)
for arg in sys.argv[1:]:
th, rest = arg.split(":")
k, v = rest.split("=")
themes[th][k] = v.upper()
for tn, t in themes.items():
B = h(t["bg"])
print(f"\n{'=' * 64}\n {tn.upper()} THEME (bg #{t['bg']})\n{'=' * 64}")
for k, hexv in t.items():
if k == "bg":
continue
c = h(hexv)
cr = contrast(c, B)
swatch = f"{bg(c)} {R}"
text = f"{bg(B)}{fg(c)} {SAMPLE.get(k, k):14}{R}"
floor = 3.0 if k in NONTEXT else 4.5
warn = "" if cr >= floor else f" !! < {floor}"
print(f" {swatch} {text} #{hexv} {cr:5.2f}:1{warn} {k}")
print("\n -- token ΔE2000 (<12 hard to distinguish; gate is >=15) --")
labs = {k: rgb_to_lab(h(t[k])) for k in TOKENS if k in t}
pairs = sorted(
(de2000(labs[a], labs[b]), a, b)
for i, a in enumerate(labs) for b in list(labs)[i + 1:]
)
for d, a, b in pairs[:6]:
flag = " !! TOO CLOSE" if d < 15 else (" ~ close" if d < 18 else "")
print(f" {d:5.1f} {a:14} vs {b:14}{flag}")
if __name__ == "__main__":
main()
+198 -18
View File
@@ -377,6 +377,31 @@ pub struct App {
/// by default; refreshed by the runtime on project load
/// and after successful DDL.
pub schema_cache: crate::completion::SchemaCache,
/// Issue #39: `true` while a dispatched DSL command's async
/// schema-cache refresh is still in flight. The runtime applies a
/// command on the worker thread and only *afterwards* sends back a
/// `SchemaCacheRefreshed` event; until that lands, `schema_cache` is
/// stale w.r.t. the command just dispatched. Validating a follow-up
/// submission against that stale cache is the issue #39 bug (a Form-B
/// insert after `add column` sees the pre-DDL columns and is wrongly
/// rejected as "trying to write SQL?"). While this flag is set, new
/// `dispatch_dsl` submissions are *held* in `held_submissions` rather
/// than validated, then drained when the refresh arrives. Armed on
/// every `ExecuteDsl` dispatch, so at most one *DSL* command is ever in
/// flight — refreshes are then strictly one-per-dispatch and in order,
/// keeping the gate a simple, provably-correct boolean. (App-lifecycle
/// commands — `load` / `undo` / `rebuild` — also refresh the cache but
/// bypass this gate; they are modal/picker-gated and so cannot overlap a
/// rapid DSL paste, the only thing this guards.) Cleared in the
/// `SchemaCacheRefreshed` handler.
awaiting_schema_refresh: bool,
/// Issue #39: submissions deferred while `awaiting_schema_refresh` is
/// set, in submission order. Each is the canonical input line plus the
/// effective mode it was submitted under; drained through
/// `dispatch_dsl` (re-validated against the now-fresh cache) when the
/// pending refresh lands. `push_history` already ran for these at
/// `submit` time, so draining re-enters at `dispatch_dsl`, not `submit`.
held_submissions: std::collections::VecDeque<(String, EffectiveMode)>,
/// Whether the undo/snapshot machinery is active this session
/// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI
/// flag; the `undo` / `redo` commands then report undo is off
@@ -596,6 +621,10 @@ impl App {
modal: None,
last_completion: None,
schema_cache: crate::completion::SchemaCache::default(),
// Issue #39: no command is in flight at construction; the
// schema-refresh gate starts open with an empty hold queue.
awaiting_schema_refresh: false,
held_submissions: std::collections::VecDeque::new(),
// Undo is on by default; the runtime flips this off for
// a `--no-undo` session (ADR-0006 Amendment 1).
undo_enabled: true,
@@ -912,7 +941,24 @@ impl App {
"schema cache refreshed",
);
self.schema_cache = cache;
Vec::new()
// Issue #39: the in-flight command's refresh has landed, so
// the gate opens. Drain any submissions held while it was in
// flight, re-validating each against the now-fresh cache.
// Stop as soon as a drained command dispatches (re-arming the
// gate via its own `ExecuteDsl`): the remaining held commands
// then wait for *its* refresh, preserving submission order. A
// held command that does not dispatch (parse error / pre-flight
// rejection) leaves the gate open, so the loop continues to the
// next held submission.
self.awaiting_schema_refresh = false;
let mut actions = Vec::new();
while !self.awaiting_schema_refresh {
let Some((input, submission_mode)) = self.held_submissions.pop_front() else {
break;
};
actions.extend(self.dispatch_dsl(&input, submission_mode));
}
actions
}
AppEvent::RelationshipsRefreshed(relationships) => {
trace!(count = relationships.len(), "relationships refreshed");
@@ -1981,6 +2027,20 @@ impl App {
}
fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec<Action> {
// Issue #39: if a previously-dispatched command's schema-cache
// refresh is still in flight, `schema_cache` is stale w.r.t. that
// command. Validating this submission now would race it (the bug:
// a Form-B insert after `add column` rejected against the pre-DDL
// schema). Hold it in submission order; the `SchemaCacheRefreshed`
// handler drains the queue once the fresh schema lands. App-level
// commands (`quit`, `help`, `load`, …) route through
// `dispatch_app_command` *before* here, so they are never held.
if self.awaiting_schema_refresh {
debug!(input, "holding submission until schema cache refresh lands");
self.held_submissions
.push_back((input.to_string(), submission_mode));
return Vec::new();
}
// The two-way mode the walker + the `[mode]` render tag read; the
// three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for
// the runtime's echo gate (ADR-0038).
@@ -2069,6 +2129,14 @@ impl App {
}];
}
self.push_output(OutputLine::echo(input, mode));
// Issue #39: a command is now in flight; its schema-cache
// refresh will land asynchronously. Arm the gate so any
// follow-up submission waits for the fresh schema rather
// than racing the stale cache. Armed on every dispatch (not
// just DDL) so only one command is ever in flight, which
// keeps the gate a simple boolean — refreshes are then
// strictly one-per-dispatch and in order.
self.awaiting_schema_refresh = true;
vec![Action::ExecuteDsl {
command: cmd,
source: input.to_string(),
@@ -2979,34 +3047,52 @@ impl App {
/// output panel.
///
/// Assembled from the command REGISTRY (ADR-0024 §help_id):
/// the framing (`help.intro`, `help.dsl_section`,
/// `help.types_reference`) comes from the catalog, and each
/// command's body is the catalog entry named by its
/// `help_id`. A newly-registered command appears here
/// automatically — no edit to this function or a hand-kept
/// the framing (`help.intro`, `help.simple_section`,
/// `help.advanced_section`, `help.types_reference`) comes from
/// the catalog, and each command's body is the catalog entry
/// named by its `help_id`. A newly-registered command appears
/// here automatically — no edit to this function or a hand-kept
/// list. Each catalog line becomes its own `OutputLine` so
/// the scroll-position math (one logical line = one display
/// row) stays accurate per the renderer's invariant.
///
/// Issue #36: the commands group by mode. App-lifecycle commands
/// (`help_id` in the `app.*` namespace) work in either mode and
/// list first, unlabelled, under the intro. The rest split by
/// [`CommandCategory`] into a simple-mode group and an
/// advanced-mode (SQL) group — replacing the old single "DSL data
/// commands (in simple mode)" header, which both used the banned
/// "DSL" term and wrongly claimed simple mode for the advanced SQL
/// forms the section already contained.
fn note_help(&mut self) {
use crate::dsl::grammar::REGISTRY;
use crate::dsl::grammar::{CommandCategory, REGISTRY};
let mut lines: Vec<String> = Vec::new();
lines.push(crate::t!("help.intro"));
// REGISTRY is ordered app-commands first; emit the
// "DSL data commands" sub-header at the first command
// whose help_id leaves the `app.` namespace.
let mut dsl_header_done = false;
for (command, _category) in REGISTRY {
let mut simple: Vec<String> = Vec::new();
let mut advanced: Vec<String> = Vec::new();
for (command, category) in REGISTRY {
let Some(help_id) = command.help_id else {
continue;
};
if !dsl_header_done && !help_id.starts_with("app.") {
lines.push(crate::t!("help.dsl_section"));
dsl_header_done = true;
let body = crate::friendly::translate(&format!("help.{help_id}"), &[]);
let block: Vec<String> = body.lines().map(str::to_string).collect();
if help_id.starts_with("app.") {
lines.extend(block);
} else if matches!(category, CommandCategory::Advanced) {
advanced.extend(block);
} else {
simple.extend(block);
}
let key = format!("help.{help_id}");
let body = crate::friendly::translate(&key, &[]);
lines.extend(body.lines().map(str::to_string));
}
if !simple.is_empty() {
lines.push(crate::t!("help.simple_section"));
lines.extend(simple);
}
if !advanced.is_empty() {
lines.push(crate::t!("help.advanced_section"));
lines.extend(advanced);
}
lines.extend(
crate::t!("help.types_reference")
@@ -4614,6 +4700,100 @@ mod tests {
);
}
#[test]
fn form_b_insert_after_ddl_is_held_until_refresh_then_dispatched() {
// Issue #39: a simple-mode Form-B insert (`insert into T values
// (…)`, no column list) submitted faster than the post-DDL
// schema-cache refresh must NOT be validated against the *stale*
// cache — doing so wrongly rejects it as "trying to write SQL?".
//
// This reproduces, deterministically, the event ordering the async
// runtime produces under fast input: a schema-mutating command is
// dispatched (arming the gate while its cache refresh is in flight),
// a follow-up insert is submitted before the refresh lands, and only
// then does the `SchemaCacheRefreshed` event arrive. Pre-fix the
// insert is validated against the pre-DDL schema and rejected; with
// the gate it is held and dispatched against the fresh schema.
use crate::completion::{SchemaCache, TableColumn};
use crate::dsl::types::Type;
// Pre-DDL schema: `Customers` has only the auto `id` (serial). This
// is the stale cache a racing Form-B insert would otherwise see —
// zero user-fillable columns, so `values ('Alice')` can't match.
let mut app = App::new();
let mut stale = SchemaCache::default();
stale.tables.push("Customers".to_string());
stale.columns.push("id".to_string());
stale.table_columns.insert(
"Customers".to_string(),
vec![TableColumn {
name: "id".to_string(),
user_type: Type::Serial,
not_null: true,
has_default: false,
}],
);
app.schema_cache = stale;
// 1. Submit the DDL. It dispatches and arms the gate: a schema
// refresh is now (conceptually) in flight.
type_str(&mut app, "add column to Customers: Name (text)");
let ddl_actions = submit(&mut app);
assert!(
matches!(ddl_actions.as_slice(), [Action::ExecuteDsl { .. }]),
"the DDL should dispatch; got {ddl_actions:?}",
);
// 2. Submit the Form-B insert *before* the refresh lands. It must be
// held — not dispatched, and crucially not rejected against the
// stale cache (no error note such as "trying to write SQL?").
type_str(&mut app, "insert into Customers values ('Alice')");
let held_actions = submit(&mut app);
assert!(
held_actions.is_empty(),
"the insert must be held while the refresh is in flight, \
not dispatched or rejected; got {held_actions:?}",
);
assert!(
!app.output.iter().any(|l| l.kind == OutputKind::Error),
"a held insert must produce no error note (e.g. the \
'trying to write SQL?' pointer); errors:\n{}",
error_lines(&app),
);
// 3. The DDL's schema refresh lands carrying the fresh schema
// (`Customers` now has `id` + `Name`). The held insert drains and
// dispatches, validated against the up-to-date cache.
let mut fresh = SchemaCache::default();
fresh.tables.push("Customers".to_string());
fresh.columns.push("id".to_string());
fresh.columns.push("Name".to_string());
fresh.table_columns.insert(
"Customers".to_string(),
vec![
TableColumn {
name: "id".to_string(),
user_type: Type::Serial,
not_null: true,
has_default: false,
},
TableColumn::new("Name", Type::Text),
],
);
let drained = app.update(AppEvent::SchemaCacheRefreshed(fresh));
assert!(
matches!(
drained.as_slice(),
[Action::ExecuteDsl {
command: Command::Insert { .. },
..
}]
),
"the held insert must dispatch once the fresh schema lands; \
got {drained:?}",
);
}
#[test]
fn simple_mode_submit_of_pure_dsl_error_has_no_advanced_pointer() {
// A DSL error that is *not* valid SQL either (unknown command)
+13 -13
View File
@@ -1886,12 +1886,12 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
entry: Word::keyword("explain"),
shape: EXPLAIN_SQL_SHAPE,
ast_builder: build_explain_sql,
// No `help_id` / `usage_ids` — this is the `Advanced` half of the
// shared `explain` entry word, so it defers to the `Simple`
// `EXPLAIN` node's help/usage (which now covers the SQL forms
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
// precedent; otherwise `note_help` would print `explain` twice.
help_id: None,
// Issue #36: its own `help` page (`data.explain_sql`), listed under the
// advanced-mode (SQL) section and shown by `help explain` next to the
// simple `EXPLAIN` form (distinct help_id ⇒ no dedup clash). `usage_ids`
// stays empty — the `Simple` `EXPLAIN` node's usage block already covers
// the shared `explain` entry word.
help_id: Some("data.explain_sql"),
hint_ids: &["explain_sql"],
usage_ids: &[],
};
@@ -1902,13 +1902,13 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
/// The shape is the post-`SELECT` portion of a top-level
/// statement; the registry's entry-word dispatch consumes the
/// leading `SELECT` keyword before the shape walks (sub-phase
/// 2c migration). `help_id` is `None` until the `help sql`
/// page lands (ADR-0030 Phase 6).
/// 2c migration). Carries its own `help` page (`data.select`),
/// listed under the advanced-mode (SQL) section (issue #36).
pub static SELECT: CommandNode = CommandNode {
entry: Word::keyword("select"),
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
ast_builder: build_select,
help_id: None,
help_id: Some("data.select"),
hint_ids: &["select"],
usage_ids: &["parse.usage.select"],
};
@@ -1924,7 +1924,7 @@ pub static WITH: CommandNode = CommandNode {
entry: Word::keyword("with"),
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
ast_builder: build_select,
help_id: None,
help_id: Some("data.with"), // issue #36: own help page, advanced section
hint_ids: &["with"],
usage_ids: &["parse.usage.with"],
};
@@ -1943,7 +1943,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
entry: Word::keyword("insert"),
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
ast_builder: build_sql_insert,
help_id: None,
help_id: Some("data.sql_insert"), // issue #36: own help page, advanced section
hint_ids: &["sql_insert"],
usage_ids: &[],
};
@@ -1957,7 +1957,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
entry: Word::keyword("update"),
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
ast_builder: build_sql_update,
help_id: None,
help_id: Some("data.sql_update"), // issue #36: own help page, advanced section
hint_ids: &["sql_update"],
usage_ids: &[],
};
@@ -1973,7 +1973,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
entry: Word::keyword("delete"),
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
ast_builder: build_sql_delete,
help_id: None,
help_id: Some("data.sql_delete"), // issue #36: own help page, advanced section
hint_ids: &["sql_delete"],
usage_ids: &[],
};
+11 -6
View File
@@ -537,8 +537,11 @@ pub struct CommandNode {
/// block). `hint_key_for_input_in_mode` disambiguates by the form
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
/// until a form's tier-3 block is authored (the surface falls back
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
/// `None` on advanced-SQL forms purely to dedup the `help` list).
/// to tier-2 ambient/error text). Parallel to `help_id` but
/// finer-grained: every form (simple and advanced) carries a
/// `hint_id`, whereas the `help <topic>` view groups forms by entry
/// word (so a shared-entry simple + SQL pair both surface under e.g.
/// `help insert`).
pub hint_ids: &'static [&'static str],
/// Catalog keys under `parse.usage.*` to render in the
/// "usage:" block when a parse error fires for this command
@@ -1156,10 +1159,12 @@ mod usage_key_tests {
#[test]
fn no_two_registered_commands_share_a_help_id() {
// `note_help` emits one help block per `help_id: Some(_)`
// with no dedup, so a duplicate help_id prints the same
// command twice in `help`. Shared-entry-word `Advanced`
// nodes (SQL_INSERT, …, EXPLAIN_SQL) therefore carry
// `help_id: None` and defer to their `Simple` sibling.
// with no dedup, so a duplicate help_id string prints the same
// block twice. Distinct help_ids are fine — a shared-entry-word
// simple + SQL pair (e.g. `data.insert` + `data.sql_insert`,
// issue #36) each get their own block, grouped under one topic
// by `help <topic>` and split across the simple/advanced
// sections of the full list.
let mut seen = std::collections::HashSet::new();
for (command, _category) in super::REGISTRY {
if let Some(id) = command.help_id {
+9 -1
View File
@@ -180,7 +180,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
// In-app `help` — framing + per-command entries keyed by
// each CommandNode's `help_id` (ADR-0024 §help_id).
("help.intro", &[]),
("help.dsl_section", &[]),
("help.simple_section", &[]),
("help.advanced_section", &[]),
("help.types_reference", &[]),
("help.detail_hint", &[]),
("help.unknown_topic", &["topic"]),
@@ -223,6 +224,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.data.delete", &[]),
("help.data.replay", &[]),
("help.data.explain", &[]),
// Issue #36: advanced-mode (SQL) help pages.
("help.data.select", &[]),
("help.data.with", &[]),
("help.data.sql_insert", &[]),
("help.data.sql_update", &[]),
("help.data.sql_delete", &[]),
("help.data.explain_sql", &[]),
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
("hint.ambient_complete", &[]),
("hint.ambient_error_with_usage", &["message", "usage"]),
+26 -3
View File
@@ -246,7 +246,13 @@ help:
# are multi-line-capable — the renderer emits one output row
# per line so scroll math stays accurate.
intro: "Supported commands:"
dsl_section: "DSL data commands (in simple mode):"
# Issue #36: the command list groups by mode. App-lifecycle commands list
# first (unlabelled, under the intro — they work in either mode); the rest
# split into these two sections by command category. (Replaces the old
# single "DSL data commands (in simple mode):" header, which used the banned
# "DSL" term and mis-labelled the advanced SQL forms it already contained.)
simple_section: "Simple-mode commands:"
advanced_section: "Advanced-mode (SQL) commands:"
# H3: footer on the full `help` list, and the not-found note
# for `help <topic>`. `{topic}` is the word the user typed.
detail_hint: "Type `help <command>` for detail on one command (e.g. `help insert`), or `help types` for the type reference."
@@ -368,8 +374,25 @@ help:
explain show data <T> | explain update <T> ... | explain delete from <T> ...
— show how the database would run a query, without
running it (safe even for update / delete)
explain <select|with|insert|update|delete …> (advanced mode)
— the same plan for the SQL you wrote
# Issue #36: advanced-mode (SQL) forms. Each has its own help page, listed
# under the "Advanced-mode (SQL) commands:" section and shown by
# `help <topic>` alongside its simple-mode sibling — so `help insert` shows
# both the simple form and `sql_insert` (like `help create` already does).
select: |-
select <cols> | * from <T> [where …] [group by …] [order by …] [limit n]
— query rows (advanced SQL)
with: |-
with <name> as (<select>) [, …] <select> — query through a named
sub-query / CTE (advanced SQL)
sql_insert: |-
insert into <T> (col, …) values (val, …) — add a row (advanced SQL)
sql_update: |-
update <T> set <col> = <val>, … where <expr> — change matching rows (advanced SQL)
sql_delete: |-
delete from <T> where <expr> — remove matching rows (advanced SQL)
explain_sql: |-
explain <select|with|insert|update|delete …> — show the plan for a SQL statement,
without running it (advanced SQL)
# Type reference, appended after the command list.
types_reference: |
Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid
+276 -5
View File
@@ -106,13 +106,13 @@ impl Theme {
// distinct from the mode-banner blue.
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
tok_type: Color::Rgb(0xF5, 0x8A, 0xAE), // rose-pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier (ADR-0057: widened ΔE from keyword 14.5→18.4)
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
tok_function: Color::Rgb(0x6C, 0xB2, 0xFF), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + rose type (ADR-0057: widened ΔE from identifier 14.3→19.5)
}
}
@@ -139,9 +139,9 @@ impl Theme {
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
tok_string: Color::Rgb(0x1B, 0x7A, 0x33), // forest green (ADR-0057: darkened for WCAG-AA, 4.42→5.18:1)
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0x7A, 0x5C, 0x00), // dark mustard/olive (ADR-0057: darkened for WCAG-AA, 3.15→5.98:1)
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
}
@@ -264,4 +264,275 @@ mod tests {
assert_ne!(t.tok_function, t.tok_type);
}
}
// ---- NFR-5 / NFR-7 (ADR-0057): WCAG-AA contrast gate ----
//
// Every text-bearing foreground must clear WCAG-AA 4.5:1 against
// the panel background, in BOTH themes, so the colour scheme stays
// legible on light and dark terminals (no dark-on-dark / light-on-
// light failures). Borders are non-text structural chrome and are
// handled by `advanced_mode_border_meets_ui_contrast` below; the
// plain `border` is intentionally exempt (decorative — see ADR-0057).
//
// These checks rely on every `Theme` colour being `Color::Rgb`
// (true-colour). `rgb_channels` panics on any other variant, which
// also guards against a future regression to a named palette colour
// (whose real contrast can't be known).
fn rgb_channels(c: Color) -> (f64, f64, f64) {
match c {
Color::Rgb(r, g, b) => (f64::from(r), f64::from(g), f64::from(b)),
other => panic!("theme colours must be Color::Rgb for contrast checks; got {other:?}"),
}
}
/// WCAG 2.x relative luminance of an sRGB colour.
// The luminance/Lab/CIEDE2000 maths below are published standards; we
// keep them in canonical `a*x + b*y` form (verified against reference
// vectors) rather than refactoring into `mul_add`, which would obscure
// the formula for a negligible test-only gain.
#[allow(clippy::suboptimal_flops)]
fn relative_luminance(c: Color) -> f64 {
let chan = |v: f64| {
let v = v / 255.0;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
let (r, g, b) = rgb_channels(c);
0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
}
/// WCAG 2.x contrast ratio between two colours (1.0 ..= 21.0).
fn contrast_ratio(a: Color, b: Color) -> f64 {
let (la, lb) = (relative_luminance(a), relative_luminance(b));
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
fn theme_label(t: &Theme) -> &'static str {
match t.background {
Background::Dark => "dark",
Background::Light => "light",
}
}
/// Every text foreground (incl. dimmed `muted`/`tok_punct` and the
/// syntax-token classes) on `bg`, both themes.
fn text_foregrounds(t: &Theme) -> [(&'static str, Color); 17] {
[
("fg", t.fg),
("muted", t.muted),
("mode_simple", t.mode_simple),
("mode_advanced", t.mode_advanced),
("system", t.system),
("error", t.error),
("warning", t.warning),
("plan_efficient", t.plan_efficient),
("tok_keyword", t.tok_keyword),
("tok_identifier", t.tok_identifier),
("tok_type", t.tok_type),
("tok_number", t.tok_number),
("tok_string", t.tok_string),
("tok_punct", t.tok_punct),
("tok_flag", t.tok_flag),
("tok_error", t.tok_error),
("tok_function", t.tok_function),
]
}
#[test]
fn all_text_colours_meet_wcag_aa_contrast() {
for t in [Theme::dark(), Theme::light()] {
let label = theme_label(&t);
for (name, c) in text_foregrounds(&t) {
let ratio = contrast_ratio(c, t.bg);
assert!(
ratio >= 4.5,
"{label} theme: {name} contrast {ratio:.2}:1 on bg is below WCAG-AA 4.5:1",
);
}
}
}
#[test]
fn advanced_mode_border_meets_ui_contrast() {
// The advanced-mode border carries a mode-warning signal, so it
// meets WCAG's 3:1 non-text / UI-component threshold in both
// themes. The plain `border` is decorative structural chrome and
// is intentionally exempt (recorded in ADR-0057).
for t in [Theme::dark(), Theme::light()] {
let label = theme_label(&t);
let ratio = contrast_ratio(t.border_advanced, t.bg);
assert!(
ratio >= 3.0,
"{label} theme: border_advanced contrast {ratio:.2}:1 below the 3:1 UI bar",
);
}
}
// ---- NFR-5 perceptual distinctness (ADR-0057) ----
//
// Contrast-against-background is necessary but not sufficient: two
// token colours can each clear 4.5:1 yet be hard to tell APART from
// each other (a real pain reported on high-quality screens). We lock
// that in with a CIEDE2000 (ΔE2000) floor between every pair of
// syntax-token classes that can share a line. The metric itself is
// validated against the Sharma et al. reference vectors.
/// sRGB `Color` → CIELAB (D65), for perceptual-difference checks.
#[allow(clippy::suboptimal_flops)]
fn rgb_to_lab(c: Color) -> (f64, f64, f64) {
let lin = |v: f64| {
let v = v / 255.0;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
let (r0, g0, b0) = rgb_channels(c);
let (r, g, b) = (lin(r0), lin(g0), lin(b0));
let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.950_47;
let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.088_83;
let f = |t: f64| {
if t > 0.008_856 {
t.cbrt()
} else {
7.787 * t + 16.0 / 116.0
}
};
let (fx, fy, fz) = (f(x), f(y), f(z));
(116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
}
/// CIEDE2000 colour difference between two CIELAB values.
#[allow(clippy::suboptimal_flops)]
fn delta_e_2000(lab1: (f64, f64, f64), lab2: (f64, f64, f64)) -> f64 {
let (l1, a1, b1) = lab1;
let (l2, a2, b2) = lab2;
let pow7 = |v: f64| v.powi(7);
let k_l = pow7(25.0);
let avg_lp = (l1 + l2) / 2.0;
let c1 = a1.hypot(b1);
let c2 = a2.hypot(b2);
let avg_c = (c1 + c2) / 2.0;
let g = if avg_c > 0.0 {
0.5 * (1.0 - (pow7(avg_c) / (pow7(avg_c) + k_l)).sqrt())
} else {
0.0
};
let a1p = (1.0 + g) * a1;
let a2p = (1.0 + g) * a2;
let c1p = a1p.hypot(b1);
let c2p = a2p.hypot(b2);
let avg_cp = (c1p + c2p) / 2.0;
let hp = |ap: f64, b: f64| -> f64 {
if ap == 0.0 && b == 0.0 {
return 0.0;
}
let ang = b.atan2(ap).to_degrees();
if ang < 0.0 { ang + 360.0 } else { ang }
};
let h1p = hp(a1p, b1);
let h2p = hp(a2p, b2);
let dlp = l2 - l1;
let dcp = c2p - c1p;
let dhp = if c1p * c2p == 0.0 {
0.0
} else if (h2p - h1p).abs() <= 180.0 {
h2p - h1p
} else if h2p - h1p > 180.0 {
h2p - h1p - 360.0
} else {
h2p - h1p + 360.0
};
let d_big_hp = 2.0 * (c1p * c2p).sqrt() * (dhp.to_radians() / 2.0).sin();
let avg_hp = if c1p * c2p == 0.0 {
h1p + h2p
} else if (h1p - h2p).abs() <= 180.0 {
(h1p + h2p) / 2.0
} else if h1p + h2p < 360.0 {
(h1p + h2p + 360.0) / 2.0
} else {
(h1p + h2p - 360.0) / 2.0
};
let t = 1.0 - 0.17 * (avg_hp - 30.0).to_radians().cos()
+ 0.24 * (2.0 * avg_hp).to_radians().cos()
+ 0.32 * (3.0 * avg_hp + 6.0).to_radians().cos()
- 0.20 * (4.0 * avg_hp - 63.0).to_radians().cos();
let d_ro = 30.0 * (-((avg_hp - 275.0) / 25.0).powi(2)).exp();
let rc = if avg_cp > 0.0 {
2.0 * (pow7(avg_cp) / (pow7(avg_cp) + k_l)).sqrt()
} else {
0.0
};
let sl = 1.0 + (0.015 * (avg_lp - 50.0).powi(2)) / (20.0 + (avg_lp - 50.0).powi(2)).sqrt();
let sc = 1.0 + 0.045 * avg_cp;
let sh = 1.0 + 0.015 * avg_cp * t;
let rt = -(2.0 * d_ro.to_radians()).sin() * rc;
((dlp / sl).powi(2)
+ (dcp / sc).powi(2)
+ (d_big_hp / sh).powi(2)
+ rt * (dcp / sc) * (d_big_hp / sh))
.sqrt()
}
#[test]
fn delta_e_2000_matches_reference_vectors() {
// Sharma, Wu & Dalal (2005) CIEDE2000 test data — validates the
// metric so the distinctness gate below rests on a correct base.
let cases = [
((50.0, 2.6772, -79.7751), (50.0, 0.0, -82.7485), 2.0425),
((50.0, 3.1571, -77.2803), (50.0, 0.0, -82.7485), 2.8615),
((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0009), 7.1792),
((50.0, -1.0, 2.0), (50.0, 0.0, 0.0), 2.3669),
];
for (l1, l2, exp) in cases {
let got = delta_e_2000(l1, l2);
assert!(
(got - exp).abs() < 0.01,
"ΔE2000 {got:.4} != reference {exp:.4}",
);
}
}
#[test]
fn syntax_token_colours_are_perceptually_distinct() {
// Every pair of syntax-token classes that can appear together on
// one line must be perceptually separable, not merely unequal in
// hex. Floor at ΔE2000 >= 15 (post-ADR-0057 minimum is ~18).
// Excludes tok_punct (== muted) and tok_error (== error), which
// deliberately alias other roles.
const MIN_DE: f64 = 15.0;
for t in [Theme::dark(), Theme::light()] {
let label = theme_label(&t);
let toks = [
("tok_keyword", t.tok_keyword),
("tok_identifier", t.tok_identifier),
("tok_type", t.tok_type),
("tok_number", t.tok_number),
("tok_string", t.tok_string),
("tok_flag", t.tok_flag),
("tok_function", t.tok_function),
];
for (i, (name_a, ca)) in toks.iter().enumerate() {
for (name_b, cb) in toks.iter().skip(i + 1) {
let de = delta_e_2000(rgb_to_lab(*ca), rgb_to_lab(*cb));
assert!(
de >= MIN_DE,
"{label} theme: {name_a} vs {name_b} ΔE2000 {de:.1} below {MIN_DE} — too similar",
);
}
}
}
}
}
+446
View File
@@ -0,0 +1,446 @@
//! Tier-4 PTY-based end-to-end tests (ADR-0008 §Tier-4, requirements TT4).
//!
//! These drive the **actual built binary** in a real pseudo-terminal and
//! assert on what a user would see on screen — catching what the lower
//! tiers can't: TTY setup, raw-mode / alternate-screen transitions, real
//! I/O timing, and graceful quit. The four flows mirror ADR-0008's
//! initial Tier-4 scope exactly:
//!
//! 1. cold launch → first DDL command → graceful quit
//! 2. project save → reopen → identical state
//! 3. project export → import into a fresh project → rebuilt state
//! 4. `undo` immediately after `DROP TABLE` (incl. the confirm modal)
//!
//! Tooling (ADR-0008, refined): `portable-pty` to spawn the binary in a
//! PTY at a fixed window size, `vt100` to parse the output stream into an
//! inspectable screen grid. ADR-0008 also named `expectrl`; we dropped it
//! — it bundles its own PTY abstraction (conflicts with portable-pty) and
//! is line-oriented, a poor fit for a full-screen TUI. A small hand-rolled
//! `wait_for` polling the vt100 screen replaces it.
//!
//! Determinism: each test gets its own temp `--data-dir` (no contact with
//! the user's real projects / resume state), a fixed 100×30 terminal (the
//! wide three-region layout, ADR-0046), and `--theme dark`. Tests run
//! **serially** (one PTY + child process at a time) so timing stays
//! predictable on the low-parallelism self-hosted CI runner, with tight,
//! fail-fast timeouts: a slow wait means a real hang, not contention.
// `PtyApp` deliberately holds a serial `MutexGuard` for its whole lifetime
// (to run PTY tests one at a time), and the screen-reading helpers hold the
// vt100 parser lock for the duration of a read. `significant_drop_tightening`
// flags both as droppable-earlier, but tightening them is either impossible
// (the guard *is* the serialization) or pointless here.
#![allow(clippy::significant_drop_tightening)]
use std::io::{Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::{Duration, Instant};
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use tempfile::TempDir;
const COLS: u16 = 100;
const ROWS: u16 = 30;
/// Tight, fail-fast wait. The self-hosted runner has little parallelism,
/// so anything slower than this is a genuine hang worth failing on.
const WAIT: Duration = Duration::from_secs(3);
const POLL: Duration = Duration::from_millis(20);
/// Tier-4 tests share one global lock so only one PTY + child runs at a
/// time (predictable timing; no resource races). Poison-tolerant so a
/// panicking test doesn't cascade-fail the rest.
static SERIAL: Mutex<()> = Mutex::new(());
fn lock_serial() -> MutexGuard<'static, ()> {
SERIAL
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A running instance of the app under a pseudo-terminal.
struct PtyApp {
_serial: MutexGuard<'static, ()>,
/// Present when this instance owns its data dir (single-launch
/// flows); `None` when the test owns it (multi-launch flows that
/// reopen the same dir).
_owned_dir: Option<TempDir>,
_master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
parser: Arc<Mutex<vt100::Parser>>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_reader: thread::JoinHandle<()>,
/// Spawn → first rendered frame (the `SIMPLE` mode label). Measured
/// for NFR-1; note this is the *debug* binary, so it's a generous
/// gross-regression signal, not the release startup figure.
startup: Duration,
}
impl PtyApp {
/// Launch against a fresh, owned temp data dir.
fn launch(args: &[&str]) -> Self {
let dir = TempDir::new().expect("create temp data dir");
let mut app = Self::launch_in(dir.path(), args);
app._owned_dir = Some(dir);
app
}
/// Launch against a caller-owned data dir (so a later launch can
/// reopen the same projects — flow 2).
fn launch_in(data_dir: &Path, args: &[&str]) -> Self {
let serial = lock_serial();
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows: ROWS,
cols: COLS,
pixel_width: 0,
pixel_height: 0,
})
.expect("open pty");
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_rdbms-playground"));
cmd.arg("--data-dir");
cmd.arg(data_dir);
cmd.arg("--theme");
cmd.arg("dark");
for a in args {
cmd.arg(a);
}
cmd.env("TERM", "xterm-256color");
cmd.env("RDBMS_PLAYGROUND_DEMO", "0");
cmd.cwd(data_dir);
let started = Instant::now();
let child = pair.slave.spawn_command(cmd).expect("spawn binary");
drop(pair.slave); // we never write to the slave directly
let parser = Arc::new(Mutex::new(vt100::Parser::new(ROWS, COLS, 0)));
let mut reader = pair.master.try_clone_reader().expect("clone pty reader");
let writer = pair.master.take_writer().expect("take pty writer");
let parser_for_reader = Arc::clone(&parser);
let reader_handle = thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => parser_for_reader
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.process(&buf[..n]),
}
}
});
let mut app = Self {
_serial: serial,
_owned_dir: None,
_master: pair.master,
writer,
parser,
child,
_reader: reader_handle,
startup: Duration::ZERO,
};
// Every flow needs a booted, idle app; block on the first frame.
app.wait_for("SIMPLE");
app.startup = started.elapsed();
app
}
/// Current visible screen as text (one line per row, trailing blanks
/// trimmed) — what a human would read.
fn screen_text(&self) -> String {
self.parser
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.screen()
.contents()
}
/// Text of just the left **Tables sidebar** region (first
/// [`SIDEBAR_W`] columns). The Output panel echoes every command, so
/// a table name typed in a command pollutes a whole-screen match —
/// table presence/absence must be read from the sidebar, where the
/// items list actually lives.
fn sidebar(&self) -> String {
const SIDEBAR_W: u16 = 28;
let parser = self
.parser
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let screen = parser.screen();
let mut out = String::new();
for row in 0..ROWS {
for col in 0..SIDEBAR_W {
if let Some(cell) = screen.cell(row, col) {
out.push_str(cell.contents());
}
}
out.push('\n');
}
out
}
/// Poll a predicate to the tight timeout, panicking with a screen dump.
fn wait_until(&self, desc: &str, pred: impl Fn(&Self) -> bool) {
let deadline = Instant::now() + WAIT;
loop {
if pred(self) {
return;
}
assert!(
Instant::now() < deadline,
"timed out after {WAIT:?} waiting for {desc}\n\
----- screen -----\n{}\n------------------",
self.screen_text(),
);
thread::sleep(POLL);
}
}
/// Wait for `needle` anywhere on screen (Output-panel messages, modals).
fn wait_for(&self, needle: &str) {
self.wait_until(&format!("{needle:?} on screen"), |a| {
a.screen_text().contains(needle)
});
}
/// Wait for a table `name` to appear in the Tables sidebar.
fn wait_for_table(&self, name: &str) {
self.wait_until(&format!("table {name:?} in sidebar"), |a| {
a.sidebar().contains(name)
});
}
/// Wait for the Tables sidebar to be empty (the `(none yet)` placeholder).
fn wait_for_no_tables(&self) {
self.wait_until("empty Tables sidebar", |a| {
a.sidebar().contains("(none yet)")
});
}
fn send(&mut self, bytes: &[u8]) {
self.writer.write_all(bytes).expect("write to pty");
self.writer.flush().expect("flush pty");
}
/// Type a command and submit it (Enter == carriage return in raw mode).
fn submit(&mut self, line: &str) {
self.send(line.as_bytes());
self.send(b"\r");
}
/// Resident set size of the child in KiB (Linux only). `process_id`
/// is read inline here rather than via a helper so the method has no
/// non-Linux callers to go dead (the CI clippy gate runs on Linux,
/// where such dead code wouldn't surface).
#[cfg(target_os = "linux")]
fn rss_kib(&self) -> Option<u64> {
let pid = self.child.process_id()?;
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
status.lines().find_map(|l| {
let rest = l.strip_prefix("VmRSS:")?;
rest.split_whitespace().next()?.parse::<u64>().ok()
})
}
/// Send Ctrl-C and assert the process exits cleanly within the wait.
fn quit(mut self) {
self.send(b"\x03");
let deadline = Instant::now() + WAIT;
loop {
match self.child.try_wait() {
Ok(Some(status)) => {
assert!(status.success(), "app exited unsuccessfully: {status:?}");
return;
}
Ok(None) => {}
Err(e) => panic!("waiting on child failed: {e}"),
}
if Instant::now() >= deadline {
let _ = self.child.kill();
panic!(
"app did not exit within {WAIT:?} of Ctrl-C\n\
----- screen -----\n{}\n------------------",
self.screen_text(),
);
}
thread::sleep(POLL);
}
}
}
impl Drop for PtyApp {
fn drop(&mut self) {
// Never leak a child process, even if a test panicked mid-flow.
let _ = self.child.kill();
let _ = self.child.wait();
}
}
// ============================ the four flows ===========================
/// Flow 1 — cold launch → first DDL command → graceful quit.
#[test]
fn cold_launch_ddl_and_quit() {
let mut app = PtyApp::launch(&[]);
app.wait_for_no_tables(); // fresh project: empty sidebar
app.submit("create table Customers with pk id(serial)");
app.wait_for_table("Customers"); // table appears in the sidebar
app.quit();
}
/// Flow 2 — create, quit, reopen the same project (`--resume`), identical
/// state. Persistence is per-command autosave; the kept temp is recorded
/// as the resume target on quit (ADR-0015).
#[test]
fn save_quit_and_reopen_restores_state() {
let dir = TempDir::new().expect("shared data dir");
let mut app = PtyApp::launch_in(dir.path(), &[]);
app.submit("create table Customers with pk id(serial)");
app.wait_for_table("Customers");
app.submit("add column to Customers: Name (text)");
app.wait_for("Name (text) ✓");
app.quit();
// Reopen the most-recently-used project from the same data root, then
// assert the *column* survived — not just the table name — so "identical
// state" means the schema, not merely that some table exists. In this
// fresh process "Name" appears only from the show-data header.
let mut reopened = PtyApp::launch_in(dir.path(), &["--resume"]);
reopened.wait_for_table("Customers");
reopened.submit("show data Customers");
reopened.wait_for("Name"); // the added column round-tripped through restart
reopened.quit();
}
/// Flow 3 — export a project, import it into a fresh project, confirm the
/// rebuilt database carries the schema and data.
#[test]
fn export_then_import_into_fresh_project() {
let zip_dir = TempDir::new().expect("zip dir");
let zip_path = zip_dir.path().join("export.zip");
let zip = zip_path.to_str().expect("utf-8 zip path");
// Source project: a table with one data row. `Name` is a regular
// column (added separately) — putting it in the `with pk` clause would
// make a compound PK and block the serial auto-fill.
let mut source = PtyApp::launch(&[]);
source.submit("create table Customers with pk id(serial)");
source.wait_for_table("Customers");
// Pace each command to completion before the next — driving a TUI, like
// a real user (or the cast driver), means waiting for each result. A
// command sent while the previous one's worker rebuild is still in
// flight can be misread against a stale schema cache (issue #39).
source.submit("add column to Customers: Name (text)");
source.wait_for("Name (text) ✓");
source.submit("insert into Customers values ('Alice')");
// The insert's OWN success echo (value + ✓) — not a bare "✓", which the
// create already painted — so export runs only once the row is in CSV.
source.wait_for("('Alice') ✓");
source.submit(&format!("export {zip}"));
source.wait_for("[ok] export");
source.quit();
// Fresh project (new data dir): import the zip. Import switches to the
// imported project, rebuilding its db from text since the export omits
// the .db (ADR-0004/0015). "Alice" is unambiguous here — it appears in
// no command typed into this instance, only in a rebuilt data row.
let mut target = PtyApp::launch(&[]);
target.submit(&format!("import {zip}"));
target.wait_for("now editing"); // switched to the imported project
target.wait_for_table("Customers"); // schema rebuilt
target.submit("show data Customers");
target.wait_for("Alice"); // data rebuilt from the CSV
target.quit();
}
/// Flow 4 — `undo` immediately after `DROP TABLE`, including the
/// confirmation modal.
#[test]
fn undo_after_drop_table_restores_it() {
let mut app = PtyApp::launch(&[]);
app.submit("create table Customers with pk id(serial)");
app.wait_for_table("Customers");
app.submit("drop table Customers");
app.wait_for_no_tables(); // gone from the sidebar
app.submit("undo");
app.wait_for("Restore that earlier state?"); // the confirm modal
app.send(b"y"); // confirm
app.wait_for_table("Customers"); // table restored in the sidebar
app.quit();
}
/// Flow 5 — issue #39 regression: commands submitted **back-to-back**, with
/// no wait between them, must still execute correctly. This is the inverse of
/// flow 3 (which paces each command on purpose). Pre-fix, a Form-B insert sent
/// immediately after `add column` was validated against the *stale* schema
/// cache — the worker hadn't yet refreshed it — and wrongly rejected as
/// "trying to write SQL?", so the row never landed. The schema-refresh gate
/// (`App::awaiting_schema_refresh`) now holds each submission until the prior
/// command's refresh arrives, making the outcome independent of input speed.
#[test]
fn back_to_back_insert_after_ddl_still_succeeds() {
let mut app = PtyApp::launch(&[]);
app.wait_for_no_tables(); // fresh project
// Fire all three with no readiness wait in between — the faster-than-human
// input that triggered issue #39 (paste / script / unpaced driver).
app.submit("create table Customers with pk id(serial)");
app.submit("add column to Customers: Name (text)");
app.submit("insert into Customers values ('Alice')");
// The insert's OWN success echo (value + ✓) — proof the row reached the
// database, not the "trying to write SQL?" rejection. If the gate
// regressed, the insert would misparse against the stale schema and this
// would time out.
app.wait_for("('Alice') ✓");
app.quit();
}
// ===================== NFR perf (measured, generous) ===================
//
// These run against the DEBUG binary, so the bounds are loose
// gross-regression catches, not the NFR targets. The real NFR-1 (startup
// < 500 ms) and NFR-3 (idle RSS < 50 MB) figures are measured on a
// --release build and recorded in the NFR verification doc (ADR-0057).
/// NFR-1 — startup to first rendered frame. Generous debug-binary bound.
#[test]
fn startup_to_first_frame_is_reasonable() {
let app = PtyApp::launch(&[]);
let ms = app.startup.as_millis();
eprintln!("NFR-1 startup (debug binary, under test harness): {ms} ms");
assert!(
app.startup < Duration::from_millis(2000),
"startup {ms} ms exceeds the generous 2000 ms debug bound — likely a real regression",
);
app.quit();
}
/// NFR-3 — idle resident memory. Generous debug-binary bound (Linux only;
/// reads the child's /proc VmRSS).
#[cfg(target_os = "linux")]
#[test]
fn idle_memory_footprint_is_reasonable() {
let app = PtyApp::launch(&[]);
// Already idle after the readiness wait; let it settle a moment.
thread::sleep(Duration::from_millis(200));
let rss = app.rss_kib().expect("read VmRSS");
eprintln!(
"NFR-3 idle RSS (debug binary): {} KiB ({:.1} MB)",
rss,
rss as f64 / 1024.0,
);
assert!(
rss < 150_000,
"idle RSS {rss} KiB exceeds the generous 150 MB debug bound — likely a real regression",
);
app.quit();
}
+74
View File
@@ -115,6 +115,80 @@ fn help_create_covers_every_form_sharing_the_entry_word() {
);
}
// ----- issue #36: advanced-mode SQL forms get distinct help content -----
#[test]
fn help_select_renders_the_sql_select_block() {
// `select` is advanced-only (no simple sibling) and used to have
// `help_id: None`, so `help select` produced the unknown-topic note.
// It now carries its own help page.
let out = output_for("help select");
let joined = out.join("\n").to_lowercase();
assert!(
joined.contains("select") && joined.contains("from"),
"help select shows the SQL select form: {out:?}",
);
assert!(
!out.iter().any(|l| l.contains("No help for")),
"help select resolves to content, not the unknown-topic note: {out:?}",
);
}
#[test]
fn help_with_renders_the_cte_block() {
let out = output_for("help with");
let joined = out.join("\n").to_lowercase();
assert!(
joined.contains("with") && joined.contains("as ("),
"help with shows the CTE form: {out:?}",
);
assert!(
!out.iter().any(|l| l.contains("No help for")),
"help with resolves to content: {out:?}",
);
}
#[test]
fn help_insert_shows_both_simple_and_sql_forms() {
// `help insert` now covers the simple form AND the advanced SQL form
// (two clearly-labelled blocks, like `help create` already does).
let out = output_for("help insert");
let joined = out.join("\n").to_lowercase();
assert!(
joined.contains("insert into"),
"simple insert form shown: {out:?}",
);
assert!(
joined.contains("advanced"),
"advanced SQL insert form shown alongside the simple one: {out:?}",
);
}
#[test]
fn help_list_splits_simple_and_advanced_sections() {
let out = output_for("help");
let joined = out.join("\n");
assert!(
out.iter().any(|l| l.contains("Simple-mode commands")),
"simple-mode section header present: {out:?}",
);
assert!(
out.iter()
.any(|l| l.contains("Advanced-mode") && l.contains("SQL")),
"advanced-mode (SQL) section header present: {out:?}",
);
// Copy rule: never say "DSL" in user-facing text (the old header did).
assert!(
!joined.contains("DSL"),
"help output must not contain 'DSL': {out:?}",
);
// The advanced query commands are now discoverable in the list.
assert!(
joined.to_lowercase().contains("select"),
"select is listed in help: {out:?}",
);
}
#[test]
fn help_types_renders_the_type_reference() {
let out = output_for("help types");
+6
View File
@@ -170,6 +170,12 @@ fn colon_escape_in_simple_mode_is_one_shot() {
echoed.text,
);
// Issue #39: dispatching `:select 1` arms the schema-refresh gate (a
// cache refresh is conceptually in flight). In the real runtime that
// refresh lands before the user types the next line; model it here so
// the follow-up submission is processed rather than held.
app.update(AppEvent::SchemaCacheRefreshed(app.schema_cache.clone()));
// Subsequent submission (unrecognised in simple mode) parse-errors,
// not echoes — confirming the mode reverted.
type_str(&mut app, "list things");
+5 -1
View File
@@ -62,6 +62,10 @@ export default defineConfig({
// Website ID is public by design (it ships in every page). The script
// loads from the Umami host, which also becomes the data destination,
// so no `data-host-url` is needed.
// - Served first-party under `umami.relplay.org` (a proxied CNAME to
// the Umami server) rather than the server's own host: privacy
// blockers (e.g. Brave Shields) block Umami only as a *third-party*
// request, so a same-registrable-domain host is not flagged.
// - data-domains: only run on the production apex, so the
// `website.relplay.pages.dev` preview and `staging.relplay.org`
// don't pollute the stats.
@@ -71,7 +75,7 @@ export default defineConfig({
tag: 'script',
attrs: {
defer: true,
src: 'https://umami.oliversturm.com/script.js',
src: 'https://umami.relplay.org/script.js',
'data-website-id': 'fd77cfa5-cffe-4fc8-addb-1c6d7b6d9939',
'data-domains': 'relplay.org',
'data-do-not-track': true,
+1 -1
View File
@@ -11,7 +11,7 @@ visitors. We keep the analytics deliberately minimal.
We measure aggregate usage with **[Umami](https://umami.is/)**, a
privacy-friendly analytics tool that we **host ourselves** (on
`umami.oliversturm.com`). Nothing is sent to a third-party advertising
`umami.relplay.org`). Nothing is sent to a third-party advertising
network.
For each page view we record: