28 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 f2b4ed00f4 docs(website): add cookieless Umami analytics + a privacy page
website / deploy (push) Successful in 37s
Wire self-hosted Umami (umami.oliversturm.com) into the site head:
- data-domains=relplay.org so the preview/staging deploys don't pollute
  the production stats
- data-do-not-track so DNT visitors aren't counted
- the Website ID is public by design (ships in every page)

Add a short /privacy page (footer-linked, kept out of the sidebar)
explaining the no-cookies / no-personal-data / no-cross-site-tracking
posture and the DNT behaviour. No cookie banner is needed: the tracker
stores nothing on the device, and first-party statistical analytics is
exempt from PECR consent under the Data (Use and Access) Act 2025.
2026-06-22 13:27:10 +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
claude@clouddev1 c86df95a0f docs(website): tabbed per-OS install instructions with auto-detection
website / deploy (push) Successful in 33s
Split the install page into Linux / macOS / Windows tabs so each visitor
sees only the commands that apply to them. A small InstallOsDetect component
seeds Starlight's synced-tabs localStorage key with the detected OS before
the tabs render, so the matching tab is pre-selected with no flash — and
only when the visitor hasn't already picked a tab, so a manual choice wins.

- installation.md -> .mdx (needs the Tabs/TabItem components)
- detection covers navigator.userAgentData and the legacy platform/UA
  fallbacks; unknown platforms fall through to the default tab
- piggybacks on Starlight's own restore mechanism rather than re-implementing
  tab switching
2026-06-22 09:36:56 +00:00
claude@clouddev1 fb536b4245 docs(website): reconcile installation docs with v0.2.0 shipped reality
ci / gate (push) Successful in 1m55s
ci / manifests (push) Successful in 3s
website / deploy (push) Successful in 35s
installation.md predated the install/packaging work that landed on main:
- drop the "once published" placeholders now that v0.2.0 is live
- fix the Homebrew/Scoop commands (tap/bucket-add forms; the bare
  `brew install rdbms-playground` was wrong)
- add the curl|sh and PowerShell one-line installers (ADR-0055),
  `cargo install` / `cargo binstall` (ADR-0056), and prebuilt binaries
- present winget as coming-soon (PR awaiting the public catalogue)

command-line-options.md: add `-V`/`--version` and the in-app `version`
command (ADR-0054).
2026-06-21 22:21:26 +00:00
claude@clouddev1 1237aa59c1 Merge branch 'main' into website
Bring v0.2.0 + install/packaging surface (crates.io, curl|sh installer,
Windows install.ps1, Scoop/Homebrew/winget, --version/version command)
onto the website branch for documentation reconciliation.
2026-06-21 22:15:41 +00:00
claude@clouddev1 ba0872dbb9 docs: website-branch session handoff (website-3) 2026-06-21 22:12:52 +00:00
claude@clouddev1 56e3456cfc docs: handoff 75 — D3 package managers complete (Scoop/Homebrew/winget)
ci / gate (push) Successful in 1m58s
ci / manifests (push) Successful in 4s
Covers the session that finished the D3 rollout: install.ps1 Windows 5.1
fixes, Scoop + Homebrew wiring and validation, the Homebrew-403 presigned-URL
saga fixed via a server-side Caddy HEAD rule, and the winget job + komac
bootstrap (PR #391335). Records the infra created off-repo (Caddy rule, the
lazyeval Gitea/GitHub bots + tokens), the gotchas, and the parked signing
tracks.
2026-06-21 22:05:00 +00:00
claude@clouddev1 0208c67e59 ci(publish): wire winget job via komac (D3 §3d)
Add the 4th publish.yaml sibling job: opens a PR to microsoft/winget-pkgs
with komac (pinned 2.16.0 prebuilt) for LazyEvaluation.RdbmsPlayground
(portable, x64 + arm64). Unlike scoop/homebrew it's a PR into Microsoft's
central, human-gated catalog - async and validated on their side.

- Auth: a classic public_repo GitHub PAT on a dedicated bot account
  (lazyeval-ci; fine-grained tokens can't open the cross-fork PR - komac
  #310), as the WINGET_GITHUB_TOKEN secret, job-scoped and passed to the API
  guards via a 0600 curl config file (never argv).
- Idempotent via two guards before submitting (already-merged version +
  already-open PR) so a repeated publish dispatch can't open a duplicate.
- No actions/checkout (komac works off URLs + the GitHub API).

Docs: ADR-0056 Amendment 4 - the model, the one-time manual `komac new`
bootstrap recipe (flags verified vs komac 2.16.0), and first-run learnings:
the fork must pre-exist, and a direct single-exe portable takes its PATH
alias from Commands[0] (not PortableCommandAlias, which is nested-only).
Plus README index + requirements D3.

Wiring only; going live needs the bootstrap PR (#391335, submitted) to merge.
2026-06-21 21:58:47 +00:00
claude@clouddev1 6bb2288470 docs(adr-0056): Scoop+Homebrew validated; record the Caddy HEAD fix
Amendment 3: scoop install and brew install both verified end-to-end on
real Windows + macOS (install + run), resolving Amendment 2's unverified
caveats (incl. the HEAD:main push).

Records the root cause + fix for the Homebrew 403: Gitea (>=1.25)
303-redirects release downloads to a method-bound SigV4 presigned S3 URL;
brew resolves with HEAD, captures the HEAD-signed URL, then GETs it -> 403
(GET-only tools are unaffected). Fixed by a server-side Caddy rule that
answers HEAD on release-download paths directly (200, no redirect) so the
download GET re-runs the redirect fresh. That rule lives in the Gitea edge
config, NOT this repo - documented so a server rebuild doesn't silently
break brew. Includes the reference Caddyfile block.

Also notes the ad-hoc mac signature runs fine via brew; Developer-ID +
notarization stays parked on the Apple org conversion. Remaining D3: winget.
2026-06-20 08:15:17 +00:00
claude@clouddev1 6d54c1e96c ci(publish): wire Scoop bucket + Homebrew tap jobs (D3 §3b/§3c)
ci / gate (push) Successful in 1m59s
ci / manifests (push) Successful in 4s
Add sibling publish.yaml jobs (scoop-bucket, homebrew-tap) that render a
manifest from the release .sha256 sidecars and idempotently push it to the
org-level lazyeval/scoop-bucket and lazyeval/homebrew-tap repos, using the
scoped lazyeval-ci bot token (LAZYEVAL_PKG_TOKEN).

Render logic lives in dependency-free bash (the CI image has no jq/ruby):
scripts/render-scoop-manifest.sh and scripts/render-homebrew-formula.sh.
scripts/test-package-renders.sh exercises both: it validates the Scoop JSON
with node and asserts fields on both manifests, and additionally runs
`ruby -c` on the formula where ruby is present (dev box), skipping it
gracefully otherwise.

A new ci.yaml `manifests` job runs that test on every push so a render
regression surfaces immediately, not at the next manual publish dispatch.
The CI image has no ruby, so in CI the gate covers the Scoop JSON (node) and
field assertions for both manifests; the formula's Ruby syntax is checked
dev-side only (the static heredoc's variable parts cannot introduce syntax
errors).

- Scoop: x64 (gnu) + arm64 (gnullvm); #/-rename fragment so the bin shim is
  version-stable; checkver, no autoupdate (the pipeline is the updater).
- Homebrew: on_macos/on_linux x arch bare-binary formula; no Windows.

Docs: ADR-0056 Amendment 2 (+ README index, requirements D3).

Unverified pending real use: scoop/brew install, the HEAD:main branch
assumption, macOS Gatekeeper-via-brew on the ad-hoc-signed binary.
2026-06-19 21:30:18 +00:00
claude@clouddev1 c0531aa048 fix(install): use install.ps1 immediately + honest PATH guidance
The persisted User PATH only reaches newly-started processes, so the old
"restart your shell" advice was wrong — a sign-out/in was actually needed
(observed on Windows 11). Update the current session's $env:Path as well
so the command works right away in the same window, and reword the notice.

Also refresh the header: verified on ARM64 Windows 11 under Windows
PowerShell 5.1 and PowerShell 7.6 against the live v0.2.0 release.
2026-06-19 14:57:25 +00:00
claude@clouddev1 42b40bc099 fix(install): make install.ps1 work on Windows PowerShell 5.1
ci / gate (push) Successful in 3m30s
The in-box Windows shell (5.1, .NET Framework) is the baseline we must
support; PowerShell 7 is an opt-in install most users don't have.

- arch detection: read PROCESSOR_ARCHITECTURE/ARCHITEW6432 from the
  environment instead of RuntimeInformation::OSArchitecture, which
  resolves from a .NET Framework facade lacking that property under 5.1
  and throws under StrictMode (the reported failure).
- force TLS 1.2 before any web request (5.1 may default to TLS 1.0/1.1).
- pass -UseBasicParsing to Invoke-WebRequest (5.1 otherwise uses the IE
  engine and can fail when it is absent).

All three are no-ops on PowerShell 7. Relates to ADR-0055.
2026-06-19 14:22:28 +00:00
claude@clouddev1 cabc8131a9 docs: handoff 74 — road to public availability; v0.2.0 live on crates.io
ci / gate (push) Successful in 3m21s
2026-06-18 22:10:41 +00:00
claude@clouddev1 8ebe213b5d ci: add the publish.yaml workflow file (completes d3af1c4)
d3af1c4 described the manual publish workflow and updated ADR-0056, but
`git commit -am` doesn't stage new untracked files, so publish.yaml
itself was left out. Add it here.
2026-06-18 22:10:21 +00:00
claude@clouddev1 d3af1c413a ci: add a manual publish workflow (crates.io, idempotent + expandable)
A workflow_dispatch publish.yaml (mirrors release-macos.yaml) with a `tag`
input, run by hand once the automated release builds exist. Publishing
stays manual and keeps the registry token off every tag push: it's
irreversible (yank-only), the split release (tag Linux/Windows +
dispatched macOS) makes a human the 'all assets up' gate, and crates.io
has no Gitea-Actions trusted-publishing path. The crates.io job is
idempotent (crates.io API pre-check + cargo publish as backstop) and
independent (no inter-job needs), so future Scoop/Homebrew/winget jobs can
be added alongside without interfering or breaking re-runs. Token via the
CARGO_REGISTRY_TOKEN secret. ADR-0056 Amendment 1 + README index also
record 0.2.0 published + binstall verified.
2026-06-18 22:03:47 +00:00
claude@clouddev1 3c87dbb391 fix(ci): create the profile dir so macOS nix prune keeps the toolchain warm
The prune step's profile path $HOME/.cache/rdbms-ci/toolchain had no
parent dir, so `nix develop --profile` errored ("cannot read directory")
and — swallowed by `|| true` — never created the profile/gc-root. With
nothing rooting the toolchain, nix-collect-garbage deleted the whole
closure every run (~3.8 GiB re-downloaded each dispatch; confirmed in the
run-74 log). Add `mkdir -p` for the parent and drop the `|| true` on the
profile realization so a future breakage fails loudly. The VM stays
bounded as before, but the toolchain now persists across runs.

release-macos.yaml is workflow_dispatch (runs from main's definition), so
this takes effect on the next dispatch — the already-published v0.2.0
macOS binaries are unaffected.
2026-06-18 21:24:34 +00:00
42 changed files with 3880 additions and 162 deletions
+23
View File
@@ -6,6 +6,11 @@
# was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 / # was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 /
# issue #35). The release job (static binary for D2) and the platform matrix # issue #35). The release job (static binary for D2) and the platform matrix
# layer on later, step by step. # layer on later, step by step.
#
# A separate, lightweight `manifests` job logic-tests the package-manifest
# render scripts (Scoop/Homebrew) used by publish.yaml — bash + node only, no
# toolchain — so a render regression surfaces on the breaking push rather than
# weeks later at the next manual publish dispatch (ADR-0056 Amendment 2).
name: ci name: ci
on: on:
push: push:
@@ -46,3 +51,21 @@ jobs:
run: nix develop -c cargo clippy --all-targets -- -D warnings run: nix develop -c cargo clippy --all-targets -- -D warnings
- name: test - name: test
run: nix develop -c cargo test --no-fail-fast run: nix develop -c cargo test --no-fail-fast
# Logic test for the package-manifest render scripts. Renders with DUMMY
# inputs and validates the output — it never publishes or touches the lazyeval
# repos (that is publish.yaml's manual job). Runs on the same image but skips
# nix: it needs only bash + node, both in the base image.
#
# NOTE: the CI image has no ruby, so the script's `ruby -c` formula syntax
# check is skipped here (it degrades gracefully); the Scoop JSON is still
# validated with node and both manifests' fields are asserted. Full formula
# syntax is checked dev-side (ruby present) on every pre-commit local run.
manifests:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: render-script tests (Scoop + Homebrew)
run: bash scripts/test-package-renders.sh
+284
View File
@@ -0,0 +1,284 @@
# Manual publication workflow (workflow_dispatch) — the outward, irreversible
# release steps a human triggers AFTER the automated `release.yaml` build has
# produced downloadable assets (and they've been eyeballed as good).
#
# Why manual + separate from release.yaml:
# * Publishing to a public registry is irreversible (crates.io versions can
# only be *yanked*, never deleted) — a human pulls this lever, and the
# registry token never sits on every tag push.
# * Our release is split (Linux/Windows on the tag, macOS dispatched), so a
# human is the natural "all assets are up — go" gate. crates.io publish
# reads SOURCE so it doesn't strictly need the release, but binstall's
# metadata points at the release assets — hence run this once builds exist.
#
# Structure: each registry is its OWN job with NO inter-job `needs`, so jobs run
# independently and one failing (or a newly-added one) never breaks another.
# Every job is IDEMPOTENT — re-dispatching when a target is already published is
# a clean no-op. Add Scoop / Homebrew / winget as sibling jobs here later.
name: publish
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish (e.g. v0.2.0)'
required: true
jobs:
crates-io:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: publish to crates.io (idempotent)
shell: bash
env:
TAG: ${{ inputs.tag }}
# A crate-scoped, publish-update crates.io token, stored as a Gitea
# Actions secret. `cargo publish` reads CARGO_REGISTRY_TOKEN from env.
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Source of truth = the [package] version at the checked-out tag
# (toolchain-free read; same approach as release.yaml's guard, which
# avoids the flake devShell's stdout banner corrupting a parse).
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
[ -n "$VER" ] || { echo "ERROR: could not read version from Cargo.toml" >&2; exit 1; }
if [ "$TAG" != "v$VER" ]; then
echo "ERROR: dispatch tag '$TAG' != 'v$VER' (Cargo.toml at that tag)" >&2
exit 1
fi
# Idempotency: if this version is already on crates.io, no-op.
# (crates.io requires a descriptive User-Agent per its data policy;
# without one the API returns 403.) Only an explicit 200 means
# "already there" — anything else proceeds, and `cargo publish` is the
# final backstop (it refuses to overwrite an existing version).
UA="rdbms-playground-release-ci (oliver@sturmnet.org)"
code=$(curl -sS -o /dev/null -w '%{http_code}' -A "$UA" \
"https://crates.io/api/v1/crates/rdbms-playground/$VER" || echo 000)
if [ "$code" = "200" ]; then
echo "rdbms-playground $VER is already on crates.io — nothing to do."
exit 0
fi
echo "crates.io returned HTTP $code for $VER (not 200) — proceeding to publish."
echo "publishing rdbms-playground $VER to crates.io ..."
nix develop -c cargo publish --locked
echo "published rdbms-playground $VER to crates.io."
# Update the lazyeval Scoop bucket (Windows). Renders the manifest from the
# release's .sha256 sidecars and commits it to lazyeval/scoop-bucket. Pushes
# with the lazyeval-ci bot token (LAZYEVAL_PKG_TOKEN), which is scoped — via
# the bot's org-team membership — to the lazyeval package repos only, so a
# leak cannot touch oli/rdbms-playground.
scoop-bucket:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4 # default ref (main) — current render script
- name: update the lazyeval Scoop bucket (idempotent)
shell: bash
env:
TAG: ${{ inputs.tag }}
# Passed via env, never inlined into the script, so the value stays
# masked in logs; it only materialises in the clone URL at runtime.
PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }}
run: |
set -euo pipefail
VER="${TAG#v}"
echo "scoop: targeting rdbms-playground $VER ($TAG)"
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
fetch_hash() {
local asset="$1" line
echo "scoop: fetching $asset.sha256" >&2
line=$(curl -fsSL "$base/$asset.sha256") \
|| { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; }
# First whitespace-delimited field is the hash. `read` is a bash
# builtin (no awk, which the slim CI image may lack).
local hash _
read -r hash _ <<<"$line"
printf '%s' "$hash"
}
h_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe")
h_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe")
echo "scoop: rendering manifest"
bash scripts/render-scoop-manifest.sh "$VER" "$h_x64" "$h_arm" > /tmp/rdbms-playground.json
node -e 'JSON.parse(require("fs").readFileSync("/tmp/rdbms-playground.json","utf8"))' \
|| { echo "ERROR: rendered Scoop manifest is not valid JSON" >&2; exit 1; }
work=$(mktemp -d)
echo "scoop: cloning lazyeval/scoop-bucket"
git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/scoop-bucket.git" "$work"
cp /tmp/rdbms-playground.json "$work/rdbms-playground.json"
cd "$work"
git config user.name "lazyeval-ci"
git config user.email "ci@lazyeval.net"
git add rdbms-playground.json
if git diff --cached --quiet; then
echo "scoop: manifest already at $VER — nothing to commit."
exit 0
fi
git commit -m "rdbms-playground $VER"
# Push to main explicitly: a freshly-created (empty) repo clone may put
# the first commit on a differently-named local branch. Assumes the
# bucket/tap default branch is `main` (Gitea's default for new repos).
git push origin HEAD:main
echo "scoop: bucket updated to rdbms-playground $VER."
# Update the lazyeval Homebrew tap (macOS + Linux). Same shape as scoop-bucket;
# writes Formula/rdbms-playground.rb into lazyeval/homebrew-tap.
homebrew-tap:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: update the lazyeval Homebrew tap (idempotent)
shell: bash
env:
TAG: ${{ inputs.tag }}
PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }}
run: |
set -euo pipefail
VER="${TAG#v}"
echo "homebrew: targeting rdbms-playground $VER ($TAG)"
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
fetch_hash() {
local asset="$1" line
echo "homebrew: fetching $asset.sha256" >&2
line=$(curl -fsSL "$base/$asset.sha256") \
|| { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; }
# First whitespace-delimited field is the hash. `read` is a bash
# builtin (no awk, which the slim CI image may lack).
local hash _
read -r hash _ <<<"$line"
printf '%s' "$hash"
}
mac_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-apple-darwin")
mac_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-apple-darwin")
lin_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-unknown-linux-musl")
lin_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-unknown-linux-musl")
echo "homebrew: rendering formula"
bash scripts/render-homebrew-formula.sh "$VER" "$mac_arm" "$mac_x64" "$lin_arm" "$lin_x64" \
> /tmp/rdbms-playground.rb
grep -q '^class RdbmsPlayground < Formula$' /tmp/rdbms-playground.rb \
|| { echo "ERROR: rendered formula looks malformed" >&2; exit 1; }
work=$(mktemp -d)
echo "homebrew: cloning lazyeval/homebrew-tap"
git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/homebrew-tap.git" "$work"
mkdir -p "$work/Formula"
cp /tmp/rdbms-playground.rb "$work/Formula/rdbms-playground.rb"
cd "$work"
git config user.name "lazyeval-ci"
git config user.email "ci@lazyeval.net"
git add Formula/rdbms-playground.rb
if git diff --cached --quiet; then
echo "homebrew: formula already at $VER — nothing to commit."
exit 0
fi
git commit -m "rdbms-playground $VER"
# Push to main explicitly: a freshly-created (empty) repo clone may put
# the first commit on a differently-named local branch. Assumes the
# bucket/tap default branch is `main` (Gitea's default for new repos).
git push origin HEAD:main
echo "homebrew: tap updated to rdbms-playground $VER."
# Update the winget package (Windows) by opening a PR to microsoft/winget-pkgs
# with komac. Unlike scoop-bucket/homebrew-tap (which push to OUR repos and are
# live at once), winget is a PR into Microsoft's central, human-gated catalog —
# asynchronous, and re-submitting the same version would open a DUPLICATE PR. So
# this job guards on both already-merged versions AND already-open PRs before
# submitting, which keeps a repeated `publish` dispatch safe.
#
# Auth: komac needs a CLASSIC GitHub PAT with `public_repo` (fine-grained tokens
# cannot open the cross-fork PR — komac #310). It is held on a dedicated GitHub
# bot account (a leak can't reach other repos — same reasoning as lazyeval-ci)
# and referenced ONLY in this job's env (job-level secret scoping keeps it out
# of the other publish jobs).
#
# PREREQUISITE: the package `LazyEvaluation.RdbmsPlayground` must already exist
# in winget-pkgs via a one-time `komac new` (interactive — run manually once;
# see ADR-0056 Amendment 4). This job only does the per-release `komac update`.
winget:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- name: submit the winget update PR (idempotent)
shell: bash
env:
TAG: ${{ inputs.tag }}
# Classic public_repo PAT for the winget bot account. komac reads it
# from GITHUB_TOKEN; the API guards below read it via a curl config file
# so the token never appears in a command line / process list.
GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
run: |
set -euo pipefail
VER="${TAG#v}"
PKG="LazyEvaluation.RdbmsPlayground"
echo "winget: targeting $PKG $VER ($TAG)"
# Auth header in a 0600 curl config (keeps the token out of argv/logs).
umask 077
printf 'header = "Authorization: Bearer %s"\nheader = "Accept: application/vnd.github+json"\n' \
"$GITHUB_TOKEN" > /tmp/gh-curlrc
api="https://api.github.com"
# Guard 1 — already merged into winget-pkgs?
# (manifests/<first-letter>/<Publisher>/<Package>/<version>)
merged=$(curl -sS -o /dev/null -w '%{http_code}' --config /tmp/gh-curlrc \
"$api/repos/microsoft/winget-pkgs/contents/manifests/l/LazyEvaluation/RdbmsPlayground/$VER" || echo 000)
if [ "$merged" = "200" ]; then
echo "winget: $PKG $VER already in winget-pkgs — nothing to do."
exit 0
fi
# Guard 2 — an open PR for this exact id+version already? (avoid a dup)
# curl -G --data-urlencode does the URL-encoding (no jq in the image).
curl -sS -G --config /tmp/gh-curlrc \
--data-urlencode "q=repo:microsoft/winget-pkgs type:pr state:open in:title \"$PKG\" \"$VER\"" \
"$api/search/issues" -o /tmp/winget-search.json
open=$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync("/tmp/winget-search.json","utf8")).total_count||0))')
if [ "$open" != "0" ]; then
echo "winget: an open PR for $PKG $VER already exists — skipping."
exit 0
fi
# Install pinned komac (prebuilt glibc binary; the CI image has no
# cargo/komac). Pinned for reproducibility — bump deliberately.
KOMAC_VER=2.16.0
curl -fsSL -o /tmp/komac.tgz \
"https://github.com/russellbanks/Komac/releases/download/v$KOMAC_VER/komac-$KOMAC_VER-x86_64-unknown-linux-gnu.tar.gz"
tar -xzf /tmp/komac.tgz -C /tmp
komac_bin=$(find /tmp -maxdepth 2 -type f -name komac | head -1)
[ -n "$komac_bin" ] || { echo "ERROR: komac binary not found after extract" >&2; exit 1; }
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
echo "winget: submitting update PR via komac $KOMAC_VER"
# NB: confirm flags against `komac update --help` on first run — komac
# evolves; --version/--urls/--submit are the stable core. komac infers
# architecture + the `portable` installer type from the binaries.
"$komac_bin" update "$PKG" \
--version "$VER" \
--urls "$base/rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe" \
"$base/rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe" \
--submit
echo "winget: update PR submitted for $PKG $VER (Microsoft review is async)."
# No `needs:` between jobs — each is independent and idempotent, so one failing
# or being added never breaks another.
+11 -2
View File
@@ -84,12 +84,21 @@ jobs:
# The runner wipes the workspace each run, so cargo target/ never # The runner wipes the workspace each run, so cargo target/ never
# accumulates. Bound the persistent nix store by generation: record the # accumulates. Bound the persistent nix store by generation: record the
# current devShell as a generation of a persistent profile (in $HOME), # current devShell as a generation of a persistent profile (in $HOME),
# keep the 2 newest, reclaim what older ones referenced. # keep the 2 newest, reclaim what older ones referenced — so the
# toolchain stays *warm* across runs and only stale generations are GC'd.
#
# The profile's parent dir MUST exist first, or `nix develop --profile`
# errors ("cannot read directory …") and the profile/gc-root is never
# created — which made `nix-collect-garbage` delete the whole toolchain
# closure every run (re-downloaded ~3.8 GiB each time; the retention was
# silently broken by the swallowed `|| true`). No `|| true` on the
# profile realization now: a future breakage should fail loudly.
if: always() if: always()
run: | run: |
echo "--- disk before ---"; df -h / | tail -1 echo "--- disk before ---"; df -h / | tail -1
P="$HOME/.cache/rdbms-ci/toolchain" P="$HOME/.cache/rdbms-ci/toolchain"
nix develop --profile "$P" -c true || true mkdir -p "$(dirname "$P")"
nix develop --profile "$P" -c true
nix-env -p "$P" --delete-generations +2 || true nix-env -p "$P" --delete-generations +2 || true
nix-collect-garbage || true nix-collect-garbage || true
echo "--- disk after ---"; df -h / | tail -1 echo "--- disk after ---"; df -h / | tail -1
+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). the specific product (SQLite, STRICT, rusqlite, PRAGMA).
ADR-internal prose and code comments may name it where ADR-internal prose and code comments may name it where
technically necessary for precision. 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 - **Confirm commits.** Per the user's global rules, every
`git commit` is preceded by an explicit message proposal `git commit` is preceded by an explicit message proposal
and user approval. No AI attribution in commit messages. and user approval. No AI attribution in commit messages.
Generated
+113 -3
View File
@@ -64,6 +64,12 @@ dependencies = [
"x11rb", "x11rb",
] ]
[[package]]
name = "arrayvec"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]] [[package]]
name = "atomic" name = "atomic"
version = "0.6.1" version = "0.6.1"
@@ -164,6 +170,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
version = "0.2.1" version = "0.2.1"
@@ -481,6 +493,12 @@ dependencies = [
"litrs", "litrs",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -976,7 +994,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [ dependencies = [
"nix", "nix 0.29.0",
"winapi", "winapi",
] ]
@@ -1038,6 +1056,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@@ -1046,7 +1076,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases 0.2.1",
"libc", "libc",
"memoffset", "memoffset",
] ]
@@ -1360,6 +1390,27 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" 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]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@@ -1548,6 +1599,7 @@ dependencies = [
"futures-util", "futures-util",
"gethostname", "gethostname",
"insta", "insta",
"portable-pty",
"pretty_assertions", "pretty_assertions",
"rand 0.10.1", "rand 0.10.1",
"ratatui", "ratatui",
@@ -1559,6 +1611,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"vt100",
"zip", "zip",
] ]
@@ -1738,6 +1791,17 @@ dependencies = [
"unsafe-libyaml-norway", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1758,6 +1822,22 @@ dependencies = [
"lazy_static", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -1968,7 +2048,7 @@ dependencies = [
"libc", "libc",
"log", "log",
"memmem", "memmem",
"nix", "nix 0.29.0",
"num-derive", "num-derive",
"num-traits", "num-traits",
"ordered-float", "ordered-float",
@@ -2240,6 +2320,27 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "vtparse" name = "vtparse"
version = "0.6.2" version = "0.6.2"
@@ -2639,6 +2740,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
+2
View File
@@ -59,8 +59,10 @@ zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
[dev-dependencies] [dev-dependencies]
insta = { version = "1.47.2", features = ["yaml"] } insta = { version = "1.47.2", features = ["yaml"] }
portable-pty = "0.9"
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
tempfile = "3.27.0" tempfile = "3.27.0"
vt100 = "0.16"
# Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test` # Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test`
# links ~25 separate integration-test binaries, each statically # links ~25 separate integration-test binaries, each statically
+53
View File
@@ -147,3 +147,56 @@ and on a nightly schedule for any extended coverage.
only slow tier and is kept narrow. only slow tier and is kept narrow.
- Adding a feature implies adding tests at the appropriate tier - 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_does_not_leak_once_the_count_or_a_clause_is_given,
seed_count_hint_also_fires_after_a_column_fill_target}`. 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 ## Out of scope
Deliberately deferred to keep this ADR shippable as a single 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 table-ident from new-name-ident visually is a future
enhancement. 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 ## References
- ADR-0023 — Unified declarative grammar tree (Proposed direction). Superseded by this ADR for execution detail. - ADR-0023 — Unified declarative grammar tree (Proposed direction). Superseded by this ADR for execution detail.
@@ -86,3 +86,234 @@ verified against cargo-binstall SUPPORT.md):
- Remaining follow-up: run the real `cargo binstall` validation at the - Remaining follow-up: run the real `cargo binstall` validation at the
first publish + matching release (the license files, © holder, and first publish + matching release (the license files, © holder, and
CONTRIBUTING are now in place). CONTRIBUTING are now in place).
## Amendment 1 — 2026-06-18: published live + a manual `publish` workflow
**`rdbms-playground 0.2.0` is published to crates.io** (`cargo install` and
`cargo binstall rdbms-playground` both verified working by the user). The
"unverified binstall" caveat is resolved — the per-target overrides
resolve correctly against the `v0.2.0` release assets.
**How publishing is wired:** a new **manual `workflow_dispatch` workflow**
(`.gitea/workflows/publish.yaml`), mirroring `release-macos.yaml`, takes a
`tag` input and runs `cargo publish` (token via the
`CARGO_REGISTRY_TOKEN` Gitea Actions secret — a crate-scoped,
publish-update token). **Not** automated on the tag, by decision: the
publish is irreversible (yank-only), keeping the registry token off every
tag push; the release is split (Linux/Windows on the tag, macOS
dispatched), so a human is the natural "all assets are up — go" gate; and
crates.io has no Gitea-Actions trusted-publishing path today, so a stored
token on the self-hosted runner would be the only automated option.
Each registry is its **own idempotent job** (no inter-job `needs`) — the
crates.io job skips cleanly if the version is already published (crates.io
API pre-check + `cargo publish` as the backstop) — so future
Scoop/Homebrew/winget jobs can be added alongside without breaking one
another or re-runs. The first such job's `tag`-vs-`Cargo.toml` guard
mirrors `release.yaml`.
## Amendment 2 — 2026-06-19: Scoop bucket + Homebrew tap (D3 §3b/§3c)
Two more package managers wired as **sibling `publish.yaml` jobs**
(`scoop-bucket`, `homebrew-tap`), following Amendment 1's independent +
idempotent pattern. Each fetches the release's `.sha256` sidecars, renders
a manifest, and commits it into a per-manager repo.
**Repos — org-level and multi-package.** Both live under a new **`lazyeval`
Gitea organisation** (created with the `oli` account, which gives the
`git.lazyeval.net/lazyeval/...` paths): `lazyeval/scoop-bucket` and
`lazyeval/homebrew-tap`. A Scoop *bucket* and a Homebrew *tap* are by
definition **collections of manifests**, so these are reusable for future
tools, not single-package repos. Homebrew's `homebrew-` repo-name prefix is
mandatory (→ referenced as `lazyeval/tap`); Scoop's bucket name is free.
Users: `scoop bucket add lazyeval <url>` (the label is local/arbitrary;
only the URL owner is real) then `scoop install rdbms-playground`; and
`brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap`
(the explicit-URL form — the `user/repo` shorthand assumes GitHub) then
`brew install lazyeval/tap/rdbms-playground`.
**Credential — a scoped bot user, not an `oli` PAT.** Gitea PATs scope by
**permission category, not per-repository** (`write:repository` grants
write to *every* repo the account can reach — there is no repo picker like
GitHub fine-grained PATs). So an `oli` token would also be able to push to
`oli/rdbms-playground` itself. Instead a dedicated bot user **`lazyeval-ci`**
is a member of a `lazyeval` org team with **Write** to the package repos
only; its `write:repository` PAT is therefore effectively scoped to those
repos and **cannot touch the main project repo**. Stored as the
`LAZYEVAL_PKG_TOKEN` Actions secret on `oli/rdbms-playground` (where the
workflow runs — *not* an org secret, which wouldn't reach a user-repo
workflow; *not* on the target repos, which only receive pushes). Passed via
`env:` (never inlined), so it stays masked and only materialises in the
clone URL at runtime; pushes go to `HEAD:main` (assumes the repos default
to `main`).
**Render scripts are dependency-free bash.** The CI job container is
`node:22-bookworm-slim` — **no jq, no ruby** — so
`scripts/render-{scoop-manifest,homebrew-formula}.sh` are pure bash
(heredocs, no external deps) taking a version + the relevant hashes and
emitting the manifest on stdout. `scripts/test-package-renders.sh` is their
test (JSON validated with `node` — present in the image — plus `jq`/`ruby`
when available; field-level assertions). The job validates the rendered
Scoop JSON with `node -e JSON.parse` before committing.
**Manifest specifics.**
- *Scoop* (`rdbms-playground.json` at bucket root): `64bit` =
`x86_64-pc-windows-gnu.exe`, `arm64` =
`aarch64-pc-windows-gnullvm.exe`; each URL carries a
`#/rdbms-playground.exe` rename fragment so the `bin` shim resolves
regardless of version. Carries `checkver` (lets `scoop status` / the
community excavator see lag) but **no `autoupdate`** — our pipeline is the
updater.
- *Homebrew* (`Formula/rdbms-playground.rb`): `on_macos`/`on_linux` ×
`on_arm`/`on_intel` selecting the four bare-binary assets (macOS direct;
Linux = the static `-musl` build). **Windows absent** — Homebrew has no
Windows port. `install` drops the single staged binary under a stable
name; the `test` block runs `--version`.
**Unverified (validate on first real use):** an actual `scoop install` and
`brew install`/`brew test`; the `HEAD:main` default-branch assumption; and
whether macOS Gatekeeper accepts the **ad-hoc-signed** mac binary via
`brew` (execution should be fine — ad-hoc satisfies arm64's signing
requirement and `brew`'s curl download sets no quarantine xattr, unlike a
browser download — but this rides on the still-parked Developer-ID signing
decision). **Remaining D3:** winget (komac on Linux CI, or a manual PR).
## Amendment 3 — 2026-06-19: validated end-to-end; a presigned-URL/HEAD gotcha + the Caddy fix it needs
**Scoop and Homebrew now install and run end-to-end** (`v0.2.0`,
user-verified on real Windows + macOS): `scoop install rdbms-playground`
and `brew install lazyeval/tap/rdbms-playground` both fetch, checksum,
install, and the installed binary launches. Amendment 2's "unverified"
caveats are resolved (the `HEAD:main` push populated both repos cleanly).
**Distribution depends on a server-side Caddy rule — RECORD THIS: it lives
in the Gitea edge config, NOT this repo, and if it is lost in a server
rebuild Homebrew silently 403s again.** Symptom: `brew install` failed with
`curl (22) … 403` on the asset URL. Root cause: Gitea (≥1.25; here 1.26.2)
serves release-asset downloads by **303-redirecting to a method-bound
AWS-SigV4 presigned S3 URL** (OVH), signed for the *incoming* request's HTTP
verb. Homebrew **resolves the URL with a HEAD, captures the returned
(HEAD-signed) presigned URL, then runs the download GET against that
captured URL** → GET-on-a-HEAD-signed-URL → 403. Reproduced precisely:
`GET on HEAD-resolved = 403`, `HEAD on HEAD-resolved = 200`, `GET on
GET-resolved = 200`, `HEAD on GET-resolved = 403`. GET-only tools
(`install.sh`, `install.ps1`, `cargo binstall`, curl) are unaffected — they
GET the original URL and let it redirect fresh to a GET-signed URL. This is
a **Homebrew defect** (it reuses an ephemeral, method-scoped credential as
if it were a durable resource locator); Gitea's per-request method-bound
signing is correct, and SigV4 *cannot* sign one URL for two verbs.
`SERVE_DIRECT=false` (Gitea proxies, no presign) would also fix it but was
declined — not reshaping storage for one client.
**Fix (deployed, "Tier A"): a Caddy rule that answers HEAD on
release-download paths directly** — 200, no redirect, no body — so brew's
resolve records the *original* URL (no presigned credential captured) and
its download GET runs through the redirect fresh → GET-signed → 200. Scoped
to `method HEAD` + the release-download path; **GET is untouched**, so every
working channel is unaffected, and a HEAD carries no payload so no client
can *break* (at most a HEAD-probing download manager loses a progress-bar
size — cosmetic). Reference Caddyfile (place before the Gitea
reverse_proxy):
```
@release_head {
method HEAD
path_regexp ^/[^/]+/[^/]+/releases/download/.+
}
handle @release_head {
header Accept-Ranges bytes
header Content-Type application/octet-stream
respond 200
}
```
A "Tier B" variant (a sidecar that fetches and returns the *real*
Content-Length so even HEAD-probing clients stay fully faithful) was specced
but proved unnecessary — brew is happy with the bare 200.
**macOS signing:** the brew-installed binary **runs** under the current
**ad-hoc** signature (`codesign --sign -`) — confirmed on Apple Silicon.
**Developer-ID signing + notarization remains parked** pending the user's
Apple account conversion to an Organization (GA plan / ADR-ci-003); it is
needed for *browser-download* trust, not for the package-manager paths,
which are all working now.
**Remaining D3:** winget only.
## Amendment 4 — 2026-06-20: winget (D3 §3d) — the last package manager
winget wired as the fourth `publish.yaml` sibling job, **completing the D3
package-manager set** (crates.io/binstall, Scoop, Homebrew, winget; plus the
install scripts + direct binaries).
**Model — fundamentally unlike Scoop/Homebrew.** winget has no
self-hosted-source equivalent: its default catalog is the central,
**human-gated** GitHub repo `microsoft/winget-pkgs`, and you get in by
**opening a PR** of manifests into it. So the job submits a PR (via
**komac**, pinned `2.16.0`, prebuilt glibc binary — the CI image has no
cargo) and **Microsoft's pipeline validates async** (schema, SHA256,
AV/SmartScreen scan, sandbox install) + moderator review. It is *not* live
on dispatch like the bucket/tap.
**PackageIdentifier `LazyEvaluation.RdbmsPlayground`** (publisher segment =
the publishing entity / `lazyeval` org / license holder — consistent with
everything else). Bare-exe → winget **`portable`** installer type; x64
(`-pc-windows-gnu`) + arm64 (`-pc-windows-gnullvm`), komac infers arch +
type from the binaries.
**Auth — a dedicated GitHub bot, classic token.** komac needs a **classic
`public_repo` PAT**; **fine-grained tokens cannot open the cross-fork PR**
(komac #310 — the PR is created on the *target* repo you don't administer,
which the fine-grained model can't express). A classic `public_repo` token
can't be scoped to one repo, so it lives on a **dedicated GitHub bot
account** (a leak can't reach other repos — the lazyeval-ci reasoning), as
the `WINGET_GITHUB_TOKEN` secret, referenced **only** in the `winget` job
(job-level scoping keeps it away from the crates.io / lazyeval tokens) and
passed to the API guards via a 0600 curl config file (never argv).
**Idempotency — stronger than the others need.** A re-submitted version
would open a *duplicate* PR, so before submitting the job guards on **both**
(1) already-merged versions (`contents` API on
`manifests/l/LazyEvaluation/RdbmsPlayground/<ver>`) and (2) an already-open
PR for the id+version (`search/issues`). Either → clean skip, so a repeated
`publish` dispatch is safe even mid-review.
**Signing:** none required to *submit* (only MSIX needs it; ours is
portable). The unsigned binary may earn an AV/SmartScreen **manual-review
label** on the first PR — usually clears; a nudge toward Trusted Signing
(the UK Ltd clears the 3-year-history bar) but not a blocker. Continues the
parked Developer-ID/notarization posture.
**One-time bootstrap (manual — NOT in CI, because `komac new` is
interactive).** Run once to create the package in winget-pkgs, then CI
`komac update` handles every release after:
```
komac token update # paste the bot's classic public_repo PAT at the prompt (not in argv/history)
komac new LazyEvaluation.RdbmsPlayground \
-v 0.2.0 \
-u https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-x86_64-pc-windows-gnu.exe \
https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-aarch64-pc-windows-gnullvm.exe \
--publisher "Lazy Evaluation Ltd" --package-name "RDBMS Playground" \
--moniker rdbms-playground --license "MIT OR Apache-2.0" \
--package-url https://relplay.org -s
```
komac downloads the two exes, detects the bare binary as **`portable`**, and
**prompts for the command alias — enter `rdbms-playground`** so users get
that on PATH (not the long versioned filename). `-s` opens the PR; fill any
remaining prompts. (Flags verified against komac 2.16.0: `-v/--version`,
`-u/--urls`, `-s/--submit`; the alias has no flag and is prompted.) Once
that first PR merges, the CI job's `komac update` takes over.
**Bootstrap learnings (2026-06-21, first real run).** GitHub bot account =
`lazyeval-ci`. Its fork **`lazyeval-ci/winget-pkgs` must exist *before*
`komac new`** — komac's auto-fork races on winget-pkgs (one of GitHub's
largest repos) and fails with *"Could not resolve to a Repository
'lazyeval-ci/winget-pkgs'"*; fork it manually (web **Fork**, or `gh repo
fork microsoft/winget-pkgs --clone=false`), wait for it to populate, then
re-run. **Alias:** for a **direct single-exe portable**, winget derives the
on-PATH command from **`Commands[0]`** (the interactive "Commands" prompt →
`rdbms-playground`) — **not** `PortableCommandAlias`, which is
**archive/nested-portable-only** and must NOT be added to our manifest
(verified against winget-cli's portable-install logic + the 1.6.0 installer
schema; komac correctly omits it). So komac's generated manifest needs no
hand-editing. First PR: **#391335**.
+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).
+5 -4
View File
File diff suppressed because one or more lines are too long
+137
View File
@@ -0,0 +1,137 @@
# Session handoff — 2026-06-18 (74)
Large session. Continues from handoff-73 (Ctrl-G demo alias). This one ran
the **road to public availability** end to end: a post-merge doc
reconciliation, then versioning, installers, crates.io/binstall, the
`cargo fmt` gate, and an actual **`v0.2.0` release that is now live on
crates.io**. Three new main-sequence ADRs (0054/0055/0056), one CI-ADR
amendment, issue **#35 closed**.
## §1. State
**Branch `main`.** **2509 pass / 0 fail / 1 ignored** (the long-standing
`friendly` doctest); **clippy clean**; **`cargo fmt --check` clean** (the
tree is now stock-rustfmt formatted, and CI gates it). `rdbms-playground
--version` → `rdbms-playground 0.2.0`.
**Released:** **`v0.2.0`** — Gitea release with all six D1 targets
(Linux/Windows via `release.yaml` on the tag; macOS via the dispatched
`release-macos.yaml`, **ad-hoc-signed**). **Published to crates.io**
(`cargo install rdbms-playground` and `cargo binstall rdbms-playground`
both user-verified).
**Push state:** earlier commits were pushed (they triggered CI/releases).
The **most recent commits are unpushed** and matter:
- `3c87dbb` macOS nix-prune fix (takes effect next macOS dispatch).
- `d3af1c4` + `8ebe213` the manual `publish.yaml` workflow — ADR-0056 in
the first, the **workflow file itself in the second** (`git commit -am`
had skipped the new untracked file).
- this handoff.
Push `main` to land them. (Push is the user's step.)
## §2. What shipped (commits `628b250`→`d3af1c4`)
- **`628b250` doc reconciliation** after the CI + website branch merges:
rewrote CLAUDE.md's stale repo-layout tree; added a Website subproject
note; fixed the CI note; **gitignored `.wrangler/` + `.vscode/`** and
removed a tracked `website/.vscode/`; updated requirements (D1 macOS
runtime-verified, DOC1 canonical-docs-on-website) + ADR-ci-003.
- **`c30a611` ADR-0054 — version surfaces.** `--version`/`-V` + an in-app
**`version`** command, both reading `CARGO_PKG_VERSION` via one
`cli::version_text()`. A **release-CI guard** fails the release unless
the `v*` tag equals `v<Cargo.toml version>`.
- **`ef99e6c` ADR-0055 — `scripts/install.sh`** (curl|sh, POSIX,
shellcheck-clean, checksum-verified, `~/.local/bin`). Verified
end-to-end against the live release. **`install.ps1`** (Windows
`irm|iex`) added later (`e9606b5`) — **written but untested here** (no
PowerShell on this box; validate on Windows).
- **`e9606b5` ADR-0056 — crates.io + binstall prep.** Publish-ready
Cargo.toml (dropped `publish=false`; homepage/keywords/categories/
`exclude`); `README.md`; `LICENSE-MIT`/`LICENSE-APACHE` (dual, © Lazy
Evaluation Ltd) + `CONTRIBUTING.md` (inbound=outbound); the
`[package.metadata.binstall]` block with per-target overrides
(linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct).
- **`41b7e9a` + `ec3c7c3` — #35 fmt gate.** One mechanical `cargo fmt`
(stock defaults, 102 files, behaviour-preserving) recorded in
`.git-blame-ignore-revs`; `ci.yaml` now gates `fmt --check` (ADR-ci-002
Amendment 1). **Closes #35.**
- **`88830ed`+`bd5be5e` — v0.2.0 bump + the guard bug.** The first
`release.yaml` run **failed** at the version guard: it piped `nix
develop -c cargo metadata` to node, but the **flake devShell prints a
banner to stdout**, corrupting the JSON. Fixed to a toolchain-free
`grep -m1 '^version = ' Cargo.toml`. The `v0.2.0` tag was re-pointed
(Option A) to the fix commit; re-run went green.
- **`3c87dbb` — macOS nix-prune fix.** The prune step's profile dir
(`~/.cache/rdbms-ci`) didn't exist, so `nix develop --profile` errored
(swallowed by `|| true`) → the gc-root was never created → the whole
toolchain (~3.8 GiB) was deleted **and re-downloaded every run**. Added
`mkdir -p` + dropped the `|| true`. Diagnosed from the run-74 log via
`tea actions runs logs 74`.
- **`d3af1c4` — manual `publish.yaml`.** `workflow_dispatch` + `tag`
input (mirrors `release-macos.yaml`). Idempotent `crates-io` job
(crates.io API pre-check + `cargo publish` backstop), independent jobs
so Scoop/Homebrew/winget slot in later. ADR-0056 Amendment 1.
## §3. Live vs manual vs parked
- **Automated on a `v*` tag:** `release.yaml` builds + publishes the four
Linux/Windows targets (+ fmt/clippy/test gate).
- **Manual `workflow_dispatch`:** `release-macos.yaml` (mac binaries —
intermittent runner) and `publish.yaml` (crates.io now; more registries
later). Run them once the tag's build is up.
- **Parked (user decisions):**
- **macOS Developer-ID signing.** The pipeline **ad-hoc-signs**
(`codesign --sign -`). The user's `Apple Development` cert is the
**wrong type** — distribution needs **`Developer ID Application`** +
**notarization** (App Store Connect API key recommended). Fine for
`curl|sh` (no quarantine); matters for browser downloads. Details in
`docs/plans/20260616-public-availability.md`.
- **Remaining D3:** Scoop (`lazyeval` bucket), Homebrew (`lazyeval`
tap), winget (komac on Linux CI, or manual PR) — each a sibling job
in `publish.yaml` + a manifest repo.
## §4. Immediate next steps
1. **Push `main`** (lands `3c87dbb` + `d3af1c4`).
2. **Add the `CARGO_REGISTRY_TOKEN` secret** (crate-scoped,
`publish-update`) so `publish.yaml` works: `tea actions secrets create
CARGO_REGISTRY_TOKEN` (paste at prompt) or the Gitea UI.
3. **Smoke-test `publish.yaml`:** dispatch it for `v0.2.0` — it should
**idempotently skip** ("already on crates.io"), exercising the path
risk-free.
4. The release ritual going forward (ADR-0054): bump `Cargo.toml` →
commit → tag `v<x.y.z>` → push tag (Linux/Windows release builds) →
dispatch `release-macos` → dispatch `publish`.
## §5. Gotchas learned (don't relearn the hard way)
- **The flake devShell prints a banner to stdout** — never pipe `nix
develop -c <cmd>` into a parser. Read Cargo.toml directly, etc.
- **Workflow-file source differs by trigger:** a **tag**-triggered run
(`release.yaml`) uses the workflow **at the tagged commit**; a
**`workflow_dispatch`** run (`release-macos`/`publish`) uses the
**default branch** (`main`). So fixing a dispatched workflow only needs
a `main` push; fixing a tag-triggered one needs the tag re-pointed.
- **Version vs tag:** `Cargo.toml` is bare `0.2.0`; the git tag is
`v0.2.0`; the guard checks `tag == "v" + version`; binstall `pkg-url`
spells `v{ version }`.
- **CI logs are reachable** via `tea actions runs logs <id>` (and `tea
actions runs list --output tsv`). Use it instead of guessing from a
step name.
- **crates.io API needs a descriptive User-Agent** (403 without one).
## §6. How to take over
1. Read handoffs 72 → 73 → 74, `CLAUDE.md`, `docs/requirements.md`, and
**`docs/plans/20260616-public-availability.md`** (the GA roadmap with
all decisions + parked items).
2. Confirm green: `cargo test` (**2509 / 1 ignored**), `cargo clippy
--all-targets`, `cargo fmt --check`.
3. ADRs for this arc: **0054** (versioning), **0055** (installer),
**0056** (crates.io/binstall + the publish workflow); CI side
**ADR-ci-002 Amendment 1** (fmt gate), **ADR-ci-003** (release matrix
+ macOS).
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
ADR amendment + README index-upkeep for decided-area changes, confirm
commit messages, never push.
5. Consider a `cargo sweep` at this milestone (`target/` grows).
+145
View File
@@ -0,0 +1,145 @@
# Session handoff — 2026-06-21 (75)
Continues from handoff-74 (v0.2.0 live on crates.io). This session **finished
the D3 package-manager rollout** — Scoop, Homebrew, and winget — plus the
Windows `install.ps1` fixes, and included a deep **presigned-URL / Homebrew**
debugging saga that's fixed by a server-side Caddy rule. Five commits; one
new ADR amendment arc (ADR-0056 Amendments 2–4).
## §1. State
**Branch `main`.** **No crate (Rust) code changed this session** — only
`.gitea/workflows/`, `scripts/`, and `docs/`. So the `cargo test` baseline
from handoff-74 (**2509 pass / 0 fail / 1 ignored**, clippy + `fmt --check`
clean) stands unchanged by construction; not re-run (nothing in the build
graph moved). New coverage this session is **`scripts/test-package-renders.sh`**
(shellcheck-clean, green), now gated on every push by a new **`ci.yaml`
`manifests` job** (bash + node; ruby-absent in CI degrades gracefully).
**Commits this session** (`cabc813`→`0208c67`; the workflow-bearing ones were
pushed, since the live `publish` dispatches + raw-URL installs the user tested
needed them on `main`):
- `42b40bc` install.ps1 → Windows PowerShell 5.1 compat
- `c0531aa` install.ps1 → immediate-use PATH + honest messaging
- `6d54c1e` Scoop + Homebrew jobs (D3 §3b/§3c)
- `6bb2288` Scoop/Homebrew validated + Caddy fix recorded (Amendment 3)
- `0208c67` winget job via komac (D3 §3d, Amendment 4)
**External / infra state created this session (NOT all in the repo):**
- **Caddy Tier-A HEAD rule on the Gitea server** — *load-bearing for Homebrew*,
lives in the Gitea edge config, **not** this repo (see §4). If lost on a
server rebuild, brew 403s again.
- **Gitea:** `lazyeval` org with repos **`scoop-bucket`** + **`homebrew-tap`**;
**`lazyeval-ci`** Gitea bot user (org team, Write to those repos);
**`LAZYEVAL_PKG_TOKEN`** secret on `oli/rdbms-playground`.
- **GitHub:** **`lazyeval-ci`** bot account; classic `public_repo` PAT →
**`WINGET_GITHUB_TOKEN`** secret on `oli/rdbms-playground`; fork
**`lazyeval-ci/winget-pkgs`**.
- **winget bootstrap PR [#391335](https://github.com/microsoft/winget-pkgs/pull/391335)**
submitted, in Microsoft review.
- **komac 2.16.0** installed locally at `~/.local/bin/komac`.
## §2. D3 — the package-manager set (the session's throughline)
| Channel | State |
| --- | --- |
| crates.io / `cargo binstall` | **live** (handoff-74) |
| `install.sh` / `install.ps1` | **live** — PS1 validated on ARM Windows 11 (5.1 + 7.6) |
| Scoop (`lazyeval/scoop-bucket`) | **live + validated** (install + run) |
| Homebrew (`lazyeval/homebrew-tap`) | **live + validated** — needs the Caddy rule (§4) |
| winget (`LazyEvaluation.RdbmsPlayground`) | **wired**; PR #391335 awaiting Microsoft merge |
How Scoop/Homebrew/winget are wired: sibling jobs in
`.gitea/workflows/publish.yaml` (manual `workflow_dispatch`, `tag` input),
each idempotent + independent. Scoop/Homebrew render **dependency-free bash**
manifests (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) from the
release `.sha256` sidecars and push to the org repos via `LAZYEVAL_PKG_TOKEN`.
winget runs `komac update --submit` to PR `microsoft/winget-pkgs`, guarded
against duplicate PRs. ADR-0056 **Amendments 2–4** carry the full design.
## §3. The Homebrew 403 saga + Caddy fix (don't lose this)
Homebrew `brew install` 403'd while every other tool worked. Root cause,
reproduced: Gitea (≥1.25; here 1.26.2) **303-redirects release downloads to a
method-bound AWS-SigV4 presigned S3 URL** (OVH), signed for the *incoming*
request's HTTP verb. Homebrew **resolves with a HEAD, captures the returned
HEAD-signed URL, then runs the download GET against it** → GET-on-a-HEAD-signed
URL → 403. (`GET on HEAD-resolved = 403`, `HEAD on HEAD-resolved = 200`,
`GET on GET-resolved = 200`.) GET-only tools (install.sh/ps1, binstall, curl)
are unaffected. This is a **Homebrew defect** (reusing an ephemeral
method-scoped credential as a durable locator); Gitea's per-request signing is
correct, and SigV4 *cannot* sign one URL for two verbs. `SERVE_DIRECT=false`
would fix it but was **declined** (don't reshape storage for one client).
**Fix (deployed, "Tier A"):** a Caddy rule answering **HEAD on
`…/releases/download/…` directly** (200, no redirect, no body) so brew's
resolve records the *original* URL and its download GET redirects fresh →
GET-signed → 200. GET untouched; a HEAD carries no payload so nothing can
break (a HEAD-probing download manager at most loses a progress-bar size).
Reference Caddyfile is in **ADR-0056 Amendment 3**.
## §4. Immediate next steps
1. **winget PR #391335:** watch validation — the one likely speed bump is an
**AV/SmartScreen manual-review label** (unsigned binary); usually clears.
Once a moderator merges, winget is live.
2. **Post-merge check (Windows):** `winget install LazyEvaluation.RdbmsPlayground`,
confirm the command is **`rdbms-playground`** (it is — a direct single-exe
portable takes its PATH alias from `Commands[0]`, which komac set; see §5).
3. After merge, future releases are hands-off: the CI `winget` job's
`komac update` carries each version (alias inherited from the merged
manifest). The release ritual is unchanged from ADR-0054, with the
`publish` dispatch now also doing Scoop/Homebrew/winget.
## §5. Gotchas learned (don't relearn the hard way)
- **The Caddy HEAD rule is server-side, not in this repo.** Homebrew (and any
HEAD-then-GET client) depends on it. Record/back it up.
- **GitHub tokens:** komac needs a **classic `public_repo`** PAT — **fine-grained
tokens can't open the cross-fork PR** (komac #310: the PR is created on the
*target* repo you don't administer, which the fine-grained model can't
express). A classic token can't be repo-scoped, so it lives on a **dedicated
bot** (`lazyeval-ci`) to bound the blast radius.
- **komac fork must pre-exist:** `komac new` auto-fork *races* on winget-pkgs
(huge repo) → *"Could not resolve to a Repository 'lazyeval-ci/winget-pkgs'"*.
Fork manually first (`gh repo fork microsoft/winget-pkgs --clone=false`),
wait, then re-run. Pre-fill `komac new` flags to skip the long interactive
prompt parade.
- **Direct portable alias = `Commands[0]`, NOT `PortableCommandAlias`.** The
latter is **archive/nested-portable-only**; adding it to a bare-exe portable
is wrong (komac correctly omits it). The "Commands" prompt → `rdbms-playground`
is the on-PATH command. Verified vs winget-cli logic + the 1.6.0 schema.
- **install.ps1 / Windows PowerShell 5.1** (the in-box shell — PS7 is opt-in):
arch via `PROCESSOR_ARCHITECTURE` env (not `RuntimeInformation::OSArchitecture`,
which is absent under 5.1's .NET-Framework facade + StrictMode); force TLS 1.2;
`-UseBasicParsing`. Also update `$env:Path` in-session (the persisted User PATH
only reaches *new* processes; "restart your shell" was wrong — sign-out/in or
the in-session update).
- **Gitea release downloads go through OVH S3 presigned URLs** — method-bound,
300 s expiry. Never assume HEAD-resolve-then-GET works against them.
## §6. Parked / deferred (user decisions)
- **macOS Developer-ID signing + notarization** — pending the Apple account →
Organization conversion. Ad-hoc signing covers all package-manager paths
(brew-installed binary *runs* on Apple Silicon); Developer-ID only matters for
browser-download Gatekeeper trust.
- **Windows code signing (Trusted Signing)** — not required to *ship* winget
(portable; only MSIX needs signing). Unsigned can earn an AV/SmartScreen
manual-review label + a user-run warning. **Lazy Evaluation Ltd (since 2012)
clears Trusted Signing's 3-year-org bar** when the user wants to remove the
warning (individual onboarding is currently paused; org path is open).
- **Release notes / CHANGELOG** — raised this session, not done. If wanted:
a `CHANGELOG.md` and/or populated Gitea release bodies, then the CI `winget`
job can pass komac `--release-notes-url …/releases/tag/$TAG`.
## §7. How to take over
1. Read handoffs 73 → 74 → 75, `CLAUDE.md`, `docs/requirements.md` (D1/D3),
and **ADR-0056 (esp. Amendments 1–4)** + the GA plan
`docs/plans/20260616-public-availability.md`.
2. Workflow unchanged: phased, test-first, `/runda` + DA before commits, ADR
amendment + README index-upkeep for decided-area changes, confirm commit
messages, never push.
3. If `cargo test` is needed, the baseline is **2509 / 1 ignored** (handoff-74).
4. Consider a `cargo sweep` at this milestone (`target/` grows).
+130
View File
@@ -0,0 +1,130 @@
# Website-branch handoff — 2026-06-21 (website-3)
Third handoff for the **website** work (separate sequence from `main`'s
`YYYYMMDD-handoff-NN.md`). Read **website-2** (2026-06-11) first for the
scaffolding, cast pipeline, and the no-advertising constraint; this note covers
everything since, and — importantly — the state has moved on (see §1).
## §1. State (read this first)
- **Branch:** `website`. **HEAD `dff7841`.** Working tree **clean**.
- **Pushed:** `origin/website` == `dff7841` (this session's work is pushed).
- **Already merged into `main`:** `dff7841` is an ancestor of `main` — every
website change below is in `main`. **`main` has since advanced ~20 commits**
(now `56e3456`, handoff 75) with **v0.2.0 + install/packaging** work. So
**`website` is ~20 commits behind `main`.**
- **Consequence for the next session:** start by **merging `main` into
`website` again** (or rebranching `website` off `main`). There is real new
user-facing surface to document — see §4.
- **Agent limitation:** this machine's SSH key for `git.lazyeval.net` was **not
available to the agent** — `git fetch`/`push` fail from inside the session.
**Push and fetch are the user's steps.** Local merges work fine.
## §2. What shipped this session (all in `main` now)
The website lives in **`website/`**; living style guide **`website/STYLE.md`**;
decisions **`docs/website/adr/20260604-adr-website-001.md`**.
**CI / deployment — the big one (ADR-website-001 §4):**
- **`.gitea/workflows/website.yaml`** — on a push to `main` or `website` that
touches `website/**`, build the Astro site with pnpm and `wrangler pages
deploy website/dist` to the Cloudflare **Pages project `relplay`** (Direct
Upload, no Git integration). `--branch` selects environment vs the project's
production branch (`main`): **`main` → production (`relplay.org`)**,
**`website` → preview (`website.relplay.pages.dev`)**.
- Pure-Node build (the `.cast` files are committed, so no cargo). Runs on the
bare `ci-public` runner (node present; pnpm via corepack, pinned by
`package.json`'s `packageManager: pnpm@10.30.3`).
- **Secrets (set in repo Actions settings):** `CLOUDFLARE_API_TOKEN` (Pages:
Edit) + `CLOUDFLARE_ACCOUNT_ID`. Pages project created with production branch
= `main`.
- **`ci.yaml` gate** now skips website-only pushes (`paths-ignore` += `website/**`,
`.gitea/workflows/website.yaml`).
- **`staging.relplay.org`** (optional): attach the custom domain to the `website`
branch alias — add it in the Pages dashboard, then point its **proxied**
CNAME at `website.relplay.pages.dev` (external/unproxied DNS falls through to
production). Cloudflare Pages maps branches to **subdomains, not sub-folders**.
**Content updates (from two `main` merges that landed seed/readline/hint/etc.):**
- **Seed page** (`reference/generating-sample-data.mdx`) refreshed for **#33/#34**:
year-as-int columns (`*_year`/`published` → plausible years), built-in value
sets (`priority`/`severity`/`rating`), advisory now **status-only**. All output
blocks re-captured; the seed cast reflects it.
- **`hint` feature** (ADR-0053) documented: `getting-help.md` → **`.mdx`** with a
"Hints" section (F1 = tier-3 live-input hint; `hint` command = last-error hint)
+ the real rendered block; `the-assistive-editor.mdx` points at F1.
- **Readline keys (#29)** + **cross-mode history recall (#30)** documented on
`the-assistive-editor.mdx`; "Planned" narrowed to multi-line entry only.
- **Landing** (`index.mdx`): the redundant plain **`<h1>` "RDBMS Playground"** is
hidden (kept for SEO/a11y via a **title-only hero**, landing-scoped in
`global.css`); the **Wordmark + tagline + buttons** render in the body so the
lockup sits where the title was and content rises above the fold (it was nearly
hidden on an iPad).
- **All 10 casts re-recorded** + a **new `hint` cast** (11 total) against the
current app.
**Casts — the F1 trick (important for future hint/keybinding casts):**
- autocast **cannot send F1** (or arrows/function keys — escape sequences split
by the per-key delay). Solution shipped on `main`: **ADR-0047 Amendment 1** —
in `--demo` mode **Ctrl-G aliases F1** and **badges AS `[F1]`**, so a cast looks
like a real F1 press. The cast generator gained `CtrlG: '^G'`.
- The **hint cast** uses it realistically: type `add column `, pause, **F1**
(Ctrl-G) mid-command to recall the syntax, finish the command from the example,
then later a failed command + `hint` for the error.
**Hint-vs-docs audit (user-requested):** one genuine conflict found —
`hint.cmd.create_table.example` declared a 3-column compound PK; **fixed upstream**
(`5a37437`, now `create table Customers with pk id(serial)`). Everything else
(export/import, indexes, constraints, DML, SQL forms, error hints, terminology)
was consistent.
## §3. Verification status
- `cd website && pnpm build` → **clean, 27 pages**. Forbidden-terms grep clean
(`DSL`/`SQLite`/`STRICT`/`rusqlite`/`PRAGMA`). Output-table box-drawing intact;
anchors resolve; hint cast content verified (`[F1]` badge + both hint blocks).
- **NOT done — needs a human visual pass:** the new **landing spacing/aesthetics**
(couldn't be eyeballed by the agent) and the **11 re-recorded casts** (verified
programmatically only). Do this in staging/production before relying on them.
## §4. NEXT WORK — priority order
1. **Re-merge `main` into `website`** (it's ~20 behind) — or rebranch off `main`.
2. **Reconcile the installation / getting-started docs with what `main` actually
shipped (v0.2.0):** the website branch's `installation.md` predates several
real install methods now live —
- **crates.io v0.2.0** (`cargo install rdbms-playground`, `cargo binstall`),
- the **curl | sh installer** (ADR-0055),
- **Windows `install.ps1`** (`fix(install)` commits),
- **package managers**: Scoop / Homebrew / **winget** (ADR-0056; winget via
komac). `installation.md` mentions brew/scoop but **not** winget, the
curl|sh installer, or binstall — reconcile against the shipped reality
(check ADR-0055/0056 + handoffs 74/75 + the `publish.yaml` workflow).
- There's also `--version`/`-V` + an in-app `version` command (ADR-0054) — a
line on the command-line-options page may be warranted.
3. **Visual sweep** (§3): landing + casts; final caption review (website-2 §2).
4. **Carryover from website-2:** light/dark player theme (deferred), open STYLE
decisions (#7/#8 mostly resolved; recheck), output-pane-scroll cast still
blocked (PgUp/PgDn unsendable — would need a typeable scroll key like #24).
## §5. Process pins
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
**append-only** (no amend/rebase/force-push). **Push/fetch are the user's
steps** (and the agent can't reach the remote anyway — §1).
- **Ground every page in source** (`src/dsl/*`, `src/friendly/strings/en-US.yaml`,
the ADRs) — not `requirements.md` markers. No engine name, no "DSL" in copy.
- **Output blocks captured from the real app**, never hand-drawn (STYLE.md). The
capture recipe (throwaway in-crate/`it` test → render → paste → delete) and the
cast pipeline (`pnpm casts [name]`, needs `../target/debug`) are in website-2.
- **Cast stance (revised 2026-06-12):** default to a cast; justify its *absence*.
- **Issues** via `tea` (repo `oli/rdbms-playground` on `git.lazyeval.net`;
append `< /dev/null` + `timeout 30`). This session filed **#33** (year ints)
and **#34** (enum value sets) — both **implemented** on `main` now.
## §6. Footnote — a git oddity in this branch's history
`c84a640 "docs(website): document the hint feature with a cast"` is an **empty
rename commit** (a `git add` tooling slip staged only the `.md`→`.mdx` rename);
its actual content is in the **next** commit `028d324`. Harmless, left in place
per the append-only rule (user-approved). No action needed.
+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.
+100 -32
View File
@@ -85,11 +85,24 @@ since ADR-0027.)
No target requires anything the user must install. ADR-ci-003.)* No target requires anything the user must install. ADR-ci-003.)*
- [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop, - [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop,
`winget`, and `cargo binstall`. `winget`, and `cargo binstall`.
*(Prebuilt binaries + checksums now published to Gitea releases *(Prebuilt binaries + checksums on Gitea releases (D1); **`cargo
(D1); the package-manager manifests (Homebrew / Scoop / winget / binstall` + crates.io live** (ADR-0056); **Scoop + Homebrew wired and
`cargo binstall`) remain to do. The asset naming validated end-to-end** on real Windows + macOS (ADR-0056 Amendments
`rdbms-playground-<tag>-<target>` is already binstall-friendly. 2 & 3) — `publish.yaml` `scoop-bucket` / `homebrew-tap` jobs render
Tracked under ADR-ci-003 "Deferred".)* dependency-free manifests from the release `.sha256` sidecars and push
them, via the scoped `lazyeval-ci` bot token, to
`lazyeval/scoop-bucket` and `lazyeval/homebrew-tap`; rendering covered
by `scripts/test-package-renders.sh`. NB: the Homebrew path depends on
a **server-side Caddy rule** answering HEAD on release-download paths
directly (Gitea's presigned-S3 redirect is method-bound and brew
reuses a HEAD-signed URL for its GET) — see ADR-0056 Amendment 3;
that rule lives in the Gitea edge config, not this repo. **winget
wired** (ADR-0056 Amendment 4) — a `publish.yaml` `winget` job submits
a PR to `microsoft/winget-pkgs` via komac (`LazyEvaluation.RdbmsPlayground`,
portable, x64+arm64), guarded against duplicate PRs; pending the
**one-time manual `komac new` bootstrap** + Microsoft review before
it goes live. Asset naming `rdbms-playground-<tag>-<target>` is
binstall-friendly.)*
## TUI shell ## TUI shell
@@ -905,28 +918,44 @@ since ADR-0027.)
for representative views. for representative views.
- [x] **TT3** Tier 3: synthetic event-loop integration tests - [x] **TT3** Tier 3: synthetic event-loop integration tests
covering the user-facing flows in this checklist. 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 → flows named in ADR-0008 (cold launch → DDL → quit; save →
reopen; export → import → rebuild; undo after DROP). reopen; export → import → rebuild; undo after DROP).
*(Verified 2026-06-07: **nothing is wired** — no *(Implemented 2026-06-22 — ADR-0008 **Amendment 1**;
`portable-pty` / `expectrl` / `vt100` dependencies, no PTY test `tests/e2e_pty.rs` (commit `fd63de3`). Drives the actual built
files; ADR-0008 §Tier-4 is a specification only. The Tier-3 binary in a real pseudo-terminal: `portable-pty` + `vt100`
`tests/it/*_e2e.rs` files are synthetic event-loop tests, not (**`expectrl` dropped** — conflicting PTY layer, replaced by a
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is hand-rolled `wait_for` on the vt100 grid). Fixed 100×30, per-test
wired only for the listed critical flows" — it was not wired at temp `--data-dir`, serial, tight fail-fast 3 s waits; table
all. Genuinely deferred.)* 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 - [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
stable Rust. stable Rust.
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea *(Partial, updated 2026-06-22. **CI is live** on the self-hosted
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` + Gitea Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings`
`cargo test` (Tiers 1–3) on the **Linux** runner for every branch + `cargo test` (now Tiers **1–4** — the `e2e_pty` target runs by
push / PR, and `release-macos` runs the suite natively on the default) on the **Linux** runner for every branch push / PR.
**macOS** runner. **Windows is build-only** — cross-compiled, not **macOS is fully covered**: `release-macos` runs the whole suite
executed (no Windows runner). **Tier 4** (PTY, TT4) is still natively on the macOS runner, and Tier 4 was confirmed green on
unwired, so "all tiers" is not yet fully met. "Stable Rust" is macOS (2026-06-22 — the 4 flows + startup; the Linux-only RSS probe
satisfied by the flake's pinned `1.95.0` (a stable release, not is `#[cfg]`-skipped). **Tier 4 in CI on Linux is met** (TT4);
nightly). Remaining for full TT5: a Windows execution runner and PTY-in-container was validated (openpty + spawn under Docker), with
Tier-4 PTY in CI.)* 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 ## Cross-cutting
@@ -1002,41 +1031,80 @@ target is measurable, it is stated numerically; where it is
necessarily qualitative, the criterion is named and the bar is necessarily qualitative, the criterion is named and the bar is
"reviewer judgement against the criterion." "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 rendered frame under 500ms on commodity hardware (developer
laptop, mid-range desktop). Measured in CI on the Linux runner laptop, mid-range desktop).
as a regression gate. *(Verified 2026-06-22, ADR-0057 — measured via the Tier-4 PTY
- [ ] **NFR-2 Performance — input latency.** Keystroke-to-render 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 latency under 16ms during normal editing; long-running queries
must execute off the UI thread so the interface remains must execute off the UI thread so the interface remains
responsive (typing, scrolling, mode switching) while a query is responsive (typing, scrolling, mode switching) while a query is
running. 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 under 50MB on the smallest target platform; no busy-loops; CPU
near zero when waiting for input. 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 palette and typography are deliberate and consistent across
views; layout uses Unicode box-drawing and symbols where they views; layout uses Unicode box-drawing and symbols where they
add clarity; rendering avoids the generic flat-default look add clarity; rendering avoids the generic flat-default look
that ships with most TUI frameworks. Criterion: a reviewer can that ships with most TUI frameworks. Criterion: a reviewer can
identify the app from a screenshot of any view. 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 information rather than decoration: mode indication, query
result types (numeric vs text vs null), error severity, result types (numeric vs text vs null), error severity,
syntax highlighting categories. Foreground/background syntax highlighting categories. Foreground/background
combinations meet WCAG-AA contrast (4.5:1 for normal text) combinations meet WCAG-AA contrast (4.5:1 for normal text)
even though we have not committed to broader accessibility. 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 quality are equivalent across Linux, macOS, and Windows on
crossterm-supported terminals. Platform-specific divergence crossterm-supported terminals. Platform-specific divergence
(e.g. font fallbacks) is documented, not silently tolerated. (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 scheme remains legible and visually coherent on both light and
dark terminal backgrounds. The mechanism (auto-detect via dark terminal backgrounds. The mechanism (auto-detect via
terminal query, explicit user setting, or both) is an terminal query, explicit user setting, or both) is an
implementation choice, but the outcome is non-negotiable: no implementation choice, but the outcome is non-negotiable: no
dark-on-dark or light-on-light readability failures on either dark-on-dark or light-on-light readability failures on either
background. 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).)*
--- ---
+38 -10
View File
@@ -24,9 +24,11 @@
%LOCALAPPDATA%\Programs\rdbms-playground. %LOCALAPPDATA%\Programs\rdbms-playground.
.NOTES .NOTES
Written but NOT tested on Windows from this environment (no PowerShell Verified end-to-end on ARM64 Windows 11 under both Windows PowerShell 5.1
here) — validate on a real Windows host. The verified sibling is and PowerShell 7.6, against the live v0.2.0 release. The x86_64 branch is
install.sh (Linux/macOS). symmetric (env-based arch detection + a confirmed matching release asset)
but has not been run directly. The sibling installer is install.sh
(Linux/macOS).
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -37,15 +39,30 @@ param(
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# Windows PowerShell 5.1 (the in-box shell) can negotiate only TLS 1.0/1.1 by
# default, which modern hosts reject. Opt into TLS 1.2 without disturbing any
# protocols already enabled. (No-op on PowerShell 7.)
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Repo = 'https://git.lazyeval.net/oli/rdbms-playground' $Repo = 'https://git.lazyeval.net/oli/rdbms-playground'
$Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground' $Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground'
$Bin = 'rdbms-playground' $Bin = 'rdbms-playground'
# Map the host CPU to the target triple we publish for Windows. # Map the host CPU to the target triple we publish for Windows.
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture # Read the architecture from the environment rather than
# RuntimeInformation::OSArchitecture: under Windows PowerShell 5.1 that type
# resolves from a .NET Framework facade that lacks OSArchitecture, which (with
# StrictMode) throws "property cannot be found". PROCESSOR_ARCHITECTURE is set
# on every PowerShell version; PROCESSOR_ARCHITEW6432 reports the true OS
# architecture when a 32-bit shell runs under WOW64.
$osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITEW6432')
if (-not $osArch) {
$osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE')
}
switch ($osArch) { switch ($osArch) {
'X64' { $target = 'x86_64-pc-windows-gnu' } 'AMD64' { $target = 'x86_64-pc-windows-gnu' }
'Arm64' { $target = 'aarch64-pc-windows-gnullvm' } 'ARM64' { $target = 'aarch64-pc-windows-gnullvm' }
default { throw "install: unsupported CPU architecture: $osArch" } default { throw "install: unsupported CPU architecture: $osArch" }
} }
@@ -65,8 +82,11 @@ try {
$shaFile = "$exe.sha256" $shaFile = "$exe.sha256"
Write-Host "downloading $asset ..." Write-Host "downloading $asset ..."
Invoke-WebRequest -Uri $url -OutFile $exe # -UseBasicParsing: Windows PowerShell 5.1's Invoke-WebRequest otherwise
Invoke-WebRequest -Uri "$url.sha256" -OutFile $shaFile # tries to use the Internet Explorer engine and can fail when it is absent.
# (No-op on PowerShell 7.)
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $exe
Invoke-WebRequest -UseBasicParsing -Uri "$url.sha256" -OutFile $shaFile
# The sidecar is "<hash> <name>"; compare just the hash. # The sidecar is "<hash> <name>"; compare just the hash.
$expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower() $expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower()
@@ -80,14 +100,22 @@ try {
Move-Item -Path $exe -Destination $dest -Force Move-Item -Path $exe -Destination $dest -Force
Write-Host "installed $Bin $Version -> $dest" Write-Host "installed $Bin $Version -> $dest"
# Add the install dir to the user PATH (persistent) if it's not there. # Persist the install dir on the user PATH (for future shells) if missing.
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath) { $userPath = '' } if (-not $userPath) { $userPath = '' }
if (($userPath -split ';') -notcontains $InstallDir) { if (($userPath -split ';') -notcontains $InstallDir) {
$newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir }
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User') [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host "added $InstallDir to your user PATH — restart your shell to pick it up"
} }
# Also update THIS session's PATH so the command works immediately. The
# persisted change only reaches newly-started processes; an already-running
# shell (and, depending on how the terminal inherited its environment, even
# a freshly-opened one) won't see it until the next sign-out/in.
if (($env:Path -split ';') -notcontains $InstallDir) {
$env:Path = "$env:Path;$InstallDir"
}
Write-Host "added $InstallDir to your PATH — '$Bin' works in this window now."
Write-Host "for shells already open elsewhere, sign out and back in (or open a fresh one)."
} }
finally { finally {
Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
+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()
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# Render the Homebrew formula for rdbms-playground to stdout.
#
# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in
# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a
# version and the four macOS/Linux asset SHA-256 hashes it prints a complete
# formula. The publish.yaml `homebrew-tap` job fetches the hashes from the
# release .sha256 sidecars and commits the result into lazyeval/homebrew-tap as
# Formula/rdbms-playground.rb.
#
# The release assets are bare binaries (no archive), so Homebrew stages the
# single downloaded file in the build dir and `install` drops it under a stable
# name. Windows is intentionally absent — Homebrew has no Windows port (Scoop /
# winget cover Windows).
#
# Usage: render-homebrew-formula.sh <version> <mac-arm> <mac-intel> <linux-arm> <linux-intel>
# <version> version, with or without a leading 'v'
# <mac-arm> sha256 of aarch64-apple-darwin
# <mac-intel> sha256 of x86_64-apple-darwin
# <linux-arm> sha256 of aarch64-unknown-linux-musl
# <linux-intel> sha256 of x86_64-unknown-linux-musl
set -euo pipefail
if [ "$#" -ne 5 ]; then
echo "usage: $0 <version> <mac-arm> <mac-intel> <linux-arm> <linux-intel>" >&2
exit 2
fi
version=${1#v}
mac_arm=$2
mac_intel=$3
linux_arm=$4
linux_intel=$5
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/v$version"
# Ruby interpolations (#{version}, #{bin}) must survive verbatim into the
# formula; they contain no '$', so this unquoted heredoc leaves them untouched
# and only expands the shell variables below.
cat <<EOF
# typed: false
# frozen_string_literal: true
# rdbms-playground — installs the prebuilt release binary for the host
# platform. Regenerated for each release by scripts/render-homebrew-formula.sh;
# do not edit by hand.
class RdbmsPlayground < Formula
desc "Cross-platform TUI playground for learning relational databases"
homepage "https://relplay.org"
version "$version"
license any_of: ["MIT", "Apache-2.0"]
on_macos do
on_arm do
url "$base/rdbms-playground-v$version-aarch64-apple-darwin"
sha256 "$mac_arm"
end
on_intel do
url "$base/rdbms-playground-v$version-x86_64-apple-darwin"
sha256 "$mac_intel"
end
end
on_linux do
on_arm do
url "$base/rdbms-playground-v$version-aarch64-unknown-linux-musl"
sha256 "$linux_arm"
end
on_intel do
url "$base/rdbms-playground-v$version-x86_64-unknown-linux-musl"
sha256 "$linux_intel"
end
end
def install
# The release asset is a single bare binary; Homebrew stages it in the
# build dir under its (versioned) basename. Install it as a stable name.
bin.install Dir["*"].first => "rdbms-playground"
end
test do
assert_match "rdbms-playground #{version}", shell_output("#{bin}/rdbms-playground --version")
end
end
EOF
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Render the Scoop manifest for rdbms-playground to stdout.
#
# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in
# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a
# version and the two Windows asset SHA-256 hashes it prints a complete,
# schema-valid Scoop manifest. The publish.yaml `scoop-bucket` job fetches the
# hashes from the release's .sha256 sidecars and commits the result into the
# lazyeval/scoop-bucket repository as rdbms-playground.json.
#
# Manifest updates are CI-driven (this script, per release), so the manifest
# carries `checkver` (so `scoop status` / the community excavator can see when
# the bucket lags upstream) but deliberately NO `autoupdate` — our pipeline is
# the updater, not Scoop's maintainer tooling.
#
# Usage: render-scoop-manifest.sh <version> <hash-x64> <hash-arm64>
# <version> version, with or without a leading 'v' (e.g. 0.2.0 or v0.2.0)
# <hash-x64> sha256 of the x86_64-pc-windows-gnu.exe asset
# <hash-arm64> sha256 of the aarch64-pc-windows-gnullvm.exe asset
set -euo pipefail
if [ "$#" -ne 3 ]; then
echo "usage: $0 <version> <hash-x64> <hash-arm64>" >&2
exit 2
fi
# Accept either 0.2.0 or v0.2.0; the manifest 'version' field is bare.
version=${1#v}
hash_x64=$2
hash_arm64=$3
repo="https://git.lazyeval.net/oli/rdbms-playground"
base="$repo/releases/download/v$version"
# The `#/rdbms-playground.exe` fragment tells Scoop to save the versioned asset
# under a stable filename, so the `bin` shim resolves regardless of version.
url_x64="$base/rdbms-playground-v$version-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe"
url_arm64="$base/rdbms-playground-v$version-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe"
# Note: \$.tag_name emits a literal $ (Scoop's JSONPath); the regex uses [0-9.]
# rather than \d so the manifest contains no backslashes to escape.
cat <<EOF
{
"version": "$version",
"description": "A cross-platform TUI playground for learning relational databases.",
"homepage": "https://relplay.org",
"license": "MIT OR Apache-2.0",
"architecture": {
"64bit": {
"url": "$url_x64",
"hash": "$hash_x64"
},
"arm64": {
"url": "$url_arm64",
"hash": "$hash_arm64"
}
},
"bin": "rdbms-playground.exe",
"checkver": {
"url": "https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground/releases/latest",
"jsonpath": "\$.tag_name",
"regex": "v([0-9.]+)"
}
}
EOF
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
#
# Tests for the package-manifest render scripts (Scoop + Homebrew).
#
# Validates that each render script emits a well-formed manifest for given
# inputs, and that the inputs land in the right places. Dependency-light:
# requires node (always in the CI image) for JSON parsing; uses jq and ruby for
# extra checks when present (both available on the dev box). No network.
#
# Run: scripts/test-package-renders.sh
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
pass() { echo "ok: $*"; }
# Distinct dummy hashes so we can assert each lands in the right slot.
VER=9.9.9
H_WIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01
H_WIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa02
H_MAC_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa03
H_MAC_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa04
H_LIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa05
H_LIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa06
# ---- Scoop ----------------------------------------------------------------
scoop=$tmp/rdbms-playground.json
# Pass a leading 'v' to confirm it gets stripped.
"$here/render-scoop-manifest.sh" "v$VER" "$H_WIN_X64" "$H_WIN_ARM" > "$scoop"
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$scoop" \
|| fail "scoop manifest is not valid JSON"
pass "scoop manifest parses as JSON"
# Field-level assertions via node (no jq dependency).
check_json() { # <jsexpr> <expected>
local got
got=$(node -e '
const m = JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
process.stdout.write(String(eval(process.argv[2])));
' "$scoop" "$1")
[ "$got" = "$2" ] || fail "scoop: $1 = '$got', expected '$2'"
}
check_json 'm.version' "$VER"
check_json 'm.bin' "rdbms-playground.exe"
check_json 'm.architecture["64bit"].hash' "$H_WIN_X64"
check_json 'm.architecture["arm64"].hash' "$H_WIN_ARM"
check_json 'm.checkver.jsonpath' '$.tag_name'
grep -q "rdbms-playground-v$VER-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe" "$scoop" \
|| fail "scoop: x64 url/fragment missing"
grep -q "rdbms-playground-v$VER-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe" "$scoop" \
|| fail "scoop: arm64 url/fragment missing"
pass "scoop manifest fields correct"
if command -v jq >/dev/null 2>&1; then
jq -e . "$scoop" >/dev/null || fail "scoop: jq rejected the manifest"
pass "scoop manifest valid per jq"
fi
# ---- Homebrew -------------------------------------------------------------
formula=$tmp/rdbms-playground.rb
"$here/render-homebrew-formula.sh" "$VER" "$H_MAC_ARM" "$H_MAC_X64" "$H_LIN_ARM" "$H_LIN_X64" > "$formula"
if command -v ruby >/dev/null 2>&1; then
ruby -c "$formula" >/dev/null || fail "homebrew formula is not valid Ruby"
pass "homebrew formula parses as Ruby"
else
echo "warn: ruby not present — skipping formula syntax check" >&2
fi
# Each hash must appear exactly once (right asset → right slot), the version
# must be present, and #{version} must survive verbatim for brew's test block.
for pair in \
"aarch64-apple-darwin:$H_MAC_ARM" \
"x86_64-apple-darwin:$H_MAC_X64" \
"aarch64-unknown-linux-musl:$H_LIN_ARM" \
"x86_64-unknown-linux-musl:$H_LIN_X64"; do
target=${pair%%:*}; hash=${pair#*:}
grep -q "rdbms-playground-v$VER-$target\"" "$formula" || fail "homebrew: url for $target missing"
grep -q "sha256 \"$hash\"" "$formula" || fail "homebrew: sha256 for $target missing"
done
grep -q 'version "'"$VER"'"' "$formula" || fail "homebrew: version line missing"
grep -q 'assert_match "rdbms-playground #{version}"' "$formula" \
|| fail "homebrew: test block #{version} interpolation was mangled"
grep -q 'rdbms-playground-v9.9.9-x86_64-pc-windows' "$formula" \
&& fail "homebrew: formula unexpectedly references a Windows asset"
pass "homebrew formula fields correct"
echo "all render tests passed"
+198 -18
View File
@@ -377,6 +377,31 @@ pub struct App {
/// by default; refreshed by the runtime on project load /// by default; refreshed by the runtime on project load
/// and after successful DDL. /// and after successful DDL.
pub schema_cache: crate::completion::SchemaCache, 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 /// Whether the undo/snapshot machinery is active this session
/// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI /// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI
/// flag; the `undo` / `redo` commands then report undo is off /// flag; the `undo` / `redo` commands then report undo is off
@@ -596,6 +621,10 @@ impl App {
modal: None, modal: None,
last_completion: None, last_completion: None,
schema_cache: crate::completion::SchemaCache::default(), 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 // Undo is on by default; the runtime flips this off for
// a `--no-undo` session (ADR-0006 Amendment 1). // a `--no-undo` session (ADR-0006 Amendment 1).
undo_enabled: true, undo_enabled: true,
@@ -912,7 +941,24 @@ impl App {
"schema cache refreshed", "schema cache refreshed",
); );
self.schema_cache = cache; 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) => { AppEvent::RelationshipsRefreshed(relationships) => {
trace!(count = relationships.len(), "relationships refreshed"); trace!(count = relationships.len(), "relationships refreshed");
@@ -1981,6 +2027,20 @@ impl App {
} }
fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec<Action> { 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 // The two-way mode the walker + the `[mode]` render tag read; the
// three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for // three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for
// the runtime's echo gate (ADR-0038). // the runtime's echo gate (ADR-0038).
@@ -2069,6 +2129,14 @@ impl App {
}]; }];
} }
self.push_output(OutputLine::echo(input, mode)); 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 { vec![Action::ExecuteDsl {
command: cmd, command: cmd,
source: input.to_string(), source: input.to_string(),
@@ -2979,34 +3047,52 @@ impl App {
/// output panel. /// output panel.
/// ///
/// Assembled from the command REGISTRY (ADR-0024 §help_id): /// Assembled from the command REGISTRY (ADR-0024 §help_id):
/// the framing (`help.intro`, `help.dsl_section`, /// the framing (`help.intro`, `help.simple_section`,
/// `help.types_reference`) comes from the catalog, and each /// `help.advanced_section`, `help.types_reference`) comes from
/// command's body is the catalog entry named by its /// the catalog, and each command's body is the catalog entry
/// `help_id`. A newly-registered command appears here /// named by its `help_id`. A newly-registered command appears
/// automatically — no edit to this function or a hand-kept /// here automatically — no edit to this function or a hand-kept
/// list. Each catalog line becomes its own `OutputLine` so /// list. Each catalog line becomes its own `OutputLine` so
/// the scroll-position math (one logical line = one display /// the scroll-position math (one logical line = one display
/// row) stays accurate per the renderer's invariant. /// 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) { fn note_help(&mut self) {
use crate::dsl::grammar::REGISTRY; use crate::dsl::grammar::{CommandCategory, REGISTRY};
let mut lines: Vec<String> = Vec::new(); let mut lines: Vec<String> = Vec::new();
lines.push(crate::t!("help.intro")); lines.push(crate::t!("help.intro"));
// REGISTRY is ordered app-commands first; emit the
// "DSL data commands" sub-header at the first command let mut simple: Vec<String> = Vec::new();
// whose help_id leaves the `app.` namespace. let mut advanced: Vec<String> = Vec::new();
let mut dsl_header_done = false; for (command, category) in REGISTRY {
for (command, _category) in REGISTRY {
let Some(help_id) = command.help_id else { let Some(help_id) = command.help_id else {
continue; continue;
}; };
if !dsl_header_done && !help_id.starts_with("app.") { let body = crate::friendly::translate(&format!("help.{help_id}"), &[]);
lines.push(crate::t!("help.dsl_section")); let block: Vec<String> = body.lines().map(str::to_string).collect();
dsl_header_done = true; 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, &[]); if !simple.is_empty() {
lines.extend(body.lines().map(str::to_string)); 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( lines.extend(
crate::t!("help.types_reference") 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] #[test]
fn simple_mode_submit_of_pure_dsl_error_has_no_advanced_pointer() { fn simple_mode_submit_of_pure_dsl_error_has_no_advanced_pointer() {
// A DSL error that is *not* valid SQL either (unknown command) // 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"), entry: Word::keyword("explain"),
shape: EXPLAIN_SQL_SHAPE, shape: EXPLAIN_SQL_SHAPE,
ast_builder: build_explain_sql, ast_builder: build_explain_sql,
// No `help_id` / `usage_ids` — this is the `Advanced` half of the // Issue #36: its own `help` page (`data.explain_sql`), listed under the
// shared `explain` entry word, so it defers to the `Simple` // advanced-mode (SQL) section and shown by `help explain` next to the
// `EXPLAIN` node's help/usage (which now covers the SQL forms // simple `EXPLAIN` form (distinct help_id ⇒ no dedup clash). `usage_ids`
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE` // stays empty — the `Simple` `EXPLAIN` node's usage block already covers
// precedent; otherwise `note_help` would print `explain` twice. // the shared `explain` entry word.
help_id: None, help_id: Some("data.explain_sql"),
hint_ids: &["explain_sql"], hint_ids: &["explain_sql"],
usage_ids: &[], usage_ids: &[],
}; };
@@ -1902,13 +1902,13 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
/// The shape is the post-`SELECT` portion of a top-level /// The shape is the post-`SELECT` portion of a top-level
/// statement; the registry's entry-word dispatch consumes the /// statement; the registry's entry-word dispatch consumes the
/// leading `SELECT` keyword before the shape walks (sub-phase /// leading `SELECT` keyword before the shape walks (sub-phase
/// 2c migration). `help_id` is `None` until the `help sql` /// 2c migration). Carries its own `help` page (`data.select`),
/// page lands (ADR-0030 Phase 6). /// listed under the advanced-mode (SQL) section (issue #36).
pub static SELECT: CommandNode = CommandNode { pub static SELECT: CommandNode = CommandNode {
entry: Word::keyword("select"), entry: Word::keyword("select"),
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL), shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
ast_builder: build_select, ast_builder: build_select,
help_id: None, help_id: Some("data.select"),
hint_ids: &["select"], hint_ids: &["select"],
usage_ids: &["parse.usage.select"], usage_ids: &["parse.usage.select"],
}; };
@@ -1924,7 +1924,7 @@ pub static WITH: CommandNode = CommandNode {
entry: Word::keyword("with"), entry: Word::keyword("with"),
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL), shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
ast_builder: build_select, ast_builder: build_select,
help_id: None, help_id: Some("data.with"), // issue #36: own help page, advanced section
hint_ids: &["with"], hint_ids: &["with"],
usage_ids: &["parse.usage.with"], usage_ids: &["parse.usage.with"],
}; };
@@ -1943,7 +1943,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
entry: Word::keyword("insert"), entry: Word::keyword("insert"),
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE), shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
ast_builder: build_sql_insert, 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"], hint_ids: &["sql_insert"],
usage_ids: &[], usage_ids: &[],
}; };
@@ -1957,7 +1957,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
entry: Word::keyword("update"), entry: Word::keyword("update"),
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE), shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
ast_builder: build_sql_update, 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"], hint_ids: &["sql_update"],
usage_ids: &[], usage_ids: &[],
}; };
@@ -1973,7 +1973,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
entry: Word::keyword("delete"), entry: Word::keyword("delete"),
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE), shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
ast_builder: build_sql_delete, 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"], hint_ids: &["sql_delete"],
usage_ids: &[], usage_ids: &[],
}; };
+11 -6
View File
@@ -537,8 +537,11 @@ pub struct CommandNode {
/// block). `hint_key_for_input_in_mode` disambiguates by the form /// block). `hint_key_for_input_in_mode` disambiguates by the form
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty /// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
/// until a form's tier-3 block is authored (the surface falls back /// 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 /// to tier-2 ambient/error text). Parallel to `help_id` but
/// `None` on advanced-SQL forms purely to dedup the `help` list). /// 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], pub hint_ids: &'static [&'static str],
/// Catalog keys under `parse.usage.*` to render in the /// Catalog keys under `parse.usage.*` to render in the
/// "usage:" block when a parse error fires for this command /// "usage:" block when a parse error fires for this command
@@ -1156,10 +1159,12 @@ mod usage_key_tests {
#[test] #[test]
fn no_two_registered_commands_share_a_help_id() { fn no_two_registered_commands_share_a_help_id() {
// `note_help` emits one help block per `help_id: Some(_)` // `note_help` emits one help block per `help_id: Some(_)`
// with no dedup, so a duplicate help_id prints the same // with no dedup, so a duplicate help_id string prints the same
// command twice in `help`. Shared-entry-word `Advanced` // block twice. Distinct help_ids are fine — a shared-entry-word
// nodes (SQL_INSERT, …, EXPLAIN_SQL) therefore carry // simple + SQL pair (e.g. `data.insert` + `data.sql_insert`,
// `help_id: None` and defer to their `Simple` sibling. // 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(); let mut seen = std::collections::HashSet::new();
for (command, _category) in super::REGISTRY { for (command, _category) in super::REGISTRY {
if let Some(id) = command.help_id { 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 // In-app `help` — framing + per-command entries keyed by
// each CommandNode's `help_id` (ADR-0024 §help_id). // each CommandNode's `help_id` (ADR-0024 §help_id).
("help.intro", &[]), ("help.intro", &[]),
("help.dsl_section", &[]), ("help.simple_section", &[]),
("help.advanced_section", &[]),
("help.types_reference", &[]), ("help.types_reference", &[]),
("help.detail_hint", &[]), ("help.detail_hint", &[]),
("help.unknown_topic", &["topic"]), ("help.unknown_topic", &["topic"]),
@@ -223,6 +224,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.data.delete", &[]), ("help.data.delete", &[]),
("help.data.replay", &[]), ("help.data.replay", &[]),
("help.data.explain", &[]), ("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 panel ambient typing assistance (ADR-0022 §6) ----
("hint.ambient_complete", &[]), ("hint.ambient_complete", &[]),
("hint.ambient_error_with_usage", &["message", "usage"]), ("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 # are multi-line-capable — the renderer emits one output row
# per line so scroll math stays accurate. # per line so scroll math stays accurate.
intro: "Supported commands:" 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 # H3: footer on the full `help` list, and the not-found note
# for `help <topic>`. `{topic}` is the word the user typed. # 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." 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> ... explain show data <T> | explain update <T> ... | explain delete from <T> ...
— show how the database would run a query, without — show how the database would run a query, without
running it (safe even for update / delete) running it (safe even for update / delete)
explain <select|with|insert|update|delete …> (advanced mode) # Issue #36: advanced-mode (SQL) forms. Each has its own help page, listed
— the same plan for the SQL you wrote # 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. # Type reference, appended after the command list.
types_reference: | types_reference: |
Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid
+275 -4
View File
@@ -106,13 +106,13 @@ impl Theme {
// distinct from the mode-banner blue. // distinct from the mode-banner blue.
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple 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_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_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error 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_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_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_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green 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_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard 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_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 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); 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] #[test]
fn help_types_renders_the_type_reference() { fn help_types_renders_the_type_reference() {
let out = output_for("help types"); let out = output_for("help types");
+6
View File
@@ -170,6 +170,12 @@ fn colon_escape_in_simple_mode_is_one_shot() {
echoed.text, 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, // Subsequent submission (unrecognised in simple mode) parse-errors,
// not echoes — confirming the mode reverted. // not echoes — confirming the mode reverted.
type_str(&mut app, "list things"); type_str(&mut app, "list things");
+23
View File
@@ -58,6 +58,29 @@ export default defineConfig({
}, },
{ tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } }, { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } },
{ tag: 'meta', attrs: { name: 'twitter:image', content: 'https://relplay.org/og-card.png' } }, { tag: 'meta', attrs: { name: 'twitter:image', content: 'https://relplay.org/og-card.png' } },
// Privacy-friendly, cookieless analytics (self-hosted Umami). The
// 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.
// - data-do-not-track: honour the browser's Do-Not-Track signal
// (those visits are not counted at all). See /privacy.
{
tag: 'script',
attrs: {
defer: true,
src: 'https://umami.relplay.org/script.js',
'data-website-id': 'fd77cfa5-cffe-4fc8-addb-1c6d7b6d9939',
'data-domains': 'relplay.org',
'data-do-not-track': true,
},
},
], ],
// Register the simple-mode command grammar with Expressive Code (Shiki). // Register the simple-mode command grammar with Expressive Code (Shiki).
expressiveCode: { shiki: { langs: [rdbmsLang, rdbmsSyntax] } }, expressiveCode: { shiki: { langs: [rdbmsLang, rdbmsSyntax] } },
+2
View File
@@ -15,6 +15,8 @@ import Default from '@astrojs/starlight/components/Footer.astro';
Made by <a href="https://www.lazyevaluation.biz/">Lazy Evaluation Ltd</a> Made by <a href="https://www.lazyevaluation.biz/">Lazy Evaluation Ltd</a>
<span class="sep">·</span> <span class="sep">·</span>
<a href="https://git.lazyeval.net/oli/rdbms-playground">Source &amp; issues</a> <a href="https://git.lazyeval.net/oli/rdbms-playground">Source &amp; issues</a>
<span class="sep">·</span>
<a href="/privacy/">Privacy</a>
</p> </p>
</div> </div>
@@ -0,0 +1,39 @@
---
// Pre-selects the install-instructions tab that matches the visitor's OS.
//
// This piggybacks on Starlight's synced-tabs mechanism rather than fighting it.
// A `<Tabs syncKey="install-os">` restores its active tab from
// `localStorage["starlight-synced-tabs__install-os"]` via a script Starlight
// inlines *before* the tabs render (so there is no flash of the wrong tab). All
// we do here is seed that same key with the detected OS — and only when the
// visitor has not already chosen a tab themselves, so a manual choice always wins.
//
// Requirements (mirrors Starlight's own restore script):
// - Must be placed *before* the `<Tabs>` it targets, so this runs first.
// - The stored value must equal one of the TabItem labels exactly
// ("Linux" / "macOS" / "Windows").
// - Inlined, so it runs during parse before the restore script's
// connectedCallback and paint.
---
<script is:inline>
(() => {
const KEY = 'starlight-synced-tabs__install-os';
try {
// A previously stored value is a deliberate user choice — never override it.
if (localStorage.getItem(KEY)) return;
const data = navigator.userAgentData;
const platform = ((data && data.platform) || navigator.platform || '').toLowerCase();
const ua = (navigator.userAgent || '').toLowerCase();
let os = null;
if (platform.includes('win') || ua.includes('windows')) os = 'Windows';
else if (platform.includes('mac') || ua.includes('mac os x')) os = 'macOS';
else if (platform.includes('linux') || platform.includes('x11') || ua.includes('linux'))
os = 'Linux';
if (os) localStorage.setItem(KEY, os);
} catch (e) {
// localStorage unavailable (private mode, blocked cookies, …) — silently
// fall back to the default first tab. No detection, no harm.
}
})();
</script>
@@ -1,64 +0,0 @@
---
title: Installation
description: Install RDBMS Playground from a prebuilt binary or a package manager, and run it for the first time.
sidebar:
order: 1
---
RDBMS Playground is a single self-contained program. There is nothing to
configure and no separate database to install — everything it needs is
built in.
## Prebuilt binaries
Download the binary for your platform, make it executable if needed, and
put it somewhere on your `PATH`.
:::note
Download links are published with each release. They are added here when the
first public version ships.
:::
## Package managers
Once published, the playground will be installable through common package
managers:
```sh
# macOS / Linux (Homebrew)
brew install rdbms-playground
# Windows (Scoop)
scoop install rdbms-playground
```
:::note
Package-manager availability lands with the first public release; the exact
names are confirmed here at that time.
:::
## Run it
Start the playground with no arguments and it opens a fresh, automatically
named temporary project so you can start experimenting immediately:
```sh
rdbms-playground
```
To open an existing project, pass its path:
```sh
rdbms-playground path/to/project
```
Useful options (run `rdbms-playground --help` for the full list):
| Option | What it does |
|---|---|
| `--resume` | Reopen the most recently used project. |
| `--data-dir <PATH>` | Use a different location for stored projects. |
| `--theme <light\|dark>` | Force a theme instead of auto-detecting. |
| `--mode <simple\|advanced>` | Start in a specific input mode. |
Next: [create your first project](/getting-started/first-project/).
@@ -0,0 +1,148 @@
---
title: Installation
description: Install RDBMS Playground with a one-line installer, a package manager, cargo, or a prebuilt binary.
sidebar:
order: 1
---
import { Tabs, TabItem } from '@astrojs/starlight/components';
import InstallOsDetect from '../../../components/InstallOsDetect.astro';
RDBMS Playground is a single self-contained program. There is nothing to
configure and no separate database to install — everything it needs is
built in.
Pick the tab for your platform — we try to select it automatically — then
use whichever method you prefer. The one-line installers are the quickest if
you just want to get going.
<InstallOsDetect />
<Tabs syncKey="install-os">
<TabItem label="Linux">
**One-line installer**
```sh
curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
```
It detects your CPU, installs to `~/.local/bin`, and prints a hint if that
directory isn't on your `PATH`. Set `RDBMS_INSTALL_DIR` to install elsewhere,
or `RDBMS_VERSION=vX.Y.Z` to pin a version. Prefer to read a script before
running it? It lives at
[`scripts/install.sh`](https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh).
**Homebrew**
```sh
brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap
brew install lazyeval/tap/rdbms-playground
```
</TabItem>
<TabItem label="macOS">
**One-line installer**
```sh
curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
```
It detects your CPU (Apple Silicon or Intel), installs to `~/.local/bin`, and
prints a hint if that directory isn't on your `PATH`. Set `RDBMS_INSTALL_DIR`
to install elsewhere, or `RDBMS_VERSION=vX.Y.Z` to pin a version. Prefer to
read a script before running it? It lives at
[`scripts/install.sh`](https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh).
**Homebrew**
```sh
brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap
brew install lazyeval/tap/rdbms-playground
```
</TabItem>
<TabItem label="Windows">
**One-line installer (PowerShell)**
```powershell
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
```
It downloads the matching `.exe`, verifies its checksum, installs to
`%LOCALAPPDATA%\Programs\rdbms-playground`, and adds that folder to your user
`PATH`. Use `-InstallDir` to choose a different location or `-Version` to pin
a release.
**Scoop**
```sh
scoop bucket add lazyeval https://git.lazyeval.net/lazyeval/scoop-bucket
scoop install rdbms-playground
```
**winget**
A winget package (`winget install LazyEvaluation.RdbmsPlayground`) is on its
way and awaiting review in the public winget catalogue. Until it lands, use
Scoop or the PowerShell one-liner above.
</TabItem>
</Tabs>
## With cargo
These work on any platform with a Rust toolchain. Install from
[crates.io](https://crates.io/crates/rdbms-playground):
```sh
cargo install rdbms-playground
```
For a faster install that fetches a prebuilt binary instead of compiling, use
[`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall) (install it
first — it is not part of `cargo` itself):
```sh
cargo binstall rdbms-playground
```
## Prebuilt binaries
Every release publishes static Linux, standalone Windows, and macOS binaries
(x86_64 and aarch64), each with a `.sha256` checksum, on the
[releases page](https://git.lazyeval.net/oli/rdbms-playground/releases).
Download the one for your platform, make it executable if needed, and put it
somewhere on your `PATH`.
## Run it
Start the playground with no arguments and it opens a fresh, automatically
named temporary project so you can start experimenting immediately:
```sh
rdbms-playground
```
To open an existing project, pass its path:
```sh
rdbms-playground path/to/project
```
Useful options (run `rdbms-playground --help` for the full list):
| Option | What it does |
|---|---|
| `--resume` | Reopen the most recently used project. |
| `--data-dir <PATH>` | Use a different location for stored projects. |
| `--theme <light\|dark>` | Force a theme instead of auto-detecting. |
| `--mode <simple\|advanced>` | Start in a specific input mode. |
| `--version` | Print the version and exit. |
The full list, with examples, is on the
[command-line options](/using-the-playground/command-line-options/) page.
Next: [create your first project](/getting-started/first-project/).
+51
View File
@@ -0,0 +1,51 @@
---
title: Privacy
description: What we measure on this site, and what we don't. No cookies, no personal data.
tableOfContents: false
---
This site is run as a free teaching tool, not a business that profiles its
visitors. We keep the analytics deliberately minimal.
## What we collect
We measure aggregate usage with **[Umami](https://umami.is/)**, a
privacy-friendly analytics tool that we **host ourselves** (on
`umami.relplay.org`). Nothing is sent to a third-party advertising
network.
For each page view we record:
- the page you viewed and the site you arrived from (the referrer),
- your browser, operating system, and device type,
- an approximate **country**.
That's it. The numbers are aggregated into counts — how many people read a
page, which pages are popular — and are never combined into a profile of an
individual.
## What we don't do
- **No cookies, and nothing stored on your device.** The tracker sets no
cookies and writes nothing to local storage. You can confirm this in your
browser's developer tools — there are no entries to clear.
- **No personal data.** Your IP address is used for a moment on our own
server to derive the approximate country and to tell repeated page-loads
apart within a single day; it is **not stored**.
- **No cross-site tracking.** We cannot and do not follow you to other sites,
and we never sell or share what we measure.
## Your choices
If your browser sends a **Do Not Track** signal, we honour it: those visits
are not counted at all.
Because the analytics use no cookies and store nothing on your device, there
is no cookie banner to dismiss — there is simply nothing to consent to.
## Questions
This site and its analytics are operated by
[Lazy Evaluation Ltd](https://www.lazyevaluation.biz/). If you have any
questions about what we measure, get in touch through the contact details
there.
@@ -29,8 +29,13 @@ rdbms-playground path/to/project
| `--no-undo` | Disable the undo machinery for this run — no snapshot is taken before each change (see [Undo, redo & history](/using-the-playground/undo-and-history/)). | | `--no-undo` | Disable the undo machinery for this run — no snapshot is taken before each change (see [Undo, redo & history](/using-the-playground/undo-and-history/)). |
| `--demo` | Turn on demonstration mode — a teaching aid that briefly shows an on-screen badge for keys that otherwise leave no visible mark (Tab, Enter, the arrows, and the like). Useful for screencasts and for demonstrating the playground to a class. Off by default. | | `--demo` | Turn on demonstration mode — a teaching aid that briefly shows an on-screen badge for keys that otherwise leave no visible mark (Tab, Enter, the arrows, and the like). Useful for screencasts and for demonstrating the playground to a class. Off by default. |
| `--log-file <PATH>` | Write diagnostic logging to `PATH`. | | `--log-file <PATH>` | Write diagnostic logging to `PATH`. |
| `-V`, `--version` | Print the version and exit. |
| `-h`, `--help` | Print the usage banner and exit. | | `-h`, `--help` | Print the usage banner and exit. |
Once the playground is running, the `version` command prints the same
version line into the output panel — handy when you want to note which
version you're on without leaving the app.
## Examples ## Examples
```sh ```sh