117 Commits
Author SHA1 Message Date
claude@clouddev1 44b495dfed docs: handoff-80 + docs direct-push carve-out (ADR-0059 Amendment 1)
Session handoff covering the clause-concept-hints feature (ADR-0058, #42) and
the working-method establishment (ADR-0059, #41; fixes #43/#44). Records the
docs direct-push carve-out: handoff/session docs and small docs amendments are
owner-direct-pushed to main, because a docs-only PR posts no ci/gate run
(paths-ignored) and stalls on the required check. Amends CLAUDE.md, ADR-0059
(Amendment 1), and its README index row.
2026-06-25 14:38:58 +00:00
oli 78272e2fd0 Merge pull request 'ci: gate feature branches via pull_request only (dedupe PR-push runs)' (#44) from ci/dedupe-pr-runs into main
ci / gate (push) Successful in 2m1s
ci / manifests (push) Successful in 4s
Reviewed-on: #44
2026-06-24 10:33:16 +01:00
claude@clouddev1 e1d7419a72 ci: gate feature branches via pull_request only (dedupe PR-push runs)
ci / gate (pull_request) Successful in 2m8s
ci / manifests (pull_request) Successful in 4s
A push to a branch with an open PR fired both the `push` and `pull_request`
triggers, running the gate (and manifests) twice. On Gitea those runs are
byte-identical: unlike GitHub it has no merge-preview ref, so its
`pull_request` checks out `refs/pull/N/head` — the same commit a branch push
would. Scope `push` to `main` and let `pull_request` gate feature branches:
halves CI on every PR push, loses no coverage, and is forward-compatible — if
Gitea ever adds the merge ref, these runs upgrade to testing the merged result
for free.
2026-06-24 09:28:54 +00:00
oli 6e28e58e45 Merge pull request 'feat(hint): clause-concept hints layered on F1 (issue #37)' (#42) from feat/clause-concept-hints into main
ci / gate (push) Successful in 2m9s
ci / manifests (push) Successful in 4s
Reviewed-on: #42
2026-06-23 23:24:29 +01:00
claude@clouddev1 5c51a00e72 Merge origin/feat into feat/clause-concept-hints (heal PR-branch divergence)
ci / gate (push) Successful in 2m4s
ci / manifests (push) Successful in 4s
ci / gate (pull_request) Successful in 2m4s
ci / manifests (pull_request) Successful in 3s
The Gitea "Update branch" button merged main into the PR branch server-side
(8ccecc4) — auto-resolving the README conflict by dropping the ADR-0059 index
row, and predating #43. The local merge (ec7a8ff) merged current main correctly
(both 0058 + 0059 rows; includes #43). This merge absorbs the stale remote
commit without changing content — the resulting tree is identical to the
correct local tip — so a normal (non-force) push now fast-forwards origin/feat.
2026-06-23 22:13:58 +00:00
claude@clouddev1 ec7a8ff3be Merge remote-tracking branch 'origin/main' into feat/clause-concept-hints 2026-06-23 22:01:39 +00:00
oli f06ee3b460 Merge pull request 'fix(workflow): explicit wt-rm + wt-new --no-track (worktree-helper corrections)' (#43) from fix/wt-rm-explicit-target into main
ci / gate (push) Successful in 2m8s
ci / manifests (push) Successful in 4s
Reviewed-on: #43
2026-06-23 22:42:01 +01:00
claude@clouddev1 279ef98ab8 fix(workflow): wt-new branches with --no-track (no inherited origin/main upstream)
ci / manifests (push) Successful in 4s
ci / gate (push) Successful in 2m6s
ci / gate (pull_request) Successful in 2m3s
ci / manifests (pull_request) Successful in 4s
Branching off origin/main set the new branch's upstream to origin/main, so a
bare `git push` failed with a name-mismatch and suggested the dangerous
`git push origin HEAD:main`. Add --no-track so the branch carries no upstream
until the first `git push -u origin <branch>` sets the right one — keeping the
fresh-from-origin/main base without the tracking side-effect.

Add a test-wt.sh assertion that a new branch has no upstream, and note the
behaviour + first-push form in ADR-0059 / CLAUDE.md.
2026-06-23 21:36:26 +00:00
claude@clouddev1 4570c4e1ea fix(workflow): wt-rm removes only the named worktree (was auto-sweeping merged ones)
The first wt-clean.sh removed every worktree whose branch was merged into
origin/main — but long-lived branches (website, the ci line) are merged too,
so it would have deleted their worktrees. Caught on review before any real use.

Replace the auto-sweep with an explicit `wt-rm <branch>` that removes only the
named worktree; safety comes from git's own refusals — it won't remove a dirty
worktree (no --force) and deletes the local branch only when it's merged into
origin/main, otherwise keeps it (and its unmerged commits).

Update test-wt.sh to 15 checks (incl. "unnamed worktree untouched", primary
refused, dirty refused, unmerged branch preserved) and the wt-clean references
in ADR-0059 / CLAUDE.md / the README index.
2026-06-23 21:27:41 +00:00
oli 3585cca5ea Merge pull request 'chore(workflow): branch-and-PR working method + ADR-number reservation (ADR-0059)' (#41) from chore/dev-workflow into main
ci / gate (push) Successful in 2m6s
ci / manifests (push) Successful in 3s
Reviewed-on: #41
2026-06-23 22:08:28 +01:00
oli 63d11402ae Merge branch 'main' into chore/dev-workflow
ci / gate (pull_request) Successful in 2m2s
ci / manifests (pull_request) Successful in 4s
2026-06-23 22:01:18 +01:00
oli 8ccecc4245 Merge branch 'main' into feat/clause-concept-hints
ci / gate (pull_request) Successful in 2m5s
ci / manifests (pull_request) Successful in 4s
2026-06-23 22:00:06 +01:00
claude@clouddev1 b5c848efcb chore(workflow): branch-and-PR working method + ADR-number reservation (ADR-0059)
ci / gate (push) Successful in 2m5s
ci / manifests (push) Successful in 4s
ci / gate (pull_request) Successful in 2m5s
ci / manifests (pull_request) Successful in 4s
Adopt a trackable working method now the repo is public:
- PRs onto a protected main; --no-ff merge commits; one worktree per branch.
- Reserve-first ADR numbering: scripts/adr-reserve.sh claims the next number
  atomically against main (push = compare-and-swap; ledger
  docs/adr/RESERVATIONS.log), so a number is stable from creation.
- Worktree helpers scripts/wt-new.sh + wt-clean.sh.
- Local-origin test harnesses (reserve 10/10, worktrees 7/7, shellcheck clean).

Record the decision in ADR-0059, supersede ADR-0000's placeholder-until-merge
numbering default, and add the lean CONTRIBUTING.md sections + CLAUDE.md
operational rules.
2026-06-23 20:41:42 +00:00
claude@clouddev1 6d4364666a docs(adr): finalize ADR-0058 number for clause-concept-hints
ci / gate (pull_request) Successful in 2m6s
ci / manifests (pull_request) Successful in 3s
Rename the placeholder draft to its reserved number (0058, allocated
reserve-first via the new dev-workflow flow), drop the ADR-XXXX
placeholders, and add the README index row.
2026-06-23 20:09:36 +00:00
claude@clouddev1 48fc5e8202 docs(adr): reserve 0059 for dev-workflow 2026-06-23 18:25:17 +00:00
claude@clouddev1 9812463620 docs(adr): reserve 0058 for clause-concept-hints 2026-06-23 18:24:29 +00:00
claude@clouddev1 208da81108 feat(hint): clause-concept hints layered on F1 (issue #37)
Add a hint.concept.* tier-3 layer surfaced when the cursor sits inside a
recognized clause (referential actions, 1:n/m:n cardinality, primary key,
unique, check, foreign key), layered beneath the per-form block. New
Node::Concept grammar wrapper records clause byte-spans; concept_topic_at_cursor
resolves the innermost containing span. Examples are mode-keyed so they stay
syntax-correct in both simple and advanced mode. Draft ADR (number at merge).
2026-06-23 13:24:54 +00:00
claude@clouddev1 e845a6ee72 docs: correct the stale fmt-gate note in CLAUDE.md; handoff-79
ci / gate (push) Successful in 2m6s
ci / manifests (push) Successful in 4s
CLAUDE.md said "fmt is intentionally not gated yet" — stale; the gate is
now fmt + clippy + test (ADR-ci-002 Amendment 1 / issue #35), run via
`nix develop -c`. Add CI-exact local-verification guidance and flag the
exit-code-vs-piped-output trap that masked the fmt failure this session.
handoff-79 records the blob removal + the CI/fmt lesson.
2026-06-22 21:43:51 +00:00
claude@clouddev1 c9d6660ba6 style: rustfmt the blob-removal code (fix the CI fmt gate)
The blob-removal commit (6b4c4dc) failed CI's fmt gate — long assert! lines
and a doc comment that stock rustfmt wraps. No behaviour change; just
`cargo fmt`. Verified via `nix develop -c cargo fmt --check`.
2026-06-22 21:43:51 +00:00
claude@clouddev1 6b4c4dcea4 feat(types)!: drop the blob column type (ADR-0005 Amendment 2)
ci / gate (push) Failing after 35s
ci / manifests (push) Successful in 3s
website / deploy (push) Successful in 35s
blob was a dead-end: declarable but never fillable (no literal in either
mode, seed-unsupported), so a blob column could only ever hold NULL. Remove
Type::Blob + Value/CellValue::Blob + the base64 CSV path + the grammar slot
+ completion/render/type-change/seed handling + the binder refusal (whose
message also carried a user-facing "DSL" copy-rule bug). The vocabulary is
now nine types; base64 stays (clipboard OSC-52).

Backward compat is the ADR-0015 migration framework's first real use: a
v1->v2 format bump (CURRENT_SCHEMA_VERSION across serializer/parser/skeleton)
with a migrator that rewrites `type: blob` -> `type: text`, and a forced .db
rebuild from the migrated text when a blob column was actually converted
(the stale .db keeps a STRICT BLOB engine column). Conversion to text is
non-destructive and CSV-free. Covered by a full-stack integration test and a
Tier-4 PTY test that opens a real legacy v1-blob project.

Also sweeps the nine-type vocabulary through CLAUDE.md, requirements.md, the
website (type reference, seed doc, highlight grammar), and the ADR-0030/0033/
0035 cross-references; CHANGELOG Removed entry; handoff-79.

BREAKING CHANGE: the `blob` column type is removed. Existing projects that
declared a blob column are migrated on first open (the column becomes text;
the original project.yaml is kept as a .v1.bak).
2026-06-22 21:25:38 +00:00
claude@clouddev1 1a2002dbf6 docs(adr-0005): Amendment 2 — drop blob from the type vocabulary
blob is a dead-end type: no literal in either mode, seed-unsupported, so
a blob column can only ever hold NULL. It rode along as the engine's
fourth storage class and never got the pedagogical scrutiny UUIDs
(excluded) and compound PKs (defended) received — and there's nothing
natural to do with binary data in a TUI teaching tool. Drop it → a
nine-type vocabulary.

Records the removal scope (Type::Blob + Value/CellValue::Blob + the
base64 CSV path + completion/grammar slot + the help/hint user-facing
strings + the binder refusal, whose message also carried a "DSL"
copy-rule bug subsumed by deletion) and the backward-compat migration:
the ADR-0015 framework's first real use — a v1→v2 migrator converts
type: blob → text and forces a .db rebuild from the migrated text.
convert-to-text chosen over drop-the-column (CSV rewrites) and
refuse-to-load. Updates the ADR index.
2026-06-22 20:00:53 +00:00
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
claude@clouddev1 bd5be5ecc7 fix(ci): read release version from Cargo.toml, not cargo metadata (ADR-0054)
ci / gate (push) Successful in 3m12s
release / test (push) Successful in 2m44s
release / build (aarch64-pc-windows-gnullvm) (push) Successful in 3m56s
release / build (aarch64-unknown-linux-musl) (push) Successful in 4m18s
release / build (x86_64-pc-windows-gnu) (push) Successful in 4m39s
release / build (x86_64-unknown-linux-musl) (push) Successful in 3m50s
The ADR-0054 version guard piped `nix develop -c cargo metadata` to node,
but the flake devShell prints a banner to stdout — corrupting the JSON
pipe, so the guard aborted under `set -e` and the v0.2.0 release failed
there (before building anything). Replace it with a toolchain-free
`grep -m1 '^version = ' Cargo.toml` (the anchor excludes dependency
`version =` keys). No real version mismatch occurred — the tagged commit
has version 0.2.0.
2026-06-17 21:58:32 +00:00
claude@clouddev1 88830ed06a chore(release): bump version to 0.2.0
ci / gate (push) Successful in 3m9s
release / test (push) Failing after 1m43s
release / build (aarch64-pc-windows-gnullvm) (push) Has been skipped
release / build (aarch64-unknown-linux-musl) (push) Has been skipped
release / build (x86_64-pc-windows-gnu) (push) Has been skipped
release / build (x86_64-unknown-linux-musl) (push) Has been skipped
First release carrying the version surfaces (--version / -V / the in-app
version command, ADR-0054), the curl|sh + PowerShell installers
(ADR-0055), crates.io/binstall readiness (ADR-0056), the verified hint
corpus, the Ctrl-G demo F1 alias, and the cargo fmt gate (#35). The
release guard checks this equals the v0.2.0 tag.
2026-06-17 21:46:52 +00:00
claude@clouddev1 ec3c7c304c ci: enable the cargo fmt --check gate (ADR-ci-002 Amendment 1)
ci / gate (push) Successful in 3m17s
Adds `cargo fmt --check` (stock defaults) to ci.yaml's gate, now that the
tree is rustfmt-clean (commit 41b7e9a). Records that reformat in
.git-blame-ignore-revs so `git blame` skips it. Amends ADR-ci-002 (the
deferred "revisit on main" fmt decision) + the ci ADR index.

Closes #35.
2026-06-17 21:40:58 +00:00
claude@clouddev1 41b7e9a049 style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
2026-06-17 21:39:19 +00:00
claude@clouddev1 e9606b5f6d feat(dist): crates.io + binstall + Windows install.ps1 + license files
ci / gate (push) Successful in 3m14s
Distribution prep on the road to public availability (plan steps 2–3a).

- Cargo.toml: publish-ready (drop publish=false; homepage/keywords/
  categories/exclude) + [package.metadata.binstall] with per-target
  overrides (linux-gnu->musl, windows-msvc->gnu/gnullvm). dry-run clean.
- scripts/install.ps1: Windows `irm | iex` one-liner — written but
  untested here (no PowerShell; validate on Windows). README Windows block.
- README.md (new); LICENSE-MIT + LICENSE-APACHE (dual, (c) Lazy
  Evaluation Ltd); CONTRIBUTING.md (inbound=outbound dual-license note).
- ADR-0055 Amendment 1 (install.ps1), ADR-0056 (crates.io/binstall),
  README index + plan updates.

The actual `cargo publish` remains a gated maintainer step (token,
irreversible) at a new tagged release; real cargo-binstall validation
pending.
2026-06-17 21:25:45 +00:00
claude@clouddev1 ef99e6c676 feat(install): curl|sh installer script (ADR-0055)
ci / gate (push) Successful in 3m19s
website / deploy (push) Successful in 1m58s
scripts/install.sh — POSIX sh, shellcheck-clean: detects uname OS/arch ->
target triple (Linux uses the static musl build; Windows rejected with a
Scoop/winget pointer), resolves the latest release (or RDBMS_VERSION),
downloads the asset + its .sha256 and verifies it, installs to
~/.local/bin with a PATH hint. RDBMS_OS/RDBMS_ARCH + --print-target are
testing seams. Verified end-to-end against the live public v0.1.0 (all
mappings, pinned + latest, checksum incl. tamper-rejection, install+run).

ADR-0055 + README index; plan-doc step 2 done + decisions recorded
(crates.io=yes, releases public, tracking via doc+ADR).
2026-06-17 19:41:34 +00:00
claude@clouddev1 c30a6114b9 feat(cli): --version/-V + in-app version command + release guard (ADR-0054)
Cargo.toml version is the single source of truth, surfaced by a
--version/-V CLI flag and an in-app `version` command (both via
cli::version_text -> cli.version_line). release.yaml gains a guard that
fails the release unless the v* tag equals v<CARGO_PKG_VERSION>, keeping
--version, the release name, and the asset in lockstep. New app command
wired across grammar/REGISTRY/dispatch/usage/help/hint-corpus/keys; 6
test-first tests. Also fixes a stale "macOS deferred" comment in
release.yaml. ADR-0054 + README index + plan-doc step 1.
2026-06-16 15:57:54 +00:00
claude@clouddev1 fe9d58e037 docs: plan the road to public availability (versioning, install, packaging) 2026-06-16 15:57:48 +00:00
claude@clouddev1 628b250db6 docs: reconcile docs after ci+website merges; gitignore wrangler/vscode
Post-merge documentation accuracy pass (CI and website branches both
merged to main; website deployed).

- CLAUDE.md: rewrite the stale repository-layout tree; add a Website &
  docs-site decision bullet (Astro+Starlight, Cloudflare Pages via Gitea
  Actions, ADR-website-001, the website branch stays open); update the CI
  note (merged to main; release-macos dispatchable + verified working).
- requirements.md: D1 — macOS targets now runtime-verified (release-macos
  dispatched end-to-end); DOC1 — canonical user docs now live on the
  deployed website.
- ADR-ci-003 (+ docs/ci/adr README): Amendment 2 — CI on main,
  release-macos dispatched + verified; macOS runtime-verified.
- docs/website/adr README: drop the stale "no CI yet".
- .gitignore: ignore .wrangler/ (Cloudflare Wrangler cache) and .vscode/;
  remove the tracked website/.vscode/ an Astro template had added.

D3 (package-manager manifests) + some install instructions remain open.
2026-06-16 15:06:49 +00:00
claude@clouddev1 784373a254 docs: handoff 73 — Ctrl-G demo-mode F1 alias (ADR-0047 Amendment 1) 2026-06-16 14:41:28 +00:00
claude@clouddev1 dff78412dd docs(website): hint cast — press F1 mid-command for realism
ci / gate (push) Successful in 3m20s
website / deploy (push) Successful in 1m44s
The previous take pressed F1 after a complete command, which no one does.
Now the cast starts `add column `, pauses, presses F1 (Ctrl-G→[F1]) to recall
the syntax, then finishes the command from the example — the real reason you
reach for a hint. The `hint`-on-an-error half is unchanged.
2026-06-15 22:19:29 +00:00
claude@clouddev1 028d32420d docs(website): hint feature docs + cast (content for c84a640)
ci / gate (push) Successful in 2m59s
website / deploy (push) Successful in 1m43s
Completes the preceding empty-rename commit: the getting-help "Hints"
section — F1 for a tier-3 teaching hint on the live input, `hint` to
explain the most recent error — with the real rendered block and a cast
showing both (the live-input hint via the demo-mode Ctrl-G→[F1] alias,
since autocast can't send F1, then `hint` on an error). the-assistive-editor
points at F1; CtrlG added to the cast generator's key map.
2026-06-15 21:56:20 +00:00
claude@clouddev1 c84a640259 docs(website): document the hint feature with a cast (ADR-0053)
getting-help gains a "Hints" section — F1 for a tier-3 teaching hint on the
live input, `hint` to explain the most recent error — with the real rendered
block and a cast showing both: the live-input hint (via the demo-mode
Ctrl-G→[F1] alias, since autocast can't send F1) and `hint` on an error.
the-assistive-editor points at F1. Page converted to .mdx to embed the cast;
CtrlG added to the cast generator's key map.
2026-06-15 21:49:29 +00:00
claude@clouddev1 407408ec29 Merge branch 'main' into website 2026-06-15 21:44:22 +00:00
claude@clouddev1 4016c3e5cd feat(demo): Ctrl-G as a demo-mode F1 alias for casts (ADR-0047 Amendment 1)
ci / gate (push) Successful in 3m3s
The contextual hint overlay (ADR-0053) opens on F1, but F1 is an escape
sequence the autocast recorder can't emit — so casts (and presenter /
teacher sessions) couldn't trigger the most teaching-relevant overlay.

In demo mode only, Ctrl-G now aliases F1: it runs the same hint logic and
badges AS [F1], so a recording is visually identical to a real F1 press.
Ctrl-G is the only fit — Ctrl+digit (e.g. Ctrl-1) isn't encodable in a
legacy terminal (arrives as a bare `1`), and the kitty protocol that would
encode it needs escape sequences autocast can't send (and the app doesn't
enable keyboard-enhancement flags). Demo-gated, so the shipped keymap
stays F1-only; outside demo mode Ctrl-G is inert.

- app.rs: hint_key guard gains the demo-gated Ctrl-G disjunct;
  demo_badge_label maps Ctrl-G -> [F1]; 3 Tier-1 tests + badge assertion.
- ADR-0047 Amendment 1 + README index; also removed two stray
  </content> / </invoke> lines accidentally committed in the ADR file.

docs: drop three more stale "deferred" entries from CLAUDE.md — readline
shortcuts (I1b, ADR-0049), tab completion (I3), and syntax highlighting
(I4) are all implemented; only multi-line input (I1) remains open.
2026-06-15 21:30:37 +00:00
claude@clouddev1 1feb803aab chore(website): re-record all casts against the current app
pnpm casts refresh so every cast reflects the merged app — the
context/state-aware keybinding strip, the readline keys, year/choice-set
seeding, and the DDL-confirmation changes. The .cast files are
regenerable artifacts.
2026-06-15 20:59:49 +00:00
claude@clouddev1 93a40970c3 docs(website): landing — Wordmark replaces the plain title heading
Hide the splash hero's redundant <h1> (kept in the DOM for SEO/a11y,
landing-scoped via a title-only hero) and render the Wordmark + tagline +
buttons in the body, so the brand lockup sits where the heading was and
the content rises above the fold (it was nearly hidden on an iPad).
Styles in global.css; doc-page headings are unaffected.
2026-06-15 20:59:49 +00:00
claude@clouddev1 96b9581089 docs(website-adr): record website CI as implemented (ADR-website-001 §4)
The Gitea Actions → Cloudflare Pages pipeline shipped; update §4 from
"No CI yet" to the implemented workflow, the `relplay` project, the
branch→environment mapping, and the required secrets.
2026-06-15 20:59:49 +00:00
claude@clouddev1 b60c0bb0ec ci: skip the crate gate for website-only changes
ci / gate (push) Successful in 3m0s
Add website/** and the website workflow to ci.yaml's paths-ignore, so a
push confined to the website subproject (built + published by
website.yaml) no longer runs clippy+test. A push that also touches crate
code still gates (paths-ignore skips only when all files match).
2026-06-15 20:24:46 +00:00
claude@clouddev1 c2baf6923b ci(website): Cloudflare Pages deploy via Gitea Actions
ci / gate (push) Successful in 3m5s
website / deploy (push) Successful in 1m47s
New .gitea/workflows/website.yaml: on a push to main or website that
touches website/**, build the Astro site with pnpm and deploy
website/dist to the `relplay` Cloudflare Pages project via wrangler —
--branch selects production (main) vs preview (website). Runs on the
bare ci-public runner (node present; pnpm via corepack). Pin pnpm with
package.json's packageManager for deterministic corepack installs.

Requires repo Actions secrets CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID.
2026-06-15 20:04:48 +00:00
claude@clouddev1 1660a6a17c docs: handoff 72 — H2 hint corpus verified (4 fixes + parse guard)
ci / gate (push) Successful in 2m59s
2026-06-15 19:03:13 +00:00
claude@clouddev1 ea38e7a151 docs(website): update seed (year + choice-sets) and readline keys for the merge
build-ci-image / build (push) Successful in 11m19s
ci / gate (push) Successful in 3m8s
Seed page reflects #33/#34: year-as-int columns, built-in value sets
(priority/severity/rating), advisory now status-only; output blocks
re-captured and the seed cast re-recorded. Assistive-editor documents
the #29 readline shortcuts (Ctrl-A/E/W/K/U, Esc) and #30 cross-mode
history recall; multi-line stays the only planned item.
2026-06-15 18:59:50 +00:00
claude@clouddev1 5a37437055 fix(hint): correct H2 corpus errors + add parse guard (handoff-71)
Semantic verification pass over the tier-3 `hint` corpus (ADR-0053).
Four content errors corrected in src/friendly/strings/en-US.yaml:

- cmd.create_table: the example `with pk id(serial), name(text),
  email(text)` declares a 3-column COMPOUND primary key, not a PK
  plus regular columns (every `with pk` column is a key member,
  ADR-0005). Rewritten to a single-column PK + `add column` for the
  rest; what/concept aligned.
- cmd.save: `save as my-shop` does not parse — `save as` takes no
  inline name, it opens a path-entry prompt. Example -> `save as`;
  what no longer implies inline naming; added a temp-vs-named concept.
- cmd.import: target `shop-copy` does not parse — the `as <target>`
  slot is a NewName ident that rejects hyphens. -> `shop_copy`.
- err.foreign_key.child_side: dropped the bogus `on delete set
  null/cascade` remedy — that governs the parent direction; a
  child-side violation is fixed by inserting the parent first
  (matches the tier-1 hint).

Adds every_cmd_hint_example_parses_in_its_mode — a catalog-driven
guard that parses every hint.cmd.* example in its taught mode,
backstopping syntactic drift (it caught the save and import errors).
Registers the new hint.cmd.save.concept key.

docs: drop two stale "deferred" entries from CLAUDE.md — project
storage (export/import, --resume, input history, migration scaffold)
and m:n convenience (C4) are all implemented (ADR-0015/0045); record
the verification pass on requirements.md H2.
2026-06-15 18:59:38 +00:00
claude@clouddev1 3fe62af886 Merge branch 'main' into website 2026-06-15 17:22:46 +00:00
claude@clouddev1 b4441507e2 docs: handoff 71 — hint content needs a semantic verification pass
User smoke-test found hint.cmd.create_table is semantically wrong: the
example `create table Customers with pk id(serial), name(text),
email(text)` reads as a 3-column table but actually declares a compound
PK (id, name, email) — everything after `with pk` is the PK column list
(ADR-0005). Root cause: Phase C examples were syntax-checked but some
were extrapolated, not verified to *do* what what/concept claims. Handoff
specifies a full per-block semantic pass (run each example / check the
ADR) + a ready-to-apply create_table fix.
2026-06-15 17:14:22 +00:00
claude@clouddev1 77c55fa669 docs(website): document the seed command, with cast (ADR-0048)
New Reference page "Generating sample data" (captured output + a
two-table seed cast showing generation, FK sampling context, and a
`set` override); cross-links from inserting-and-editing-data and
columns; seed added to the rdbms highlight grammar;
querying/sql-queries renumbered. Cast stance in STYLE.md revised to
"justify the absence". Refs #33, #34.
2026-06-12 14:41:37 +00:00
claude@clouddev1 4691d7950a Merge branch 'main' into website 2026-06-12 13:22:52 +00:00
claude@clouddev1 069f9277d1 fix(website): landing card links + keep inline code from breaking mid-token
- Add a teal 'more' link pinned to each landing feature card's
  bottom-right, pointing at the relevant doc page (modes, the SQL echo,
  undo & history, query plans, the assistive editor).
- Stop short inline code (flags like --all-rows) from breaking after a
  hyphen: white-space:nowrap on inline code only; block code in <pre>
  is unaffected and still wraps/scrolls.
2026-06-11 19:24:21 +00:00
claude@clouddev1 09b64cbfb7 feat(website): Open Graph social card
Add a 1200x630 social card (dark bg, monospace, teal database-table
motif, the relplay/RDBMS reveal) shown when pages are shared. Source
SVG in src/assets/og-card.svg, rasterised to public/og-card.png with
sharp. Wire site-wide og:image + twitter summary_large_image tags via
Starlight head, with the absolute relplay.org URL.
2026-06-11 18:59:02 +00:00
claude@clouddev1 abd3739168 feat(website): brand layer — teal palette, table logo, wordmark, footer
Anchor the site to the product's identity (Phase B branding):
- Accent palette mapped to the app's in-TUI teal (--sl-color-accent-*,
  light + dark); trim the splash hero's oversized desktop bottom padding.
- Header logo: a database-table glyph with a teal primary-key cell
  (light/dark variants); favicon redrawn to match.
- Landing wordmark 'RELational PLAYground': teal picks out REL+PLAY
  ('relplay', the domain) and R/D/B/M/S (spelling out RDBMS). Sizes
  locked in em off one master scale so the lockup zooms as a rigid unit.
- Footer override: default footer + an understated company (Lazy
  Evaluation Ltd) and source/issues line.

No engine names or 'DSL' in user-facing copy.
2026-06-11 18:50:07 +00:00
claude@clouddev1 a72d53de51 docs(website-adr): hosting target Vercel -> Cloudflare via Gitea Actions
Record the deployment decision (2026-06-11): static build deploys to
Cloudflare (Workers static assets or Pages; no Astro adapter needed),
driven by a planned Gitea Actions pipeline. Update the ADR-website-001
index entry to match.
2026-06-11 15:37:11 +00:00
claude@clouddev1 6777216e37 feat(website): set production site URL (relplay.org); record Phase B decisions
Set Starlight `site` to https://relplay.org (apex) — enables the
sitemap, canonical URLs, and Open Graph URL resolution; clears the
sitemap build warning. Record the resolved STYLE.md decisions:
single-version launch, fixed-dark cast theme (casts bake RGB colours,
so light/dark would need dual-theme recordings), and the site origin.
2026-06-11 15:36:29 +00:00
claude@clouddev1 13c9c1bcd9 feat(website): add payoff captions to joins/relationship-diagram/sql-echo
Use the ADR-0047 Ctrl+]-delimited demo caption to narrate the payoff
moment of three casts with a neutral one-liner (no key names): the join
result, the relationship diagram, and the m:n junction expansion. Add a
`caption` step kind to the cast generator. Captions show at the climax
during playback and clear as the cast quits.
2026-06-11 13:54:42 +00:00
claude@clouddev1 946dd88db6 feat(website): pace modes/undo-redo/joins casts (split submits, surface state)
Split the short pivotal commands from their submit so they read before
the screen changes (`mode advanced` in modes/joins; `undo`/`redo` before
their confirm modals), and surface the source tables in joins with
`show data` before the join, since the schema sidebar is hidden at 90 cols.
2026-06-11 13:49:40 +00:00
claude@clouddev1 ad43cce945 fix(website): connect box-art in plaintext output blocks
Tighten line-height (1.75 -> 1.2) on plaintext code blocks only, so the
box-drawing in rendered output (tables, query results, plan trees)
connects vertically as it does in the app. Command blocks keep the
looser spacing.
2026-06-11 13:35:29 +00:00
claude@clouddev1 7099bd3cde docs(website): expand the SQL-echo section; prune over-promised notes
Rewrite "Seeing the SQL behind a command" with the learning framing,
a grounded ALTER TABLE example, and the sql-echo cast. Drop the
"multiple result tabs" promise (won't-do on main) and the planned
`hint`-command note (superseded by the hint panel).
2026-06-11 13:28:37 +00:00
claude@clouddev1 5908891d6b feat(website): sql-echo cast — the DSL→SQL teaching echo demo
Advanced-mode cast running simple commands (create table, add column),
culminating in `create m:n relationship` expanding to a full junction
table, each tagged `Executing SQL:`. Recorded at height 34 so the long
m:n echo + junction structure stay fully on screen. Verified against
real app output.
2026-06-11 13:28:37 +00:00
claude@clouddev1 6778c338d4 docs(website): document m:n, --demo, schema sidebar, responsive input
Document the features the main merge shipped: `create m:n relationship`
(relationships ref + build-the-library note), the `--demo` teaching
flag (command-line-options), the Ctrl-O schema sidebar (output-pane,
now .mdx to embed the new cast), and horizontal/two-row input
(assistive-editor).
2026-06-11 12:26:31 +00:00
claude@clouddev1 823b413ca3 feat(website): schema-sidebar cast + Ctrl-O/Esc cast keys
Add a `schema-sidebar` cast that reveals the ADR-0046 sidebar with
Ctrl-O (the only way to show it at 90 cols) and steps through the
Tables and Relationships panels. Teach the generator the CtrlO/Esc
control codes; quote control codes so `^[`/`^]` stay valid YAML.
2026-06-11 12:26:25 +00:00
claude@clouddev1 a0dd202f67 feat(website): pace the projects cast + show table state; record cast guidelines
Projects cast review fixes:
- Pace the confirms: `save as` name and `new` now type, pause, then Enter as
  separate steps, so the viewer can read them before they execute.
- Insert `show tables` at each phase (before save / after `new` / after load),
  since the schema sidebar is hidden at 90 cols (ADR-0046) — the sequence now
  reads "books -> no tables -> books" so the round-trip is followable.

STYLE.md: new "Cast pacing & clarity" guidelines (beat-before-submit; surface
state where the sidebar would). Handoff item 2: chase these up across the
existing casts.
2026-06-11 10:56:46 +00:00
claude@clouddev1 595386e370 docs: note caption-banner review for existing casts in the handoff
Expand next-work item 2: review whether neutral step-caption banners (the demo
overlay) would improve the existing casts — narrating phases or calling out the
relationship diagram / teaching echo — cast-by-cast, with the no-naming-keys
constraint.
2026-06-11 10:43:38 +00:00
claude@clouddev1 51a29e5069 docs: website-branch session handoff (website-2)
Captures everything since website-1: the ADR-namespace move, all Reference +
Guides + the new SQL queries page, the cast pipeline + 7 casts (incl. the new
projects cast via #24 vi-nav), --demo on all casts (#22), and the main merge
(m:n/ADR-0045, UI sidebar+responsive input/ADR-0046, demo overlays/ADR-0047,
logging, FK fixes).

Flags the next-session work: document the merge's new features (m:n command,
--demo flag, ADR-0046 UI) which are not yet in the docs; the no-advertising
constraint (vi keys / Ctrl+] secret); cast tooling limits (no arrow keys);
the capture-harness recipe; Phase B; and open STYLE decisions.
2026-06-11 10:19:22 +00:00
claude@clouddev1 e782a280cc feat(website): projects cast (vi-nav load picker) + --demo on all casts
- New projects cast: create → save as library → new (fresh) → load → navigate
  the picker to the saved project (j, now possible via #24 vi-nav) → Enter
  loads it, the table is restored. Runs under an isolated --data-dir so the
  picker lists only this cast's projects.
- Turn on the demonstration overlay (--demo, #22 / ADR-0047) for ALL casts,
  for a consistent viewer experience: special keys show a badge — e.g.
  [ENTER], and [TAB] at the assistive-editor's completion moment, finally
  making that keystroke visible. Plain j/k navigation stays unbadged, so the
  picker navigation is not surfaced.
- Generator: per-cast `dataDir` (isolated data root) + default-on `--demo`
  (opt out with demo:false). All 7 casts regenerated.

Convert projects.md → .mdx and embed. Build clean (26 pages). Visual playback
of all casts pending a tunnel check.
2026-06-11 10:17:04 +00:00
claude@clouddev1 927e6b2d50 Merge branch 'main' into website (m:n, logging, UI nav, demo overlays, vi-nav)
Brings a large batch of app work onto the website branch so the docs (and
casts) can reflect it:

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

Merged clean — no conflicts (the docs/website/ ADR namespace split kept the new
main ADRs 0045–0047 from colliding). Tests on the merged tree: 2290 passed,
0 failed (1 ignored doctest, inherited from main).
2026-06-11 10:06:18 +00:00
claude@clouddev1 52860c3267 feat(website): casts for first-project/modes/undo-redo; quit invisibly via Ctrl-C
Three more casts on "doing" pages:
- first-project reuses the quickstart cast (the create→insert→show tour)
- modes (new): a simple command, then `mode advanced` where the same command
  also prints "Executing SQL: …" (the teaching echo — "learn the SQL underneath")
- undo-redo (new): insert two rows, `undo` (Y-confirm modal) backs one out,
  `redo` restores it

Also fix the cast endings (review feedback): scripts ended by typing a `quit`
command, which — once the trim drops the shell exit — left a dangling "quit" in
frame with no payoff. End every cast with Ctrl-C instead (the app's quit key,
KeyCode::Char('c')+CTRL): it types nothing, so the cast ends cleanly on the
last content frame. Generator gains a `CtrlC` key; all six casts regenerated.

Convert the three pages to .mdx and embed. Build clean (26 pages); 6 casts.
2026-06-10 16:36:07 +00:00
claude@clouddev1 ce153bde4c docs(website): add SQL queries reference page (advanced query surface)
New dedicated Reference page for the advanced-mode SQL query features
under-covered by "Querying & inspecting": DISTINCT, GROUP BY/HAVING, set
operations (UNION/INTERSECT/EXCEPT), subqueries (IN + correlated NOT EXISTS),
CTEs (WITH), and expressions (CASE/CAST/functions) — each with a worked example
on the library schema and real captured output. Adds an explicit "supported
subset" boundary note (views, triggers, transactions, window functions,
multi-statement batches are not available) rather than linking to general SQL,
which would advertise unsupported features. Grounded in ADR-0030 §3/§13 and the
SQL grammar tests.

Cross-link added from Querying & inspecting. Build clean (26 pages);
box-drawing output verified; forbidden terms clean.
2026-06-10 15:31:05 +00:00
claude@clouddev1 302329d5b2 docs(website): record the cast-placement policy in STYLE.md
"A cast wherever the app does something": broad on Getting-started /
Using-the-playground / Guides + the landing; selective on Reference (motion
beats the still, static output kept regardless); skip pure-lookup/conceptual.
Casts are selective (a representative slice, not every command); autoplay only
the landing; all re-record via `pnpm casts`.
2026-06-10 14:32:48 +00:00
claude@clouddev1 65a48fa5ae feat(website): joins cast on the querying-with-joins guide
Fourth cast: build a minimal two-table schema with rows, switch to advanced
mode (`mode advanced`), and run a join pairing each book with its author —
shows the mode switch + SQL + multi-table result, motion that complements the
guide's static examples. Convert the guide to .mdx and embed above the intro.

Recorded via `pnpm casts`; build clean (25 pages).
2026-06-10 14:26:27 +00:00
claude@clouddev1 bb7887ea82 feat(website): relationship-diagram cast on the relationships page
Third earmarked cast: declare a 1:n relationship, then `show relationship`
draws the two-table connector diagram — showcases the V1 relationship
visualization with motion a still block can't. Convert the relationships
reference page to .mdx and embed it above the syntax (the static diagrams
below remain the exact reference).

Recorded via `pnpm casts`; build clean (25 pages).
2026-06-10 14:13:14 +00:00
claude@clouddev1 a8f84c9d17 feat(website): refine casts — trim shell, autoplay+loop landing, cap size
Address cast review feedback:

- Trim every cast to the in-app region (generate.mjs): the recording now
  starts with the app already running and ends on the last in-app frame —
  drops the `$ rdbms-playground` launch and the return-to-shell frame (the
  latter was the stray cursor-under-$ artifact). Opt out per cast with
  `keepShell: true` for demos that document the CLI launch.
- Landing quickstart cast: autoPlay + loop, with a 2.5s hold on the final
  frame so it pauses before restarting.
- Cap the demo at max-width 46rem and centre it, so the player (fit:'width')
  no longer scales its font up to the full splash column.

Casts re-recorded via `pnpm casts`. Build clean (25 pages).

Tab-keypress visibility deferred to an in-app overlay primitive (filed as
issue #22 — also serves the planned guided-lesson system); the cast notes
Tab in its caption for now.
2026-06-10 13:56:39 +00:00
claude@clouddev1 1f82fb2c79 chore(website): upgrade astro 6.4.5 + starlight 0.40.0 (clears markdown deprecation)
Astro 6.4.5 deprecated markdown.remarkPlugins/rehypePlugins/remarkRehype in
favour of the new unified() API. The warning came from @astrojs/starlight
0.39.3's own integration code, not our config; Starlight 0.40.0 adopts the new
API, so it's gone.

- astro 6.4.4 -> 6.4.5; @astrojs/starlight 0.39.3 -> 0.40.0
  (brings astro-expressive-code 0.43.1, @astrojs/markdown-remark 7.2.0)
- Starlight 0.40.0 adds an optional @astrojs/markdown-satteri peer (an opt-in
  high-performance markdown engine); it's an optional peer so pnpm doesn't
  install it, and we have no need for it — we stay on the default
  markdown-remark/unified pipeline

Verified: deprecation gone; pnpm build clean (25 pages); rendering signals
unchanged vs baseline (highlighting, > prompt + copy-button :has() CSS, asides,
embedded casts); pnpm audit clean.
2026-06-10 13:17:49 +00:00
claude@clouddev1 44f91724b6 feat(website): assistive-editor cast content (completes c904dbb)
The previous commit captured only the .md→.mdx rename — a botched `git add`
(a stale .md pathspec aborted the whole add) dropped the actual content. This
adds it:

- casts.mjs: the assistive-editor cast definition (Tab completion → the [ERR]
  validity indicator catching a misspelled table → friendly error → corrected
  command). Behavior verified by a throwaway spike before scripting.
- public/casts/assistive-editor.cast (generated via `pnpm casts`)
- embed the cast under the intro on the assistive-editor page

Verified: pnpm build clean (25 pages); cast bundled, served, and referenced.
Visual playback check pending (verify via dev server/tunnel).
2026-06-10 13:05:04 +00:00
claude@clouddev1 c904dbb68b feat(website): assistive-editor cast (completion + [ERR] indicator)
Add a second demo, earmarked as prime cast material: Tab completes a table
name, then the [ERR] validity indicator catches a misspelled table before
submit, the friendly error confirms it, and the corrected command runs.
Behavior verified by a throwaway spike before scripting.

- casts.mjs: assistive-editor cast definition
- public/casts/assistive-editor.cast (generated)
- convert the-assistive-editor.md -> .mdx and embed the cast under the intro

Verified: pnpm build clean (25 pages); cast bundled, served, and referenced
on the page. Visual playback check pending (verify via dev server/tunnel).
2026-06-10 13:04:31 +00:00
claude@clouddev1 fbf449f9e0 feat(website): asciinema cast pipeline + landing quickstart demo
Settle the cast toolchain (STYLE.md #9) and build the demo pipeline end to
end. Driver: autocast, chosen by spike — its !Interactive feeds keys to the
running TUI and captures the redraw, the right model for a full-screen
crossterm app. asciinema-automation was rejected (assumes shell echo/\n Enter;
produced a garbled cast against the TUI).

- add asciinema-player; Cast.astro (player island) + Demo.astro (the WASM-swap
  seam, ADR-website-001 §3)
- casts-src/: human-readable command-lists (casts.mjs) + generate.mjs, exposed
  as `pnpm casts`; expands steps to autocast YAML and records to public/casts/.
  Command-lists are the durable source; .cast files are regenerable (final
  re-record sweep due once the app is locked).
- quickstart.cast (create -> add columns -> insert -> show data) embedded on
  the landing page above the feature cards.

Verified: pnpm build clean (25 pages); player + cast bundled and served;
landing HTML references the cast. Visual playback check pending (no headless
browser here — verify via dev server over the tunnel).
2026-06-10 12:59:50 +00:00
claude@clouddev1 c0cc92a741 docs(website): rewrite Build the library + add Querying with joins guide
Build the library: polish and extend from a 2-table draft into the full
guided build — all four tables, the authors→books 1:n and the books↔members
m:n through the loans bridge (bridge-table concept taught in context), the
isbn unique constraint, and captured show-relationships / show-data output.
Remove the draft marker; it is now publication-quality. Uses the same sample
rows as the Reference pages so output matches across the site.

Querying with joins (new): joins built up from two tables → the three-table
bridge join → a filtered join → a group-by aggregate, all in advanced mode
with real captured output.

Verified: pnpm build clean (25 pages); no forbidden terms; internal links
resolve. Advanced-mode `mode advanced`/`mode simple` and the unique
constraint checked against source.
2026-06-10 12:11:39 +00:00
claude@clouddev1 10655e46de docs(website): fill the six Reference stubs with worked examples + output
Expand columns, relationships, indexes, constraints, inserting-and-editing-
data, and querying-and-inspecting from syntax-only stubs into full pages,
each with worked examples on the library schema and real captured app output
(structure boxes, relationship diagrams, data tables, show-lists, query
plans, cascade summaries). All output captured verbatim from the app — never
hand-drawn. Both simple- and advanced-mode forms shown where both apply;
advanced syntax verified against tests/source.

STYLE.md: record the output-block convention (plain unlabelled fence,
captured from a throwaway harness, not hand-drawn).

Verified: pnpm build clean (24 pages); no forbidden terms; internal links
and heading anchors resolve.
2026-06-10 11:50:18 +00:00
claude@clouddev1 619c200ea1 Merge branch 'main' into website (V1 relationship visualization)
Brings main's relationship-visualization feature (ADR-0044 in the main
namespace) and Gitea-migration cleanup onto the website branch, so the
docs can be written against the new diagram output:

- show relationship <name> / show table <T> render two-table connector
  diagrams (child-FK-left, parent-right, n…1 cardinality)
- compound-FK bus routing + pairing line
- ~2000 lines across src/{app,db,event,output_render,runtime,ui}.rs,
  new insta snapshots, tests/it/{show_list,compound_fk}.rs

Merged clean — no conflicts. The prior commit moved the website ADR out
of docs/adr/ into its own namespace, so main's ADR-0044
(relationship-visualization) lands with no collision.

Tests on the merged tree: 2207 passed, 0 failed, 0 skipped
(1 ignored doctest, inherited from main).
2026-06-10 11:06:14 +00:00
claude@clouddev1 dfb5f0b1b1 docs: move website ADR + plan into a dedicated docs/website/ namespace
The website subproject drew ADR numbers from the same global integer pool
as main, so every merge risked a collision — this already happened twice
(drafted 0042, bumped to 0044, both landing on numbers main had taken; main
has now used 0044 for relationship visualization). Give the website its own
ADR namespace so the two never compete again.

- docs/adr/0044-public-website-...md
    -> docs/website/adr/20260604-adr-website-001.md  (id: ADR-website-001)
- docs/plans/20260604-adr-0044-website.md
    -> docs/website/plans/20260604-website-implementation-plan.md
- new docs/website/adr/README.md index (dated <date>-adr-website-<NNN> seq)
- docs/adr/README.md: drop the 0044 entry, add a namespace pointer in the
  intro (keeps the list tail == merge-base, so main's 0044 merges cleanly)
- ADR-0000: record the subproject-ADR-namespace convention
- update references in STYLE.md, astro.config.mjs, the plan body

Handoff files left untouched as point-in-time history.
2026-06-10 11:04:55 +00:00
claude@clouddev1 39e97ac3f9 docs: website-branch session handoff (website-1)
First handoff for the website work, on a branch-scoped name sequence
(…-website-handoff-N.md) to avoid colliding with main's handoff-NN files.
Captures stack/layout, the five-section structure, binding conventions
(no DSL / no engine name / fence + prompt + copy rules), the canonical
library schema, a verified-syntax cheat-sheet, the dev-server IPv4 gotcha,
next-work priorities (fill the 6 Reference stubs, iterate guides, Phase B
landing, deferred casts), and process pins.
2026-06-10 10:41:45 +00:00
claude@clouddev1 936d9254c0 feat: add "Using the playground" section + Reference skeleton
Restructure the docs into five top-level sections, splitting the
application you drive from the database language you build with.

- New "Using the playground" section: command-line options; the assistive
  editor (completion, highlighting, [ERR]/[WRN] indicator, hints, in-line
  editing); the output pane (scrolling); projects (save/load/new/rebuild);
  undo/redo & history; export & import; clipboard; getting help. Grounded in
  the in-app help/usage and ADR-0003/0022/0027.
- Reference: seed the remaining topic pages (Columns, Relationships,
  Indexes, Constraints, Inserting & editing data, Querying & inspecting)
  with real syntax synopses; worked examples to follow.
- Surface the assistive editor on the landing page and in Getting started;
  restore cross-links now that targets exist.

Plan + STYLE updated to the five-section structure. 24 pages, build green,
links resolve, content clean; planned features carry "planned" callouts.
2026-06-10 10:40:07 +00:00
claude@clouddev1 44390e765d feat: simple-mode code-block highlighting, prompt, and copy rules
Add a custom Shiki grammar for the simple-mode command language
(src/grammars/rdbms.mjs), registered with Expressive Code. Two language ids
share it: rdbms (real commands) and rdbms-syntax (abstract templates).
Simple-mode blocks now highlight; advanced examples keep sql.

Separation + copy ergonomics via CSS (global.css): a decorative, copy-safe
"> " prompt on rdbms command lines (not in the copy buffer), and the copy
button hidden on multi-command rdbms blocks and on rdbms-syntax templates
(the app input is single-line, so a multi-command paste is not runnable);
single-command, sql, and sh blocks keep copy.

Content: convert 22 simple-mode fences to rdbms; lead the simplest examples
(first project, Tables reference) with bare "with pk" (the beginner default
that creates a ready-made id key), pointing to the named form. Record the
fence + prompt conventions in STYLE.md.
2026-06-09 22:30:44 +00:00
claude@clouddev1 995c0ba8eb docs: reconcile website doc inventory with merged main scope
The merge from main added user-facing surface the pre-merge inventory had
listed as planned. Mark them documented-as-shipped: show tables /
relationships / indexes + show relationship/index <name> (V5/V5a),
help [<command>] + help types (H3), compound-primary-key foreign-key
references (T3, ADR-0043), and friendlier parse-error messaging (H1a).
Refresh the test count to 2193 and note requirements.md now uses a [/]
partial marker (trust the code, not the marker).
2026-06-09 22:30:44 +00:00
claude@clouddev1 c72c624daa chore: bind website dev/preview server to IPv4 loopback (127.0.0.1)
Astro/Vite's default localhost bind resolves to IPv6 ::1 on this host,
which silently breaks `ssh -L 4321:127.0.0.1:4321` tunnels (they target
IPv4). Pin server.host to 127.0.0.1 so dev/preview is reachable over an
IPv4 loopback forward. Loopback-only — no network exposure.
2026-06-09 21:44:43 +00:00
claude@clouddev1 9e774b2dfa docs: ADR numbering discipline — assign numbers at merge-to-main
Codifies the fix for the ADR-0042 cross-branch collision (resolved this
merge by renumbering the website ADR to 0044): ADR numbers are assigned
when a branch merges to main, not at creation. On a branch, draft under
a placeholder (ADR-XXXX title / draft-<slug>.md filename); main's
docs/adr/README.md is the single source of truth for the next free
number.

- ADR-0000: new "Numbering discipline" section.
- CLAUDE.md: pointer to it from the documentation-discipline note.
2026-06-09 20:30:36 +00:00
claude@clouddev1 40de389bcb Merge branch 'main' into website (Gitea migration + ADR renumber)
Brings website up to date with main (18 commits): H1a parse-error
pedagogy, V5/H3/V5a show+help commands, ADR-0043 compound-PK FK,
handoffs 58-59, and the GitHub->Gitea doc scrub (Cargo.toml repository,
CLAUDE.md, ADR-0001 amendment, requirements).

Conflict: docs/adr/README.md. main and website had each created an
ADR-0042 (main: H1a parse-error pedagogy; website: public website &
docs site). Renumbered the website ADR to 0044 (next free after main's
0042/0043) and updated all references (ADR file, plan file, STYLE.md,
astro.config.mjs, README index). Website build verified green.
2026-06-09 20:28:27 +00:00
claude@clouddev1 0fcb7b1105 docs: website docs structure + first content pages
Phase D foundation. Configures the pragmatic four-section sidebar
(Getting started / Guides / Reference / Concepts) and replaces the
template example pages with grounded content built on the shared
"library" example database (authors/books/members/loans):

- Getting started: installation, first project, simple vs advanced,
  the example library.
- Reference: Types (all ten + serial/shortid + advanced aliases),
  Tables (create/drop, compound PK, advanced CREATE TABLE).
- Concepts: projects & storage (readable files, derived database,
  autosave, temp projects).
- Guides: Build the library (draft, to be refined for teaching).

Command syntax grounded in en-US.yaml usage/help, command.rs, and
types.rs (verified against tests). Records the settled doc decisions
in STYLE.md. Build green (10 pages, Pagefind); content clean of
"DSL"/engine-name.
2026-06-06 07:34:57 +00:00
claude@clouddev1 cea99e8b70 chore: scaffold website (Astro 6 + Starlight + Tailwind v4)
Phase A of docs/plans/20260604-adr-0042-website.md. Scaffolds the site
under website/ from the Starlight template; adds Tailwind v4 (via
@tailwindcss/vite) bridged to Starlight with @astrojs/starlight-tailwind
(src/styles/global.css + customCss). Production build is green: static
output, Pagefind search index, sharp image optimization.

Template placeholders (title, example pages, sidebar) are left for
Phase B/D. Reconciles the ADR/plan/index wording from "Astro 5" to
"Astro 6" to match the scaffolded toolchain.
2026-06-05 15:00:12 +00:00
claude@clouddev1 1fad29c0f9 docs: ADR-0042 — public website + documentation site plan
Planning artifacts for the first public website, recorded before any
code is written.

- ADR-0042: the decisions — Astro 5 + Starlight + Tailwind v4 (over
  SvelteKit); asciinema .cast demos reusable in docs (scripted-input
  driver, not history.log replay); in-page WASM playground deferred
  behind a stable demo seam, with the portable-core vs native-edge
  boundary recorded for a future ADR; portable static hosting (Vercel
  target); monorepo (website/); website is the canonical docs home;
  full-feature-set docs with "planned" callouts; user-facing copy uses
  no engine name and no "DSL"; install via prebuilt binaries + package
  managers.
- docs/plans/20260604-adr-0042-website.md: implementation plan with the
  grounded documentation inventory and phases A–E.
- website/STYLE.md: living documentation style guide + open-decisions log.
- docs/adr/README.md: index updated for ADR-0042 (numerical order).
2026-06-05 08:13:36 +00:00
238 changed files with 29834 additions and 5500 deletions
+7
View File
@@ -0,0 +1,7 @@
# Revisions to ignore in `git blame` — bulk, mechanical, no-behaviour-change
# commits whose authorship is noise. Enable locally with:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# (Forges that support it, e.g. GitHub, pick this up automatically.)
# style: format the whole tree with cargo fmt (stock defaults, #35)
41b7e9a04992cd9708f1775b57044de838b48b85
+47 -10
View File
@@ -2,28 +2,45 @@
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
# just enter the flake devShell and run cargo.
#
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree
# isn't clean under stock rustfmt; revisit on main). The release job (static
# binary for D2) and the platform matrix layer on later, step by step.
# Gate = fmt + clippy + test. The fmt gate (`cargo fmt --check`, stock defaults)
# 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
# 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
on:
push:
# Branch pushes only — a tag push hits the same commit the branch push
# already gated, so `branches: ['**']` drops the redundant tag-triggered
# run (the release workflow owns tags). Pushing commits + a tag together
# still gates the commits via the branch push.
branches: ['**']
# Skip the gate for docs-only changes — markdown can't affect clippy/test.
# A push touching code *and* docs still runs (not all files are ignored).
# Only `main` (the post-merge gate + canonical branch). Feature branches
# are gated via `pull_request` instead. Running both on a push to a PR'd
# branch was pure duplication on Gitea: unlike GitHub it has no merge-
# preview ref — its `pull_request` checks out `refs/pull/N/head`, the same
# commit a branch push would, so the two runs were byte-identical
# (docs.gitea.com/usage/actions/faq). Gating on `pull_request` is also
# forward-compatible: if Gitea ever adds the merge ref, these runs upgrade
# to testing the merged result for free. Tags stay unmatched (release.yaml
# owns them).
branches: [main]
# Skip the gate for changes that can't affect clippy/test — docs, markdown,
# and the website subproject (it has its own workflow, website.yaml, that
# builds + publishes it). A push touching crate code *and* these still runs
# (paths-ignore only skips when *all* changed files match).
# Note: flake/toolchain changes are NOT ignored — they can shift the
# toolchain and thus lint/test outcomes.
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'website/**'
- '.gitea/workflows/website.yaml'
pull_request:
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'website/**'
- '.gitea/workflows/website.yaml'
jobs:
gate:
@@ -33,7 +50,27 @@ jobs:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: fmt (check, stock defaults)
run: nix develop -c cargo fmt --check
- name: clippy (warnings denied)
run: nix develop -c cargo clippy --all-targets -- -D warnings
- name: test
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
# accumulates. Bound the persistent nix store by generation: record the
# 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()
run: |
echo "--- disk before ---"; df -h / | tail -1
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-collect-garbage || true
echo "--- disk after ---"; df -h / | tail -1
+28 -3
View File
@@ -5,11 +5,15 @@
# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild):
# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2)
# x86_64-pc-windows-gnu aarch64-pc-windows-gnullvm (standalone .exe)
# macOS is deferred — its arboard/AppKit link needs Apple's SDK (see ADR-ci-001).
# D3 package-manager manifests layer on later.
# The two macOS targets are built separately by the dispatched
# release-macos.yaml (native Tart runner; ADR-ci-003 amendment), uploading to
# the same release. D3 package-manager manifests layer on later.
#
# Tests run once (host) before the matrix, so a tag can never publish untested
# code, even one pointing at a commit that was never gated on a branch.
# code, even one pointing at a commit that was never gated on a branch. The
# version guard (ADR-0054) refuses to publish a tag whose vX.Y.Z disagrees with
# Cargo.toml's version, keeping `--version`, the release name, and the asset in
# lockstep.
name: release
on:
push:
@@ -23,6 +27,27 @@ jobs:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: version guard — tag must equal Cargo.toml version (ADR-0054)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Read the [package] version straight from Cargo.toml — toolchain-free
# and robust. (An earlier `nix develop -c cargo metadata | node` version
# broke: the flake devShell prints a banner to stdout, corrupting the
# JSON pipe.) `^version = ` is anchored, so it matches only the package
# version, never the `version = ` inside dependency inline tables.
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
echo "tag=$TAG cargo=v$VER"
if [ -z "$VER" ]; then
echo "ERROR: could not read the package version from Cargo.toml" >&2
exit 1
fi
if [ "$TAG" != "v$VER" ]; then
echo "ERROR: release tag '$TAG' != 'v$VER' (Cargo.toml). Bump Cargo.toml to the release version, commit, then retag (ADR-0054)." >&2
exit 1
fi
- name: test
run: nix develop -c cargo test --no-fail-fast
+66
View File
@@ -0,0 +1,66 @@
# Build the docs/marketing website and deploy it to Cloudflare Pages.
#
# One Pages project, two branches (no second project, no sub-folders — Pages
# maps a branch to a *subdomain alias*, not a path):
# main → production (the project's production branch → relplay.org)
# website → preview (alias `website.<project>.pages.dev`; a custom
# `staging.relplay.org` can be attached to it)
# wrangler treats `--branch=<production-branch>` as a production deploy and any
# other branch as a preview, so a single workflow covers both — the Pages
# project's production branch MUST be set to `main`.
#
# Pure-Node build: the `.cast` recordings are committed, so no cargo/Rust is
# needed here. Runs on the bare `ci-public` runner (node already present; pnpm
# via corepack, pinned by package.json's `packageManager`). No job container —
# unlike the Rust gate, this needs none.
#
# Required Actions secrets (set once in the repo/org settings):
# CLOUDFLARE_API_TOKEN — token with "Cloudflare Pages: Edit" on the account
# CLOUDFLARE_ACCOUNT_ID — the account id that owns the Pages project
name: website
on:
push:
branches: [main, website]
# Only when the site (or this workflow) actually changes — crate-only
# pushes don't redeploy the site.
paths:
- 'website/**'
- '.gitea/workflows/website.yaml'
workflow_dispatch:
jobs:
deploy:
runs-on: ci-public
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- name: preflight — toolchain present
run: |
node --version
corepack --version
- name: enable pnpm (pinned by packageManager)
run: corepack enable
- name: install
run: pnpm install --frozen-lockfile
- name: build
run: pnpm build
- name: deploy to Cloudflare Pages
shell: bash
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
# `dist` is relative to website/ (the working-directory). The branch
# name decides production (main) vs preview (anything else).
npx --yes wrangler@4 pages deploy dist \
--project-name=relplay \
--branch="$BRANCH"
+6
View File
@@ -12,7 +12,13 @@
*.snap.new
*.pending-snap
# Website tooling — Cloudflare Wrangler local cache/state (regenerable;
# CI deploys from website/, this dir only appears on a local wrangler run)
.wrangler/
# Editor / OS
.DS_Store
*.swp
*.swo
# Astro/template-seeded editor configs we don't track (e.g. website/.vscode)
.vscode/
+73
View File
@@ -0,0 +1,73 @@
# 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
- Pressing **F1** while the cursor sits inside a clause now adds a short
"About this clause" explanation beneath the command hint — covering
referential actions (`on delete`/`on update`), one-to-many and
many-to-many relationships, primary keys, unique and check constraints,
and foreign keys. The example shown matches whichever mode you're in.
- `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.
### Removed
- The `blob` column type. It could be declared but never given a value
(there was no way to type a binary value in either mode), so it was a
dead end with nothing to teach. The vocabulary is now nine types.
Existing projects that used a `blob` column still open — the column is
converted to `text` on first load (a backup of the original project file
is kept alongside it).
### 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
+137 -54
View File
@@ -37,9 +37,9 @@ Current decisions at a glance (each backed by an ADR):
simple to advanced (ADR-0003). No other sigils.
- **Project format:** `project.yaml` + `data/<table>.csv` +
`history.log`; `playground.db` is a derived artifact (ADR-0004,
amended by ADR-0015). Implemented through Iteration 4 +
cleanup; export/import (Iter 5) and migration framework /
--resume / persistent input history (Iter 6) pending.
amended by ADR-0015). Fully implemented (ADR-0015 Iterations
16): export/import, `--resume`, persistent input history, and
the migration framework scaffold are all done.
- **Project storage runtime:** every command persists through to
db + yaml + csv + history.log in one execution context, gated
by the combined db persistence logic; commit-db-last ordering
@@ -58,8 +58,8 @@ Current decisions at a glance (each backed by an ADR):
`[temp]`-marker / contents-allowlist guards. Anything
unexpected → refuse, never delete the wrong thing.
- **Types:** `text`, `int`, `real`, `decimal`, `bool`, `date`,
`datetime`, `blob`, `serial`, `shortid`. Compound primary keys
supported. No real UUIDs (ADR-0005). FK column type
`datetime`, `serial`, `shortid`. Compound primary keys
supported. No real UUIDs; `blob` dropped (ADR-0005 Amendment 2). FK column type
compatibility via `Type::fk_target_type()``serial → int`,
`shortid → text`, others identity (ADR-0011).
- **Safety:** append-only `history.log` for replay and scripting
@@ -108,58 +108,95 @@ Current decisions at a glance (each backed by an ADR):
SQL `select` / `with` / `insert` / `update` / `delete`
(ADR-0039). `EXPLAIN QUERY PLAN` never executes, so
explaining a destructive command is safe.
- **Continuous integration & release** (built on the `ci` branch,
2026-06-15; decisions in `docs/ci/adr/` — **ADR-ci-001/002/003**,
a namespace kept separate from the main ADR sequence to avoid
cross-branch number collisions, like the website's): a self-hosted
- **Continuous integration & release** (developed on the `ci` branch,
**merged to `main` 2026-06-15**; decisions in `docs/ci/adr/` —
**ADR-ci-001/002/003**, a namespace kept separate from the main ADR
sequence to avoid cross-branch number collisions, like the website's):
a self-hosted
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
`1.95.0` — one source of toolchain for dev *and* CI) plus a
prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` +
`cargo test` on every branch push / PR. **Release** on a `v*` tag
prebuilt CI image. **Gate** (`ci.yaml`, every step run via `nix
develop -c` so dev and CI share the pinned toolchain): `cargo fmt
--check` + `clippy -D warnings` + `cargo test --no-fail-fast` on
every branch push / PR. **Verify locally the CI way before pushing**
— `nix develop -c cargo fmt --check`, `nix develop -c cargo clippy
--all-targets -- -D warnings`, `nix develop -c cargo test` — and read
the **exit code**, not piped output (a `cargo fmt --check | tail &&
echo clean` masks the real result; a bare host `cargo` may also differ
from the pinned toolchain). **Release** on a `v*` tag
(`release.yaml`): the four non-macOS **D1** targets cross-built
with `cargo-zigbuild` (Linux musl static + standalone Windows
`.exe`); the two macOS targets via the **dispatched**
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
release with `.sha256`s. **`fmt` is intentionally not gated yet**
(the tree isn't stock-`rustfmt`-clean). `workflow_dispatch` is
Gitea-default-branch-only, so `release-macos` is dispatchable once
this lands on `main`.
release with `.sha256`s. **`fmt` is gated** (`cargo fmt --check`,
stock defaults — enabled once the tree was reformatted on `main`,
ADR-ci-002 Amendment 1 / issue #35). Now that this is on `main`,
`release-macos` is dispatchable (`workflow_dispatch` is
Gitea-default-branch-only) — **dispatched and verified working**: the
macOS build + de-nix/re-sign + upload runs end-to-end and the binaries
launch.
- **Website & docs site** (developed on the `website` branch, **merged
to `main`**; the branch **stays open** for future staging deploys;
decisions in `docs/website/adr/` — **ADR-website-001**, its own
namespace like CI's): an **Astro + Starlight + Tailwind** marketing
landing page plus the **canonical** user docs, under `website/`.
Showcase demos are **asciinema casts** (a scripted-input driver, paced
+ re-recordable — *not* `history.log` replay; the `--demo` overlay,
ADR-0047, dresses them). **Deployed to Cloudflare Pages via Gitea
Actions** (`.gitea/workflows/website.yaml`; the crate gate is skipped
for website-only changes). Two copy rules bind user-facing text: **no
engine name** (continues ADR-0002) and **no "DSL"** (say "simple mode"
/ "advanced mode"). Install docs are still partial — package-manager
manifests + some installation instructions are pending (`requirements.md`
D3 / DOC1).
## Repository layout
```
.
├── Cargo.toml # dependencies, lints (nursery)
├── CLAUDE.md # this file
├── Cargo.toml / Cargo.lock # dependencies, lints (nursery)
├── CLAUDE.md # this file
├── flake.nix / flake.lock # pinned Rust toolchain, one source for dev + CI (ADR-ci-002)
├── rust-toolchain.toml # toolchain pin
├── .gitea/ # Gitea Actions workflows + prebuilt CI image (ADR-ci-001..003, website)
├── ci/ # CI build helpers (e.g. winstub/ — Windows link stub)
├── docs/
│ ├── adr/ # all decision records (read 0000 first)
│ ├── handoff/ # session-handover notes
── requirements.md # the Phase-1 checklist with progress
│ ├── adr/ # project-wide decision records (read 0000 first)
│ ├── ci/{adr,handoff}/ # CI subproject ADRs (ci-001..003) + handoffs
── website/{adr,plans}/ # website subproject ADRs (website-001) + plan
│ ├── handoff/ # session-handover notes
│ ├── plans/ # working plans
│ └── requirements.md # the Phase-1 checklist with progress
├── src/
│ ├── action.rs # Action enum (Quit / ExecuteDsl)
│ ├── app.rs # App state + pure update() + Tier-1 tests
│ ├── cli.rs # CLI args (--theme, --log-file)
│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables
│ ├── action.rs # Action enum (Quit / ExecuteDsl / …)
│ ├── app.rs # App state + pure update() + Tier-1 tests
│ ├── cli.rs # CLI args (--theme, --log-file, --demo, --no-undo, --resume, …)
│ ├── clipboard.rs # copy output to the system clipboard
│ ├── completion.rs # Tab completion + schema cache
│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables
│ ├── dsl/
│ │ ├── action.rs # ReferentialAction enum + parsing
│ │ ├── command.rs # Command AST + RelationshipSelector + RowFilter
│ │ ├── mod.rs # re-exports
│ ├── parser.rs # parse entry point → unified-grammar walker
│ ├── shortid.rs # base58 generator + validator
│ ├── types.rs # user-facing Type enum + fk_target_type
│ └── value.rs # Value/Bound + per-type validation
│ ├── event.rs # AppEvent (input + DSL outcomes)
│ ├── lib.rs # module re-exports for tests
│ ├── logging.rs # tracing setup, file-backed
│ ├── main.rs # binary entry; thin
│ ├── mode.rs # Simple/Advanced mode enum
│ ├── runtime.rs # Tokio loop, terminal setup, dispatch
│ ├── snapshots/ # insta snapshots for Tier-2 tests
── theme.rs # light/dark themes
│ └── ui.rs # ratatui rendering
└── tests/
└── walking_skeleton.rs # Tier-3 integration tests
│ │ ├── grammar/ # hand-rolled unified grammar nodes (DSL + SQL)
│ │ ├── walker/ # grammar walker (driver / context / highlight / outcome)
│ │ ├── command.rs parser.rs types.rs value.rs action.rs shortid.rs sql_functions.rs
├── echo.rs # command echo / SQL rendering
├── event.rs # AppEvent (input + DSL outcomes)
├── friendly/ # friendly-error layer + string catalog (strings/en-US.yaml) + keys
├── input_render.rs # input-field render + ambient hint classification
│ ├── output_render.rs # output-panel render helpers (incl. relationship diagrams)
│ ├── logging.rs main.rs mode.rs runtime.rs # tracing / entry / mode enum / Tokio loop
│ ├── persistence/ # csv + yaml + history IO + migrations
│ ├── project/ # open/create, lock, naming, prettifier
│ ├── seed/ # seed generators / heuristics / vocabulary (ADR-0048)
│ ├── snapshots/ # insta snapshots for Tier-2 tests
│ ├── theme.rs type_change.rs ui.rs undo.rs # themes / column type-change / render / undo ring
── lib.rs # module re-exports for tests
├── tests/
│ ├── it/ # Tier-3 integration tests (consolidated into one binary)
└── typing_surface_matrix.rs # typing-surface matrix (separate Tier-3 target)
└── website/ # Astro + Starlight docs/marketing site (ADR-website-001)
├── src/ public/ casts-src/ # pages + assets + asciinema cast sources
└── astro.config.mjs package.json … # deploys to Cloudflare Pages via Gitea Actions
```
Key invariants in the code:
@@ -182,7 +219,10 @@ Key invariants in the code:
ADR. In-flight discussion stays in conversation or issues
until it settles. The ADR-0000 index-upkeep rule applies:
every ADR change updates `docs/adr/README.md` in the same
edit.
edit. ADR **numbers** are assigned at merge-to-`main` (draft
under a placeholder `ADR-XXXX` / `draft-<slug>.md` on a
branch) to avoid cross-branch collisions — see ADR-0000
"Numbering discipline".
- **Issue tracking.** Bugs and enhancements are filed as Gitea
issues (see *Issue tracking — Gitea via `tea`* below).
`docs/requirements.md` and the ADRs remain the source of truth
@@ -214,10 +254,64 @@ Key invariants in the code:
the specific product (SQLite, STRICT, rusqlite, PRAGMA).
ADR-internal prose and code comments may name it where
technically necessary for precision.
- **Changelog discipline.** `CHANGELOG.md` (repo root) tracks
notable **user-facing** changes (Keep a Changelog + SemVer).
Update it in the **same change** that introduces a user-facing
behaviour change: add or amend a bullet under `[Unreleased]`
in the right category (Added / Changed / Deprecated / Removed /
Fixed / Security), phrased for end users under the two copy
rules above (no engine name; no "DSL" — say "simple mode" /
"advanced mode"). A change with no user-visible effect (pure
refactor, internal tests, CI plumbing) gets **no** entry — and
the judgement of "user-facing" includes scripted / pasted /
power-user paths, not just the interactive happy path. At
release time, rename `[Unreleased]` to the new version + date,
add the compare link, and **sweep the commits/handoffs since
the last tag** as a backstop for anything missed.
- **Confirm commits.** Per the user's global rules, every
`git commit` is preceded by an explicit message proposal
and user approval. No AI attribution in commit messages.
## Branch-and-PR working method (ADR-0059)
The trackable working method for the now-public repo. Full rationale +
forks in **ADR-0059**; the public-facing subset is `CONTRIBUTING.md`. The
operational rules an agent must follow:
- **Everything lands via a PR onto a protected `main`.** No direct pushes
to `main` (one exception: the ADR-reservation ledger line, D4/D5). One
logical change per branch; conventional prefixes (`feat/ fix/ docs/
chore/ refactor/ test/ ci/`) matching the commit type.
- **One worktree per branch — never switch the primary checkout.** Start a
branch with `scripts/wt-new.sh <branch>` (creates branch off `origin/main`
with `--no-track` + a sibling `<repo>-worktree-<segment>` worktree; first
push is `git push -u origin <branch>`); remove a finished one
with `scripts/wt-rm.sh <branch>` (removes only the *named* worktree — it
never scans or auto-removes, so long-lived branches like `website`/`ci`
are never at risk). This prevents the same-directory clobber hazard.
- **Reserve ADR numbers up front** the moment an ADR is known to be needed
(branch start *or* mid-branch): `scripts/adr-reserve.sh <slug> "<title>"`
atomically claims the next number against `main` (ledger
`docs/adr/RESERVATIONS.log`; push = compare-and-swap, retried). The
number is stable from creation — cite it freely in commits/cross-refs.
**The human runs this script** (its push must originate from them);
agents write/test it but don't run its pushing path. Then create
`docs/adr/<NNNN>-<slug>.md` + its README index row as normal branch work.
- **Merge with `--no-ff` merge commits; never rebase or squash** (append-
only history). Push and merge are **human steps** — agents prepare
branches, edits, PR text, and commit-message proposals, but never push
or merge.
- **Paste the `/runda` / DA review into the PR** so the public repo carries
the audit trail.
- **Handoff / session docs are owner-direct-pushed to `main`, not PR'd** (a
deliberate extension of the reservation-ledger carve-out). Reason: a
docs-only change is `paths-ignore`d by `ci.yaml`, so it posts no `ci / gate`
run — and branch protection *requires* `ci / gate*`, so a docs-only PR
stalls forever on a required check that never arrives. Direct-push (owner
is whitelisted, docs paths burn no CI) sidesteps that. The same applies to
small docs amendments like this one. If docs-via-PR is ever wanted, first
add a "skipped → success" gate shim (or drop the docs paths-ignore).
## Issue tracking — Gitea via `tea`
Extends (does not replace) the generic Gitea/`tea` safety rules in
@@ -335,16 +429,8 @@ all of `target/`, forcing a full from-scratch rebuild).
These are explicitly tracked (mostly in `requirements.md`) but
not yet implemented:
- **Project storage** (track 2): largely implemented through
Iteration 4 + cleanup pass + safety hardening (Iterations
14 of ADR-0015). Pending pieces: `export` / `import` (Iter
5), `--resume` + persistent input history hydration +
migration framework scaffold (Iter 6).
- **Modify relationship** (C3a): drop+add covers the use case
today.
- **m:n convenience** (C4): auto-generates a junction table
with appropriate FKs — depends on relationships being solid
(they are).
- **Strong syntax-help in parse errors** (H1a): point users at
missing keywords/clauses rather than the unexpected
character. *(H1 — the friendly **database**-error layer — is
@@ -355,11 +441,8 @@ not yet implemented:
- **Session log + Markdown export** (V4): the bigger UX
project — scrollable session journal, smart structure
rendering, save-as-markdown.
- **Readline shortcuts** (I1b): Ctrl-A/Ctrl-E, Ctrl-W/Ctrl-K/
Ctrl-U.
- **Multi-line input** (I1): Enter inserts newline,
Ctrl-Enter submits.
- **Tab completion** (I3), **syntax highlighting** (I4).
- **ER diagram export** (V3).
- **Full TT5** (CI): the pipeline is live (see the CI decision
above / `docs/ci/adr/`), but "all tiers on all OSes" isn't
+81
View File
@@ -0,0 +1,81 @@
# Contributing to rdbms-playground
Contributions are welcome — bug reports, ideas, and pull requests. The
project lives on Gitea at
<https://git.lazyeval.net/oli/rdbms-playground>; please file issues and
open pull requests there. It's approaching its first public release, so
the most useful contributions right now are bug reports and rough edges
you hit while learning.
## Getting set up
The toolchain is pinned with a Nix flake, so dev and CI share one Rust
version. With [Nix](https://nixos.org/download) (flakes enabled):
```sh
nix develop # a shell with the pinned toolchain
```
Run everything through `nix develop -c …` so you match CI exactly.
## The checks your change must pass
CI runs these on every push and PR; run them locally first, and **check the
exit code** (a piped `… | tail` can hide a failure):
```sh
nix develop -c cargo fmt --check
nix develop -c cargo clippy --all-targets -- -D warnings
nix develop -c cargo test
```
All three must be green, with no skipped tests. New behaviour needs tests —
the suite runs in tiers from unit up to a PTY-driven end-to-end harness.
## Branches and pull requests
- **`main` is protected** and always green; it isn't pushed to directly.
Every change lands through a pull request.
- **Branch off `main`**, one logical change per branch, named with a
conventional prefix: `feat/…`, `fix/…`, `docs/…`, `chore/…`,
`refactor/…`, `test/…`, `ci/…`.
- **Commit messages** follow [Conventional Commits](https://www.conventionalcommits.org/)
(`feat:`, `fix:`, `docs:` …) and reference the issue (`… (#123)` /
`Closes #123`).
- **Open a PR against `main`.** The CI gate must pass before it can merge;
PRs land as merge commits — history is append-only, so please don't
force-push or rebase shared branches.
- Keep one PR to one concern; don't fold unrelated changes together.
## User-facing text
Two rules bind anything a user can see (errors, help, notes):
- **Don't name the database engine** — say "the database" / "the engine".
- **Don't say "DSL"** — say "simple mode" / "advanced mode".
## Significant changes get a decision record
Architectural or otherwise consequential changes are recorded as ADRs in
[`docs/adr/`](docs/adr/) — start with [`docs/adr/README.md`](docs/adr/README.md).
If your change touches a decided area, read the relevant ADR first; if it
would change a decision, propose a new ADR rather than quietly diverging.
Opening an issue to discuss a substantial change before building it is
always welcome.
## Code style
Match the surrounding code — its naming, comment density, and idioms. The
Clippy nursery lints are enabled and must pass clean.
## License of contributions
Unless you explicitly state otherwise, any contribution you intentionally
submit for inclusion in this project — as defined in the Apache-2.0
license — shall be **dual-licensed under `MIT OR Apache-2.0`** (the
project's licenses), without any additional terms or conditions.
This is the standard Rust "inbound = outbound" arrangement: your
contribution is offered under the same licenses the project distributes
under, so — via Apache-2.0 §5 — it carries the Apache-2.0 §3 patent grant
to all users. No separate CLA is required.
Generated
+114 -4
View File
@@ -64,6 +64,12 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arrayvec"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]]
name = "atomic"
version = "0.6.1"
@@ -164,6 +170,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -481,6 +493,12 @@ dependencies = [
"litrs",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "either"
version = "1.15.0"
@@ -976,7 +994,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [
"nix",
"nix 0.29.0",
"winapi",
]
@@ -1038,6 +1056,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -1046,7 +1076,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
"memoffset",
]
@@ -1360,6 +1390,27 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix 0.28.0",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -1535,7 +1586,7 @@ dependencies = [
[[package]]
name = "rdbms-playground"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"arboard",
@@ -1548,6 +1599,7 @@ dependencies = [
"futures-util",
"gethostname",
"insta",
"portable-pty",
"pretty_assertions",
"rand 0.10.1",
"ratatui",
@@ -1559,6 +1611,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"vt100",
"zip",
]
@@ -1738,6 +1791,17 @@ dependencies = [
"unsafe-libyaml-norway",
]
[[package]]
name = "serial2"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
dependencies = [
"cfg-if",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -1758,6 +1822,22 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1968,7 +2048,7 @@ dependencies = [
"libc",
"log",
"memmem",
"nix",
"nix 0.29.0",
"num-derive",
"num-traits",
"ordered-float",
@@ -2240,6 +2320,27 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vt100"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
dependencies = [
"itoa",
"unicode-width",
"vte",
]
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"memchr",
]
[[package]]
name = "vtparse"
version = "0.6.2"
@@ -2639,6 +2740,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
+43 -2
View File
@@ -1,12 +1,21 @@
[package]
name = "rdbms-playground"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
description = "A cross-platform TUI playground for learning relational databases."
license = "MIT OR Apache-2.0"
repository = "https://git.lazyeval.net/oli/rdbms-playground"
homepage = "https://relplay.org"
readme = "README.md"
publish = false
keywords = ["database", "sql", "tui", "learning", "playground"]
categories = ["command-line-utilities", "database"]
# Keep the published crate to the code that builds the binary — the website,
# decision records, and CI plumbing are repo-only (ADR-0056).
exclude = ["/website", "/docs", "/.gitea", "/.codegraph"]
# `publish = false` removed (ADR-0056): the crate is intended for
# crates.io. The actual `cargo publish` is a deliberate, irreversible
# maintainer step (needs the crates.io token) — do it at a tagged release
# whose assets the binstall metadata below points at.
[dependencies]
anyhow = "1.0.102"
@@ -50,8 +59,10 @@ zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
[dev-dependencies]
insta = { version = "1.47.2", features = ["yaml"] }
portable-pty = "0.9"
pretty_assertions = "1.4.1"
tempfile = "3.27.0"
vt100 = "0.16"
# Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test`
# links ~25 separate integration-test binaries, each statically
@@ -85,3 +96,33 @@ nursery = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
# cargo-binstall (ADR-0056): let `cargo binstall rdbms-playground` fetch the
# prebuilt release binary instead of compiling from source. Our release assets
# are BARE binaries (no archive) named `rdbms-playground-v<version>-<target>`
# (`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003), so `pkg-fmt = "bin"`.
# `{ version }` excludes the leading `v`, so the template spells `v{ version }`.
#
# Target mapping: macOS host triples match our asset triples directly. But we
# ship the fully-static *-linux-MUSL build (glibc hosts are *-linux-gnu) and
# *-windows-GNU/GNULLVM (most Windows hosts are *-msvc), so those common host
# triples need explicit overrides pointing at the asset we actually publish.
#
# NOTE: unverified against a real `cargo binstall` run (binstall isn't a dep and
# nothing is on crates.io yet) — validate at the first publish + matching release.
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-{ target }{ archive-suffix }"
pkg-fmt = "bin"
bin-dir = "{ bin }{ binary-ext }"
[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-unknown-linux-musl"
[package.metadata.binstall.overrides.aarch64-unknown-linux-gnu]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-unknown-linux-musl"
[package.metadata.binstall.overrides.x86_64-pc-windows-msvc]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-pc-windows-gnu.exe"
[package.metadata.binstall.overrides.aarch64-pc-windows-msvc]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-pc-windows-gnullvm.exe"
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Lazy Evaluation Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lazy Evaluation Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+98
View File
@@ -0,0 +1,98 @@
# rdbms-playground
A cross-platform terminal app for **learning relational database
concepts** — tables, columns, primary and foreign keys, relationships,
indexes, queries, and query plans — in a safe sandbox.
It's a teaching tool, not a database administration tool. It meets
beginners with guided, keyword-based commands (**simple mode**) and grows
with them to raw SQL (**advanced mode**), so the same playground works
from "what is a primary key?" through to writing real queries and reading
their execution plans.
Website & documentation: **<https://relplay.org>**
## Install
### One line (Linux / macOS)
```sh
curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
```
Detects your OS and CPU, downloads the matching release binary, verifies
its SHA-256 checksum, and installs it to `~/.local/bin`. Set
`RDBMS_INSTALL_DIR` to install elsewhere, or `RDBMS_VERSION=vX.Y.Z` to
pin a version. (Prefer to read before you run? The script lives at
`scripts/install.sh`.)
### One line (Windows, PowerShell)
```powershell
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
```
Downloads the matching `.exe`, verifies its checksum, installs to
`%LOCALAPPDATA%\Programs\rdbms-playground`, and adds it to your user
PATH. Or use a package manager (Scoop / winget) once those land.
### With `cargo binstall`
If you have [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall)
(install it first — it is not part of `cargo` itself):
```sh
cargo binstall rdbms-playground
```
### From source
```sh
cargo install rdbms-playground # from crates.io
# or, from a clone:
cargo build --release # binary at target/release/rdbms-playground
```
### Prebuilt binaries
Every release publishes static Linux, standalone Windows, and macOS
binaries (x86_64 and aarch64) with `.sha256` checksums on the
[releases page](https://git.lazyeval.net/oli/rdbms-playground/releases).
Windows users can also use the binary directly (package-manager support
is planned).
## A quick taste
```
create table Customers with pk id(serial)
add column Customers: name (text)
add column Customers: email (text)
insert into Customers values ('Ann', 'ann@example.io')
show data Customers
```
Press **F1** while typing for a contextual hint about the command you're
building, or type `help` for the full command list. Switch to raw SQL
with `mode advanced` (or prefix a single line with `:`).
## Project status
Approaching its first public release. See the website for current
features; installation via package managers (Homebrew, Scoop, winget) is
on the roadmap.
## License
Dual-licensed under either of
- MIT license ([LICENSE-MIT](LICENSE-MIT))
- Apache License 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
at your option.
## Contribution
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual-licensed as above, without any additional terms or
conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -38,6 +38,49 @@ The index lists ADRs in numerical order. Each entry shows the
number, title, and — where relevant — status annotations such as
"Superseded by ADR-NNNN" or "Deprecated".
## Numbering discipline
ADR numbers are a single global sequence, so two branches can each grab
"the next number" independently and collide on merge. (This happened when
the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by
renumbering the former to ADR-0044.) To prevent it:
**Reserve the number up front, via `scripts/adr-reserve.sh`** (ADR-0059,
which superseded the earlier placeholder-until-merge default). The moment
you know an ADR is needed — at branch start or mid-branch — run
`scripts/adr-reserve.sh <slug> "<title>"`. It atomically claims the next
free number against `main` (the remote ref is the registry; a `git push`
is the compare-and-swap, retried on contention) and records it in the
append-only ledger `docs/adr/RESERVATIONS.log`. The number is then **stable
from creation**, so it is safe to cite in commit messages (immutable under
the no-rewrite rule), in other ADRs, and in the PR from the first commit.
Create the ADR as `docs/adr/<NNNN>-<slug>.md` and add its README index row
as part of the branch's normal work.
A number is "taken" once its ledger line (or `NNNN-*.md` file) is on
`main`; the script reads both to compute the next free number — never
compute "next" by hand from a feature branch. The full rationale (why
reserve-first beats number-on-merge, issue-number ids, or an allocator bot)
is in **ADR-0059**.
### Subproject ADR namespaces
A long-lived subproject developed on its own branch can escape the shared
integer pool entirely by keeping its decision records in a **separate
namespace**, rather than fighting collisions on every merge. The **website**
(`docs/website/adr/`) is the first: its ADRs use a dated sequence —
`<date>-adr-website-<NNN>.md`, referenced in prose as `ADR-website-NNN`
and are indexed by their own `docs/website/adr/README.md`. Because the
date-plus-subproject prefix is disjoint from `main`'s integer sequence, a
website ADR and a `main` ADR can never claim "the same number" again. (This
namespace was created on 2026-06-10 after the website's ADR collided with
`main`'s on consecutive numbers — drafted 0042, bumped to 0044, both times
landing on a number `main` had taken; the move retired it from the pool as
**ADR-website-001**.) The main `docs/adr/` index carries a pointer to each
such namespace. Use this for a new subproject only when it is genuinely
self-contained and branch-isolated; one-off cross-cutting decisions stay in
the global sequence.
## Out-of-scope discipline
ADRs (and the plans they spawn) lean heavily on "out of scope" language.
+142
View File
@@ -132,3 +132,145 @@ verbatim, trailing zeros and all (`100.10`). Exact decimal
require rewriting the user's standard-SQL operators into function
calls, defeating both the "validated SQL runs verbatim" model and
the goal of teaching ordinary SQL.
## Amendment 2 — drop `blob` from the vocabulary (2026-06-22)
**This supersedes the `blob` row of the Decision table.** The type
set is now **nine types**: `text`, `int`, `real`, `decimal`, `bool`,
`date`, `datetime`, `serial`, `shortid`.
### Why `blob` is removed
The original Decision listed `blob` purely to mirror the engine's
storage classes (its only note was "Binary data."). Unlike the two
other vocabulary judgements made here — compound PKs *defended*
("skipping them would teach the wrong lesson") and true UUIDs
*excluded* ("in favour of TUI legibility") — `blob` never received the
same pedagogical scrutiny. It rode along as the fourth storage class
and was never re-examined.
In practice `blob` is a **dead-end type** in this application:
- **No value can be entered for it through the command surface.**
There is no blob literal in either mode — neither the simplified
syntax nor advanced SQL has one (ADR-0014: "DSL literal not
supported this iteration"; ADR-0038: "blob has no literal … neither
the DSL nor advanced SQL has a blob-literal syntax"). The binder
refuses outright (`src/dsl/value.rs`).
- **`seed` cannot generate it** (ADR-0048: `blob → unsupported`), and a
`NOT NULL blob` column is a hard refusal.
- So a blob column can only ever hold NULL via normal use. The only
way bytes get in at all is hand-editing the base64 cell in a CSV —
not something the app teaches or surfaces.
The missing literal was filed as a perpetual "pre-existing gap" across
many handoffs and never actioned. The right resolution is not to build
a blob-entry affordance — **there is nothing natural to *do* with
binary data in a terminal teaching tool, and nothing about the `blob`
type that serves this app's learning goals** (pedagogy wins ties; this
app is not the place to teach storage-class breadth). Removing it makes
the type set smaller (still "teachable in five minutes", now with one
less dead-end), removes a standing copy-rule bug (the binder's refusal
message named the internal "DSL" term — flagged 2026-06-22), and
simplifies the type-change matrix (ADR-0017's `↔ blob` static refusals
vanish) and the seed generator.
### What this changes
- `Type::Blob` and its keyword/aliases (`blob`, `binary`, `varbinary`)
are removed. Because the variant set is the single source of truth,
this clears both parse paths at once — `from_sql_name` (the
alias-aware command path) *and* `FromStr`/`parse::<Type>()` (the
keyword-only path the `.db` metadata read uses) — once the explicit
`blob` arms are deleted (compiler-guided). The type slot offered by
completion and the advanced-SQL grammar (`BLOB_SLOT`) loses `blob`;
`output_render`/`describe` lose the `BLOB` rendering. The data-layer
value plumbing it fed (`Value::Blob` / `CellValue::Blob(Vec<u8>)` and
the base64 CSV encode/decode in `persistence::csv_io`) is removed with
it.
- **User-facing strings** lose their blob entries: the `help types`
reference (catalog `help.types_reference`) drops `blob` from the
ten-name list, and the blob value-slot hint ("Type a quoted blob
literal or null", `hint.value_slot_blob`) — plus its `keys.rs`
declaration and the walker mapping — is removed. The binder's blob
refusal in `src/dsl/value.rs` (whose message named the internal "DSL"
term — the copy-rule bug that surfaced this whole question) is deleted
outright, so that fix needs no separate rewording.
- The `base64` dependency **stays**`clipboard.rs` uses it for OSC-52,
independent of `blob`.
- ADR-0011 (FK target-type compatibility) and ADR-0017 (type-change
compatibility) lose their `blob` rows; ADR-0014/0038/0048's
blob-literal/seed notes become moot. Prose that says "ten-type"
(ADR-0030/0033/0035, `CLAUDE.md`, the website type reference) becomes
"nine-type".
### Backward compatibility — a project migration
`blob` was a creatable column type, so a saved project may name it. The
type appears in **two artifacts**, and the migration must reconcile
both:
1. **`project.yaml`** (authoritative) — a column declared `type: blob`.
On load this is parsed by `from_sql_name`, which returns `None` once
the type is gone → the project would fail to open.
2. **The derived `playground.db`** (ADR-0004/0015) — which carries the
type *twice over*: the user-facing string `"blob"` in the
`__rdbms_playground_columns` metadata table, **and** an actual
`STRICT … BLOB` column in the SQLite schema. Crucially, load uses an
existing `.db` **as-is**`rebuild_from_text` runs only when the
`.db` is *missing* (`runtime.rs`, ADR-0015 §7). Worse, the metadata
read parses the stored type with `parse::<Type>().ok()` and falls
back to `Type::Text` on failure — so a stale blob `.db` would *not*
error; it would silently read the column as `text` while the engine
column stays `BLOB`: an inconsistent, broken state.
**So a YAML-only type-swap is insufficient.** The migration is:
1. **Migrate the YAML** via the ADR-0015 framework
(`src/persistence/migrations.rs`): register the first real migrator
(v1 → v2) that rewrites every `type: blob` to `type: text`. This is
the framework's first production use — note its by-design
consequence: bumping `latest_version` to 2 re-versions **every**
project on next load (a `project.yaml.v1.bak` is written, the body
rewritten) — harmless for blob-free projects (a pure version bump),
but it does touch every file once.
2. **Force a rebuild of the `.db` from the migrated YAML** whenever the
migration actually changed a column type (i.e. a `blob` was present)
— reusing the existing `rebuild_from_text` path so the engine schema
and metadata are regenerated as `text`, not left stale. A pure
version bump with no schema change does **not** force a rebuild.
**Conversion target: `text`** (decided 2026-06-22). Non-destructive —
the CSV already stores blob cells as base64 *text*, so any bytes survive
as a recoverable string, and an empty column (the overwhelmingly common
case) simply becomes an empty `text` column. It is constraint-safe:
`text` is a valid primary-key and FK-target type (`fk_target_type` is
identity for both), so a former-blob PK or FK target stays valid. And it
keeps the migration **CSV-free**: the column keeps its name and position,
so every CSV header still matches the rebuilt schema (the rebuild's
strict header==schema check, `do_rebuild_from_text`) and the existing
cells decode as `text`.
The considered alternatives were rejected: **drop the column** is
cleaner semantically (there is no legitimately-entered content to keep)
but the strict header check means it would force the migration to *also*
rewrite every affected `data/<table>.csv` to strip the column — not
worth the machinery for a type that has existed unusably for a few days
with very few users; and **refuse to load** strands the project. The
rebuild-on-migration step (above) is required regardless of target,
because the stale `.db` keeps a `STRICT … BLOB` *engine* column that can
neither hold text nor be reported as anything usable until regenerated.
### Consequences
- A smaller, fully-usable type set: every remaining type can be both
created *and* populated through the command surface.
- The type-change matrix and `seed` shed their blob special-cases; the
binder sheds its blob refusal (and its copy-rule violation).
- The ADR-0015 migration framework gets its first real exercise,
validating that path end-to-end.
- A breaking change for the rare project carrying a `blob` column,
mitigated to a graceful, non-destructive load by the migration.
- Re-introducing `blob` later (should a concrete teaching use appear)
is a new ADR superseding this amendment — the same bar every other
type change clears.
+54 -1
View File
@@ -146,4 +146,57 @@ and on a nightly schedule for any extended coverage.
- CI cost is real but bounded: Tiers 13 are fast; Tier 4 is the
only slow tier and is kept narrow.
- Adding a feature implies adding tests at the appropriate tier
(or tiers); coverage is not retrofitted later.
(or tiers); coverage is not retrofitted later.
## Amendment 1 — 2026-06-22: Tier 4 realized (TT4)
Tier 4 was a specification only (no PTY deps, no tests) until this
amendment. It is now implemented in `tests/e2e_pty.rs`, and the realized
shape refines the original decision in three ways.
**Tooling — `expectrl` dropped.** The decision named
`portable-pty` + `expectrl` + `vt100`. We kept **`portable-pty`** (0.9, to
spawn the real binary in a PTY at a fixed size) and **`vt100`** (0.16, to
parse the output stream into an inspectable cell grid), both current and
maintained. We **dropped `expectrl`**: it bundles its own PTY abstraction
(which conflicts with `portable-pty`) and matches line-by-line, a poor fit
for a full-screen TUI. A small hand-rolled `wait_for(predicate, timeout)`
that polls the vt100 screen replaces it — fewer dependencies, and assertions
read the actual rendered grid.
**Harness shape.** Each test spawns the binary
(`env!("CARGO_BIN_EXE_rdbms-playground")`) under a fresh PTY at **100×30**
(the wide three-region layout, ADR-0046), with its **own temp `--data-dir`**
so it never touches real projects or resume state, and `--theme dark` for
deterministic styling. Tests run **serially** (one PTY + child at a time)
via a poison-tolerant global lock, so timing stays predictable on the
low-parallelism self-hosted runner. Waits are **tight and fail-fast** (3 s):
a slow wait is a genuine hang, and a timeout panics with a full screen dump
for debuggability.
Two non-obvious robustness rules the harness encodes:
- **Read table presence from the Tables *sidebar* region, not the whole
screen** — the Output panel echoes every command, so a table name typed
in a command would pollute a whole-screen match.
- **Pace each command to completion before the next** (real-user / cast
cadence). A command submitted while the previous one's worker rebuild is
still in flight can be misread against a stale schema cache (issue #39,
discovered here — interactive use is unaffected).
**The four flows** mirror this ADR's Tier-4 scope: (1) cold launch → first
DDL → graceful quit (Ctrl-C); (2) create → quit → reopen via `--resume`,
asserting the *column* (not just the table name) round-trips; (3) export →
import into a fresh project → schema **and** data rebuilt; (4) `undo` after
`DROP TABLE`, through the Y/N confirm modal.
**CI.** The target runs by default in `cargo test`, so the existing Linux
gate exercises it on every push — this **advances TT5** (Tier 4 now runs in
CI on Linux). PTY-in-container was validated (openpty + child spawn work
under the Docker runtime the Gitea runner uses); the first real CI run is
the final confirmation. **Windows execution remains out of scope** (no
Windows runner), and the original Tier-4 note about a *nightly broader
coverage* schedule is **not** implemented — the focused four flows are the
committed TT4 scope; broader coverage is deferred.
**Piggybacked NFR measurement.** The same harness measures NFR-1 (startup
to first frame) and NFR-3 (idle RSS, Linux) — see ADR-0057.
@@ -824,6 +824,63 @@ of issue #4; no `AmbientHint` / renderer change. Covered by
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
seed_count_hint_also_fires_after_a_column_fill_target}`.
## Amendment 8 — submission gate: hold input until the schema-cache refresh lands (2026-06-22)
§9 established the schema cache as the source of truth for the
walker's schema-aware dispatch, "refreshed by the runtime … after
successful DDL". That refresh is **asynchronous**: the runtime applies
a command on the worker thread (ADR-0010) and only afterwards posts a
`SchemaCacheRefreshed` event back through the **same FIFO channel that
carries key events**. Between dispatching a DDL command and that event
landing, `schema_cache` is stale w.r.t. the command just run.
Validation in `App::update` is pure-sync (a core invariant — it cannot
do a DB round-trip), so it can only consult that cache. Under
**faster-than-human input** (paste, scripted input, an unpaced PTY
driver), the next submission's Enter is already queued *ahead* of the
refresh event, so it is validated against the stale cache. A simple-mode
**Form-B insert** (`insert into T values (…)`, columns derived from the
cache) submitted right after `add column` then sees the pre-DDL columns,
its value arity can't match, and the friendly layer tags it *"trying to
write SQL?"* — even though the identical line succeeds at human speed
(issue **#39**).
**Decision — gate submission on the pending refresh.** `App` carries
`awaiting_schema_refresh: bool` + a `held_submissions` FIFO queue.
`dispatch_dsl` arms the flag on **every** `ExecuteDsl` dispatch; while
armed, a new DSL submission is **held** (queued in submission order)
rather than validated. The `SchemaCacheRefreshed` handler clears the
flag and **drains** the queue against the now-fresh cache, stopping as
soon as a drained command re-arms the gate (so the remainder wait for
*its* refresh — order preserved). A held command that doesn't dispatch
(parse error / pre-flight rejection) leaves the gate open and the loop
continues. App-lifecycle commands (`quit` / `help` / `load` / `undo` /
`rebuild`) route through `dispatch_app_command` *before* `dispatch_dsl`
and so are never held.
**Why arm on every dispatch, not just DDL.** It keeps at most one DSL
command in flight, so refreshes are strictly one-per-dispatch and in
order — making the gate a provably-correct boolean. Arming only on
schema-mutating commands would let a *preceding* non-DDL command's
refresh clear the gate early and drain a held Form-B insert against a
cache that predates the DDL — reintroducing the bug in a corner case.
(App-lifecycle commands also refresh but bypass the gate; they are
modal/picker-gated and so cannot overlap a rapid DSL paste, the only
thing this guards.)
**Scope.** Interactive input only. The `replay` / history-log / startup
rebuild-from-text batch path already re-snapshots the schema
**synchronously, inline, before every line** (`run_replay`,
`build_schema_cache`), so it has always had this ordering guarantee;
this amendment brings the interactive path in line with it. No
interactive-user impact (the gate clears in milliseconds); held input is
never lost because the runtime sends a `SchemaCacheRefreshed` after
*every* dispatch, success or failure. Covered by
`app::tests::form_b_insert_after_ddl_is_held_until_refresh_then_dispatched`
(Tier-1, deterministic event ordering) and the Tier-4 PTY regression
`e2e_pty::back_to_back_insert_after_ddl_still_succeeds` (the unpaced
inverse of flow 3).
## Out of scope
Deliberately deferred to keep this ADR shippable as a single
@@ -706,6 +706,56 @@ documentation is still hand-curated for round 1.
table-ident from new-name-ident visually is a future
enhancement.
## Amendment 1 — advanced-mode SQL forms get their own `help` pages, and the list groups by mode (2026-06-22, issue #36)
`help_id` (§Node taxonomy) drives two surfaces: the `help` **list**
(`note_help` emits one `help.<id>` block per `Some` id) and the
`help <topic>` **lookup** (`note_help_topic` shows the block of every
node whose entry word matches the topic). The dedup rule is that
`help_id` *strings* are unique (one block ⇒ printed once).
Originally the six advanced-mode SQL **DML/query** forms — `SELECT`,
`WITH`, `SQL_INSERT`, `SQL_UPDATE`, `SQL_DELETE`, `EXPLAIN_SQL` —
carried `help_id: None`. That was a list-formatting shortcut (avoid a
second `insert` entry), but it had a side effect: `help select` /
`help with` resolved to **nothing** (the unknown-topic note), and
`help insert` showed only the simple form — even though the SQL surface
is genuinely different and advanced mode exists precisely for learners
moving to raw SQL. (The advanced SQL **DDL** forms — `sql_create_table`
etc. — already had their own `help.ddl.sql_*` pages, so the gap was
inconsistent as well as a pedagogy hole.)
**Decision.** Give every advanced SQL form its **own** `help_id`
(`data.select`, `data.with`, `data.sql_insert`, `data.sql_update`,
`data.sql_delete`, `data.explain_sql`) with a hand-curated
`help.data.*` page (the catalog body stays hand-written, per "What's
out of scope" above — this amendment doesn't change that). Because the
ids are **distinct strings**, the dedup invariant
(`no_two_registered_commands_share_a_help_id`) is untouched, and:
- **`help <topic>` shows every form sharing the entry word** — so
`help insert` shows the simple block *and* the `sql_insert` block
(exactly as `help create` already showed the simple + SQL create
forms). Advanced-only `help select` / `help with` now resolve.
- **The list groups by mode.** `note_help` now splits the REGISTRY by
[`CommandCategory`]: app-lifecycle commands (ids in the `app.*`
namespace, usable in either mode) list first, unlabelled, under the
intro; then a **`help.simple_section`** ("Simple-mode commands:")
group and a **`help.advanced_section`** ("Advanced-mode (SQL)
commands:") group. This replaces the single `help.dsl_section` header
("DSL data commands (in simple mode):"), which both used the banned
"DSL" term (ADR-0002 user-facing posture) and wrongly claimed simple
mode for the advanced SQL forms the section already held.
This partially realises ADR-0030 §6's "Polish" item (a `help sql`
page): rather than one combined page, each form has its own, reached
through the normal `help <topic>` surface and discoverable in the
advanced-mode list section. `note_help_topic` needed **no** change —
the new `help_id`s make the forms resolve automatically. Covered by
`help_command::{help_select_renders_the_sql_select_block,
help_with_renders_the_cte_block, help_insert_shows_both_simple_and_sql_forms,
help_list_splits_simple_and_advanced_sections}`.
## References
- ADR-0023 — Unified declarative grammar tree (Proposed direction). Superseded by this ADR for execution detail.
+2 -2
View File
@@ -172,7 +172,7 @@ representation; the SQL surface does not round-trip through it.
### 5. Type vocabulary — the playground's, not the engine's
Advanced-mode DDL uses the playground's own ten-type
Advanced-mode DDL uses the playground's own nine-type
vocabulary (ADR-0005). There is **no fallback to engine
storage types**: a column created in advanced mode is a
first-class `serial` / `decimal` / `date` / … exactly as a
@@ -373,7 +373,7 @@ ADR when taken up (ADR-0026-style).
- ADR-0002 — the engine is an implementation detail; "no
engine name in user-facing strings" — §7 extends it.
- ADR-0003 — the simple / advanced mode model this builds on.
- ADR-0005 — the ten-type vocabulary advanced DDL uses (§5).
- ADR-0005 — the nine-type vocabulary advanced DDL uses (§5).
- ADR-0009 — the DSL conventions; the DSL stays usable in
advanced mode.
- ADR-0012 / ADR-0013 — the metadata tables the `Command` core
+1 -1
View File
@@ -1649,7 +1649,7 @@ more.
## See also
- ADR-0005 — the ten-type vocabulary INSERT works with.
- ADR-0005 — the nine-type vocabulary INSERT works with.
- ADR-0006 — the auto-snapshot before destructive ops.
- ADR-0014 — the DSL DML model + cascade-summary precedent.
- ADR-0015 — the persistence write-through path.
+3 -3
View File
@@ -58,7 +58,7 @@ Two things from the earlier phases shape this one:
executed as-is, the engine would make the table, but the
playground would lose what the user meant: that `id` is `serial`,
that a `REFERENCES` clause is a *named relationship*, that `STRICT`
applies, that the ten-type vocabulary governs. Recovering that
applies, that the nine-type vocabulary governs. Recovering that
needs the parsed statement either way.
ADR-0030 §4 said "DDL → a `Command` … run the typed executor." That
@@ -92,7 +92,7 @@ mode. Unlike the DML `Sql*` commands they **execute structurally**:
the handler reads the parsed structure and performs the schema change
through the playground's metadata-maintaining machinery — writing
`__rdbms_playground_columns` / `__rdbms_playground_relationships`,
applying `STRICT`, using the ten-type vocabulary — so an
applying `STRICT`, using the nine-type vocabulary — so an
advanced-mode-created object is a first-class playground object,
identical to a simple-mode-created one (ADR-0030 §5).
@@ -772,5 +772,5 @@ honouring it is not the lesson.
- **ADR-0017** — the column type-change classification §7 shares.
- **ADR-0029** — column constraints; **ADR-0025** — indexes;
**ADR-0011** — FK column-type compatibility; **ADR-0005** — the
ten-type vocabulary.
nine-type vocabulary.
- **ADR-0006** — undo; each DDL statement is one undo step (§10).
+38 -2
View File
@@ -414,5 +414,41 @@ time-boxed-`recv` path. We therefore test the **pure pieces**
exhaustively (label fn, capture state machine, nearest-deadline helper)
and assert plumbing via Tier-3, rather than over-claiming an integration
test of the `tokio` timeout itself.
</content>
</invoke>
## Amendment 1 — `Ctrl-G` demo-mode alias for F1 (2026-06-15)
**Context.** The contextual `hint` overlay (ADR-0053 / H2) is opened with
**F1**. But F1 reaches the app only as an escape sequence (`\eOP` /
`\e[11~`), and the `autocast` recorder used for our screencasts **cannot
emit escape sequences** — so a cast can never trigger F1, and the single
most teaching-relevant overlay is unreachable in recordings. The same
wall already bit step-captions (which is why `Ctrl+]`, a single control
byte, was chosen over `Ctrl+!`).
**Decision.** In **demo mode only**, **`Ctrl-G`** is an alias for F1. It
runs the exact F1 hint logic (live-input → form hint; empty input →
recent-error / getting-started) and is **badged as `[F1]`** (not
`[CTRL-G]`) so a recorded cast is visually identical to a genuine F1
press. `Ctrl-G` is the only viable choice: it is a single legacy control
byte autocast can send, whereas `Ctrl`+digit (e.g. the mnemonic `Ctrl-1`)
is **not encodable in a legacy terminal at all** — digits have no control
byte, so `Ctrl-1` arrives as a bare `1`; the kitty protocol *would* encode
it but only as an escape sequence (the very thing autocast can't send),
and this app deliberately does not enable keyboard-enhancement flags.
**Why demo-gated.** The shipped keymap stays F1-only — a real user never
trips the alias, and demo mode is also the mode teachers/presenters run,
so the alias is available exactly where it's wanted. Outside demo mode
`Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm
excludes CONTROL, so no `g` is typed).
**Scope.** `hint_key` guard in `App::handle_key` gains the demo-gated
`Ctrl-G` disjunct; `demo_badge_label` maps `Ctrl-G → [F1]` (consulted
only in demo mode). Test-first: three `app.rs` Tier-1 tests (alias fires
on input + on empty input; inert when demo off) + the badge-map
assertion. The keybinding strip (ADR-0051) is **not** changed — F1 stays
the advertised key; `Ctrl-G` is a recorder aid, and the badge already
reads `[F1]`.
*(Editorial: this amendment also removed two stray `</content>` /
`</invoke>` lines accidentally committed at the end of this file.)*
@@ -0,0 +1,73 @@
# ADR-0054: Release versioning policy + version surfaces (`--version` / `version`)
## Status
Accepted — **implemented 2026-06-16** (plan:
`docs/plans/20260616-public-availability.md`, step 1). First step on the
road to public availability. Adds a `--version` / `-V` CLI flag and an
in-app `version` command, both reporting `CARGO_PKG_VERSION`, plus a
release-CI guard that the `v*` tag equals that version. No prior issue or
`requirements.md` item existed for this — it was an untracked gap.
## Context
Before this, `Cargo.toml` carried `version = "0.1.0"`, the binary exposed
**no** way to report its version, and `release.yaml` named assets from the
**git tag** (`rdbms-playground-<tag>-<target>`) while building from
`Cargo.toml`. Tag and crate version were **decoupled**: tagging `v0.2.0`
would publish an asset named `…-v0.2.0-…` containing a binary that (had it
been able to say) reported `0.1.0`. On the way to public availability —
where users download a versioned artifact and file bug reports against "the
version I'm running" — that drift is a correctness problem.
## Decision
1. **`Cargo.toml` `version` is the single source of truth.** This is the
idiomatic Rust position and avoids making `Cargo.toml` lie. The version
is read at compile time via `env!("CARGO_PKG_VERSION")`; no build-time
injection of the tag into the crate.
2. **Two user-facing surfaces, one source:**
- **`--version` / `-V`** — CLI flag (hand-rolled parser, mirrors
`--help`): prints and exits before any other work (`main.rs`).
- **`version`** — an in-app app command (REGISTRY node `app::VERSION`,
`AppCommand::Version`), emitting the same line into the output panel.
Both go through `cli::version_text()` → the catalog key
`cli.version_line` (`"rdbms-playground {version}"`), so there is exactly
one rendered string and one version source.
3. **Release-CI discipline.** `release.yaml`'s pre-build `test` job gains a
**version guard**: it reads the `[package]` version directly from
`Cargo.toml` (`grep -m1 '^version = '` — toolchain-free; an earlier
`cargo metadata | node` form broke on the flake devShell's stdout banner)
and **fails the release** unless the pushed tag equals `v<that version>`.
So `--version`, the release name, and the downloaded asset are always in
lockstep — enforced by the machine, not by memory.
4. **The release ritual:** bump `Cargo.toml` → commit → tag `v<that
number>` → push the tag. The guard rejects any deviation.
### Rejected / deferred
- **Inject the tag into the build** (tag as source of truth): fiddly with
cargo and makes `Cargo.toml` a placeholder/lie. Rejected.
- **Git-hash + build-date enrichment** (a `build.rs` so dev builds read
`0.2.0 (a1b2c3d)`): useful for bug reports, but not needed for the
tag↔release↔`--version` consistency this ADR is about. Deferred; can be
added behind the same `version_text()` seam without changing the policy.
- **UI placement beyond the `version` command** (status-bar string, etc.):
the command + `help` listing is enough for now (user decision).
## Consequences
- A release can no longer ship a binary whose self-reported version
disagrees with its tag/filename.
- Cutting a release now *requires* a `Cargo.toml` bump commit — a small,
deliberate step (and a natural place to update a changelog later).
- New keys: `cli.version_line` (+ `help.app.version`, `parse.usage.version`,
`hint.cmd.version.what`/`.example`); a new REGISTRY command means the
comprehensiveness coverage test now also requires `hint.cmd.version`,
which is supplied. Tested: CLI flag parse (`--version`/`-V`/default-off),
`version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses to
`AppCommand::Version` and emits the version.
- This is step 1 of `docs/plans/20260616-public-availability.md`; the
installer (`install.sh`) and package-manager work (D3) build on top.
+86
View File
@@ -0,0 +1,86 @@
# ADR-0055: `curl | sh` install script (`scripts/install.sh`)
## Status
Accepted — **implemented 2026-06-17** (plan:
`docs/plans/20260616-public-availability.md`, step 2). Step 2 on the road
to public availability, building on ADR-0054 (versioned releases) and
ADR-ci-001/003 (the Gitea releases it downloads from). Tracked by the
plan + this ADR (no Gitea issue — user decision, 2026-06-17).
## Context
Until now, installing meant: find the releases page, work out which of
the six assets matches your machine, download it, `chmod +x`, move it
onto `PATH`, and (on macOS) wonder about Gatekeeper. That is too much
friction for a teaching tool aimed at beginners. The Gitea releases are
**publicly downloadable** (confirmed), with deterministic asset names
(`rdbms-playground-<tag>-<target>[.exe]`) and `.sha256` sidecars
(ADR-ci-003), and a `releases/latest` API — enough to script a one-liner
install.
## Decision
Ship **`scripts/install.sh`**, run as
`curl -fsSL <gitea-raw>/scripts/install.sh | sh`:
- **POSIX `sh`** (no bashisms) — it runs under the `sh` of `curl | sh`;
kept **shellcheck-clean** (`-s sh`).
- **Platform detection** from `uname` → target triple: Linux →
`<arch>-unknown-linux-musl` (the fully-static build — one universal
Linux artifact, no glibc/version coupling), macOS → `<arch>-apple-darwin`;
`x86_64`/`amd64` and `aarch64`/`arm64` both map. **Windows is rejected**
with a pointer to Scoop/winget/the releases page (the binary is a `.exe`,
not a `curl|sh` target).
- **Version:** the `releases/latest` API tag by default; `RDBMS_VERSION`
pins a specific tag.
- **Integrity:** always download the `.sha256` sidecar and **verify**
(`sha256sum`/`shasum -a 256`); a mismatch aborts the install. HTTPS only.
- **Install location:** `~/.local/bin` by default (user-writable, no
sudo), overridable via `RDBMS_INSTALL_DIR`; prints a PATH hint if the
dir isn't on `PATH`.
- **macOS note:** a `curl` download is **not** Gatekeeper-quarantined, so
the binary runs as-is even while it is only ad-hoc-signed; proper
Developer-ID signing + notarization (for *browser* downloads) is a
separate, postponed task (see the plan's signing item).
- **Testing seams:** `RDBMS_OS`/`RDBMS_ARCH` force detection and
`--print-target` prints the resolved triple and exits — so the mapping
is checkable without a download.
### Rejected / deferred
- **Hosting the script on the website domain** (Cloudflare): nicer URL,
but adds a moving part; the **Gitea repo raw URL** is simplest and the
binaries live there anyway (user decision). The website may later
*reference* the same command.
- **Uploading `install.sh` as a release asset** for a stable link:
optional; the branch raw URL is fine for now.
## Amendment 1 — `install.ps1` (Windows) added (2026-06-17)
Windows was originally deferred to Scoop/winget; the user opted for **both**
a PowerShell one-liner now *and* package managers later. Added
**`scripts/install.ps1`** (`irm <url> | iex`): maps the host CPU to our
`*-windows-gnu`/`-gnullvm` `.exe`, resolves the latest release (or
`-Version`/`RDBMS_VERSION`), downloads + **SHA-256-verifies**, installs to
`%LOCALAPPDATA%\Programs\rdbms-playground` (`-InstallDir`/`RDBMS_INSTALL_DIR`
override), and adds that dir to the **user PATH**. **Caveat:** unlike
`install.sh` (verified end-to-end), this was **written but not tested from
this environment** (no PowerShell available) — validate on a real Windows
host. Scoop/winget (D3) remain the idiomatic package-manager routes.
## Consequences
- A first-time user runs one line and gets a checksum-verified binary on
`PATH`. The website's install copy (website branch, separate agent) can
point at this command.
- **Verified end-to-end** (2026-06-17) against the live public `v0.1.0`:
all four Linux/macOS platform mappings + Windows/unknown-arch rejection;
pinned and latest paths; checksum verification incl. a tamper-rejection
check; install + run on Linux x86_64. (The installed `v0.1.0` predates
`--version`, ADR-0054 — a non-issue, and the reason to cut a new
release.)
- **No automated regression guard in CI yet:** shellcheck isn't in the
flake, and there's no shell-test harness here (no bats). Recommended
follow-up: add a `shellcheck scripts/*.sh` gate (touches ADR-ci-002 —
needs shellcheck in the devShell). For now the guard is local
shellcheck + the documented end-to-end verification.
@@ -0,0 +1,319 @@
# ADR-0056: crates.io publish-readiness + `cargo binstall` metadata (D3)
## Status
Accepted — **prepared 2026-06-17** (plan:
`docs/plans/20260616-public-availability.md`, step 3a). The crate is made
**ready to publish** and carries `cargo-binstall` metadata. The actual
`cargo publish` is a gated maintainer step (see Ordering). First D3
package-manager mechanism; builds on ADR-0054 (versioned releases),
ADR-0055 (installer), ADR-ci-003 (release assets). Tracked by plan + ADR
(no Gitea issue — user decision).
## Context
`cargo binstall rdbms-playground` (and `cargo install`) need the crate on
**crates.io** (user decision, 2026-06-17). The manifest had
`publish = false`, a `readme = "README.md"` pointing at a **missing**
file, and no `keywords`/`categories`. Our release assets are **bare
binaries** (not archives) named `rdbms-playground-v<version>-<target>`
(`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003); critically the
**release target triples differ from users' host triples** — we ship the
static `*-linux-musl` build (hosts are `*-linux-gnu`) and
`*-windows-gnu`/`-gnullvm` (hosts are `*-msvc`); only macOS matches.
## Decision
**Publish-readiness (this change):**
- Drop `publish = false`; add `homepage = "https://relplay.org"`,
`keywords`, `categories = ["command-line-utilities", "database"]`, and
an `exclude` (`/website`, `/docs`, `/.gitea`, `/.codegraph`) so the
published crate is code-only (585 files/8.3 MiB → 353/913 KiB
compressed).
- Author **`README.md`** (the `readme` target + crates.io front page;
engine-neutral and "simple/advanced mode" wording per ADR-0002 / the
website copy rules), with install instructions (curl|sh, binstall,
source, prebuilt).
- Add **`LICENSE-MIT`** and **`LICENSE-APACHE`** (the latter the verbatim
canonical text, added by the maintainer; both © Lazy Evaluation Ltd —
the publication entity), and a **`CONTRIBUTING.md`** stating the
"inbound = outbound" dual-license arrangement (so Apache-2.0 §5 makes
the §3 patent grant explicit on the self-hosted forge). Dual license
kept (not MIT-only) — user decision after reviewing the patent-grant
rationale.
**`cargo binstall` metadata** (`[package.metadata.binstall]`, syntax
verified against cargo-binstall SUPPORT.md):
- `pkg-fmt = "bin"` (bare binary), `bin-dir = "{ bin }{ binary-ext }"`,
and a base `pkg-url` using `v{ version }` (the `{ version }` placeholder
excludes the leading `v`).
- **Per-target `overrides`** mapping the common host triples to the asset
we actually publish: `x86_64`/`aarch64-unknown-linux-gnu` → the `-musl`
asset; `x86_64`/`aarch64-pc-windows-msvc` → the `-gnu`/`-gnullvm`
`.exe`. macOS needs no override (host triple == asset triple). The docs
do **not** promise automatic musl/gnu or msvc/gnu fallback, hence
explicit overrides.
**Ordering / gating (important):**
- `cargo publish` is **irreversible** (needs the crates.io token; a
version can't be un-published, only yanked) — a deliberate **maintainer
step**, not done here.
- binstall's `pkg-url` resolves to a **tagged release's** assets, so
publish **at a new tagged version whose release already exists**, and
publish **after** that release is built. **Do not publish `0.1.0`** — it
would diverge from the already-released `0.1.0` binaries (which predate
`--version`, ADR-0054). The clean path: bump → tag → release builds →
`cargo publish`.
## Verification
- `cargo publish --dry-run --allow-dirty` packages + verify-builds cleanly
(353 files, 913 KiB compressed; no metadata errors).
- `cargo metadata` confirms the `binstall` block + all four `overrides`
parse.
- **Unverified:** an actual `cargo binstall` run — cargo-binstall isn't a
dependency and nothing is on crates.io yet. **Validate at the first
publish + matching release** (especially the windows-msvc→gnu and
linux-gnu→musl overrides).
## Consequences
- The crate can be published at the next tagged release with `cargo
publish` (+ the token); `cargo install rdbms-playground` and `cargo
binstall rdbms-playground` then work.
- Remaining D3: Scoop, Homebrew (`lazyeval` tap), winget (komac/manual) —
each a manifest + a per-release bump, tracked in the plan.
- Remaining follow-up: run the real `cargo binstall` validation at the
first publish + matching release (the license files, © holder, and
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).
+462
View File
@@ -0,0 +1,462 @@
# ADR-0058: Clause-concept hints — a `hint.concept.*` layer surfaced by cursor position (issue #37)
## Status
Accepted — **implemented 2026-06-23** on branch `feat/clause-concept-hints`
(pending merge). Number **0058**, reserved up front via the reserve-first
flow (ADR-0059) rather than the older placeholder-until-merge default —
this ADR is the first to carry a number stable from creation. Extends
**ADR-0053** (contextual `hint`, D3/D4) additively; does not supersede it.
Closes the deferred extension tracked as Gitea issue **#37**.
Implementation: `Node::Concept { topic, inner }` (`dsl/grammar/mod.rs`) +
the span-recording driver arm (`dsl/walker/driver.rs`) +
`WalkContext::concept_spans` / `ConceptSpan` (`dsl/walker/context.rs`);
the `concept_topic_at_cursor` resolver (`dsl/walker/mod.rs`); 10
`Node::Concept` wrappers across `shared.rs` / `ddl.rs` /
`sql_create_table.rs` (one shared `REFERENTIAL_CLAUSES`, two extracted
cardinality sub-`Seq`s); the mode-keyed `emit_tier3_block` +
`note_hint_for_input` layering (`app.rs`); the seven `hint.concept.*`
blocks + `hint.block.concept_heading` (`strings/en-US.yaml`) with
`keys.rs` declarations; and the test set (14 resolver unit tests, 3
comprehensiveness gates incl. the recursive `Node::Concept` visitor, 3 F1
integration tests, the `hint_block_with_concept` snapshot). Suite green
across all tiers; `fmt`/`clippy` clean.
Revised after a `/runda` review (2026-06-22) that grounded the mechanism
against the code and caught two design gaps: the clause mapping (D4)
covered only the advanced-SQL constraint grammar and missed the
simple-mode constraint suffix (ADR-0029); and the single-`example`
content model collided with ADR-0053 D6's mode-correct-example rule for
topics reachable in both modes. Both are resolved here — D4 now maps both
grammars, and D5 adopts **mode-keyed examples** (user decision,
2026-06-22).
References ADR-0053 (the three-tier `hint` model + the `hint.cmd.*` /
`hint.err.*` corpus), ADR-0024 (the unified grammar/walker, `HintMode`-
per-node, `MatchedPath` spans), ADR-0022 (ambient tier-2 typing
assistance), ADR-0013/0045 (the relationship + `m:n` grammar), ADR-0035
(advanced-mode `CREATE TABLE` constraint grammar).
## Context
ADR-0053 delivers tier-3 teaching hints at **per-form** granularity: one
`hint.cmd.<form>` block (`what` / `example` / `concept`) per command form,
surfaced by F1 on the live input. Position-awareness *within* a form is
owned by tier-2 (the ambient candidate list / slot prose, ADR-0022).
During ADR-0053 Phase B we identified a third teaching point the per-form
model does not serve: **concepts that live inside a clause**, where a
learner may want teaching deeper than tier-2's candidate list but narrower
than the whole-form block. The canonical examples are the most
load-bearing relational concepts in the tool:
- the **referential actions** clause — `… on delete ⟨cascade | set null |
restrict | no action⟩` (and `on update`);
- the **cardinality** marker — `1:n` (one parent, many children) and
`m:n` (many-to-many, auto-junction per ADR-0045);
- the **primary-key** declaration — `with pk(…)` in simple mode and the
`primary key` constraint in `create table (…)`;
- the **column-constraint slots** in `create table (…)``unique`,
`check (…)`, `foreign key` / `references`.
A student parked in one of these clauses is, by definition, looking at the
exact relational concept that clause embodies. ADR-0053 explicitly
deferred this ("clause-concept hints", issue #37) and recorded that the
per-form keying does **not** lock it out: a later `hint.concept.<topic>`
namespace can be surfaced when the cursor sits in a recognized clause,
layered on top of the per-form block.
This ADR is that follow-up. It is **pedagogy-led, not metrics-led** — we
do not expect to accumulate usage statistics about which clauses learners
pause at, so the clause set is chosen by relational-teaching value, not
data.
### Two user decisions taken up front (2026-06-22)
The issue left two scope questions open. Both were decided by the user
before design:
1. **Detection precision: "anywhere inside the clause."** The hint fires
whenever the cursor sits *within* a recognized clause's span —
including parked inside already-typed text (e.g. `add 1:n
relation|ship …`) — not only at the slot boundary where the next token
is about to be typed.
2. **Clause scope: all four families** — referential actions, the
create-table constraint slots, `with pk`, and `1:n` / `m:n`
cardinality.
### Why the existing `HintMode`/`pending_hint_mode` mechanism is not enough
Tier-2 slot prose is driven by `Node::Hinted { mode, inner }`: entering
the node sets `ctx.pending_hint_mode`, and the resolver reads it at the
end of the cursor-trimmed walk (`hint_resolution_at_input_in_mode`,
`src/dsl/walker/mod.rs:107`). Crucially, the driver **clears
`pending_hint_mode` on every successful match** (`src/dsl/walker/
driver.rs:154`): it records only "the Hinted slot the cursor is *about to
fill*", and the moment the slot's content matches, the signal is gone.
That is exactly the *boundary* semantics tier-2 wants — and exactly the
opposite of "anywhere inside the clause". The instant the learner finishes
typing `cascade`, `pending_hint_mode` clears, so a cursor parked on the
finished `on delete cascade` would see nothing. Decision (1) above
therefore needs a **span-based** mechanism that survives the clause being
fully matched, not the single-slot `pending_hint_mode`.
## Decision
### D1 — A span-recording grammar wrapper, `Node::Concept`
Add a new grammar combinator, sibling to `Node::Hinted`:
```rust
Node::Concept { topic: &'static str, inner: &'static Node }
```
It is a pass-through for matching (it walks `inner` and returns `inner`'s
result verbatim — it never changes what parses), but as a side effect the
driver records the **byte span the node covered** into a new context
field:
```rust
// WalkContext
pub concept_spans: Vec<ConceptSpan>, // { topic: &'static str, start: usize, end: usize }
```
- `start` = the first non-whitespace byte the node began matching at
(post `skip_whitespace`, matching the driver's existing convention).
- `end` = the byte position the node's walk reached — the end of the
matched span on a full match, or the stop position on an incomplete /
failed inner. This makes the span the real extent of the clause as typed
so far.
Unlike `pending_hint_mode`, `concept_spans` is **append-only across the
whole walk** and is never cleared on match — so a fully-typed clause keeps
its span, satisfying decision (1).
**Precise push rule** (grounded in `NodeWalkResult`,
`src/dsl/walker/driver.rs:97`): walk `inner`, then push `{ topic, start,
end }` iff the inner result is one of `Matched { end }` (end = `end`),
`Incomplete { position }`, or `Failed { position }` (end = `position`) —
i.e. the node **committed** (consumed at least its first token, or ran out
mid-clause). On `NoMatch { .. }` (the node never engaged — e.g. a `Choice`
tried this branch and it didn't apply) push **nothing**. This is the
`NodeWalkResult`-level analogue of the driver's existing
`pending_hint_mode` clear-on-match leak-avoidance, and it means a failed
`Choice` branch leaves no stale span.
`Node::Concept` is **orthogonal to `Node::Hinted`** — a clause that wants
both a tier-2 slot prose *and* a tier-3 concept span nests them (`Concept{
… inner: Hinted{ … } }`). Neither changes the parse.
### D2 — Cursor → topic resolution: innermost containing span
A new resolver:
```rust
pub fn concept_topic_at_cursor(
input: &str, cursor: usize,
schema: Option<&SchemaCache>, mode: Mode,
) -> Option<&'static str>
```
walks the **full** input buffer with `WalkBound::EndOfInput` (the dormant
`WalkBound::Position` path is *not* used — see D3 rationale), collects
`ctx.concept_spans`, and returns the `topic` of the **innermost span
containing `cursor`**. "Innermost" = the containing span with the latest
`start` (equivalently, the narrowest), so when a `references` clause
(constraint slot) wraps a referential-actions clause, a cursor on `on
delete` resolves to the more specific `referential_actions`, while a
cursor on `references Parent` resolves to `foreign_key`.
Containment is inclusive of `start` and `end` (`start <= cursor <= end`),
so the boundary case — cursor *at* the point of entering the clause —
also resolves, meaning this subsumes the simpler boundary detection for
free.
The `cursor` passed in is the one **already computed by `feedback_view()`**
(`src/app.rs:3204` returns `(view, cursor, _off)`), which strips the `:`
one-shot sigil and adjusts the cursor offset to index into the stripped
`view`. The concept resolver walks that same `view`, so the `:`-strip is
handled with no extra offset arithmetic.
### D3 — Why full-buffer span-containment, not `WalkBound::Position`
`WalkBound::Position(cursor)` (defined, `#[allow(dead_code)]`,
`src/dsl/walker/outcome.rs:26`) slices the source at the cursor and walks
the prefix. Two reasons it is the wrong tool here:
- **It discards text after the cursor.** A cursor parked early in a
complete command (`add 1:n| relationship from … on delete cascade`)
would truncate to `add 1:` and lose the parsed structure that tells us
we are inside a well-formed relationship command. Full-buffer walking
keeps the whole parse; the cursor is used *only* for span containment.
- **It still rides `pending_hint_mode`'s clear-on-match**, which D1
already establishes is unsuitable for inside-clause detection.
Span-recording over a full `EndOfInput` walk needs no new walk-bound
behaviour and leaves the dormant `Position` path untouched.
### D4 — The clause set and their topics (both grammars)
**Seven** `hint.concept.<topic>` blocks, placed via `Node::Concept`
wrappers across **both** the simple-mode (`grammar/ddl.rs`, ADR-0029) and
advanced-mode (`grammar/sql_create_table.rs`, ADR-0035) grammars. The
codebase has **two distinct constraint grammars** — simple-mode `create
table` column constraints (`COLUMN_CONSTRAINT_SUFFIX`, `ddl.rs:1082`:
`not null` / `unique` / `default` / `check`; PK is `with pk`, FK is a
relationship) and the advanced-SQL constraint set (`COL_CONSTRAINT_CHOICES`,
`sql_create_table.rs`: adds `primary key` / `references`). A topic
reachable in both modes is wrapped in **both** grammars (or once, if the
node is physically shared — e.g. `REFERENTIAL_CLAUSES` in `shared.rs` is
reused by the relationship command *and* the advanced `references` clause).
| Topic | Modes | Grammar node(s) to wrap |
|---|---|---|
| `referential_actions` | both | `REFERENTIAL_CLAUSES` (`shared.rs` — one wrapper, reused in both) |
| `cardinality_one_to_many` | simple only | a new `CARDINALITY_1N` sub-`Seq` extracted from `ADD_RELATIONSHIP_NODES` (`ddl.rs:479`) |
| `cardinality_many_to_many` | simple only | a new `CARDINALITY_MN` sub-`Seq` extracted from `CREATE_M2N` head (`ddl.rs`) |
| `primary_key` | both | `WITH_PK` (`ddl.rs`, simple) + the `primary key` constraint node (`sql_create_table.rs`, advanced) |
| `unique` | both | the `unique` constraint node in `ddl.rs` (`UNIQUE_CONSTRAINT`, `:1051`) + in `sql_create_table.rs` |
| `check` | both | the `check (…)` constraint node in `ddl.rs` (`:1064`) + in `sql_create_table.rs` |
| `foreign_key` | advanced only | `REFERENCES_CLAUSE` (`sql_create_table.rs:206`) |
If a `unique` / `check` node turns out to be physically shared between the
two grammars (as `REFERENTIAL_CLAUSES` is), one wrapper suffices — the
mode-keyed example (D5) is selected by the **live mode** at F1 time, not by
which grammar node matched, so wrapper-sharing never affects correctness.
The exact wrapper count is pinned during implementation; the *topic* set is
the seven above.
**Nesting is intentional.** `foreign_key`'s `REFERENCES_CLAUSE` contains
`referential_actions`' `REFERENTIAL_CLAUSES`, so a cursor on `references
Parent` resolves to `foreign_key` and a cursor on the inner `on delete`
resolves to `referential_actions` — D2's innermost-wins handles it.
**Deliberate omissions** (faithful to issue #37's explicit list of
"primary key | unique | check | foreign key"): the **`not null`** and
**`default`** column constraints get **no** concept block in v1. They are
mechanical rather than the headline relational concepts #37 targets;
adding them later is purely additive (a wrapper + a block + a coverage
entry).
Cardinality is **split** (`_one_to_many` vs `_many_to_many`) rather than a
single "cardinality" block: the two are pedagogically distinct — `m:n`
auto-creates a junction table (ADR-0045) — the cursor is always
unambiguously in one or the other, and both are **simple-mode-only**
(`1:n`/`m:n` are app syntax, not SQL), so each carries a single plain
example with no mode conflict.
`referential_actions` is one block covering both `on delete` and `on
update` and all four actions (the action set is the concept; the block
explains the choice rather than each action in isolation).
### D5 — The content model: `what` / `example` / `concept`, with mode-keyed examples
Each `hint.concept.<topic>` block keeps the ADR-0053 D3 three-part shape —
`what` / `example` / `concept`. The one extension is the **example**: to
honour ADR-0053 D6's "mode-correct example, no cross-mode sharing" rule
for topics reachable in both modes (the `/runda` finding), a both-mode
topic carries **two** examples keyed by mode; a single-mode topic carries
one plain example.
```yaml
# both-mode topic — mode-keyed example
hint.concept.referential_actions:
what: "Decide what happens to child rows when the parent they point at is deleted, or its key changes."
example:
simple: "add 1:n relationship from Customers.id to Orders.customer_id on delete cascade"
advanced: "create table Orders (id int primary key, customer_id int references Customers(id) on delete cascade)"
concept: "A foreign key forbids orphans by default (restrict). `cascade`
deletes the children with the parent; `set null` keeps them but clears
the link. The action encodes a real rule about your data."
# single-mode topic — one plain example
hint.concept.cardinality_one_to_many:
what: "Link two tables so one parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "1:n is one parent, many children. The child table holds the
foreign key column pointing back at the parent — that single shared
value is what groups a parent's children together."
```
`what`/`example` are always present; `concept` is the teaching payload
(it is what makes this tier-3). The both-mode topics are
`referential_actions`, `primary_key`, `unique`, `check`; the single-mode
(simple-only) topics are `cardinality_one_to_many`,
`cardinality_many_to_many`; `foreign_key` is single-mode (advanced-only).
The blocks live under the existing `hint:` namespace in
`src/friendly/strings/en-US.yaml`, alongside `hint.cmd.*` / `hint.err.*`,
and every leaf key is declared in
`src/friendly/keys.rs::KEYS_AND_PLACEHOLDERS` (the validator requires
exhaustive declaration — no prefix exemption for the `hint.*` families).
For both-mode topics that means `hint.concept.<topic>.example.simple` +
`.example.advanced`; for single-mode topics, `hint.concept.<topic>.example`.
**Authoring constraints** (project copy rules — see `CLAUDE.md`): concept
text is user-facing, so it must **never name the database engine** and
must **not say "DSL"** — use "simple mode" / "advanced mode". The `concept`
prose teaches the relational idea in plain language; examples are
copyable, mode-correct lines.
### D6 — Rendering: the concept block layers under the form block
This is an **additive** F1 surface. When F1 fires on live input
(`note_hint_for_input`, `src/app.rs:3200`):
1. Resolve and emit the per-form `hint.cmd.<form>` block exactly as today
(unchanged).
2. **Then** call `concept_topic_at_cursor(buffer, cursor, …)`. If it
yields a topic and `hint.concept.<topic>.what` exists, emit a second
tier-3 block beneath the form block.
The two blocks are visually distinguished by their **heading**: the form
block keeps the existing `hint.block.heading` ("Hint"); the concept block
carries a distinct sub-heading from a new `hint.block.concept_heading`
(e.g. "About this clause") so the learner sees the form-level help *and*
the clause-level concept without confusing the two.
`emit_tier3_block` gains **two** small parameters: the heading key, and an
optional mode used to resolve the example. Its example lookup becomes:
prefer `<stem>.example.<mode>` (where `<mode>` is `simple`/`advanced` from
the live `effective_mode`), else fall back to a plain `<stem>.example`.
This keeps a single renderer for all three tier-3 families — `hint.cmd.*`
and `hint.err.*` keep a plain `.example` (lookup falls straight through),
while `hint.concept.*` both-mode topics resolve the mode-keyed variant.
The mode-correct-example contract of ADR-0053 D6 is thereby honoured by
the *content keying* + this lookup, not by duplicating the renderer.
The concept block is **never shown alone** and **never replaces** the form
block — consistent with the issue ("layered on top of … does not
replace"). If the per-form resolution fails (rare; falls back to tier-2),
the concept layer is skipped too — F1 keeps a single coherent behaviour.
The submitted `hint` command (last-error route, D2 of ADR-0053) is
**unchanged** — it has no cursor, so concept hints do not apply there.
The rendered shape of a form-block-plus-concept-block is locked by a new
`insta` snapshot (`hint_block_with_concept`).
### D7 — Comprehensiveness, validation, fallback
- **Validation (build/test gate):** every `hint.concept.<topic>` leaf key
is declared in `keys.rs`; the existing `keys_validate_against_catalog`
test fails on a typo or an undeclared key, in either direction.
- **Comprehensiveness coverage test:** extend the ADR-0053
comprehensiveness test with a `CONCEPT_TOPICS` table — each entry a
`(topic, ModeCoverage)` where `ModeCoverage` is `Both` or
`Single` — asserting: (a) every topic resolves to `hint.concept.<topic>`
with a `.what` (and, per its `ModeCoverage`, either `.example.simple`
+`.example.advanced` for `Both`, or a plain `.example` for `Single`);
and (b) every `Node::Concept` wrapper reachable from the REGISTRY
carries a topic in the table. (b) needs a **recursive `Node`-tree
visitor** — `Node` is a recursive enum (`Seq(&[Node])` / `Choice` /
`Optional` / `Repeated` / `Hinted` / `Concept`), so the test walks each
REGISTRY shape collecting `Node::Concept` topics. A wrapper added
without content (or a topic with a wrapper missing) fails the test —
this is what makes the clause set enforceable rather than aspirational,
since `keys.rs` only checks that *referenced* keys resolve.
- **Graceful degrade:** `emit_tier3_block` already returns `false` when a
`.what` key is absent, so a missing concept block degrades to "form
block only" — never a blank or an error. The validation above means
that path is a safety net, not the plan.
## Forks
- **Detection precision = anywhere inside the clause** (user, 2026-06-22)
rather than slot-boundary-only. Drives the span-recording mechanism
(D1) over the simpler `pending_hint_mode` reuse.
- **Clause scope = all four families** (user, 2026-06-22) rather than the
issue's "start with referential actions + constraint slots" subset.
- **Mechanism = full-buffer span-containment, not `WalkBound::Position`**
(D3) — keeps the whole parse and leaves the dormant cursor-bound path
untouched. *Engineering choice; rationale in D3.*
- **Mode-keyed examples for both-mode topics** (user, 2026-06-22,
resolving the `/runda` finding) rather than splitting into
`_simple`/`_advanced` blocks — one concept block per topic, two examples
selected by live mode (D5/D6). Honours ADR-0053 D6's mode-correct-example
rule without duplicating concept prose.
- **Cardinality split into two blocks** (D4) rather than one — the `1:n`
and `m:n` concepts diverge (junction table). *Pedagogy choice.*
- **`primary_key` shared between `with pk` and the constraint slot** (D4)
— one concept, two syntaxes (a mode-keyed example per D5). *Content
choice.*
- **Distinct sub-heading for the concept block** (D6) rather than merging
into one block or an undifferentiated second "Hint" — keeps form-level
and clause-level guidance legible. *UX choice.*
## Consequences
- **A fourth contextual surface exists** within tier-3: clause-level
teaching, deeper than tier-2's candidate list, narrower than the
per-form block, surfaced by cursor position on F1.
- **One new grammar node** (`Node::Concept`), **one new `WalkContext`
field** (`concept_spans`), **one new resolver**
(`concept_topic_at_cursor`), **two new renderer parameters** (heading
key + example mode), and **~10 `Node::Concept` wrappers** across both
constraint grammars (exact count pinned at implementation per D4's
shared-node note), plus **two extracted cardinality sub-`Seq` nodes**.
No change to what parses; no change to tier-2; no change to the
submitted `hint` path.
- **A new content sub-namespace**`hint.concept.*` (seven topics; the
four both-mode topics carry two examples each) — enters the catalogue
under `hint:`, validated by `keys.rs` and the comprehensiveness test.
Ongoing surface: a future clause worth teaching ships its
`Node::Concept` wrapper + block + coverage entry together (a checklist
item for feature ADRs that add clauses).
- **CHANGELOG:** a new `[Unreleased] → Added` bullet (user-facing F1
behaviour), phrased under the copy rules (no engine name; "simple
mode"/"advanced mode", not "DSL").
- **Testing:** Tier-1 unit tests for `concept_topic_at_cursor` across each
clause (cursor at boundary, cursor inside typed text, cursor outside →
`None`, nested `references``on delete` innermost-wins, both modes);
the F1 layering logic (form block always; concept block appended iff a
topic resolves; concept skipped when the form block falls back); the
`:` one-shot strip carried through (it must still resolve advanced
forms); Tier-2 `insta` snapshot `hint_block_with_concept`; Tier-3
integration tests (type a partial relationship command, move the cursor
into `on delete`, F1 → form block + referential-actions concept block,
**buffer / cursor / completion memo untouched**); the comprehensiveness
+ `keys.rs` validation tests (D7).
## Out of scope
- **Concept hints on the submitted `hint` command** — OOS: no cursor; the
last-error route is unchanged (D6).
- **A concept block for every clause in the grammar** — OOS for v1: the
eight wrappers cover the highest-value relational concepts (D4). More
can be added additively later, gated by the comprehensiveness test.
- **Surfacing concept text in the always-on tier-2 ambient panel** — OOS
(rejected): tier-2 stays terse by design (ADR-0022); the concept layer
is on-demand via F1, consistent with all of tier-3.
- **A `hint.concept.<topic>` reachable by an explicit argument** (e.g.
`hint cascade`) — OOS: ADR-0053 D1 keeps `hint` argument-free; `help
<topic>` owns explicit reference lookup.
- **Localisation beyond `en-US`** — OOS (deferred): the catalogue is i18n-
structured (ADR-0019) but English-only for v1 (requirements X2).
## Content inventory (implementation tracking)
Seven `hint.concept.<topic>` blocks, authored to the ADR-0053 D7 voice
(`what` / `concept` always; example(s) per mode coverage):
- [x] `referential_actions` *(both — 2 examples)* — on delete/update;
cascade / set null / restrict / no action.
- [x] `cardinality_one_to_many` *(simple — 1)* — one parent owns many
children; the FK lives on the child.
- [x] `cardinality_many_to_many` *(simple — 1)* — junction table; ADR-0045
auto-creation.
- [x] `primary_key` *(both — 2)* — uniquely identifies a row; shared by
`with pk` (simple) and the `create table` constraint (advanced).
- [x] `unique` *(both — 2)* — no two rows share the value; distinct from a
PK.
- [x] `check` *(both — 2)* — a per-row rule enforced on write.
- [x] `foreign_key` *(advanced — 1)* — a child column promising to point
at a real parent.
Implementation artifacts: the `Node::Concept` wrappers (D4 table, ~10
across both grammars + the two extracted cardinality sub-`Seq`s); the
`CONCEPT_TOPICS` coverage table (topic + `ModeCoverage`); the `keys.rs`
declarations (mode-keyed example leaves for the four both-mode topics);
the `hint_block_with_concept` snapshot; the CHANGELOG bullet.
+215
View File
@@ -0,0 +1,215 @@
# ADR-0059: Branch-and-PR working method — worktrees, merge commits, and reserve-first ADR numbering
## Status
Accepted — **2026-06-23**, on branch `chore/dev-workflow` (pending merge).
Number **0059**, the second ADR reserved via the reserve-first flow this
ADR itself defines (the first was ADR-0058). Updates the **Numbering
discipline** section of **ADR-0000** (supersedes its placeholder-until-merge
default with reserve-first). Does not affect the subproject ADR namespaces
(`docs/website/adr/`, `docs/ci/adr/`).
## Context
The repository is now public, and feature work is moving from
commit-straight-to-`main` toward branches. A public, multi-branch project
needs a written, trackable working method instead of ad-hoc habits.
The sharp edge that forced the issue is **ADR numbering**. ADR numbers are
a single global integer sequence; two branches that each grab "the next
number" collide on merge (this happened, and was the original motivation
for ADR-0000's numbering rules). The deeper problem is fundamental:
> A contiguous integer requires a single allocator. Plain git branches have
> **no shared allocator** until they reconverge at merge — so any number
> claimed independently on a branch is speculative and can collide.
Earlier work papered over this for two long-lived subprojects by giving
them their **own namespaces** (`ADR-website-NNN`, `ADR-ci-NNN`), which is
right for a nameable body of decisions but doesn't generalise to every
feature branch. ADR-0000 also wrote a **placeholder-until-merge** default
("assign the number at merge"), but it had never actually been exercised,
and exercising it revealed two fatal frictions:
1. **ADR numbers are referenced from the first commit onward** — in commit
messages (which are immutable under our no-rewrite rule, so a wrong
number there is wrong *forever*), in other ADRs, in the PR. A number
that only crystallises at merge leaves every earlier reference dangling.
2. ADRs are *not* low-collision in practice: they're born precisely on the
**long-lived feature branches most likely to run in parallel**, so
"rare collision" is not a safe assumption.
So the requirement is a number that is **stable from creation, contiguous,
and collision-free** — which is only possible by acquiring a lock *up
front*. The insight that unlocks it: **`main` is the registry and an atomic
`git push` is the lock** — git serialises ref updates, so a push is a
compare-and-swap. We just have to acquire it *before* referencing the
number, not at merge.
This is a **genuine-choice** area (reasonable practitioners run GitHub
Flow, Git Flow, trunk-based, etc.); the decisions below adapt mainstream
practice to this repo's constraints (append-only history, self-hosted
Gitea, solo-with-subagents development) rather than claiming a canonical
default.
## Decision
### D1 — Everything lands via a PR onto a protected `main`
`main` is always-green and protected; no direct pushes (one narrow
exception — D4). Every change is a branch + pull request onto `main`. One
logical change per branch/PR. Branch names carry a conventional prefix
(`feat/ fix/ docs/ chore/ refactor/ test/ ci/`) aligned with the commit
type. PRs reference their issue (`Closes #N`); the review we already
perform (the Devil's-Advocate / `/runda` pass) is pasted into the PR so the
public repo carries the audit trail.
### D2 — One worktree per branch (never switch the primary checkout)
Each branch is developed in its **own git worktree**, a sibling of the
primary checkout named `<repo>-worktree-<last-segment-of-branch>` (the
convention already in use for the `website` and `ci` worktrees). Switching
the primary checkout's branch while another shell sits in the same
directory is how work gets clobbered — worktrees make that impossible.
Helpers:
- **`scripts/wt-new.sh <branch>`** — fetch `main`, create the branch off
`origin/main` (with `--no-track`, so it doesn't inherit `origin/main` as
upstream — otherwise a bare `git push` errors on the name mismatch and
suggests the dangerous `HEAD:main`), and add the sibling worktree; prints
its path. First push is `git push -u origin <branch>`.
- **`scripts/wt-rm.sh <branch>`** — remove the worktree for the **named**
branch, and nothing else. It does **not** scan for "merged" worktrees to
auto-remove: long-lived branches (`website`, the `ci` line) are merged
into `main` too, so a merged-detection sweep would wrongly delete them
(caught immediately on review, before any real use — the initial design
was an auto-sweep `wt-clean`). Naming the target is the safety. Git's own
refusals do the rest: it won't remove a dirty worktree (no `--force`),
and it deletes the local branch only if it is merged into `origin/main`
(otherwise the worktree goes but the branch + its unmerged commits stay).
Stale bookkeeping for a hand-deleted directory is the built-in
`git worktree prune`'s job.
### D3 — Merge commits (`--no-ff`); never rebase or squash
Branches land with a **merge commit** (`--no-ff`). This is the only
strategy consistent with the project's append-only / no-history-rewrite
rule: squash and rebase-merge both rewrite history. Fast-forward-only is
out because it would require rebasing branches onto `main`. History gains
merge bubbles; that is the accepted cost of never rewriting.
### D4 — Reserve-first ADR numbering via `adr-reserve.sh`
ADR numbers are **reserved up front, the moment you know an ADR is
needed** — which may be mid-branch or late, so reservation is decoupled
from branch creation. `scripts/adr-reserve.sh <slug> [title]`:
1. reads an append-only ledger (`docs/adr/RESERVATIONS.log`) plus the
existing `NNNN-*.md` ADRs on `main`; next = highest + 1;
2. appends `<NNNN> <slug> <date> <title>`, commits, and **pushes to
`main`**;
3. if the push is rejected because `main` moved (someone reserved first),
it re-fetches, recomputes, and retries — the push is the compare-and-swap,
so allocations are unique by construction, not by luck.
It runs from any branch/worktree without touching the working tree (it does
the allocation in a throwaway worktree on a detached `origin/main`), and on
a *non-race* rejection (e.g. protection misconfigured) it **aborts loudly**
rather than spinning. The number is therefore **stable from the moment it
returns** — safe to cite in immutable commit messages and other ADRs from
the first commit, which is what the references-problem (Context) demands.
The ADR file is created as `docs/adr/NNNN-<slug>.md` in the working branch
and its README index row is added there as normal work; the ledger line on
`main` is the allocation record and never conflicts with feature work
(feature branches don't touch it). The ledger may be pruned later; it's a
lock record, not state. **Collisions** (two reservations in the same
instant) are resolved automatically by the retry; the README index edit is
a secondary tripwire if one ever slips through.
This **supersedes ADR-0000's placeholder-until-merge default.** Subproject
namespaces (`ADR-website-NNN`, `ADR-ci-NNN`) are unchanged — they solve a
different problem (a nameable long-lived body of decisions) and stay.
### D5 — Branch protection configuration
On `main` (Gitea → Settings → Branches):
- **Require pull requests** + **require the CI status check** (`ci / gate*`)
to pass before merge, and **block merge of out-of-date branches** (this
is what makes "`main` is the lock" airtight for the substantive merges).
- **Whitelist the repo owner for direct push.** This is the one narrow
carve-out that lets `adr-reserve.sh` push the append-only ledger line
directly; everything substantive still goes through a PR. Without it the
reserve script aborts with a clear "is the owner whitelisted?" message.
### D6 — Who does what
**Pushing and merging are human steps**; automated agents prepare branches,
edits, PR text, and commit-message proposals but never push or merge. The
reserve script is **run by the human** (so the ledger push originates from
the human's invocation, consistent with that rule) — agents may write and
test the script but not run its pushing path.
## Forks (all user-chosen, 2026-06-22/23)
- **Merge commits (`--no-ff`)** over squash or fast-forward-only — the only
option consistent with append-only history (D3).
- **Strict everything-via-PR** over allowing trivial direct commits — one
carve-out only, the reservation line (D1/D4/D5).
- **Branch protection enforced in Gitea** over convention-only — mechanical
guarantee for a public repo (D5).
- **Reserve-first contiguous integers** over: number-on-merge (rejected —
breaks immutable references), Gitea-issue-number ids (rejected — non-
contiguous, overlaps the legacy 00010057 range), or an allocator bot
(rejected — machinery + a PR-rule exemption). Reserve-first keeps the
short stable "ADR 0059" reference and needs no new infrastructure beyond
one script (D4).
- **Worktrees prescribed** over single-checkout branch switching — prevents
the same-directory clobber hazard (D2).
## Consequences
- A written, public-facing **`CONTRIBUTING.md`** (lean: setup, the CI gate,
branch/PR conventions, the user-facing copy rules, "significant changes
get an ADR"). The maintainer-facing mechanics (worktrees, the reserve
lock, the whitelist) live here and in `CLAUDE.md`, not in CONTRIBUTING.
- Three scripts enter `scripts/`: `adr-reserve.sh`, `wt-new.sh`,
`wt-rm.sh`, each with a local-`origin` test harness
(`test-adr-reserve.sh`, `test-wt.sh`) — the reserve script's allocation,
idempotence, race-retry discriminator, and hard-abort paths are all
covered (the real-remote push is exercised by the human running it).
- `docs/adr/RESERVATIONS.log` appears on `main` as the allocation ledger.
- ADR-0000's Numbering-discipline section is updated to point here.
- `CLAUDE.md` gains the operational rules an agent must follow.
## Out of scope
- **Migrating the legacy `00010057` ADRs** to any new scheme — they keep
their numbers; reserve-first applies going forward (next is `0060`).
- **Automating PR creation/merge** — humans push and merge (D6).
- **A CONTRIBUTING-side description of the reserve lock** — it's maintainer
infrastructure, not contributor guidance (kept here instead).
## Amendment 1 (2026-06-25): docs direct-push carve-out
Extends the D5 owner-whitelist: **handoff / session docs (and small docs
amendments like this one) are owner-direct-pushed to `main`, not PR'd.**
Discovered while preparing the first handoff under this flow: a **docs-only
PR cannot merge** under the protection rules. `ci.yaml` `paths-ignore`s
`docs/**` and `**/*.md`, so a docs-only change posts **no `ci / gate` run**;
but D5 requires `ci / gate*` before merge — so the required check never
arrives and the PR stalls on "expected/waiting". Rather than weaken the gate
or add CI machinery, this trivial low-risk class (handoffs, session notes,
small docs fixes) rides the existing owner-direct-push carve-out: the owner
is already whitelisted, and a docs-only push to `main` is itself
`paths-ignore`d, so it burns no CI.
Substantive changes — code, and the ADRs/docs that accompany code — still go
through a PR as normal (their non-docs files trigger the gate). **If
docs-via-PR is ever wanted** (e.g. a docs-only ADR through review), the clean
fix is a "skipped → success" gate shim (a job that reports `ci / gate`
success when the real gate is path-skipped) or dropping the docs
`paths-ignore`; until then, direct-push is the sanctioned path for docs-only
changes.
+18 -5
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
0058 clause-concept-hints 2026-06-23 Clause-concept hints
0059 dev-workflow 2026-06-23 Branch-and-PR working method
+10
View File
@@ -133,3 +133,13 @@ declaration of the dev *and* build environment.
flake for `requirements.md` **TT5** (CI runs the tiers) and the
**D1/D2/D3** distribution items (the release uses a static musl target
built through this flake).
## Amendment 1 — 2026-06-17: `fmt` gate enabled (issue #35)
The deferred "revisit on `main`" is done. With the CI + website branches
merged and before the first public release, the tree was reformatted once
with **stock `cargo fmt`** (no `rustfmt.toml` — stable rustfmt supports no
meaningful customisation, and the pinned 1.95.0 toolchain makes
`fmt --check` deterministic) in a single mechanical commit (`41b7e9a`,
102 files, behaviour-preserving; recorded in `.git-blame-ignore-revs`).
`ci.yaml`'s gate is now **`fmt --check` + clippy + test**. Closes **#35**.
+11
View File
@@ -20,6 +20,17 @@ This ADR records the **cross-platform build strategy**; it sits on top of
**ADR-ci-002** (the nix flake, which now carries the cross toolchain) and
**ADR-ci-001** (the pipeline, whose release job this fills in).
## Amendment 2 — 2026-06-16: CI on `main`; `release-macos` dispatched + verified
The CI branch is **merged to `main`**, so `release-macos.yaml`
(`workflow_dispatch`, Gitea-default-branch-only) is now triggerable. It
has been **dispatched and verified end-to-end**: both `*-apple-darwin`
targets build on the Tart runner, the de-nix/re-sign step runs, the
assets upload to the tagged release, and the binaries launch. macOS is
therefore runtime-verified (alongside the original Linux x86_64 +
Windows aarch64); only **Linux aarch64** and **Windows x86_64** remain
link-clean / valid-format without a runtime smoke-test.
## Amendment — 2026-06-14: macOS implemented (closes D1)
macOS is no longer deferred. The two `*-apple-darwin` targets now build on a
+2 -2
View File
@@ -19,5 +19,5 @@ here too).
## Index
- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image**`node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision).
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard``x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible).
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner; becomes triggerable once CI is on `main`), de-nixes the binary's libiconv load path (`install_name_tool``/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard``x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). **Amendment 1 (2026-06-17, issue #35):** the deferred `fmt` gate is enabled — the tree was reformatted once with **stock `cargo fmt`** (no `rustfmt.toml`; pinned toolchain makes `fmt --check` deterministic) in a single mechanical commit (`41b7e9a`, 102 files, behaviour-preserving, in `.git-blame-ignore-revs`), and `ci.yaml`'s gate is now **`fmt --check` + clippy + test**.
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner), de-nixes the binary's libiconv load path (`install_name_tool``/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). **Amendment 2 (2026-06-16):** CI is **merged to `main`**, so `release-macos` is now triggerable (`workflow_dispatch` is default-branch-only) and has been **dispatched + verified end-to-end** (build → de-nix/re-sign → upload, binaries launch). Runtime-verified by the user: Linux x86_64, Windows aarch64, **and both macOS targets**; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
+181
View File
@@ -0,0 +1,181 @@
# Website-branch handoff — 2026-06-10 (website-1)
First handoff for the **website** work. This is a **separate sequence** from
`main`'s `YYYYMMDD-handoff-NN.md` files — branch-scoped name on purpose, so
the two don't collide. Continue numbering these `…-website-handoff-N.md`.
## State
- **Branch:** `website`. **HEAD `936d925`.** Not pushed (push is the user's
step). Working tree clean.
- The website lives in **`website/`** (monorepo; the playground crate is at
the repo root). Decisions: **ADR-0044**
(`docs/adr/0044-public-website-and-documentation-site.md`). Implementation
plan: **`docs/plans/20260604-adr-0044-website.md`**. Living style guide:
**`website/STYLE.md`** (read this first — it has the binding conventions
and an open-decisions log).
## Stack & layout
- **Astro 6 + Starlight + Tailwind v4**, all under `website/`.
- `website/astro.config.mjs` — Starlight config (title, 5-section sidebar,
Expressive Code `langs`, `server.host: '127.0.0.1'`).
- `website/src/grammars/rdbms.mjs` — custom Shiki grammar, **two language
ids**: `rdbms` (real commands) and `rdbms-syntax` (abstract templates).
- `website/src/styles/global.css` — Starlight↔Tailwind bridge + the `> `
command prompt + copy-button hiding (CSS `:has()`).
- `website/src/content/docs/` — the five sections.
## Commands
```sh
cd website
pnpm install # node_modules is gitignored — reinstall on a fresh checkout
pnpm dev # serves http://127.0.0.1:4321 (see dev-server gotcha below)
pnpm build # static dist/, 24 pages, Pagefind search index
```
**Dev-server gotcha (already fixed, don't re-break):** Astro/Vite's default
`localhost` bind resolves to IPv6 `::1` here, which breaks SSH
`-L 4321:127.0.0.1:4321` tunnels. `server.host: '127.0.0.1'` in the config
pins IPv4. Tunnel with `ssh -L 4321:127.0.0.1:4321 <host>`.
**Verify after changes:** `pnpm build` clean; then from the repo root
`grep -rniE '\b(DSL|SQLite|STRICT|rusqlite|PRAGMA)\b' website/src/content/`
must be empty; internal links should resolve (build doesn't fail on broken
links — no validator installed — so sanity-check by hand or with a small
script).
## Documentation structure (5 sections, autogenerated per directory)
1. **Getting started** — install, first-project, modes, example-library. **(real)**
2. **Using the playground** — command-line-options, the-assistive-editor,
the-output-pane, projects, undo-and-history, export-and-import,
copy-to-clipboard, getting-help. **(real, grounded)** *This is "the app you
drive", distinct from the database-language Reference.*
3. **Guides** — build-the-library **(DRAFT — marked; to be iterated for
teaching quality before publication)**.
4. **Reference** — types **(real)**, tables **(real)**; columns,
relationships, indexes, constraints, inserting-and-editing-data,
querying-and-inspecting **(STUBS — real syntax synopsis + an "In progress"
note; THEY NEED WORKED EXAMPLES — this is the main remaining bulk)**.
5. **Concepts** — projects-and-storage **(real)**.
- Landing: `index.mdx` splash (feature cards incl. the assistive editor +
start-here links).
## Binding conventions (STYLE.md / ADR-0044 §7)
- **No "DSL"** in user-facing copy → "simple mode" / "advanced mode".
- **No engine name** (SQLite/STRICT/rusqlite/PRAGMA) → "the database" / "the
engine".
- **Code fences:** simple-mode commands → ` ```rdbms ` (highlighted; gets a
decorative copy-safe `> ` prompt via CSS). Abstract syntax templates →
` ```rdbms-syntax ` (highlighted, **no** prompt, no copy button). Advanced
SQL → ` ```sql `. Shell → ` ```sh `.
- **One command per line** in `rdbms` blocks. A multi-line *single* statement
(advanced `CREATE TABLE`) goes in ` ```sql `.
- **Copy button** is hidden on multi-line `rdbms` blocks and on
`rdbms-syntax` (the app input is single-line — a multi-command paste isn't
runnable); kept on single-command `rdbms`, and all `sql`/`sh`.
- Unshipped features → `:::caution[Planned]` aside; never presented as
shipped.
- **Ground every page in source**`parse.usage.*` / `help.*` in
`src/friendly/strings/en-US.yaml`, `src/dsl/command.rs`, `src/dsl/types.rs`,
the ADRs. **Do not** trust `requirements.md` markers (handoff-59 found
~46% mis-marked; it now uses a `[/]` partial legend) — verify against code.
## Canonical example database (use in every example)
A small **library**: `authors`(author_id serial pk, name text, birth_year
int) · `books`(book_id serial pk, title text, author_id int→authors,
published int, isbn text unique) · `members`(member_id serial pk, name text,
joined date) · `loans`(loan_id serial pk, book_id int→books, member_id
int→members, loaned_on date, returned_on date). 1:n author→books; m:n
books↔members via loans. **Simplest examples lead with bare `with pk`** (a
default `id` key); the library build uses **named** keys (`author_id`, …) so
relationships read clearly.
## Verified syntax cheat-sheet (don't re-derive)
- Simple create: `create table <T> with pk [<col>(<type>)[, ...]]` (bare =
default `id`); `add column [to] [table] <T>: <name> (<type>)`;
`rename column [in] [table] <T>: <old> to <new>`; `change column … (<type>)
[--force-conversion|--dont-convert]`; `drop column [from] [table] <T>: <col>
[--cascade]`.
- Advanced create: `create table T (id serial primary key, …, col int
references parent(col))` (verified against `tests/it/sql_create_table.rs`).
- Relationship: `add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
[on delete <action>] [on update <action>] [--create-fk]`;
compound: `from <P>.(a, b) to <C>.(x, y)`; drop by name or by endpoints.
- Data: `insert into T [(cols)] [values] (vals)`; `update T set … (where … |
--all-rows)`; `delete from T (where … | --all-rows)`.
- Inspect: `show data <T> [where …] [limit n]`, `show table <T>`, `show
tables|relationships|indexes`, `show relationship|index <name>`.
- `explain <…>` (query plan; safe — never executes). Advanced `select …`.
- 10 types: text int real decimal bool date datetime blob serial shortid;
advanced-mode SQL aliases (integer/varchar/timestamp/numeric/…).
- App commands: save / save as / new / load / rebuild / export [path] /
import <zip> [as <t>] / undo / redo / replay <path> / mode / help
[<command>] / help types / copy [all|last] / quit. (`hint` and `seed` are
NOT implemented — mark planned/omit.)
## Next work (priority order)
1. **Fill the 6 Reference stubs** with worked examples on the library schema
(the remaining bulk). Each has a syntax synopsis + an "In progress" note —
expand to full content: worked example(s), both simple + advanced forms
where both apply, cross-links. This is the biggest chunk.
2. **Iterate the Guides** for teaching quality; add guides (model a 1:n / m:n,
querying with joins). The user flagged guides as the most important
didactic content, to be polished before publication.
3. **Phase B — landing polish:** use the `frontend-design` skill; set
Starlight `site` (production URL) once the domain is known (enables sitemap
+ OG/SEO); add a logo + favicon (small Starlight config). Branding palette
when the user wants it (staying on Starlight; community themes were
surveyed — see ADR-0044 / chat).
4. **asciinema casts — DEFERRED until the app is final** (ADR-0044 §2). When
starting, settle STYLE.md open-decision #9: scripted-input driver
(`asciinema-automation` vs `autocast` — prove with a throwaway test run),
`.cast` script format + repo location, terminal geometry, light/dark
player theme, file naming. The **assistive editor** is prime cast material
(completion / `[ERR]`/`[WRN]` indicator are motion a still block can't
show) — earmark a cast for it on the landing + the assistive-editor page.
5. Remaining STYLE.md open decisions: **versioning** (leaning single-version
for launch) and **SEO/meta** (settle with Phase B + the `site` URL).
## Process pins
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
**append-only** (no amend/rebase/force-push). Push is the user's step.
- **ADR numbering:** assigned at merge-to-`main` (ADR-0000 "Numbering
discipline", added this branch). The website ADR is **0044** — renumbered
from 0042 on the `main` merge, because `main` had independently used 0042
(H1a) and 0043 (compound-FK).
- **Issues:** Gitea via `tea` (repo `oli/rdbms-playground` on
`git.lazyeval.net`); append `< /dev/null` + `timeout 30`; never raw API.
- **Escalate genuine forks**, declare epistemic status, write down the DA pass
(`/runda`) on non-trivial plans.
## Commit history on this branch (newest first)
```
936d925 feat: add "Using the playground" section + Reference skeleton
44390e7 feat: simple-mode code-block highlighting, prompt, and copy rules
995c0ba docs: reconcile website doc inventory with merged main scope
c72c624 chore: bind website dev/preview server to IPv4 loopback (127.0.0.1)
9e774b2 docs: ADR numbering discipline — assign numbers at merge-to-main
40de389 Merge branch 'main' into website (Gitea migration + ADR renumber)
0fcb7b1 docs: website docs structure + first content pages
cea99e8 chore: scaffold website (Astro 6 + Starlight + Tailwind v4)
1fad29c docs: ADR-0042 — public website + documentation site plan (now ADR-0044)
```
## Review status (what the user has signed off)
Highlighting / `> ` prompt / copy behavior — good. Voice, altitude,
terminology — good. Responsive layout (checked in Polypane) — good. Locked
decisions: bare `with pk` leads the simplest examples; copy hidden on
multi-command blocks (not per-command copy); the 5-section structure with
"Using the playground" near the top; assistive editor surfaced on
landing + Getting started. **The 6 Reference stubs have not been reviewed for
content** — only their syntax synopses exist.
+205
View File
@@ -0,0 +1,205 @@
# Website-branch handoff — 2026-06-11 (website-2)
Second handoff for the **website** work (separate sequence from `main`'s
`YYYYMMDD-handoff-NN.md`). Read `website-1` (2026-06-10) first for the
original scaffolding context; this note covers everything since.
## State
- **Branch:** `website`. **HEAD `e782a28`.** Working tree clean. Pushed
through an earlier point; currently **ahead of `origin/website`** (push is
the user's step — never push).
- The website lives in **`website/`** (monorepo; the playground crate is at
the repo root). Living style guide + binding conventions:
**`website/STYLE.md`** (read first). Website decisions:
**`docs/website/adr/20260604-adr-website-001.md`** (the website ADR namespace
— see below). Plan: `docs/website/plans/20260604-website-implementation-plan.md`.
## What changed since website-1 (commit highlights, newest first)
```
e782a28 feat(website): projects cast (vi-nav load picker) + --demo on all casts
927e6b2 Merge branch 'main' (m:n, logging, UI nav, demo overlays, vi-nav)
52860c3 feat(website): casts for first-project/modes/undo-redo; quit via Ctrl-C
ce153bd docs(website): add SQL queries reference page (advanced query surface)
302329d docs(website): record the cast-placement policy in STYLE.md
bb7887e feat(website): relationship-diagram cast on the relationships page
65a48fa feat(website): joins cast on the querying-with-joins guide
c0cc92a docs(website): rewrite Build the library + add Querying with joins guide
10655e4 docs(website): fill the six Reference stubs with worked examples + output
a8f84c9 feat(website): refine casts — trim shell, autoplay+loop landing, cap size
… (cast pipeline, the astro/starlight upgrade, the ADR-namespace move)
```
## ADR namespace (important — avoids the recurring collision)
Website decision records live in their **own namespace**:
`docs/website/adr/` (`<date>-adr-website-<NNN>.md`, id `ADR-website-NNN`),
indexed by `docs/website/adr/README.md`. They do **not** draw from `main`'s
global ADR integer pool, so a `main` ADR and a website ADR can never collide
again (this is why the latest merge of `main`'s ADR-0045/0046/0047 was
conflict-free). Recorded in ADR-0000 "Numbering discipline". The main
`docs/adr/README.md` intro carries a pointer to the website namespace.
## Content status
**Done & verified (build clean, grounded in source, forbidden-terms clean):**
- **Getting started** — installation, first-project, modes, example-library.
- **Using the playground** — command-line-options, the-assistive-editor,
the-output-pane, projects, undo-and-history, export-and-import,
copy-to-clipboard, getting-help.
- **Guides** — build-the-library (full 4-table build, 1:n + m:n bridge),
querying-with-joins.
- **Reference** — types, tables, columns, relationships, indexes, constraints,
inserting-and-editing-data, querying-and-inspecting, **sql-queries** (the
advanced SELECT surface: DISTINCT, GROUP BY/HAVING, set ops, subqueries,
CTEs, CASE/CAST/functions, with a "supported subset" boundary note).
- **Concepts** — projects-and-storage.
- Landing `index.mdx` splash with the quickstart cast.
**26 pages build clean.** Only expected warning: sitemap needs `site` (Phase B).
## asciinema casts — the pipeline + the 7 casts
Pipeline (STYLE.md "asciinema casts", ADR-website-001 §2):
- **Driver: `autocast`** (chosen by spike; `asciinema-automation` can't drive a
full-screen TUI). Sources in `website/casts-src/casts.mjs`; `pnpm casts`
runs `casts-src/generate.mjs` → autocast YAML → `public/casts/<name>.cast`.
Command-lists are the durable source; **`.cast` files are regenerable** —
re-run `pnpm casts` (needs a `cargo build` binary at `../target/debug`).
- **Components:** `Cast.astro` (asciinema-player island) wrapped by
`Demo.astro` (the WASM-swap seam, ADR-website-001 §3). Embed via `<Demo
src="/casts/NAME.cast" … />` in an **.mdx** page (md can't import).
- **Generator features:** trims each cast to the in-app region (drops shell
launch + the return-to-shell); ends casts with **Ctrl-C** (`{ key: 'CtrlC' }`)
not a typed `quit` (invisible, so the cast ends on the last content frame);
per-cast `holdEnd`, `dataDir` (isolated data root, wiped per run), and
**`--demo` is default-on** (opt out with `demo:false`).
- **The 7 casts:** quickstart (landing, autoplay+loop), assistive-editor,
relationship-diagram, joins, modes, undo-redo, **projects** (the new one:
save as → new → load via the picker, navigated with `j`).
### `--demo` (#22 / ADR-0047) is on for ALL casts
The demonstration overlay shows a **badge** for special keystrokes
(`[ENTER]`, `[TAB]`, arrows, etc.) — plain characters are NOT badged. This
makes e.g. the assistive-editor's Tab completion visible (`[TAB]`), and the
projects cast's `j`/`k` picker navigation stays *un*surfaced (plain chars) by
design. Captions exist too (a stealth `Ctrl+]`-delimited banner) — usable in
casts for neutral "point something out" labels, not yet used.
### ⚠️ Cast tooling limits (don't rediscover these)
- autocast can only send **single characters, ASCII control codes (`^X`), and
waits** — **NO arrows / PageUp/PageDown / Home/End / function keys** (those
are escape sequences; the per-key delay makes the terminal read a lone Esc —
verified). So any interaction we want to demo must be reachable via typeable
keys. This is why #24 (vi `j/k` in the picker) was needed for the projects
cast, and why **output-pane scrolling has no cast** (needs PgUp/PgDn).
- `Ctrl+]` (caption toggle) and `Ctrl-C` (quit) ARE sendable (`^]`, `^C`).
### ⚠️ No-advertising constraint (user, 2026-06-11)
The docs must **NOT** advertise that the load picker supports **vi keys**, nor
that **`Ctrl+]`** is the caption/banner trigger. The `--demo` flag itself MAY
be documented lightly as "a teaching helper that shows special keystrokes" —
and nothing more. Casts may *use* vi-nav and captions (the viewer sees only the
result/banner, not the keystroke), but cast captions must not name `j/k` or
`Ctrl+]`.
## NEXT WORK — priority order
### 1. Document the features the `main` merge brought (the biggest gap)
The merge (`927e6b2`) added app features that are **not yet documented**:
- **m:n convenience command**`create m:n relationship …` (C4, **ADR-0045**).
The relationships page currently models m:n only via the manual loans-bridge.
Document the convenience command (it auto-generates the junction table).
Ground in ADR-0045 + `tests/it/m2n.rs` + `tests/typing_surface/create_m2n.rs`
for exact syntax. Likely a new section on the **relationships** reference
page and/or a mention in the build-the-library guide.
- **`--demo` flag** — document on **command-line-options** as a teaching helper
that "shows special keystrokes" (per the no-advertising constraint above —
do NOT mention badges-for-vi or captions/`Ctrl+]`).
- **ADR-0046 UI** — the **schema sidebar** (auto-shows on wide terminals,
`Ctrl-O` navigation mode to peek/expand), **responsive two-row input** +
horizontal scroll, and the geometry-fixed hint panel. Decide where in *Using
the playground* (a new "the schema sidebar" page, or fold into the-output-pane
/ the-assistive-editor). Ground in ADR-0046.
- FK-message fixes + the X1 logging sweep: **no user-doc impact** (note only).
Whenever output changed because of the merge, **re-verify any affected static
output blocks** (capture-harness recipe below).
### 2. Consider a final cast re-record sweep + optional captions
- All casts re-record with `pnpm casts` once the app is "final".
- **Chase up two pacing/clarity guidelines across the existing casts** (added
to STYLE.md "Cast pacing & clarity" 2026-06-11; the projects cast already
follows them):
1. **Don't submit a command too fast** — a typed-and-`Enter`ed-in-one-instant
command vanishes before the viewer reads it. Re-review each cast for
type-then-instant-Enter (especially modal confirms / short commands) and
add a pause before `Enter` (split `type` and `key:'Enter'` steps).
2. **Show state where the sidebar would** — at 90 cols the schema sidebar is
hidden (ADR-0046), so insert `show tables` / `show table` / `show data`
where state changes, so the viewer can follow what happened.
- **Review whether caption banners would improve the existing casts.** The
demo overlay can show a neutral step **caption** (the stealth `Ctrl+]`
banner) to label or narrate a moment — e.g. marking the phases of the
build-the-library/projects casts, or calling out the relationship diagram /
the teaching echo in the modes cast. Go cast-by-cast and decide where a
caption adds clarity vs. adds noise. Constraint: caption **text must not name
keys** (no `j/k`, no `Ctrl+]`); it narrates *what* is happening, not *how* it
was typed. (Captions are wired but not yet used in any cast.)
- Output-pane scrolling cast remains blocked (PgUp/PgDn unsendable). If desired
later, it needs an app-side typeable scroll key (file an enhancement like #24)
— otherwise leave it to static docs.
### 3. Phase B — landing/site polish
- Set Starlight **`site`** (production URL) → clears the sitemap warning,
enables sitemap + canonical/OG. Then SEO/meta conventions (STYLE #8).
- Logo + favicon; branding palette (staying on Starlight).
- **Light/dark player theme**: the asciinema player theme is currently fixed
(`asciinema`); sync it to the Starlight theme toggle (folded into STYLE #8).
- Use the `frontend-design` skill.
### 4. Open STYLE.md decisions
- **#7 Versioning** (leaning single-version for launch).
- **#8 SEO/meta** + the player light/dark theme.
## Capture-harness recipe (how to get accurate static output for new pages)
Output blocks must be **captured from the real app, never hand-drawn**
(STYLE.md). Pattern used throughout:
- For `pub` render fns (`render_data_table`, `render_explain_plan`) + the DB
worker API: a throwaway **external** test `tests/doc_capture.rs` that builds
the library schema via `Database` and prints rendered output; run
`cargo test --test doc_capture -- --nocapture --ignored`; paste verbatim
(trim trailing spaces); delete the file.
- For `pub(crate)` fns (`render_structure_with_diagrams`,
`render_relationship_diagram`): an in-crate `#[ignore]` test in
`src/output_render.rs`'s test module instead.
- **Verify box-drawing integrity** after pasting (top `┬` count == bottom `┴`,
equal line lengths) — a mis-paste truncated a CTE border once and the check
caught it.
## Verify-after-changes checklist
```sh
cd website && pnpm build # clean; 26 pages; only the `site` warning
# from repo root — forbidden terms must be empty:
grep -rniE '\b(DSL|SQLite|STRICT|rusqlite|PRAGMA)\b' website/src/content/
# internal links + heading anchors: spot-check in dist/ (no link validator installed)
cd website && pnpm casts # regenerate all casts (needs target/debug binary)
```
Dev server + tunnel for visual checks (player playback, sizing, badges):
`cd website && pnpm dev` (binds 127.0.0.1:4321) then `ssh -L 4321:127.0.0.1:4321 <host>`.
**Visual playback of all 7 casts (now with `--demo` badges) is still pending a
tunnel check by the user.**
## Process pins
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
**append-only** (no amend/rebase/force-push). Push is the user's step.
- **Ground every page in source** (`src/dsl/*`, `en-US.yaml`, the ADRs) — not
`requirements.md` markers. No engine name, no "DSL" in user-facing copy.
- **Issues** via `tea` (repo `oli/rdbms-playground` on `git.lazyeval.net`;
append `< /dev/null` + `timeout 30`). Open/related: **#22** (demo overlay,
implemented), **#24** (vi picker nav, implemented). Both merged via `927e6b2`.
- Escalate genuine forks; declare epistemic status; write down the `/runda` DA
pass on non-trivial plans.
+120
View File
@@ -0,0 +1,120 @@
# Session handoff — 2026-06-15 (71)
Short, focused handover. Continues immediately from handoff-70 (which
shipped H2 / the contextual `hint`, ADR-0053). **A user smoke-test
surfaced a correctness bug in the hint content, and it implicates the
whole corpus.** This handoff exists so the next session does a
**systematic semantic verification pass over every hint block** — context
ran too low to do it now.
## §1. State
**Branch:** `main`, clean, all committed (local; push pending). **2499
pass / 1 ignored, clippy clean.** Open issues: #35#38 (see handoff-70).
H2 / ADR-0053 is *functionally* complete; the **content is not
trustworthy** until the pass below is done.
## §2. The bug (confirmed)
`hint.cmd.create_table` (in `src/friendly/strings/en-US.yaml`) reads:
```
What: Create a new table — its columns, their types, and a primary key.
Example: create table Customers with pk id(serial), name(text), email(text)
Concept: A table is a set of rows that share the same columns. The primary
key uniquely identifies each row; a `serial` key numbers the rows for you.
```
**This is wrong.** In the DSL, **everything after `with pk` is the
primary-key column list** (a possibly *compound* PK, ADR-0005). So the
example does **not** create a table with `pk=id` plus regular columns
`name`/`email` — it creates a table whose **compound primary key is
(id, name, email)**. Non-key columns are added *separately* with
`add column`. The `what` ("its columns, their types") and the example
both mislead a learner badly.
- **Evidence:** real test usage is `create table Orders with pk
id(serial), CustId(int)` (a 2-column *compound PK*) and the common form
`create table X with pk id(int)` (single-column PK only). The usage
template `create table <Name> with pk [<col>(<type>)[, ...]]` is itself
misleading — the `[, ...]` is the PK list, not regular columns.
- **Correct mental model:** `create table <T> with pk <pk-cols…>` then
`add column <T>: <name> (<type>)` for each non-key column. Confirm
against ADR-0005 (compound PK) and ADR-0009 (DSL syntax) when fixing.
## §3. Root cause — why this needs a *full* pass
During Phase C I verified *some* examples against `parse.usage.*`
templates and real test greps, but for others I **extrapolated** beyond
verified syntax. For `create_table` I saw `... with pk id(int)` (single
col) and wrongly generalised to "pk + more columns," misreading the
`with pk` list as a column list. The examples are **syntactically**
checked but not **semantically** — i.e. not verified to *do what the
`what`/`concept` claims*.
So the corpus needs a pass that, for **every** `hint.cmd.*` and
`hint.err.*` block, checks:
1. the `example` parses **and runs**, and
2. it actually demonstrates what `what`/`concept` says, and
3. `what`/`concept` are factually true of the real behaviour.
**Don't trust grep+extrapolation.** Prefer: run the example in the app
(or a Tier-3 test), or check it against the authoritative ADR.
## §4. The pass — how to do it (next session)
The corpus lives in `src/friendly/strings/en-US.yaml` under `hint.cmd.*`
(per command form) and `hint.err.*` (per runtime error class). The
inventory and authoritative syntax sources:
- **`hint.cmd.<form>`** — for each, cross-check the example against the
matching `parse.usage.<form>` template **and** the form's ADR, and run
it. Highest-risk (extrapolated, verify first): **DDL**`create_table`
(known wrong), `add_column`, `add_index`, `add_constraint`,
`change_column`, `drop_*`, `create_m2n`; **advanced-SQL** — confirm
each is in the supported SQL subset (`select`, `with` CTE,
`sql_insert/update/delete`, `sql_create_table`, `sql_alter_table`,
`sql_create_index/drop_index/drop_table`, `explain_sql`); **DML**
`seed` forms, `explain`, `show_*`, `update`/`delete` (`--all-rows` /
required-WHERE wording). App commands are lower-risk (reference-style).
- **`hint.err.<class>`** — verify the fix recipe in `example` is actually
the right remedy and `concept` matches the engine's real behaviour
(FK sides, `on delete` actions, check/not_null/unique semantics).
- Relevant ADRs: 0005 (types + compound PK), 0009 (DSL syntax), 0011 (FK
type compat), 0013 (relationships/rebuild), 0014 (data ops +
required-WHERE), 0025 (indexes), 0028/0039 (explain), 00300036 (SQL
subset), 0048 (seed). `docs/requirements.md` for scope.
**Suggested method:** drive the app (`/run` or a small PTY/Tier-3 harness)
and actually execute each example; or add a test that parses+runs every
`hint.cmd.*` example and asserts success. The latter would also be a
durable regression guard — consider adding it as part of the pass (it
upgrades the comprehensiveness coverage test from "a block exists" to
"the example actually works").
## §5. Immediate fix ready to apply
`create_table` is diagnosed (§2). The corrected block should make the
example a PK-only `create table` and move the regular columns to a
follow-up `add column`, e.g.:
```
What: Create a new table with its primary key.
Example: create table Customers with pk id(serial)
Concept: A table is a set of rows sharing the same columns. `with pk`
declares the primary key (one column, or several for a compound
key); add the other columns afterwards with `add column`.
```
Apply this (and re-check `create_m2n` / `add_*` while there), but only as
part of the systematic pass — a one-off fix risks leaving siblings wrong.
## §6. How to take over
1. Read handoffs 70 → 71, `CLAUDE.md`.
2. Confirm green: `cargo test` (2499 / 1 ignored), `cargo clippy
--all-targets`.
3. Do the §4 pass (consider the run-every-example test in §4). Test-first,
`/runda` before commit, confirm the commit message with the user.
4. Pedagogy wins — these are teaching strings; correctness and clarity
over cleverness.
+113
View File
@@ -0,0 +1,113 @@
# Session handoff — 2026-06-15 (72)
Short, focused handover. Continues from handoff-71, which asked the next
session to run a **systematic semantic verification pass over every
`hint` block** (handoff-70 shipped H2 / ADR-0053, but a user smoke-test
found a wrong hint and implicated the whole corpus). **That pass is now
done.** Four content errors fixed, a durable parse-guard added, two stale
docs corrected. Commit `5a37437`.
## §1. State
**Branch:** `main`, clean, all committed (local; **push pending** — your
step). **2500 pass / 0 fail / 1 ignored** (the long-standing `friendly`
doctest), **clippy clean** (nursery, all targets). The +1 vs handoff-71's
2499 is the new guard test. Open Gitea issues unchanged: **#35#38**.
## §2. The verification pass (commit `5a37437`)
Method: cross-checked every `hint.cmd.*` example against its
`parse.usage.*` template, ground-truthed every concept claim against the
authoritative ADR **and a named existing test** (not grep+extrapolation —
the trap handoff-71 §3 warned about), and parse-validated all 49 command
examples via a new guard.
### Four content errors fixed (`src/friendly/strings/en-US.yaml`)
| Block | Bug | Fix |
|---|---|---|
| `cmd.create_table` | Example `with pk id(serial), name(text), email(text)` declares a **3-column compound PK**, not a PK + regular columns. Every `with pk` column is a key member — confirmed by the grammar test comment *"Every `create table` column is a primary-key column"* (`ddl.rs`), ADR-0005. | Single-column PK + `add column` for the rest; `what`/`concept` aligned. |
| `cmd.save` | `save as my-shop` **does not parse**`build_save` yields `AppCommand::SaveAs` with **no inline name**; `save as` opens a path-entry modal (`iteration4b` tests). | Example → `save as`; `what` de-implied; added an accurate temp-vs-named-auto-save `concept`. |
| `cmd.import` | Target `shop-copy` **does not parse** — the `as <target>` slot is an `IdentSource::NewName` ident that tokenises only up to the hyphen. (The zip path is a BarePath and *does* accept hyphens, hence `export my-shop.zip` is fine.) | → `shop_copy`. |
| `err.foreign_key.child_side.concept` | Offered `on delete set null/cascade` as the remedy — but `error_hint_class` maps child_side to **insert/update** violations; `on delete` governs the **parent** direction. The tier-1 hint (line 64) correctly omits it. | Corrected: parent must exist first; clarified `on delete` is the *other* direction. |
### Durable guard added
`every_cmd_hint_example_parses_in_its_mode` (`src/dsl/grammar/mod.rs`,
in the `hint_key_tests` module). **Catalog-driven** — it iterates
`catalog().keys()` for `hint.cmd.*.example` rather than the REGISTRY, so
an orphaned/mis-keyed block can't slip past; floor-asserts ≥49 examples.
Each parses in its taught mode (advanced for the SQL surface, simple
otherwise). It caught the `save` and `import` errors **test-first** (red
before the YAML fix). Registered the new `hint.cmd.save.concept` key in
`keys.rs` (the `keys_validate_against_catalog` test requires every catalog
key be declared).
### Verified correct (not changed)
All other `cmd`/`err` blocks. Notably the guard-*concept* claims were each
confirmed against a named runtime test, not assumed:
`drop_column_refuses_primary_key` / `…_column_in_a_relationship`,
`drop_table_with_inbound_relationship_errors`,
`add_not_null_column_without_default_to_populated_table_is_refused`. The
corrected `create_table` story stays coherent with the `Customers`-
referencing examples (id serial PK → `add column` name/email → `insert`
skips the auto id).
## §3. Docs corrected (same commit)
Discovered while verifying `create_m2n` (which **is** implemented —
`db.rs::do_create_m2n_relationship` + `tests/it/m2n.rs`):
- **CLAUDE.md** carried two **stale "deferred" claims**, both already
implemented. Removed/updated: (a) the at-a-glance project-format line
said export/import (Iter 5) + `--resume`/input-history/migration (Iter
6) were "pending" — all `[x]` in `requirements.md` (ADR-0015); (b) the
"Things deliberately deferred" list still had the **m:n convenience
(C4)** bullet and the same project-storage bullet. `requirements.md`
was already correct (C4 done 2026-06-10, ADR-0045), so only a
verification-pass note was appended to its **H2** entry.
## §4. Scope note — what the guard does *not* do
The bug class here is **semantic** (an example that parses and runs but
misrepresents the prose — e.g. `create_table`). The guard enforces only
the **syntactic floor**: examples parse in their mode. It backstops
future typos/clause-drift but cannot police meaning. Semantic correctness
of the current corpus rests on this session's review (recorded in the
commit + requirements.md H2). A stronger-but-brittler option was offered
to the user and **not built pending their call**: per-form assertions
that each example resolves to the *expected command shape* (e.g.
create_table → single-column PK). `hint.err.*` examples are fix-recipe
prose, not runnable, so they're verified by review only — inherent.
## §5. Next session — start here
The hint corpus is now trustworthy. Open roadmap (verify against the CI
merge first, per handoff-70 §5):
1. **Push** (your step) — this commit + the still-unpushed backlog from
handoffs 70/71 (the CI merge + all of H2).
2. **#35 (cargo fmt gate)** — the natural pairing with the merged CI; the
user wanted it done once, before first publication. The tree is **not**
fmt-clean (~1800 pre-existing diffs).
3. Other `requirements.md` open items: **TT4** PTY tier-4 (unwired),
**I1** multi-line input, **I5/B3** in-flight cancellation, **V4**
session journal (own ADR), **TU1** tutorial system (own ADR).
4. Hint follow-ups if wanted: **#37** clause-concept hints, **#38**
diagnostic route + `diagnostic.*` blocks, **#36** `help` advanced-SQL.
## §6. How to take over
1. Read handoffs 70 → 71 → 72, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green: `cargo test` (**2500 / 1 ignored**) + `cargo clippy
--all-targets` (clean).
3. For anything in the `hint` area, read **ADR-0053** first. For the
corpus, `src/friendly/strings/en-US.yaml` (`hint.cmd.*` / `hint.err.*`)
is the content; the guard in `src/dsl/grammar/mod.rs` is the regression
net.
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
ADR amendment + README index-upkeep for decided-area changes, confirm
commit messages with the user.
5. Consider a `cargo sweep` at this milestone (`target/` grows; see
CLAUDE.md "Build hygiene").
+112
View File
@@ -0,0 +1,112 @@
# Session handoff — 2026-06-16 (73)
Short, focused handover. Continues from handoff-72 (which completed the
H2 hint-corpus verification pass). This session shipped one small
feature — a **`Ctrl-G` demo-mode alias for F1** — plus follow-on doc
hygiene. Commit `4016c3e`.
## §1. State
**Branch:** `main`, clean, all committed (local; **push pending** — your
step; the backlog now spans the CI merge, H2, the hint-corpus fixes,
handoffs 71/72/73, and this Ctrl-G commit). **2503 pass / 0 fail / 1
ignored** (the long-standing `friendly` doctest), **clippy clean**
(nursery, all targets). Open Gitea issues unchanged: **#35#38**.
## §2. Why Ctrl-G (the problem)
Casts are recorded with **`autocast`** (ADR-0047 demo mode: `--demo` /
`RDBMS_PLAYGROUND_DEMO`). The contextual hint overlay (ADR-0053 / H2)
opens on **F1** — but F1 reaches the app only as a terminal **escape
sequence** (`\eOP` / `\e[11~`), and **autocast cannot emit escape
sequences**. So the single most teaching-relevant overlay was
unreachable in recordings (and in presenter/teacher sessions, which also
run `--demo`). Same wall that pushed step-captions onto `Ctrl+]` (a
single control byte) rather than `Ctrl+!`.
### Chord choice — why Ctrl-G, why not Ctrl-1
The user's first instinct was `Ctrl-1` (mnemonic, near F1). **Not
possible:** in a legacy terminal `Ctrl`+digit has no control byte —
`Ctrl-1` arrives as a bare `1` (would type "1" into the buffer). The
kitty keyboard protocol *would* encode it, but only as an escape
sequence (the very thing autocast can't send), and this app deliberately
does **not** push `KeyboardEnhancementFlags` (`runtime.rs` does only
`enable_raw_mode` + `EnterAlternateScreen`). So the usable space is
exactly **`Ctrl`+letter** (single legacy control bytes). After excluding
taken chords (`Ctrl-C` quit, `Ctrl-O` nav, `Ctrl+]` caption,
`Ctrl-A/E/W/K/U` readline per ADR-0049), byte-collisions
(`Ctrl-H/I/J/M/[`), flow-control (`Ctrl-S/Q`), and likely-future
(`Ctrl-R/P/N/Y/L/V`), **`Ctrl-G`** is the clean survivor (BEL/"abort" in
line editors — nothing destructive to shadow).
## §3. What shipped (commit `4016c3e`)
ADR-0047 **Amendment 1**. **In demo mode only**, `Ctrl-G` aliases F1:
- Runs the *exact* F1 hint logic (`hint_key` guard in
`App::handle_key`, `src/app.rs` ~line 1226 — `key.code == F(1) ||
(self.demo_mode && Ctrl-G)`), so live-input → form hint, empty input →
recent-error / getting-started.
- **Badges as `[F1]`** (not `[CTRL-G]`): `demo_badge_label` maps
`Ctrl-G → Some("[F1]")` (consulted only in demo mode — the caller
gates). So a recorded cast is **visually identical to a real F1
press**.
- **Demo-gated:** the shipped keymap stays F1-only. Outside demo mode
`Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm
excludes CONTROL, so no `g` is typed).
- The keybinding strip (ADR-0051) is **not** changed — F1 stays the
advertised key; `Ctrl-G` is a recorder aid and the badge already reads
`[F1]`.
**Tests (test-first, `src/app.rs` Tier-1):** `ctrl_g_in_demo_mode_-
aliases_f1_on_input`, `…_on_empty_input`, `ctrl_g_outside_demo_mode_-
is_inert`, plus a `Ctrl-G → [F1]` assertion added to
`demo_badge_label_maps_the_invisible_keys`. All confirmed red→green; the
"inert" test passed on the pre-change code, proving the demo-gate.
### Using it in a cast
With `--demo` active, send **`Ctrl-G`** in the autocast script wherever
you want the hint overlay to appear; the viewer sees the `[F1]` badge.
## §4. Doc hygiene done alongside (same commit)
- **ADR-0047 file:** removed two stray `</content>` / `</invoke>` lines
(tool-call residue accidentally committed when the ADR was authored).
- **CLAUDE.md "Things deliberately deferred":** dropped three **stale**
entries — **I1b** readline shortcuts (done, ADR-0049), **I3** tab
completion, and **I4** syntax highlighting (both done; requirements.md
even carries a 2026-06-07 reconciliation note that I3/I4 were
"shipped but marked not yet"). Only **I1** (multi-line input) remains —
it is genuinely still open (`[ ]` in requirements.md). *(This follows
the handoff-72 cleanup that removed the equally-stale m:n/C4 +
project-storage entries.)*
## §5. Open / next (unchanged from handoff-72 §5)
The hint corpus is trustworthy and the cast-F1 gap is closed. Roadmap:
1. **Push** (your step).
2. **#35 (cargo fmt gate)** — precondition (CI merged) is met; the user
wants it done once, before first publication. Needs a `rustfmt.toml`-
vs-defaults decision first; tree is ~1800 hunks dirty.
3. Other open `requirements.md` items: **I1** multi-line input, **I5/B3**
in-flight cancellation, **TT4** PTY tier-4 (unwired), **DOC1**/**E2**
user docs (partial), **TT5** Windows-execution + Tier-4-in-CI, **D3**
packaging manifests. Design-first (`[~]`): **V4** session journal,
**TU1** tutorial, **C3a**, **V3**.
4. Hint follow-ups if wanted: **#36** `help` advanced-SQL, **#37** hint
clause-concepts, **#38** hint diagnostic route.
## §6. How to take over
1. Read handoffs 71 → 72 → 73, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green: `cargo test` (**2503 / 1 ignored**) + `cargo clippy
--all-targets` (clean).
3. For demo-mode / casting, read **ADR-0047** (+ its Amendment 1); for
the hint overlay it aliases, **ADR-0053**.
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
ADR amendment + README index-upkeep for decided-area changes, confirm
commit messages with the user.
5. Consider a `cargo sweep` at this milestone (`target/` grows; see
CLAUDE.md "Build hygiene").
+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 24).
## §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 24** 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 14)** + 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.
+125
View File
@@ -0,0 +1,125 @@
# Session handoff — 2026-06-22 (79)
A long session continuing the open-issue sweep (handoff-77/78). Cleared the
**help** issue (#36) and the **schema-cache** bug (#39) earlier; this note
mainly records the big piece: **dropping the `blob` column type** (ADR-0005
Amendment 2), which grew out of a one-line copy-rule fix.
## §1. State
**Branch `main`.** Commits this session (`1a2002d` ADR-0005 Am2 + `6b4c4dc`
blob removal, plus the earlier `07575da` #39 · `e88fa79` handoff-77 · `3ad4aff`
#36 · `64818c0` handoff-78) — **the user pushed these.** A follow-up
**`style:` rustfmt commit + this CLAUDE.md/handoff doc commit** land on top
(see §5) and are the only unpushed work at handoff time.
**Suite green: 2521 passed / 0 failed / 1 ignored** (1810 lib + 8 e2e_pty + 503
it + 200 typing), `fmt --check` + `clippy -D warnings` clean — **verified via
`nix develop -c`** (the CI-exact way; see §5's lesson).
**Closed this session:** #36, #39. **Open:** #37 (clause hints), #38
(diagnostic route — needs a do/defer/close call), #40 (winget release notes).
## §2. What shipped — drop `blob` (ADR-0005 Amendment 2)
**Why** (recorded in the ADR): `blob` was a dead-end type — declarable but
never fillable (no literal in either mode, `seed`-unsupported), so a blob
column could only ever hold NULL. It rode along as the engine's fourth
storage class and never got the pedagogical scrutiny UUIDs (excluded) and
compound PKs (defended) did. The vocabulary is now **nine types**.
**Discovered via:** the `value.rs:106` binder message *"…not supported in DSL
yet"* — a user-facing copy-rule violation (it `passthrough`es to the user via
`DbError::InvalidValue`). Rather than reword, we removed the type; the message
is deleted outright.
**Implementation (test-first throughout):**
- **Removal** (compiler-guided): `Type::Blob` + its `keyword`/`sqlite`/`all()`/
`from_sql_name` alias arms; `Value::Blob`; `CellValue::Blob(Vec<u8>)` + the
base64 CSV encode/decode; the `BLOB_SLOT` grammar slot; completion's `blob`
candidate; `output_render`'s `BLOB`; the type-change `↔ blob` static refusal;
the seed blob arms; the binder refusal. User-facing strings: dropped `blob`
from `help.types_reference` and removed the `hint.value_slot_blob` slot hint.
`base64` **stays** (clipboard OSC-52).
- **Backward-compat migration** (the ADR-0015 framework's **first real use**):
a **v1→v2 format bump**. `parse_schema` hard-pinned the version to 1, so this
rippled into a `CURRENT_SCHEMA_VERSION = 2` constant used by the serializer +
parser + skeleton, the first registered migrator (`migrate_v1_to_v2`:
rewrites `type: blob``type: text` + bumps the version), and ~30 `version: 1`
test fixtures. The runtime (already wired to `ensure_project_yaml_migrated`)
**forces a `.db` rebuild** from the migrated text **only when a blob column
was actually converted** (`body_declares_blob_column` pre-read at both open
sites) — the stale `.db` keeps a `STRICT … BLOB` engine column + `"blob"`
metadata it would otherwise use as-is. Conversion target **text** (not drop)
was the user's call (non-destructive, CSV-free, PK/FK-safe).
- **Tests:** migrator unit tests (incl. a column *named* `blob` + a `blob`
substring in a CHECK left untouched); a **full-stack** integration test
`tests/it/blob_removal_migration.rs` (v1 blob project → migrate → `text`
column + row data preserved + `.bak`); a **Tier-4 PTY** end-to-end test
`e2e_pty::opens_a_legacy_v1_blob_project_by_migrating_to_text` (the real
binary opens a seeded legacy v1-blob project via `--resume` → migrate +
rebuild + `text` column) — added in the implementation `/runda` pass to
cover the runtime's migrate-on-open glue; updated all-types tests to nine;
regenerated 5 typing-surface snapshots (reviewed — only the `blob`/`data`
candidate vanished).
- **Docs:** ADR-0005 Amendment 2 (committed `1a2002d`) + README index; swept
`ten-type``nine-type` / removed `blob` from CLAUDE.md, `requirements.md`,
the website (`reference/types.md`, the seed doc's stale `not null blob`
bullet, the `rdbms.mjs` highlight grammar), and the cross-reference counts in
ADR-0030/0033/0035. CHANGELOG `[Unreleased] → Removed`.
## §3. Decisions & notes for next time
- **convert-to-text, not drop-the-column** (user, 2026-06-22): drop would force
rewriting every `data/*.csv` past the rebuild's strict header check — not
worth it for a few-days-old, few-users tool.
- **Full v2 framework migration, not parse-time leniency** (user): the proper
recorded migration with `.bak`, exercising the dormant framework.
- **`BlobLit` grammar terminal deliberately left** — a pre-existing,
`#[allow(dead_code)]` speculative token never used by any grammar; orthogonal
to the type vocabulary. Its "blob literal" string is unreachable. Removing it
is a separate optional cleanup (touches the grammar core).
- **Known assumption:** the migrator + `body_declares_blob_column` match the
serializer's *inline* column format (`- { name: …, type: … }`). A
hand-written *block-style* `project.yaml` with `type: blob` wouldn't convert
(and would then fail to parse). `project.yaml` is always machine-serialized
inline, so this is fine in practice — noted in case a future format change
moves away from inline columns.
- **Other ADRs (0030/0035) keep their historical `blob` content** (type-change
matrix, `binary`/`varbinary` aliases) per the project's supersede-don't-rewrite
ADR philosophy; ADR-0005 Amendment 2 is the authoritative record.
## §4. Process pins / next
- Commits user-confirmed, no AI attribution, append-only, on `main`; **push is
the user's step.**
- **`/runda` was run on both the ADR (design) and the implementation.** The
implementation pass found one real gap — the runtime's migrate-on-open glue
was untested — closed with the new Tier-4 PTY test. It also verified the
import path migrates (extract→migrate→rebuild) and the non-migration
`parse_schema` callers fail-safe.
- Consider a `cargo sweep` at this milestone.
- **Next open issues:** #37 (clause hints, on-mission), #38 (escalate
do/defer/close first), #40 (winget release notes).
## §5. CI/fmt lesson (cost a red CI run — don't repeat)
The blob commit `6b4c4dc` **failed the CI `fmt` gate** (run 92, `ci.yaml`).
Two compounding causes, both fixed here:
1. **`fmt` IS gated now** (`cargo fmt --check`, ADR-ci-002 Amendment 1 / issue
#35) — but `CLAUDE.md` still said "fmt is intentionally not gated yet." That
stale note bred complacency. **Corrected `CLAUDE.md`** (the gate is fmt +
clippy + test, run via `nix develop -c`).
2. **A broken local check masked the failure:** `cargo fmt --check 2>&1 | tail
-1 && echo "fmt clean"` tests `tail`'s exit code (always 0), **not** fmt's —
so "fmt clean" printed over real diffs. **Always read the exit code**, never
a piped `… | tail && echo clean`.
**Going forward, verify the CI way:** `nix develop -c cargo fmt --check` /
`nix develop -c cargo clippy --all-targets -- -D warnings` / `nix develop -c
cargo test` — matching the pinned 1.95.0 toolchain *and* the devShell env, and
checking exit codes. (A bare host `cargo` happened to be 1.95.0 this time, so
the divergence was the masking pipe, not the toolchain — but `nix develop -c`
guards both.) The `style:` rustfmt fix + this doc update land on top of
`6b4c4dc`; CI re-runs green on the next push.
+144
View File
@@ -0,0 +1,144 @@
# Session handoff — 2026-06-25 (80)
A long session. It began as the next open-issue item — **#37, clause-concept
hints** — but the act of cutting a branch for it surfaced that this
now-public repo had **no written, trackable working method**. So the bulk of
the session became establishing one (**ADR-0059**) and hardening it through
real use, with two follow-up fixes the dogfooding turned up. Both arcs are
fully merged; the tree is clean.
## §1. State
**Branch `main` at `78272e2`.** Everything below is merged; **no open PRs**;
worktrees are just the primary (`main`) + the long-lived `website`. All
feature/fix worktrees were cleaned up with `wt-rm`.
**Suite green at #37's merge:** **1831 lib + 8 e2e_pty + 503 it + 200 typing
(2542 total), 0 failed, 1 ignored**; `fmt --check` + `clippy -D warnings`
clean (via `nix develop -c`). The new workflow scripts carry their own
local-`origin` harnesses: `test-adr-reserve.sh` **10/10**, `test-wt.sh`
**16/16**, all `shellcheck`-clean.
**Issues:** closed this session — **#37** (done, ADR-0058) and **#38**
(won't-do, see §4). Still open — **#40** (winget release notes, deferred to
next release; see §4). (#36/#39 were closed in earlier sessions.)
## §2. What shipped — #37 clause-concept hints (ADR-0058, merged #42)
The deferred ADR-0053 extension: a tier-3 **`hint.concept.*`** layer shown by
**F1** when the cursor sits **inside a recognized clause**, layered under the
per-form `hint.cmd.*` block. Seven topics — referential actions, `1:n`/`m:n`
cardinality, primary key, unique, check, foreign key.
- **Mechanism:** a new transparent `Node::Concept { topic, inner }` grammar
wrapper records the clause's byte span into `WalkContext::concept_spans`
(**append-only — survives the clause being fully matched**, unlike
`pending_hint_mode` which clears on match and so only knows the slot
boundary). `concept_topic_at_cursor` walks the full buffer and returns the
**innermost** containing span. No use of the dormant `WalkBound::Position`.
- **Two user forks:** detection = "anywhere inside the clause"; **all four
clause families** in v1. **Mode-keyed examples** (`example.simple` /
`example.advanced`) honour ADR-0053 D6 for topics reachable in both modes;
`emit_tier3_block` picks by live mode.
- **`/runda` caught two design gaps before any code** (clause map missed the
simple-mode constraint suffix; single-`example` model collided with D6) —
both fixed. **Comprehensiveness gate**: a recursive `Node::Concept` visitor
cross-checks grammar wrappers ↔ `CONCEPT_TOPICS` ↔ catalogue blocks.
- 21 new tests (14 resolver, 3 gates, 3 F1 integration, 1 snapshot).
## §3. The working method (ADR-0059, merged #41; fixes #43, #44)
The session's main artifact — the trackable flow for the public repo:
- **PRs onto a protected `main`** (always-green; one direct-push carve-out,
§4), one logical change per branch, conventional prefixes, the `/runda`/DA
review pasted into each PR. **Merge commits (`--no-ff`)**; never
rebase/squash (append-only).
- **One worktree per branch** (`<repo>-worktree-<segment>`) — never switch the
primary checkout. `scripts/wt-new.sh <branch>` (off `origin/main`,
`--no-track`) / `scripts/wt-rm.sh <branch>` (removes only the **named**
worktree).
- **Reserve-first ADR numbering**`scripts/adr-reserve.sh <slug>`. The fix
for the real collision problem: a contiguous integer needs an allocator and
plain git branches have none, so **`main` is the registry and an atomic
`git push` is the lock** (compare-and-swap, retried on contention),
recorded in the append-only ledger **`docs/adr/RESERVATIONS.log`**. The
number is **stable from creation** (safe in immutable commit messages +
cross-refs). Supersedes ADR-0000's placeholder-until-merge default;
subproject namespaces (`ADR-website/ci-NNN`) unchanged.
- **Branch protection** (you applied in Gitea): require PR + the `ci / gate*`
check + up-to-date-before-merge; **owner whitelisted for direct push** (the
reservation ledger line, now also handoff/session docs — §4).
- **Fixes found by dogfooding:**
- **#43** — `wt-clean` (auto-sweep of all merged worktrees) → explicit
**`wt-rm <branch>`**. The auto-sweep would have deleted long-lived
branches (`website`, `ci`) since they're merged into `main` too. Also
**`wt-new --no-track`**: branching off `origin/main` had set the new
branch's upstream to `origin/main`, so a bare `git push` errored on a
name-mismatch and suggested the dangerous `HEAD:main`.
- **#44** — CI **dedupe**: `push: branches: [main]` (was `['**']`) so a
push to a PR'd branch no longer runs the gate twice. On **Gitea** the
`push` and `pull_request` runs were byte-identical — Gitea has **no
merge-preview ref**; its `pull_request` checks out `refs/pull/N/head`, the
same commit a branch push would (`docs.gitea.com/usage/actions/faq`).
Keeping the gate on `pull_request` is forward-compatible: if Gitea ever
adds the merge ref, the runs upgrade to testing the merged result for free.
## §4. Decisions
- **#38 closed — won't build** the pre-submit-diagnostic tier-3 route. The
ADR-0053 D6 deferral reasons still hold: `Diagnostic` carries no class key
(a class field would have to thread through every diagnostic-creation
site — broad change) for marginal value (tier-2 already surfaces
diagnostics live; many classes duplicate the runtime-error/clause-concept
tiers). Reconsider only if a diagnostic class has genuinely unique teaching
value. Rationale recorded on the issue.
- **#40 deferred** (open): wire `CHANGELOG` into komac's winget
`--release-notes-url`. Best coupled to the **next `v*` release + first real
winget *update* test** — the initial winget PR to `microsoft/winget-pkgs`
is still unconfirmed after several days, so end-to-end validation needs a
confirmed package first.
- **Docs direct-push carve-out** (this handoff is the first to use it):
handoff / session docs (and the reservation ledger) are **owner
direct-pushed to `main`**, not PR'd. See §5 for *why* a PR doesn't work.
## §5. Process lessons (now in ADR-0059 / CLAUDE.md)
- **Update a branch in ONE lane** — locally (`git fetch && git merge
origin/main`), **not** the Gitea "Update branch" button when a conflict is
possible. The button silently mis-resolved the README clash on #42
(dropped the `0059` index row) and, combined with a local merge, diverged
the PR branch; healed with an append-only merge (no force-push).
- **Primary checkout rests on `main`; feature work in worktrees.** Mid-session
the primary was stuck on `feat` (bootstrap artifact), which is what made
worktree cleanup awkward — there was no `main` checkout holding the
scripts. Resolved by switching the primary to `main` and moving `feat` to a
worktree.
- **Docs-only PRs are blocked by the required gate.** `paths-ignore`
(`docs/**`, `**/*.md`) means a docs-only change posts **no `ci / gate`
run**, and branch protection requires `ci / gate*` — so the required check
never arrives and the PR is stuck "expected/waiting." That is the concrete
reason handoff/session docs are direct-pushed (§4). If docs-via-PR is ever
wanted, add a "skipped → success" gate shim (or drop the paths-ignore).
- **Gitea CI specifics worth remembering:** no merge-preview ref (above); the
reserve script's CAS via atomic push; `--no-track` on branch creation to
avoid the inherited-`origin/main` upstream.
## §6. Next
- **Next session is free to pick a feature** — no specific item is queued;
consult `docs/requirements.md` for the remaining backlog (e.g. **TT4 /
Tier-4-in-CI** still not wired; **D3 packaging** — winget pending
confirmation; the larger deferred UX items: tutorial system, session-log +
markdown export V4, multi-line input I1, ER-diagram export V3).
- **#40** when the next release happens (with the first winget update test).
- **Use the new flow:** `wt-new` to start a branch, `adr-reserve` the moment
an ADR is needed, PR onto `main`, `wt-rm` when done. Push/merge stay your
steps; agents prepare but never push.
## §7. Process pins
- Commits user-confirmed, no AI attribution, append-only. **Push/merge are
the user's steps.** This handoff + the §4 carve-out doc amendments are
direct-pushed to `main` by the owner (docs-only, paths-ignored,
owner-whitelisted).
+193
View File
@@ -0,0 +1,193 @@
# Plan — road to public availability (versioning + install + packaging)
Status: **planning** (2026-06-16). Captures the decisions taken in
discussion plus the open questions, so the work can proceed in steps.
The versioning piece is being implemented first and is recorded as a
decision in **ADR-0054**; the install-script and package-manager pieces
are roadmap items here until each is built.
Scope note: this repo lives on the self-hosted Gitea at
`git.lazyeval.net` (`oli/rdbms-playground`); releases publish there
(ADR-ci-001/003). Publication branding uses the company name **Lazy
Evaluation Ltd → `lazyeval`**, not the personal `oli` username, for
anything user-facing (taps, buckets).
---
## 1. Versioning + version surfaces — DECIDED (→ ADR-0054, building now)
- **`Cargo.toml` `version` is the single source of truth.** `--version`
reads `env!("CARGO_PKG_VERSION")` (compile-time, no deps).
- **Two surfaces:** a `--version` / `-V` CLI flag (prints + exits, like
`--help`), and an in-app **`version`** app command.
- **CI discipline:** `release.yaml` gains a guard that asserts the pushed
tag `vX.Y.Z` equals `CARGO_PKG_VERSION`, and **fails the release on
mismatch** — so the binary's self-reported version, the release name,
and the downloaded asset always agree.
- **Release ritual:** bump `Cargo.toml` → commit → tag `v<that number>`
→ push tag. (The guard enforces it.)
- Optional enrichment (not decided): a `build.rs` baking a git short-hash
+ build date so non-tagged dev builds read `0.2.0 (a1b2c3d)`. Good for
bug reports; can be added later.
---
## 2. `install.sh` (curl | sh) — DONE 2026-06-17 (ADR-0055)
**Shipped** `scripts/install.sh` (POSIX sh, shellcheck-clean). Verified
end-to-end against the live public `v0.1.0` release: platform mappings
(Linux/macOS × x86_64/aarch64; Windows + unknown arch error cleanly),
pinned (`RDBMS_VERSION`) and latest (`releases/latest`) paths, SHA-256
verification (incl. a tamper-rejection check), install to
`~/.local/bin`, PATH hint. **`install.ps1` (Windows) added 2026-06-17**
(user chose both a one-liner *and* Scoop/winget; ADR-0055 Amendment 1):
`irm | iex`, maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`,
SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH —
**written but untested from this env** (no PowerShell; validate on
Windows). The website copy that references these commands is the
**website branch's** job (separate agent), later.
A **shellcheck CI gate** for `scripts/` is a recommended follow-up (not
added — shellcheck isn't in the flake yet; touches ADR-ci-002).
Original decided shape (for reference):
- **Hosted from the Gitea repo URL** on `git.lazyeval.net` (simplest):
`curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh`
(exact raw-URL form to confirm against the Gitea version).
- **Behaviour:** POSIX `sh`; detect `uname` OS+arch → target triple
(Linux → the **musl static** build, our universal Linux artifact);
query the latest release via the Gitea API
(`/repos/oli/rdbms-playground/releases/latest`) → tag → deterministic
asset name `rdbms-playground-<tag>-<target>`; download + **verify the
`.sha256`**; install to `~/.local/bin` (fallback `/usr/local/bin` with
a sudo prompt); `chmod +x`; print a PATH hint if needed.
- **macOS:** binaries are signed (see §4 signing note); a `curl`
download does **not** apply the Gatekeeper quarantine xattr, so the
installed binary runs without `xattr` faff.
- **Windows:** not `curl | sh` — provide a PowerShell `install.ps1`
(`irm … | iex`) and/or steer to Scoop/winget (§3).
- **Security posture:** HTTPS only; in-script checksum verification;
document the download-inspect-run alternative (`curl|sh` is a trust
tradeoff).
- **Deliverables we own now:** `scripts/install.sh` (+ later
`install.ps1`); confirm releases are **publicly downloadable**; decide
whether to also upload `install.sh` as a release asset for a stable
link. Website copy referencing the command is the **website branch's**
job (separate agent), later.
---
## 3. D3 — package managers (roadmap; each layers on the release assets)
Common thread: a manifest pointing at our checksummed assets + a
per-release step to bump it. Ordered cheapest → most gatekept.
### 3a. `cargo binstall` + crates.io — PREPARED 2026-06-17 (ADR-0056)
**Done (prep):** crate made publish-ready (dropped `publish = false`;
added `homepage`/`keywords`/`categories`/`exclude`; authored `README.md`
+ `LICENSE-MIT`); `[package.metadata.binstall]` added with per-target
overrides (linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct);
`cargo publish --dry-run` clean (913 KiB compressed). Dual license kept;
`LICENSE-MIT` + `LICENSE-APACHE` (© Lazy Evaluation Ltd) + `CONTRIBUTING.md`
(inbound=outbound) all in place. **Gated / remaining:** the actual `cargo
publish` (token, irreversible) at a **new tagged release** (not 0.1.0); a
real `cargo binstall` validation.
- **Bootstrapping matters (user-flagged):** `binstall` is **not** a
built-in cargo subcommand — users must install **`cargo-binstall`**
first (its own `curl|sh`/PowerShell installer, or
`cargo install cargo-binstall`). **Our instructions must say this.**
- Add `[package.metadata.binstall]` to `Cargo.toml` (pkg-url template →
our Gitea release assets; our naming already fits).
- **DECIDED (2026-06-17): publish to crates.io** — so the frictionless
`cargo binstall rdbms-playground` resolves the crate, and the project
is discoverable there. (A crates.io publish is its own small task:
metadata completeness — description/license/repository/keywords/readme
— and `cargo publish`; the `[package.metadata.binstall]` URL template
points binstall at our Gitea release assets.) *(Verify current
cargo-binstall behaviour when wiring.)*
### 3b. Scoop (Windows)
- A **bucket** repo under `lazyeval` on Gitea with a JSON manifest
(`.exe` URL + hash + `autoupdate`). Release job commits the bump.
- Users: `scoop bucket add lazyeval <gitea-url>; scoop install rdbms-playground`.
### 3c. Homebrew (macOS/Linux)
- A **company-branded tap**`lazyeval/homebrew-tap` (on Gitea) — with a
Ruby formula (per-arch `url` + `sha256`). Release job commits the bump.
- Users: `brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap`
then `brew install lazyeval/tap/rdbms-playground` (the explicit-URL tap
form, since the `user/repo` shorthand assumes GitHub).
- *"Notability bars"* = the acceptance criteria for the default
**homebrew-core** tap (must be sufficiently popular/maintained). Our
own `lazyeval` tap sidesteps that entirely — no review gate.
### 3d. winget (Windows)
- Manifests are YAML PR'd to `microsoft/winget-pkgs` (reviewed by MS).
- **`wingetcreate` is Windows-only** (.NET) — no good without a Windows
runner. **Automatic path to evaluate first: `komac`** — a
cross-platform (Rust) winget manifest creator/submitter that runs on
our **Linux** CI. *(Verify komac's current capabilities/auth model.)*
- **Fallback:** a manual YAML PR per release — acceptable given releases
are infrequent (user-confirmed).
### Cross-cutting (3a3d)
- Two extra repos (tap + bucket) under `lazyeval`, with CI push
credentials — setup TBD (user: "we'll figure that out").
- **`cargo-dist`/"dist"** automates installers + Homebrew + CI, but is
**GitHub-Actions/Releases-centric**; on self-hosted Gitea it won't drop
in cleanly (installer-script generation might be reusable). Likely
hand-roll the manifests + a small "update on release" job instead.
*(Verify cargo-dist's current Gitea support before fully ruling out.)*
---
## Open decisions
1. **crates.io:** **RESOLVED 2026-06-17 — yes, publish.** (See §3a.)
2. **Tracking:** **RESOLVED 2026-06-17 — doc + ADR only, no Gitea
issues.**
3. **Release downloads public:** **CONFIRMED 2026-06-17** — the Gitea
releases are publicly downloadable (no auth); `install.sh` relies on
it and was verified against the live `v0.1.0`.
### Still open / postponed
- **macOS signing — CONFIRMED BUG (2026-06-16), POSTPONED by the user
(2026-06-17)** pending the correct signing ID. Details:
- `release-macos.yaml` does `codesign --force --sign -` (ad-hoc) and has
**no signing scaffolding at all** (no keychain import, no secrets) —
so a downloaded binary is *not* properly signed (user-verified).
- **The credential the user has is the wrong type:** `Apple Development:
Oliver Sturm (W687M898E4)` is a *development* cert (Gatekeeper won't
trust it for distribution). Distribution needs a **`Developer ID
Application`** cert (same format, different type). Signing under the
company name *"Lazy Evaluation Ltd"* would need an **Organization**
Apple Developer account; a personal account signs as "Oliver Sturm".
- **Notarization** (required with Developer ID for non-quarantined trust
on browser downloads): after signing, `xcrun notarytool submit`. Creds
= an **App Store Connect API key** (Issuer ID + Key ID + `.p8`,
recommended for CI) *or* Apple ID + app-specific password + Team ID.
A bare CLI binary can't be *stapled* (only bundles/dmg/pkg) — Gatekeeper
does an online check instead.
- **Urgency caveat:** the `curl|sh` path doesn't need any of this (curl
downloads aren't quarantined); signing matters for browser downloads
from the releases page. Fix when the right cert + creds exist; corrects
the ad-hoc docs once landed.
## Sequencing
1. ✅ **Version discipline** (ADR-0054) — `--version`/`-V` + `version`
command + CI tag-match guard + tests.
2. ✅ **`scripts/install.sh`** (ADR-0055) — built + verified against the
live public release.
3. Package managers, cheapest first:
- ✅ **`cargo binstall` + crates.io** — *prepared* (ADR-0056);
publish gated on a new tagged release + the token.
- **← next:** Scoop (`lazyeval` bucket) → Homebrew (`lazyeval` tap) →
winget (komac / manual). Two `lazyeval` repos (tap + bucket) + CI
push creds to set up.
4. **Cut a release at a new version** — bump `Cargo.toml` (0.1.0 →
0.1.1/0.2.0; the ADR-0054 guard checks the tag), tag, push; the four
Linux/Windows targets build immediately. (macOS leg awaits signing.)
+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
13 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 00530056 + handoffs 7375. 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. 510×) 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.
+121 -41
View File
@@ -70,8 +70,11 @@ since ADR-0027.)
natively on a Tart Apple-Silicon runner via the dispatched
`release-macos.yaml`. All uploaded to the Gitea release with a
`.sha256` each. Decisions in `docs/ci/adr/` (ADR-ci-001/002/003).
Runtime-verified by the user: Linux x86_64 + Windows aarch64; the
others are link-clean / valid format.)*
Runtime-verified by the user: Linux x86_64, Windows aarch64, and both
macOS targets (the `release-macos.yaml` dispatch — now triggerable
since CI is on `main` — was run end-to-end and the binaries launch);
Linux aarch64 + Windows x86_64 remain link-clean / valid-format
only.)*
- [x] **D2** Single static binary, no runtime dependencies.
*(Done 2026-06-15, per platform: **Linux** is fully static (musl +
`crt-static`); **Windows** is a standalone `.exe` (Zig statically
@@ -82,11 +85,24 @@ since ADR-0027.)
No target requires anything the user must install. ADR-ci-003.)*
- [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop,
`winget`, and `cargo binstall`.
*(Prebuilt binaries + checksums now published to Gitea releases
(D1); the package-manager manifests (Homebrew / Scoop / winget /
`cargo binstall`) remain to do. The asset naming
`rdbms-playground-<tag>-<target>` is already binstall-friendly.
Tracked under ADR-ci-003 "Deferred".)*
*(Prebuilt binaries + checksums on Gitea releases (D1); **`cargo
binstall` + crates.io live** (ADR-0056); **Scoop + Homebrew wired and
validated end-to-end** on real Windows + macOS (ADR-0056 Amendments
2 & 3) — `publish.yaml` `scoop-bucket` / `homebrew-tap` jobs render
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
@@ -388,7 +404,7 @@ since ADR-0027.)
referenced column), then `ALTER TABLE … ALTER COLUMN TYPE` (4f —
runtime-decomposed to `change_column_type` with `ForceConversion`, the
§7 advanced policy: lossy converts with a note, incompatible + static
refusals (`↔ blob`, non-`int → serial`) refuse, `int → serial` allowed;
refusals (non-`int → serial`) refuse, `int → serial` allowed;
the internal-`__rdbms_*` guard folded into `do_change_column_type`),
then `ALTER TABLE` add/drop constraint + add FK (4g — `ADD [CONSTRAINT
<name>] (CHECK | UNIQUE | FOREIGN KEY)` + `DROP CONSTRAINT <name>`;
@@ -414,7 +430,7 @@ since ADR-0027.)
refused with an engine-neutral message naming the construct.
Implementation pending.)*
- [x] **Q3** User-facing simplified types map transparently to
SQLite STRICT types in generated DDL. *(All ten types implemented
SQLite STRICT types in generated DDL. *(All nine types implemented
and tested.)*
- [x] **Q4** Supported SQL subset specification — **ADR-0030**.
Advanced mode is a standard-SQL surface, engine-neutral; the
@@ -446,10 +462,11 @@ since ADR-0027.)
## Type system (per ADR-0005)
- [x] **T1** All ten user-facing types implemented: `text`,
`int`, `real`, `decimal`, `bool`, `date`, `datetime`, `blob`,
- [x] **T1** All nine user-facing types implemented: `text`,
`int`, `real`, `decimal`, `bool`, `date`, `datetime`,
`serial`, `shortid`. *(Mapping to SQLite STRICT covered by
ADR-0005; FK target type rule by ADR-0011.)*
ADR-0005; FK target type rule by ADR-0011; `blob` dropped by
ADR-0005 Amendment 2.)*
- [x] **T2** `shortid` generation: base58, 1012 characters,
omits ambiguous characters; generated client-side at insert.
*(Implemented per ADR-0014; auto-fills omitted shortid
@@ -820,7 +837,12 @@ since ADR-0027.)
(`what`/`example`/`concept`) covers every command form + the 9 runtime
error classes, enforced by a comprehensiveness coverage test. Deferred:
the pre-submit-diagnostic route + `diagnostic.*` blocks (#38),
clause-concept hints (#37).)*
clause-concept hints (#37). **Content verified 2026-06-15 (handoff-71):**
a semantic pass over every `hint.cmd.*`/`hint.err.*` block fixed four
errors — `create_table` (compound-PK misread), `save` (no inline name),
`import` (hyphen-rejecting target), and `foreign_key.child_side` (wrong
`on delete` remedy) — and added a catalogue-driven guard test that parses
every command example in its taught mode.)*
- [x] **H3** `help` provides general reference and per-command
help.
*(Done 2026-06-07: the **general reference** is `help` (no arg) —
@@ -883,7 +905,10 @@ since ADR-0027.)
exists (~55 lines, covers the WHERE-expression and
table-creation boundaries). **Missing:** a DSL
command-surface reference and a standalone type-system
reference under `docs/`.)*
reference under `docs/`. **Note (2026-06-16):** the **canonical**
user docs now live on the **website** (ADR-website-001, deployed) —
it covers the full feature set; the in-repo `docs/` reference pieces
named here remain the outstanding part of DOC1.)*
## Testing (per ADR-0008)
@@ -894,28 +919,44 @@ since ADR-0027.)
for representative views.
- [x] **TT3** Tier 3: synthetic event-loop integration tests
covering the user-facing flows in this checklist.
- [~] **TT4** Tier 4: PTY-based end-to-end for the four critical
- [x] **TT4** Tier 4: PTY-based end-to-end for the four critical
flows named in ADR-0008 (cold launch → DDL → quit; save →
reopen; export → import → rebuild; undo after DROP).
*(Verified 2026-06-07: **nothing is wired** — no
`portable-pty` / `expectrl` / `vt100` dependencies, no PTY test
files; ADR-0008 §Tier-4 is a specification only. The Tier-3
`tests/it/*_e2e.rs` files are synthetic event-loop tests, not
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is
wired only for the listed critical flows" — it was not wired at
all. Genuinely deferred.)*
*(Implemented 2026-06-22 — ADR-0008 **Amendment 1**;
`tests/e2e_pty.rs` (commit `fd63de3`). Drives the actual built
binary in a real pseudo-terminal: `portable-pty` + `vt100`
(**`expectrl` dropped** — conflicting PTY layer, replaced by a
hand-rolled `wait_for` on the vt100 grid). Fixed 100×30, per-test
temp `--data-dir`, serial, tight fail-fast 3 s waits; table
presence read from the Tables sidebar region (Output echoes
commands); commands paced to completion (stale-schema-cache
misparse on faster-than-human input → issue #39). All four flows
green (flow 2 asserts a **column** round-trips, not just the
table name). Was previously a spec-only deferral; the earlier
2026-06-07 "nothing is wired" note is now resolved.)*
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
stable Rust.
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` +
`cargo test` (Tiers 13) on the **Linux** runner for every branch
push / PR, and `release-macos` runs the suite natively on the
**macOS** runner. **Windows is build-only** — cross-compiled, not
executed (no Windows runner). **Tier 4** (PTY, TT4) is still
unwired, so "all tiers" is not yet fully met. "Stable Rust" is
satisfied by the flake's pinned `1.95.0` (a stable release, not
nightly). Remaining for full TT5: a Windows execution runner and
Tier-4 PTY in CI.)*
*(Partial, updated 2026-06-22. **CI is live** on the self-hosted
Gitea Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings`
+ `cargo test` (now Tiers **14** — the `e2e_pty` target runs by
default) on the **Linux** runner for every branch push / PR.
**macOS is fully covered**: `release-macos` runs the whole suite
natively on the macOS runner, and Tier 4 was confirmed green on
macOS (2026-06-22 — the 4 flows + startup; the Linux-only RSS probe
is `#[cfg]`-skipped). **Tier 4 in CI on Linux is met** (TT4);
PTY-in-container was validated (openpty + spawn under Docker), with
the first real gate run the final confirmation. "Stable Rust" is
satisfied by the flake's pinned `1.95.0`.
**Windows — automated execution is the one open piece.** Windows
binaries are first-class: cross-built and shipped for every release
(D1/D2), and we verify them by **running the suite on Windows by
hand from time to time** so the Windows experience stays sound
between releases. A standing Windows CI runner is more involved to
operate than the Linux and macOS runners already in use; we intend
to keep looking for a clean solution but make no promises on timing.
Until then, Windows users get exactly the same builds, verified
manually rather than on every push. So TT5 stays `[/]` by deliberate
scope, not neglect.)*
## Cross-cutting
@@ -991,41 +1032,80 @@ target is measurable, it is stated numerically; where it is
necessarily qualitative, the criterion is named and the bar is
"reviewer judgement against the criterion."
- [ ] **NFR-1 Performance — startup.** Cold launch to first
All seven were formally verified 2026-06-22 (ADR-0057). Approach:
contrast/distinctness **gated** by tests, startup/memory **measured**
against the targets, the rest **verified by argument / reviewer note**.
- [x] **NFR-1 Performance — startup.** Cold launch to first
rendered frame under 500ms on commodity hardware (developer
laptop, mid-range desktop). Measured in CI on the Linux runner
as a regression gate.
- [ ] **NFR-2 Performance — input latency.** Keystroke-to-render
laptop, mid-range desktop).
*(Verified 2026-06-22, ADR-0057 — measured via the Tier-4 PTY
harness; release-binary median **~29 ms** to first frame (5
trials, this dev machine), ~17× under target. The harness test
`startup_to_first_frame_is_reasonable` gates a generous
debug-binary bound for gross-regression detection; a tight 500 ms
CI gate was declined as flaky on a shared runner — user decision.)*
- [x] **NFR-2 Performance — input latency.** Keystroke-to-render
latency under 16ms during normal editing; long-running queries
must execute off the UI thread so the interface remains
responsive (typing, scrolling, mode switching) while a query is
running.
- [ ] **NFR-3 Performance — resource footprint.** Idle memory
*(Verified 2026-06-22, ADR-0057 — by architecture: database work
runs on the dedicated worker thread (ADR-0010) and terminal input
on a separate Tokio task (`spawn_event_reader`, `runtime.rs`), so
a running query cannot block input/render. No automated sub-16 ms
latency test — it would be flaky and low-value.)*
- [x] **NFR-3 Performance — resource footprint.** Idle memory
under 50MB on the smallest target platform; no busy-loops; CPU
near zero when waiting for input.
- [ ] **NFR-4 Visual quality — distinctive design.** Colour
*(Verified 2026-06-22, ADR-0057 — release-binary idle RSS
**~10 MB** (5 trials, Linux), ~5× under target. The event loop
blocks on `recv` (no busy-loop). The harness test
`idle_memory_footprint_is_reasonable` gates a generous
debug-binary RSS bound on Linux.)*
- [/] **NFR-4 Visual quality — distinctive design.** Colour
palette and typography are deliberate and consistent across
views; layout uses Unicode box-drawing and symbols where they
add clarity; rendering avoids the generic flat-default look
that ships with most TUI frameworks. Criterion: a reviewer can
identify the app from a screenshot of any view.
- [ ] **NFR-5 Visual quality — colour use.** Colour conveys
*(Reviewer note 2026-06-22, ADR-0057 — satisfied by the bespoke
two-theme palette, the three-region framed layout, and the
annotated plan/relationship rendering; qualitative, not
automatable.)*
- [x] **NFR-5 Visual quality — colour use.** Colour conveys
information rather than decoration: mode indication, query
result types (numeric vs text vs null), error severity,
syntax highlighting categories. Foreground/background
combinations meet WCAG-AA contrast (4.5:1 for normal text)
even though we have not committed to broader accessibility.
- [ ] **NFR-6 Cross-platform parity.** Behaviour and visual
*(Verified 2026-06-22, ADR-0057 — **gated** in `src/theme.rs`:
every text foreground clears 4.5:1 on both themes; the
advanced-mode border clears 3:1 (plain border decorative-exempt);
token pairs clear ΔE2000 ≥ 15. Writing the gate caught + fixed two
shipped light-theme defects (`tok_string` 4.42:1, `tok_flag`
3.15:1) and two near-duplicate dark pairs, `65eab71`.)*
- [/] **NFR-6 Cross-platform parity.** Behaviour and visual
quality are equivalent across Linux, macOS, and Windows on
crossterm-supported terminals. Platform-specific divergence
(e.g. font fallbacks) is documented, not silently tolerated.
- [ ] **NFR-7 Light and dark background support.** The colour
*(Reviewer note 2026-06-22, ADR-0057 — partly evidenced by the CI
matrix (Linux + macOS execute the suite incl. Tier 4; Windows is
build-only). Documented divergence: on terminals/multiplexers
without true-colour passthrough the 24-bit palette is quantised to
256 colours and near hues can collide (a terminal-capability
issue). Full parity awaits a Windows execution runner — see TT5.)*
- [x] **NFR-7 Light and dark background support.** The colour
scheme remains legible and visually coherent on both light and
dark terminal backgrounds. The mechanism (auto-detect via
terminal query, explicit user setting, or both) is an
implementation choice, but the outcome is non-negotiable: no
dark-on-dark or light-on-light readability failures on either
background.
*(Verified 2026-06-22, ADR-0057 — the WCAG-AA contrast gate
(NFR-5) runs over **both** themes, so the legibility outcome is
enforced on light and dark. Selection mechanism is `--theme` +
`COLORFGBG` auto-detect (OSC-11 querying deferred).)*
---
@@ -0,0 +1,146 @@
# ADR-website-001: Public website and documentation site
## Status
Accepted (2026-06-04). Implementation plan:
[`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md).
> History: drafted as ADR-0042, renamed to ADR-0044 (each collided with a
> number `main` had independently assigned — H1a took 0042, compound-PK FK
> took 0043, then relationship-visualization took 0044). Moved to the
> website ADR namespace (`docs/website/adr/`, id **ADR-website-001**) on
> 2026-06-10 to end the recurring cross-branch number collision: website
> decision records now draw from their own dated sequence and never the
> main global ADR pool (see ADR-0000 "Numbering discipline"). Content is
> unchanged from the original draft.
## Context
RDBMS Playground is nearing its first public release and needs a public
website that does two jobs: **attract** — a landing page that shows the
playground in action — and **document** — a thorough, canonical reference
for everything the playground can do.
The documentation-heavy surface is already implemented and verified (full
simple- and advanced-mode command set, the ten-type vocabulary,
relationships, constraints, indexes, EXPLAIN, undo/history/replay, projects,
export/import, the teaching echo, clipboard, friendly errors, tab completion
and syntax highlighting; 2151 tests passing at the time of writing). The
site is therefore largely a presentation-and-writing effort, not a
wait-for-features one. A grounded inventory of what is shippable now lives
in the implementation plan.
Several choices here had no canonical default; they were settled during a
planning + `/runda` pass with the user and are recorded below.
## Decision
1. **Stack — Astro 6 + Starlight + Tailwind v4.** Astro's content-first,
zero-JS-by-default model with the Starlight docs theme fits a
marketing-landing-plus-heavy-docs site better than the alternative
considered, SvelteKit + Tailwind (the usual go-to here). Interactive
pieces are added as Astro islands (Svelte or vanilla), so Svelte is still
available where it earns its place. Tailwind v4 is wired via the official
`@tailwindcss/vite` plugin; the `@astrojs/starlight-tailwind` plugin
bridges Tailwind with Starlight's theming.
2. **Demo medium — asciinema.** Showcase sequences are recorded as
asciinema `.cast` files (text-based, small, faithful to the full
alternate-screen render) and embedded with `asciinema-player`. The same
casts are reused inline in the docs — one recording format serves both
the landing page and documentation enrichment. Recordings are produced by
a **scripted-input driver** that types commands into a real PTY with
viewer-friendly pacing; the app's own `history.log` **replay** (ADR-0034)
re-executes commands without typing animation or pacing and is therefore
suitable only for state-deterministic docs snippets, not the hero demo.
3. **In-page WASM playground — deferred** (OOS: **deferred**, not rejected).
A live, type-it-yourself playground compiled from the Rust app to
WebAssembly is desirable but is a multi-week sub-project, so it does not
block the site. The demo section is designed with a stable seam (a single
`Demo` component contract) so a WASM playground island can replace the
asciinema player later with no change to call sites. Recorded boundary
for that future work:
- **Portable core (runs on `wasm32-unknown-unknown` largely as-is):**
`src/dsl/*` (parser, types, grammar, walker), the pure `App::update()`,
`ui.rs`, `theme.rs`, `friendly/*`, output rendering; an in-memory DB
path already exists (`Connection::open_in_memory()`). `rusqlite`
compiles to the browser target via its `ffi-sqlite-wasm-rs` feature.
- **Native edge needing `cfg`-gated browser replacements:** the
multi-thread Tokio runtime + the dedicated DB **worker thread**
(ADR-0010) → current-thread/in-line async; `crossterm` terminal +
event-stream → a browser backend (e.g. Ratzilla's DOM/Canvas) + DOM
events; `arboard`, `zip`, file persistence (ADR-0015), file logging;
and the rusqlite **backup-API** undo (ADR-0006) → a SQL dump/restore.
When taken up, this becomes its own ADR + iteration plan.
4. **Hosting — portable static build; Cloudflare is the target (decided
2026-06-11).** Astro 6 builds to static HTML/CSS with no adapter, so the
output deploys equally to Cloudflare, Vercel, Netlify, or GitHub Pages — we
stay uncoupled from any one host. **Planned pipeline: Gitea Actions →
Cloudflare.** Cloudflare now steers new projects to **Workers (static
assets)** over Pages; either serves the static `dist/` and needs no Astro
adapter (the `@astrojs/cloudflare` adapter is only for SSR, which the site
does not use). The future in-page WASM playground (§3), if it needs
COOP/COEP headers, can get them from Cloudflare `_headers`. **CI implemented
2026-06-15** (`.gitea/workflows/website.yaml`): a push touching `website/**`
builds the static site with pnpm and deploys `dist/` to the Cloudflare Pages
project **`relplay`** via `wrangler` (Direct Upload — no Git integration).
The `--branch` label selects environment against the project's production
branch (`main`): **`main` → production (`relplay.org`)**, **`website`
preview (`website.relplay.pages.dev`)**, with `staging.relplay.org` attachable
to the `website` branch alias. The crate's CI gate (`ci.yaml`) skips
website-only pushes; the build is pure-Node (the `.cast` files are committed,
so no cargo). Secrets: `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.
5. **Repo topology — monorepo.** The site lives under `website/` in the
playground repo; the crate stays at the repo root. The repo as a whole
moves to its public home later; site and crate travel together.
6. **Canonical docs home — the website.** User-facing documentation lives on
the site. In-repo `docs/` keeps ADRs, handoffs, and development notes;
`docs/simple-mode-limitations.md` (requirement DOC1) was a development aid
and now *feeds* the site's content rather than competing with it. The
sharing recipes promised by requirement E2 become a docs page.
7. **Documentation scope and conventions.** Document the **full supported
feature set**. Any capability not yet fully implemented (a small minority
— e.g. multi-line input, query cancellation, `seed`, `m:n` convenience,
ER-diagram export, the `show tables`/`relationships`/`indexes` family) is
either omitted or carries a clear **"planned / not yet available"**
callout — never presented as shipped. Two wording rules bind all
user-facing copy:
- **No engine name** (SQLite/STRICT/rusqlite/PRAGMA) — continues the
user-facing posture of ADR-0002; copy says "the database"/"the engine".
- **No "DSL"** — it is internal jargon. The two input modes are **simple
mode** (the playground's keyword command language) and **advanced
mode** (SQL).
8. **Install documentation — two mechanisms.** The install page documents
**prebuilt release binaries** (self-hosted download — not GitHub
Releases, since the repo will move) and **package managers**. Both can be
written now against the planned mechanisms; concrete download URLs slot in
at release. (Distribution items D1D3 in `requirements.md` remain the
tracking home for the release tooling itself.)
## Consequences
- The site can ship on the strength of already-implemented features; it is
gated on writing and recording, not on finishing the app.
- One recording format (asciinema `.cast`) serves both marketing and docs,
and is reusable as the app evolves (re-run the script, re-record).
- The WASM playground is preserved as a real future option without holding
up launch; the demo seam keeps the upgrade cheap.
- A single canonical docs home removes the divergence risk of maintaining
user docs in two places.
- Website build choices (Decisions 1, 2, 4, 5) are recorded here for
traceability but do not, by themselves, warrant further ADRs; only
app-architecture decisions (notably the future WASM port) will.
## Out of scope
- **In-page WASM playground***deferred* (see Decision 3); revisit as its
own ADR + iteration plan.
- **Hosted/SaaS playground or a server-backed doc CMS***rejected*: a
static site fully satisfies the need, consistent with ADR-0007's
no-hosted-publishing stance. Revisit only if real demand emerges.
+19
View File
@@ -0,0 +1,19 @@
# Website Architecture Decision Records
Decision records for the **public website + documentation site** subproject
(the Astro/Starlight site under `website/`). These are kept in their own
namespace, separate from the project-wide ADRs in
[`docs/adr/`](../../adr/README.md), so website decisions never compete with
the main global ADR sequence for numbers — see
[ADR-0000 "Numbering discipline"](../../adr/0000-record-architecture-decisions.md).
**Numbering.** Files are named `<date>-adr-website-<NNN>.md` and referenced
in prose as `ADR-website-NNN`. The `<date>` (the ADR's accepted/created day,
`YYYYMMDD`) plus the `website` segment keeps the namespace disjoint from
`main`'s integers. Assign the next free `NNN` from this index. Every ADR
change updates this index in the same edit (the ADR-0000 index-upkeep rule
applies here too).
## Index
- [ADR-website-001 — Public website and documentation site](20260604-adr-website-001.md) — **Accepted 2026-06-04** (formerly ADR-0044 in the main index; moved here 2026-06-10 to end recurring cross-branch number collisions). The first public website: a marketing landing page plus the **canonical** user docs. Stack **Astro 6 + Starlight + Tailwind v4** (chosen over SvelteKit + Tailwind for a docs-heavy + marketing site; interactive bits as Astro islands). Showcase demos are **asciinema** `.cast` recordings (scripted-input driver for paced, re-recordable sessions — *not* `history.log` replay), reused inline in docs. The **in-page WASM playground is deferred** (OOS: deferred) behind a stable `Demo` seam, with the portable-core vs native-edge boundary recorded for a future ADR + iteration plan. Portable **static build** (**Cloudflare** target via **Gitea Actions**, host-agnostic); **monorepo** (`website/`). **§4 update (CI implemented):** the static→Cloudflare Pages deploy now runs via Gitea Actions (`.gitea/workflows/website.yaml`; the crate gate is skipped for website-only changes); both `website` and `main` are merged and the site is **deployed**. Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1D3 track the release tooling). Plan: [`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md)
@@ -0,0 +1,198 @@
# Plan: public website and documentation site
**Date:** 2026-06-04 · **Status:** ready to build
Decisions for this work are recorded in
[ADR-website-001](../adr/20260604-adr-website-001.md): Astro 6 +
Starlight + Tailwind v4; asciinema demos reusable in docs; the in-page WASM
playground deferred behind a stable demo seam; portable static hosting
(Vercel target); monorepo (`website/`); website is the canonical docs home;
full-feature-set docs with "planned" callouts; install docs cover prebuilt
binaries + package managers. This plan is the *how*.
## Repository layout
The site lives under `website/` in this repo; the crate stays at the root.
```
website/
├── package.json # pnpm; astro, @astrojs/starlight, tailwind v4
├── astro.config.mjs # Starlight integration + sidebar nav
├── src/
│ ├── pages/index.astro # marketing landing (custom, not Starlight)
│ ├── components/
│ │ ├── Demo.astro # demo SLOT — the WASM-playground seam
│ │ └── Cast.astro # asciinema-player island wrapper
│ ├── content/docs/ # Starlight MDX docs (the bulk of the work)
│ └── styles/ # shared Tailwind + Starlight theme tokens
├── public/casts/ # recorded *.cast asciinema files
├── README.md # local dev + recording recipe
└── STYLE.md # living documentation style guide
```
Root `.gitignore` gains `website/node_modules`, `website/dist`,
`website/.astro`.
## Documentation inventory (grounded — drives Phase D scope)
Built from `docs/handoff/5559`, `docs/adr/*`, the command REGISTRY
(`src/dsl/grammar/mod.rs:603`, which also auto-assembles in-app `help`), the
`Command` enum (`src/dsl/command.rs:149`), and
`src/friendly/strings/en-US.yaml`**not** the coarse `requirements.md`
checkboxes (handoff-59 found those ~46% mis-marked; they now use a `[/]`
"partial" legend — trust the code, not the marker). Refreshed **2026-06-09
after merging `main`**, which added the show-list/detail, `help <command>`,
and compound-PK FK surface (see the dedicated bullet below). Test state:
**2193 passing, 0 failing, 1 ignored.**
**SHIPPED — document as-is (the doc core):**
- Input modes: simple, advanced (SQL), `:` one-shot escape, `mode` command,
per-project mode restore (ADR-0003/0015/0037).
- Full simple-mode command surface: create/drop table; add/drop/rename/
change column; add/drop 1:n relationship (named, ON DELETE/UPDATE
CASCADE/SET NULL/RESTRICT, `--create-fk`); add/drop index; insert/update/
delete (required WHERE + `--all-rows`; complex WHERE: AND/OR/NOT, LIKE,
IS NULL, IN, BETWEEN); show table/data (where/limit); add/drop constraint;
explain (ADR-0009/0013/0014/0025/0026/0028/0029).
- Full advanced-mode SQL: CREATE/DROP/ALTER TABLE (cols, constraints, inline
+ table FKs, rename, alter-column-type), CREATE [UNIQUE]/DROP INDEX; SELECT
(joins, GROUP BY/HAVING, ORDER BY, LIMIT/OFFSET, UNION/INTERSECT/EXCEPT,
WITH [RECURSIVE] CTEs); INSERT (multi-row, ON CONFLICT, RETURNING)/UPDATE/
DELETE; full expression grammar incl. CASE, CAST, curated functions;
EXPLAIN over SQL (ADR-00300039).
- Types: all ten, advanced-mode SQL aliases, serial/shortid auto-fill
(ADR-0005/0011/0017/0018). Constraints: PK incl. compound, NOT NULL,
UNIQUE, CHECK, DEFAULT, FK (ADR-0029/0013/0035).
- Undo/redo, history.log journal, replay, `--resume`, `--no-undo`
(ADR-0006/0034). Projects & storage: project.yaml + CSV + history.log,
save/save as/load/new/rebuild, temp projects, `--data-dir`
(ADR-0004/0015). Export/import (zip), clipboard copy/copy all/copy last
(ADR-0007/0041).
- Friendly errors (all five categories) + validity indicator
(ADR-0019/0027), DSL→SQL teaching echo (ADR-0038), EXPLAIN plan tree
(ADR-0028), box-drawing tables (ADR-0016), tab completion + syntax
highlighting + in-line editing (ADR-0022).
- **Added by the `main` merge (2026-06-09):** schema-inspection commands
`show tables` / `show relationships` / `show indexes` and the singular
`show relationship <name>` / `show index <name>` detail views (V5/V5a);
`help [<command>]` per-command detail + `help types` + general reference
(H3); **compound-primary-key foreign-key references** — DSL
`from <P>.(a, b) to <C>.(x, y)` and SQL `FOREIGN KEY (a, b) REFERENCES
P(x, y)` (single-column form unchanged) (ADR-0043, T3); friendlier
parse-error near-miss messaging (H1a, ADR-0042). These need coverage: a
schema-inspection page (the `show` family) and compound-FK examples on the
Relationships page.
**DOCUMENT WITH CAVEAT:** `add unique index` is advanced-only; simple-mode
table rename is intentionally absent (rename is `ALTER TABLE … RENAME TO`);
`hint` (H2) is still partial; a compound-FK *violation* message names only
the first column pair (enforcement is correct — a messaging-only residual).
**OMIT or MARK "planned":** multi-line input (I1), readline shortcuts (I1b),
in-flight cancellation / query timeout (I5/B3), `seed` (SD1), `m:n`
convenience (C4), one-step modify relationship (C3a), relationship line-art
(V1), ER-diagram export (V3), session-log + Markdown export (V4).
**Mine verbatim for docs:** `en-US.yaml` `help.app.*`, `help.ddl.*`,
`help.data.*`, `help.types_reference`, `parse.usage.*` (one-line syntax
templates), `hint.*` — keeps docs and in-app help consistent.
## Phases
### A — Scaffold
`pnpm create astro@latest` (Starlight template) in `website/`; `astro add
tailwind` (Tailwind v4 via `@tailwindcss/vite`); add
`@astrojs/starlight-tailwind`. Confirm `pnpm dev` serves and `pnpm build`
emits a static `dist/`. Echo build steps for traceability.
### B — Landing page
Custom `src/pages/index.astro` (Starlight owns `/docs/*`). Hero + value prop
("learn relational databases by doing"), feature highlights from the
inventory, an embedded demo cast above the fold. Use the `frontend-design`
skill to avoid generic AI aesthetics; honour NFR-4/5/7 (distinctive design,
meaningful colour, light/dark).
### C — asciinema recording workflow
Record real `rdbms-playground` sessions to `public/casts/*.cast` using a
**scripted-input driver** (e.g. `asciinema-automation`/autocast, or an
expect/doitlive script) for paced, re-recordable demos. Record at a fixed
sensible cols×rows; provide light + dark player themes. `Cast.astro` wraps
`asciinema-player` as a `client:visible` island; the same component embeds
casts inline in docs. Document the recipe in `website/README.md`.
(`asciinema` 2.4.0 is installed.)
### D — Documentation (the bulk)
**Five** top-level sidebar sections (autogenerated per directory). The key
split: *Using the playground* = the application you drive; *Reference* = the
database language you build with.
- **Getting started** — install (prebuilt binaries + package managers),
first project, simple vs. advanced mode, the example library.
- **Using the playground** — command-line options; the assistive editor
(completion, syntax highlighting, the `[ERR]`/`[WRN]` validity indicator,
hints, in-line editing); the output pane (PageUp/PageDown scrolling — the
fuller V4 session-log / Markdown export is *planned*, mark it); projects
(save / load / new / rebuild); undo, redo & history (+ replay); export &
import (E2 recipes); copy to clipboard; getting help (`help` /
`help <command>` / `hint`). (ADR-0003 "app-level commands" + ADR-0022/0027
typing assistance + the CLI.)
- **Guides** — task walkthroughs.
- **Reference** — the database language: Tables, Columns, Relationships,
Indexes, Constraints, Inserting & editing data, Querying & inspecting
(`show` / `select`), Types, Query plans (EXPLAIN), Errors explained, the
simple-command → SQL teaching echo.
- **Concepts** — the *why*: projects & storage model, the derived database,
how undo works.
**Surface the assistive editor prominently** — it is a differentiator and
most helps beginners: a landing-page card + a Getting-started mention, both
linking into *Using the playground*. It is prime asciinema-cast material
(completion / validity indicator are motion a still code block can't show).
Build order: Tier 1 simple-mode reference + types + constraints + input
modes + mined help/usage strings → Tier 2 advanced SQL + relationships +
project lifecycle + undo/history → Tier 3 teaching echo + EXPLAIN + errors +
completion/highlighting → Tier 4 clipboard + hints + editing.
Conventions live in the **living style guide** `website/STYLE.md` (binding
rules from ADR-website-001 §7 — no engine name, **no "DSL"**, "planned" callouts —
plus finer conventions and an open-decisions log for depth/splitting/example
dataset/etc. as they settle). Sources to mine: `src/dsl/command.rs`,
`src/dsl/grammar/*`, the REGISTRY, `en-US.yaml`, `docs/adr/*`,
`docs/simple-mode-limitations.md`.
### E — Hosting & portability
Keep the default static build (no adapter); `dist/` deploys to Vercel or any
static host. `website/README.md` notes the Vercel preset (root dir
`website/`) and the one-line `@astrojs/vercel` switch if SSR is ever needed.
## Demo seam (WASM hook)
`Demo.astro` exposes a stable contract (`{ src, title, height, autoplay }`).
At launch it renders `Cast.astro`; later a `Playground.astro` WASM island
swaps in behind the same props on the landing page and in docs, with zero
call-site changes. Boundary details are in ADR-website-001 §3.
## Verification
- `pnpm dev` renders landing + docs; `pnpm build` emits a clean static
`dist/` with no errors/warnings.
- Landing shows at least one playing `.cast`; the same component renders a
cast inline in a docs page (proves reuse).
- Starlight link-check passes (broken internal links fail the build).
- Docs grep clean of forbidden terms: **no "DSL"**, no engine name.
- A `dist/` static deploy works on Vercel (manual import) — confirms
portability. (No CI gate yet, per ADR-website-001 §4.)
## Notes / recommendations (non-blocking)
- **Doc drift:** consider generating the command reference from source (the
`help` REGISTRY / `en-US.yaml`) rather than hand-writing all of it.
- **Accessibility/SEO:** pair each hero `.cast` with a text transcript or the
equivalent docs snippet.
- **Branding/domain & analytics** unspecified — assume none until decided;
no third-party trackers without consent.
- Tailwind v4 + Starlight have occasional theme-token friction; the
`@astrojs/starlight-tailwind` plugin is the supported bridge.
- Starlight ships local search (Pagefind) by default.
- No `README.md` exists at the repo root yet — wanted for the destination
repo; out of this plan's core scope but flagged.
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
#
# adr-reserve.sh — atomically reserve the next main-sequence ADR number.
#
# WHY THIS EXISTS
# A contiguous ADR integer needs a single allocator, but git branches have
# no shared allocator until they merge — which is why two parallel branches
# that each invent "the next number" collide. This script makes `main` the
# allocator and an atomic `git push` the lock:
#
# 1. read an append-only ledger (docs/adr/RESERVATIONS.log) + the existing
# NNNN-*.md ADRs on main; next = highest + 1;
# 2. append "<NNNN> <slug> <date> <title>" to the ledger, commit, PUSH;
# 3. if the push is rejected because main moved (someone reserved first),
# re-fetch, recompute, and retry.
#
# A push to a ref is a compare-and-swap, so the remote `main` ref is the
# mutex and step 3 is the retry-on-contention. The number is therefore
# stable from the moment this returns — safe to cite in commit messages
# (which are immutable) and in other ADRs from the very first commit.
# (Decision recorded in the dev-workflow ADR.)
#
# It runs from ANY branch / worktree / directory inside the repo and never
# touches your working tree: the allocation happens in a throwaway worktree
# on a detached origin/main.
#
# PREREQUISITE
# You must be allowed to push directly to `main` (the repo owner is
# whitelisted in the branch-protection rule for exactly this). Everything
# substantive still goes through a PR; this one append-only ledger line is
# the sole sanctioned direct push. If protection rejects the push, the
# script aborts with a clear message rather than spinning.
#
# USAGE
# scripts/adr-reserve.sh <slug> [title...]
# scripts/adr-reserve.sh clause-concept-hints "Clause-concept hints"
#
# OUTPUT
# The reserved zero-padded number on stdout (e.g. 0058); progress on stderr.
set -euo pipefail
log() { printf ' reserve-adr: %s\n' "$*" >&2; }
die() { printf 'reserve-adr: ERROR: %s\n' "$*" >&2; exit 1; }
REMOTE="origin"
BRANCH="main"
LEDGER="docs/adr/RESERVATIONS.log"
MAX_ATTEMPTS=20
[ $# -ge 1 ] || die "usage: adr-reserve.sh <slug> [title...]"
slug="$1"; shift
title="${*:-}"
# Slug must match the NNNN-<slug>.md filename convention.
[[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] \
|| die "slug must be lowercase-kebab-case (got: '$slug')"
root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repo"
log "allocating next ADR number for '$slug' against $REMOTE/$BRANCH"
git -C "$root" fetch --quiet "$REMOTE" "$BRANCH" || die "git fetch $REMOTE $BRANCH failed"
# Throwaway detached worktree on origin/main — isolated from your working tree.
tmp_base="$(mktemp -d)"
wt="$tmp_base/wt"
# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT`
cleanup() {
git -C "$root" worktree remove --force "$wt" >/dev/null 2>&1 || true
rm -rf "$tmp_base"
}
trap cleanup EXIT
git -C "$root" worktree add --quiet --detach "$wt" "$REMOTE/$BRANCH" \
|| die "could not create temp worktree at $wt"
# Highest number across existing top-level ADR files and the ledger, + 1.
compute_next() {
local max=0 n base
shopt -s nullglob
for f in "$wt"/docs/adr/[0-9][0-9][0-9][0-9]-*.md; do
base="$(basename "$f")"; n=$((10#${base%%-*}))
(( n > max )) && max=$n
done
if [ -f "$wt/$LEDGER" ]; then
while read -r num _; do
[[ "$num" =~ ^[0-9]{4}$ ]] || continue
n=$((10#$num)); (( n > max )) && max=$n
done < "$wt/$LEDGER"
fi
printf '%04d' $(( max + 1 ))
}
# Idempotence: if this slug is already reserved, report it and stop.
if [ -f "$wt/$LEDGER" ]; then
existing="$(awk -v s="$slug" '$2==s {print $1; exit}' "$wt/$LEDGER" || true)"
if [ -n "${existing:-}" ]; then
log "'$slug' is already reserved as ADR $existing — nothing to do"
echo "$existing"
exit 0
fi
fi
attempt=0
while (( attempt < MAX_ATTEMPTS )); do
attempt=$(( attempt + 1 ))
git -C "$wt" fetch --quiet "$REMOTE" "$BRANCH"
git -C "$wt" reset --hard --quiet "$REMOTE/$BRANCH"
num="$(compute_next)"
# Dry run: report the number we WOULD reserve and stop (no commit/push).
# Useful for a preview and for testing the read/allocation path.
if [ -n "${ADR_RESERVE_DRY_RUN:-}" ]; then
log "DRY RUN: would reserve ADR $num for '$slug' (no push)"
echo "$num"
exit 0
fi
mkdir -p "$wt/docs/adr"
printf '%s %s %s %s\n' "$num" "$slug" "$(date +%F)" "$title" >> "$wt/$LEDGER"
git -C "$wt" add "$LEDGER"
git -C "$wt" commit --quiet -m "docs(adr): reserve $num for $slug"
log "attempt $attempt: claiming ADR $num"
if push_out="$(git -C "$wt" push "$REMOTE" "HEAD:$BRANCH" 2>&1)"; then
log "reserved ADR $num for '$slug' (pushed to $REMOTE/$BRANCH)"
echo "$num"
exit 0
fi
# Distinguish a lost race (retryable) from a hard rejection (not).
if grep -qiE 'non-fast-forward|fetch first|tip of your .* is behind|stale info' <<<"$push_out"; then
log "race lost (main moved) — re-fetching and retrying"
continue
fi
printf '%s\n' "$push_out" >&2
die "push to $BRANCH was rejected and this is not a race — likely branch \
protection (is the owner whitelisted for direct pushes to $BRANCH?). Aborting."
done
die "gave up after $MAX_ATTEMPTS contended attempts — main is changing very fast?"
+122
View File
@@ -0,0 +1,122 @@
<#
.SYNOPSIS
Download and install a prebuilt rdbms-playground binary (Windows).
.DESCRIPTION
The Windows counterpart of scripts/install.sh. Detects the CPU
architecture, downloads the matching release .exe from the Gitea
releases, verifies its SHA-256 checksum, installs it to
%LOCALAPPDATA%\Programs\rdbms-playground, and adds that directory to
your user PATH.
Quick start:
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
We ship gnu / gnullvm Windows builds (x86_64 / aarch64); this maps the
host architecture to the right asset.
.PARAMETER Version
Install a specific tag (e.g. v0.2.0) instead of the latest release.
Defaults to $env:RDBMS_VERSION, else the latest release.
.PARAMETER InstallDir
Install directory. Defaults to $env:RDBMS_INSTALL_DIR, else
%LOCALAPPDATA%\Programs\rdbms-playground.
.NOTES
Verified end-to-end on ARM64 Windows 11 under both Windows PowerShell 5.1
and PowerShell 7.6, against the live v0.2.0 release. The x86_64 branch is
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()]
param(
[string]$Version = $env:RDBMS_VERSION,
[string]$InstallDir = $(if ($env:RDBMS_INSTALL_DIR) { $env:RDBMS_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\rdbms-playground" })
)
Set-StrictMode -Version Latest
$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'
$Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground'
$Bin = 'rdbms-playground'
# Map the host CPU to the target triple we publish for Windows.
# 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) {
'AMD64' { $target = 'x86_64-pc-windows-gnu' }
'ARM64' { $target = 'aarch64-pc-windows-gnullvm' }
default { throw "install: unsupported CPU architecture: $osArch" }
}
# Resolve the release tag (explicit -Version, else the latest release).
if (-not $Version) {
$Version = (Invoke-RestMethod -Uri "$Api/releases/latest").tag_name
if (-not $Version) { throw 'install: could not determine the latest release tag' }
}
$asset = "$Bin-$Version-$target.exe"
$url = "$Repo/releases/download/$Version/$asset"
$tmp = Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
try {
$exe = Join-Path $tmp "$Bin.exe"
$shaFile = "$exe.sha256"
Write-Host "downloading $asset ..."
# -UseBasicParsing: Windows PowerShell 5.1's Invoke-WebRequest otherwise
# 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.
$expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower()
$actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLower()
if ($expected -ne $actual) {
throw "install: checksum mismatch (expected $expected, got $actual) — refusing to install"
}
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
$dest = Join-Path $InstallDir "$Bin.exe"
Move-Item -Path $exe -Destination $dest -Force
Write-Host "installed $Bin $Version -> $dest"
# Persist the install dir on the user PATH (for future shells) if missing.
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath) { $userPath = '' }
if (($userPath -split ';') -notcontains $InstallDir) {
$newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir }
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
}
# 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 {
Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
+166
View File
@@ -0,0 +1,166 @@
#!/bin/sh
# install.sh — download and install a prebuilt rdbms-playground binary.
#
# Quick start (Linux / macOS):
# curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
#
# What it does: detects your OS + CPU, downloads the matching release binary
# from the Gitea releases (Linux uses the fully-static musl build), verifies
# its SHA-256 checksum, and installs it to ~/.local/bin.
#
# Environment overrides:
# RDBMS_VERSION install a specific tag (e.g. v0.2.0) instead of the latest
# RDBMS_INSTALL_DIR install directory (default: $HOME/.local/bin)
# RDBMS_OS force the OS (testing): Linux | Darwin
# RDBMS_ARCH force the CPU (testing): x86_64 | aarch64
#
# Flags:
# --print-target print the resolved target triple and exit (no download)
# -h, --help print this help and exit
#
# Notes:
# * Windows is not installable via this script (the binary is a .exe) —
# use Scoop/winget (planned) or download the .exe from the releases page.
# * macOS: a curl download is not quarantined by Gatekeeper, so the binary
# runs without extra steps. (Developer-ID signing + notarization is a
# separate, planned improvement for browser downloads.)
#
# POSIX sh — no bashisms, so it runs under the `sh` of `curl | sh`.
set -eu
REPO_BASE="https://git.lazyeval.net/oli/rdbms-playground"
API_BASE="https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground"
BIN_NAME="rdbms-playground"
PRINT_TARGET=0
err() {
printf 'install: %s\n' "$1" >&2
exit 1
}
info() { printf '%s\n' "$1" >&2; }
usage() {
# Lines 2..(first blank) of this file are the human-readable header.
sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
}
# Resolve the Rust target triple for the current (or forced) platform.
# Linux -> <arch>-unknown-linux-musl (the fully-static build)
# macOS -> <arch>-apple-darwin
detect_target() {
os="${RDBMS_OS:-$(uname -s)}"
arch="${RDBMS_ARCH:-$(uname -m)}"
case "$os" in
Linux | linux) os_part="unknown-linux-musl" ;;
Darwin | darwin | macos | macOS) os_part="apple-darwin" ;;
MINGW* | MSYS* | CYGWIN* | *Windows* | *windows*)
err "Windows is not supported by this installer — use Scoop/winget (planned) or download the .exe from $REPO_BASE/releases" ;;
*) err "unsupported operating system: $os" ;;
esac
case "$arch" in
x86_64 | amd64) arch_part="x86_64" ;;
aarch64 | arm64) arch_part="aarch64" ;;
*) err "unsupported CPU architecture: $arch" ;;
esac
printf '%s-%s' "$arch_part" "$os_part"
}
# Download $1 to file $2 (curl or wget).
download() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1" -o "$2"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$2" "$1"
else
err "need either curl or wget on PATH"
fi
}
# Fetch $1 to stdout (curl or wget).
fetch() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1"
elif command -v wget >/dev/null 2>&1; then
wget -qO- "$1"
else
err "need either curl or wget on PATH"
fi
}
# The release tag to install: $RDBMS_VERSION if set, else the latest release.
resolve_version() {
if [ -n "${RDBMS_VERSION:-}" ]; then
printf '%s' "$RDBMS_VERSION"
return
fi
json=$(fetch "$API_BASE/releases/latest") ||
err "could not query the latest release from $API_BASE"
# Portable JSON scrape (no jq): the latest-release object carries exactly
# one "tag_name": "<tag>" field.
tag=$(printf '%s' "$json" |
grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' |
head -1 | sed 's/.*"\([^"]*\)"$/\1/')
[ -n "$tag" ] || err "could not parse the latest release tag"
printf '%s' "$tag"
}
# Verify file $1 against sha256 sidecar $2 (format: "<hash> <name>").
verify_checksum() {
expected=$(awk '{print $1; exit}' "$2")
if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "$1" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "$1" | awk '{print $1}')
else
info "warning: no sha256 tool found — skipping checksum verification"
return 0
fi
[ "$expected" = "$actual" ] ||
err "checksum mismatch (expected $expected, got $actual) — refusing to install"
}
main() {
while [ $# -gt 0 ]; do
case "$1" in
--print-target) PRINT_TARGET=1 ;;
-h | --help)
usage
exit 0
;;
*) err "unknown argument: $1 (try --help)" ;;
esac
shift
done
target=$(detect_target)
if [ "$PRINT_TARGET" = "1" ]; then
printf '%s\n' "$target"
exit 0
fi
version=$(resolve_version)
asset="$BIN_NAME-$version-$target"
url="$REPO_BASE/releases/download/$version/$asset"
dir="${RDBMS_INSTALL_DIR:-$HOME/.local/bin}"
tmp=$(mktemp -d 2>/dev/null) || err "could not create a temporary directory"
trap 'rm -rf "$tmp"' EXIT INT TERM
info "downloading $asset ..."
download "$url" "$tmp/$BIN_NAME" || err "download failed: $url"
download "$url.sha256" "$tmp/$BIN_NAME.sha256" || err "checksum download failed: $url.sha256"
verify_checksum "$tmp/$BIN_NAME" "$tmp/$BIN_NAME.sha256"
mkdir -p "$dir" || err "could not create install directory: $dir"
chmod +x "$tmp/$BIN_NAME"
mv "$tmp/$BIN_NAME" "$dir/$BIN_NAME" || err "could not install to $dir"
info "installed $BIN_NAME $version -> $dir/$BIN_NAME"
case ":${PATH:-}:" in
*":$dir:"*) ;;
*) info "note: $dir is not on your PATH. Add it, e.g.: export PATH=\"$dir:\$PATH\"" ;;
esac
}
main "$@"
+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
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Verifies adr-reserve.sh against a local file:// origin (no network/keys).
# Exercises: number computation, dry-run, real reserve+push, sequential
# allocation, idempotence, hard-rejection abort (vs infinite loop), and the
# race-vs-abort discriminator.
set -euo pipefail
SCRIPT="$(cd "$(dirname "$0")" && pwd)/adr-reserve.sh"
PASS=0 FAIL=0
check() { # <desc> <expected> <actual>
if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
else echo "FAIL: $1 — expected '$2' got '$3'"; FAIL=$((FAIL+1)); fi
}
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
# ---- seed a bare origin with ADRs up to 0057 -------------------------------
git init -q --bare "$T/origin.git"
git clone -q "$T/origin.git" "$T/seed"
git -C "$T/seed" config user.email t@t.io
git -C "$T/seed" config user.name tester
mkdir -p "$T/seed/docs/adr"
echo "# legacy" > "$T/seed/docs/adr/0056-alpha.md"
echo "# legacy" > "$T/seed/docs/adr/0057-beta.md"
git -C "$T/seed" add -A
git -C "$T/seed" commit -qm "seed: ADRs through 0057"
git -C "$T/seed" branch -M main
git -C "$T/seed" push -q origin main
# the repo the script runs inside (origin = our bare repo)
git clone -q "$T/origin.git" "$T/work"
git -C "$T/work" config user.email t@t.io
git -C "$T/work" config user.name tester
run() { ( cd "$T/work" && "$@" ); }
echo "--- Test 1: dry-run computes 0058 (no push) ---"
out="$(run env ADR_RESERVE_DRY_RUN=1 bash "$SCRIPT" feature-one "Feature One" 2>/dev/null)"
check "dry-run number" "0058" "$out"
git -C "$T/seed" pull -q origin main
check "dry-run pushed nothing" "no" "$( [ -f "$T/seed/docs/adr/RESERVATIONS.log" ] && echo yes || echo no )"
echo "--- Test 2: real reserve → 0058, pushed to origin ---"
out="$(run bash "$SCRIPT" feature-one "Feature One" 2>/dev/null)"
check "first reserve number" "0058" "$out"
git -C "$T/seed" pull -q origin main
check "ledger landed on origin" "0058 feature-one" "$(awk 'NR==1{print $1" "$2}' "$T/seed/docs/adr/RESERVATIONS.log")"
echo "--- Test 3: second slug → 0059 (sequential) ---"
out="$(run bash "$SCRIPT" feature-two 2>/dev/null)"
check "second reserve number" "0059" "$out"
echo "--- Test 4: idempotence — re-reserving feature-one returns 0058 ---"
out="$(run bash "$SCRIPT" feature-one 2>/dev/null)"
check "idempotent re-reserve" "0058" "$out"
git -C "$T/seed" pull -q origin main
check "no duplicate ledger line" "1" "$(grep -c ' feature-one ' "$T/seed/docs/adr/RESERVATIONS.log")"
echo "--- Test 5: hard rejection (branch protection) aborts, does NOT loop ---"
cat > "$T/origin.git/hooks/pre-receive" <<'HOOK'
#!/bin/sh
echo "protected branch: push declined" >&2
exit 1
HOOK
chmod +x "$T/origin.git/hooks/pre-receive"
set +e
run bash "$SCRIPT" feature-three >/dev/null 2>"$T/err"; rc=$?
set -e
check "abort exit code nonzero" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
check "abort cites protection, not a loop" "yes" "$(grep -qi 'not a race\|protection' "$T/err" && echo yes || echo no)"
rm -f "$T/origin.git/hooks/pre-receive"
echo "--- Test 6: race discriminator matches a real non-ff message ---"
ff_msg=" ! [rejected] main -> main (non-fast-forward)
error: failed to push some refs"
check "non-ff treated as race" "yes" \
"$(grep -qiE 'non-fast-forward|fetch first|tip of your .* is behind|stale info' <<<"$ff_msg" && echo yes || echo no)"
echo
echo "==== $PASS passed, $FAIL failed ===="
[ "$FAIL" -eq 0 ]
+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"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Smoke-tests wt-new.sh / wt-rm.sh against a local file:// origin.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
WT_NEW="$here/wt-new.sh"
WT_RM="$here/wt-rm.sh"
PASS=0 FAIL=0
check() { if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
else echo "FAIL: $1 — expected '$2' got '$3'"; FAIL=$((FAIL+1)); fi; }
exists() { [ -d "$1" ] && echo yes || echo no; }
has_branch() { git -C "$T/primary" show-ref --verify --quiet "refs/heads/$1" && echo yes || echo no; }
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
git init -q --bare "$T/origin.git"
git clone -q "$T/origin.git" "$T/primary"
git -C "$T/primary" config user.email t@t.io
git -C "$T/primary" config user.name tester
echo seed > "$T/primary/README.md"
git -C "$T/primary" add -A
git -C "$T/primary" commit -qm seed
git -C "$T/primary" branch -M main
git -C "$T/primary" push -q origin main
run() { ( cd "$T/primary" && "$@" ); }
target1="$T/primary-worktree-thing-one"
target2="$T/primary-worktree-thing-two"
echo "--- wt-new rejects a non-conventional branch name ---"
set +e; run bash "$WT_NEW" my-thing >/dev/null 2>&1; rc=$?; set -e
check "bad-prefix rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
echo "--- wt-new creates branch + sibling worktree ---"
out="$(run bash "$WT_NEW" feat/thing-one 2>/dev/null)"
check "prints target path" "$target1" "$out"
check "worktree dir exists" "yes" "$(exists "$target1")"
check "worktree on the new branch" "feat/thing-one" "$(git -C "$target1" rev-parse --abbrev-ref HEAD)"
# It must NOT inherit origin/main as upstream (else a bare `git push` errors
# with a name-mismatch and suggests the dangerous `HEAD:main`). No upstream
# until the first `git push -u` sets the right one.
check "new branch has no upstream" "none" "$(git -C "$target1" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none)"
echo "--- wt-new refuses to clobber an existing branch ---"
set +e; run bash "$WT_NEW" feat/thing-one >/dev/null 2>&1; rc=$?; set -e
check "duplicate rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
# Second worktree, and make the merge states explicit:
# thing-one → merged into origin/main (push its tip to main),
# thing-two → ahead of main (unmerged), clean.
run bash "$WT_NEW" feat/thing-two >/dev/null 2>&1
git -C "$target1" commit -q --allow-empty -m "thing-one work"
git -C "$target1" push -q origin HEAD:main
git -C "$target2" commit -q --allow-empty -m "thing-two work (unmerged)"
git -C "$T/primary" fetch -q origin
echo "--- wt-rm removes ONLY the named worktree (the other is untouched) ---"
run bash "$WT_RM" feat/thing-one >/dev/null 2>&1
check "named worktree removed" "no" "$(exists "$target1")"
check "UNNAMED worktree untouched" "yes" "$(exists "$target2")"
check "merged branch deleted" "no" "$(has_branch feat/thing-one)"
echo "--- wt-rm errors on an unknown branch ---"
set +e; run bash "$WT_RM" feat/nope >/dev/null 2>&1; rc=$?; set -e
check "unknown branch rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
echo "--- wt-rm refuses the primary checkout ---"
set +e; run bash "$WT_RM" main >/dev/null 2>&1; rc=$?; set -e
check "primary refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
check "primary still present" "yes" "$(exists "$T/primary")"
echo "--- wt-rm refuses a dirty worktree ---"
touch "$target2/untracked-file"
set +e; run bash "$WT_RM" feat/thing-two >/dev/null 2>&1; rc=$?; set -e
check "dirty refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
check "dirty worktree kept" "yes" "$(exists "$target2")"
rm -f "$target2/untracked-file"
echo "--- wt-rm removes the worktree but KEEPS an unmerged branch ---"
run bash "$WT_RM" feat/thing-two >/dev/null 2>&1
check "unmerged worktree removed" "no" "$(exists "$target2")"
check "unmerged branch preserved" "yes" "$(has_branch feat/thing-two)"
echo
echo "==== $PASS passed, $FAIL failed ===="
[ "$FAIL" -eq 0 ]
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
#
# wt-new.sh — start a branch in its own git worktree.
#
# WHY: every branch is developed in an isolated worktree, never by switching
# the primary checkout (two shells in one checkout is how you clobber work).
# This creates the branch off an up-to-date `main` and a sibling worktree
# named to match the repo's existing convention:
#
# <parent>/<repo>-worktree-<last-segment-of-branch>
# e.g. feat/clause-concept-hints -> ../rdbms-playground-worktree-clause-concept-hints
#
# USAGE
# scripts/wt-new.sh <branch>
# scripts/wt-new.sh feat/clause-concept-hints
#
# Prints the new worktree path; cd there to start working.
set -euo pipefail
log() { printf ' wt-new: %s\n' "$*" >&2; }
die() { printf 'wt-new: ERROR: %s\n' "$*" >&2; exit 1; }
[ $# -eq 1 ] || die "usage: wt-new.sh <branch> (e.g. feat/my-thing)"
branch="$1"
# Conventional prefixes keep branch names aligned with commit types.
case "$branch" in
feat/*|fix/*|docs/*|chore/*|refactor/*|test/*|ci/*) : ;;
*) die "branch should start with feat/ fix/ docs/ chore/ refactor/ test/ ci/ (got: $branch)" ;;
esac
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
# Sibling-of-the-primary-checkout location, matching existing worktrees.
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
parent="$(dirname "$main_wt")"
repo="$(basename "$main_wt")"
seg="${branch##*/}"
target="$parent/${repo}-worktree-${seg}"
[ -e "$target" ] && die "worktree path already exists: $target"
git show-ref --verify --quiet "refs/heads/$branch" \
&& die "branch already exists: $branch (use 'git worktree add' to attach a worktree to it)"
log "fetching origin/main"
git fetch --quiet origin main || die "git fetch origin main failed"
log "creating branch '$branch' + worktree at $target"
# `--no-track`: branch off origin/main for a fresh base, but DON'T inherit
# origin/main as the upstream. Without this, branching off a remote-tracking
# ref makes git set the new branch's upstream to origin/main, so a later bare
# `git push` fails with a name-mismatch and suggests the dangerous
# `git push origin HEAD:main`. With no upstream, the first `git push -u origin
# <branch>` sets the correct one.
git worktree add --quiet --no-track -b "$branch" "$target" origin/main \
|| die "git worktree add failed"
log "ready — cd into it:"
echo "$target"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# wt-rm.sh — remove the worktree for a named branch.
#
# EXPLICIT BY DESIGN: it removes ONLY the branch you name. There is no
# scanning and no "is it merged into main?" heuristic to decide *what* to
# remove — long-lived branches (e.g. `website`, the `ci` line) are merged
# into main too, so auto-detection would happily delete them. Naming the
# target is the safety.
#
# Safety still comes from git's own refusals, not from cleverness here:
# - the worktree won't be removed if it has uncommitted/untracked changes
# (no `--force`);
# - the local branch is deleted ONLY if it is fully merged into
# `origin/main` — otherwise the worktree goes but the branch (and its
# unmerged commits) is kept.
#
# USAGE
# scripts/wt-rm.sh <branch>
# scripts/wt-rm.sh feat/clause-concept-hints
#
# For a worktree whose directory you already deleted by hand, use the
# built-in `git worktree prune` (it only clears bookkeeping — never a
# directory or a branch).
set -euo pipefail
log() { printf ' wt-rm: %s\n' "$*" >&2; }
die() { printf 'wt-rm: ERROR: %s\n' "$*" >&2; exit 1; }
[ $# -eq 1 ] || die "usage: wt-rm.sh <branch>"
branch="$1"
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
# Resolve the named branch to its worktree path.
path=""; cur=""
while read -r key val; do
case "$key" in
worktree) cur="$val" ;;
branch) [ "${val#refs/heads/}" = "$branch" ] && path="$cur" ;;
esac
done < <(git worktree list --porcelain)
[ -n "$path" ] || die "no worktree is checked out on branch '$branch' (see: git worktree list)"
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
here="$(git rev-parse --show-toplevel)"
[ "$path" != "$main_wt" ] || die "refusing to remove the primary checkout ($path)"
[ "$path" != "$here" ] || die "refusing to remove the worktree you're standing in — run this from elsewhere"
log "removing worktree $path (branch $branch)"
git worktree remove "$path" \
|| die "could not remove $path — it likely has uncommitted or untracked changes; clean it (or remove by hand) first"
# Delete the local branch only if it is safely merged into origin/main;
# otherwise keep it so unmerged commits are never lost. (No auto-fetch — it
# judges against your current origin/main; run `git fetch` first if you want
# a freshly-merged branch to be eligible.)
if git rev-parse --verify --quiet origin/main >/dev/null \
&& git merge-base --is-ancestor "$branch" origin/main; then
git branch -D "$branch" >/dev/null
log "deleted local branch $branch (merged into origin/main)"
else
log "kept local branch $branch — not merged into origin/main; delete with 'git branch -D $branch' if you're sure"
fi
log "done"
+662 -213
View File
File diff suppressed because it is too large Load Diff
+37 -37
View File
@@ -30,9 +30,7 @@ use std::path::{Component, Path, PathBuf};
use tracing::{debug, info};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
use crate::project::{
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
};
use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local};
/// File names excluded from `export` zips. These are either
/// derived (`playground.db`), per-process (`.lock`),
@@ -118,20 +116,14 @@ impl std::fmt::Display for ArchiveError {
limit = format_args!("{limit:02}"),
))
}
Self::InvalidZip(detail) => f.write_str(&crate::t!(
"archive.invalid_zip",
detail = detail,
)),
Self::NotAProjectArchive => {
f.write_str(&crate::t!("archive.not_a_project_archive"))
Self::InvalidZip(detail) => {
f.write_str(&crate::t!("archive.invalid_zip", detail = detail,))
}
Self::MultipleTopFolders => {
f.write_str(&crate::t!("archive.multiple_top_folders"))
Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")),
Self::MultipleTopFolders => f.write_str(&crate::t!("archive.multiple_top_folders")),
Self::UnsafeEntry(entry) => {
f.write_str(&crate::t!("archive.unsafe_entry", entry = entry,))
}
Self::UnsafeEntry(entry) => f.write_str(&crate::t!(
"archive.unsafe_entry",
entry = entry,
)),
}
}
}
@@ -216,13 +208,7 @@ pub fn export_project(
.unix_permissions(0o644);
add_directory_entry(&mut writer, project_name, dst_zip)?;
add_directory_recursive(
&mut writer,
project_path,
project_name,
&options,
dst_zip,
)?;
add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?;
writer.finish().map_err(|e| ArchiveError::Zip {
path: dst_zip.to_path_buf(),
@@ -392,10 +378,7 @@ pub struct ZipInspection {
///
/// Returns the resolved target path and the suffix that was
/// applied (0 if the original name was free, 2..=99 otherwise).
pub fn resolve_import_target(
parent: &Path,
name: &str,
) -> Result<(PathBuf, u32), ArchiveError> {
pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> {
let direct = parent.join(name);
if !direct.exists() {
return Ok((direct, 0));
@@ -495,10 +478,12 @@ pub fn extract_into(
source,
})?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
entry
.read_to_end(&mut buf)
.map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
out.write_all(&buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
@@ -523,7 +508,11 @@ mod tests {
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap();
fs::write(
p.join(HISTORY_LOG),
"T|ok|create table Customers with pk id(serial)\n",
)
.unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
// Stray atomic-write staging file — must be excluded.
@@ -536,7 +525,9 @@ mod tests {
)
.unwrap();
fs::write(
p.join(crate::undo::SNAPSHOTS_DIR).join("0").join(PLAYGROUND_DB),
p.join(crate::undo::SNAPSHOTS_DIR)
.join("0")
.join(PLAYGROUND_DB),
[0u8; 16],
)
.unwrap();
@@ -618,11 +609,15 @@ mod tests {
let zip_path = tmp.path().join("notaproject.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap();
w.start_file("foo/bar.txt", SimpleFileOptions::default())
.unwrap();
w.write_all(b"hi").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}");
assert!(
matches!(err, ArchiveError::NotAProjectArchive),
"got: {err:?}"
);
}
#[test]
@@ -631,13 +626,18 @@ mod tests {
let zip_path = tmp.path().join("multi.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("a/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}");
assert!(
matches!(err, ArchiveError::MultipleTopFolders),
"got: {err:?}"
);
}
#[test]
+93 -24
View File
@@ -30,6 +30,10 @@ pub struct Args {
/// `--help` / `-h`: print usage to stdout and exit. The
/// runtime checks this flag before doing any other work.
pub help: bool,
/// `--version` / `-V`: print the version (`CARGO_PKG_VERSION`,
/// the single source of truth — ADR-0054) and exit. Checked
/// alongside `--help` before any other work.
pub version: bool,
/// `--no-undo`: disable the auto-snapshot / undo machinery for
/// this run (ADR-0006 Amendment 1). When set, no snapshots are
/// taken — zero per-command overhead — and `undo` / `redo`
@@ -62,6 +66,17 @@ pub fn help_text() -> String {
crate::t!("help.cli_banner")
}
/// Version line printed by `--version` / `-V` and the in-app `version`
/// command (ADR-0054).
///
/// `CARGO_PKG_VERSION` is the single source of truth — it equals the `v*`
/// release tag (the release CI guards that), so what the binary reports
/// always matches the downloaded artifact.
#[must_use]
pub fn version_text() -> String {
crate::t!("cli.version_line", version = env!("CARGO_PKG_VERSION"))
}
#[derive(Debug)]
pub enum ArgsError {
MissingValue(&'static str),
@@ -81,10 +96,7 @@ pub enum ArgsError {
impl std::fmt::Display for ArgsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingValue(flag) => f.write_str(&crate::t!(
"cli.missing_value",
flag = flag,
)),
Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)),
Self::InvalidValue {
flag,
value,
@@ -95,10 +107,7 @@ impl std::fmt::Display for ArgsError {
value = value,
expected = expected,
)),
Self::Unknown(arg) => f.write_str(&crate::t!(
"cli.unknown_argument",
arg = arg,
)),
Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)),
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
"cli.multiple_paths",
first = first,
@@ -129,6 +138,7 @@ impl Args {
let mut project_path: Option<PathBuf> = None;
let mut resume = false;
let mut help = false;
let mut version = false;
let mut no_undo = false;
let mut mode: Option<Mode> = None;
// Demonstration mode (ADR-0047): the env var is the default,
@@ -143,6 +153,9 @@ impl Args {
"--help" | "-h" => {
help = true;
}
"--version" | "-V" => {
version = true;
}
"--resume" => {
resume = true;
}
@@ -208,6 +221,7 @@ impl Args {
project_path,
resume,
help,
version,
no_undo,
mode,
demo,
@@ -241,7 +255,11 @@ fn default_theme() -> Theme {
// Standard convention: 0..=6 and 8 are dark backgrounds,
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
let is_dark = matches!(code, 0..=6 | 8);
return if is_dark { Theme::dark() } else { Theme::light() };
return if is_dark {
Theme::dark()
} else {
Theme::light()
};
}
Theme::default()
}
@@ -294,10 +312,19 @@ mod tests {
#[test]
fn mode_flag_simple_and_advanced() {
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple));
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "simple"]).unwrap().mode,
Some(Mode::Simple)
);
assert_eq!(
Args::parse(["--mode", "advanced"]).unwrap().mode,
Some(Mode::Advanced)
);
// Case-insensitive, like the `mode` command.
assert_eq!(Args::parse(["--mode", "ADVANCED"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "ADVANCED"]).unwrap().mode,
Some(Mode::Advanced)
);
}
#[test]
@@ -330,7 +357,10 @@ mod tests {
#[test]
fn data_dir_flag_parses() {
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data")));
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/playground-data"))
);
}
#[test]
@@ -350,13 +380,11 @@ mod tests {
#[test]
fn data_dir_and_positional_can_coexist() {
let args = Args::parse([
"--data-dir",
"/tmp/data",
"/home/me/MyProject",
])
.unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap();
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/data"))
);
assert_eq!(
args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/MyProject"))
@@ -366,7 +394,10 @@ mod tests {
#[test]
fn two_positional_paths_error() {
let err = Args::parse(["/a", "/b"]).unwrap_err();
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
assert!(
matches!(err, ArgsError::MultiplePaths { .. }),
"got: {err:?}"
);
}
#[test]
@@ -435,7 +466,10 @@ mod tests {
// Absent `--demo` (and absent env var in the test runner),
// demo mode is off — zero footprint for real users.
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
assert!(!args.demo, "demo is off unless --demo or the env var is given");
assert!(
!args.demo,
"demo is off unless --demo or the env var is given"
);
}
#[test]
@@ -464,7 +498,10 @@ mod tests {
}
// Disabling values.
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode");
assert!(
!demo_value_is_truthy(v),
"{v:?} should not enable demo mode"
);
}
}
@@ -473,6 +510,38 @@ mod tests {
// Make sure the path-vs-flag distinction is robust:
// unknown flags don't get silently swallowed as paths.
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err();
assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}");
assert!(
matches!(&err, ArgsError::Unknown(s) if s == "--bogus"),
"got: {err:?}"
);
}
// ---- ADR-0054: --version / -V ----
#[test]
fn version_long_flag_parses() {
assert!(Args::parse(["--version"]).unwrap().version);
}
#[test]
fn version_short_flag_parses() {
assert!(Args::parse(["-V"]).unwrap().version);
}
#[test]
fn version_defaults_off() {
assert!(!Args::parse(std::iter::empty::<&str>()).unwrap().version);
}
#[test]
fn version_text_carries_the_cargo_version() {
// The binary's self-reported version IS Cargo.toml's (the
// single source of truth, ADR-0054) — and the release CI guards
// that the `v*` tag equals it.
let text = version_text();
assert!(
text.contains(env!("CARGO_PKG_VERSION")),
"version line should embed CARGO_PKG_VERSION; got {text:?}"
);
}
}
+256 -213
View File
@@ -15,10 +15,10 @@
//! `app.rs`; this module owns the candidate computation.
use crate::dsl::grammar::IdentSource;
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::Expectation;
use crate::dsl::{ParseError, parse_command};
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::mode::Mode;
/// Composite literal candidates whose lexed shape is more than
@@ -275,11 +275,7 @@ pub struct Completion {
/// (case-insensitive starts-with), combined, sorted, and
/// deduplicated.
#[must_use]
pub fn candidates_at_cursor(
input: &str,
cursor: usize,
cache: &SchemaCache,
) -> Option<Completion> {
pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option<Completion> {
candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
}
@@ -358,7 +354,11 @@ pub fn candidates_at_cursor_with_in_mode(
let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode);
if pre.expected.iter().any(|e| matches!(e, Expectation::Flag(_))) {
if pre
.expected
.iter()
.any(|e| matches!(e, Expectation::Flag(_)))
{
start = run;
}
}
@@ -473,22 +473,19 @@ pub fn candidates_at_cursor_with_in_mode(
// walk's `current_table_columns`; fall back to "the union of
// the look-ahead from_scope's bindings' columns" when leading
// produced no in-scope columns. Phase-1 DSL paths unaffected.
let lookahead_union_columns: Vec<TableColumn> =
if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| {
c.name.eq_ignore_ascii_case(&col.name)
}) {
out.push(col.clone());
}
let lookahead_union_columns: Vec<TableColumn> = if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
out.push(col.clone());
}
}
out
} else {
Vec::new()
};
}
out
} else {
Vec::new()
};
let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() {
None
} else {
@@ -507,30 +504,23 @@ pub fn candidates_at_cursor_with_in_mode(
// column list (the structural error path surfaces the
// unresolved-prefix message).
let prefix_qualifier = peek_back_qualifier(input, start);
let qualified_columns: Option<Vec<String>> = prefix_qualifier
.as_ref()
.map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(
q,
resolution_from_scope,
resolution_cte_bindings,
cache,
)
}
});
let qualified_columns: Option<Vec<String>> = prefix_qualifier.as_ref().map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
}
});
let expected = if probe.expected.is_empty() {
expected_at(leading, mode)
@@ -574,8 +564,7 @@ pub fn candidates_at_cursor_with_in_mode(
Some(crate::dsl::grammar::HintMode::ProseOnly(_))
);
if partial_prefix.is_empty()
&& (prose_only_slot
|| (is_value_literal_signature(&expected) && !has_schema_ident))
&& (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident))
{
return None;
}
@@ -642,11 +631,17 @@ pub fn candidates_at_cursor_with_in_mode(
// Source 1.5: type-name candidates when the walker expects
// a column-type slot. Type names are a closed set sourced
// from `Type::all()` (ADR-0005 declaration order:
// text/int/real/decimal/bool/date/datetime/blob/serial/
// text/int/real/decimal/bool/date/datetime/serial/
// shortid). The walker surfaces this as
// `Expectation::Ident { source: Types }`.
let type_names: Vec<String> = if expected.iter().any(|e| {
matches!(e, Expectation::Ident { source: IdentSource::Types, .. })
matches!(
e,
Expectation::Ident {
source: IdentSource::Types,
..
}
)
}) {
Type::all()
.iter()
@@ -725,7 +720,13 @@ pub fn candidates_at_cursor_with_in_mode(
// filtered like every other source; empty prefix offers the whole
// set. Tagged `CandidateKind::Function` for its own colour.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
let mut functions: Vec<String> = if has_sql_expr_slot {
crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS
@@ -741,9 +742,15 @@ pub fn candidates_at_cursor_with_in_mode(
// curated vocabulary is offered so a learner can discover `email` /
// `product` / … by Tab. Same `Function` kind / `tok_function` colour
// as SQL functions (no new theme colour — ADR-0048 §Grammar).
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
functions.extend(
crate::seed::KNOWN_GENERATORS
@@ -765,38 +772,36 @@ pub fn candidates_at_cursor_with_in_mode(
// (the `typing_over_diag` path) — keeps the alias from flashing as
// a bogus "unknown column" while typing. Mixed into `identifiers`
// so it sorts/dedups/colours uniformly with column candidates.
let alias_candidates: Vec<String> =
if has_sql_expr_slot && prefix_qualifier.is_none() {
// Once the partial *exactly* matches an in-scope qualifier,
// discoverability is served — the learner has a whole alias
// in hand and now needs the "add `.column`" hint
// (`diagnostic.alias_used_as_column`), not sibling aliases
// that merely share the prefix. Offering them would also let
// the `typing_over_diag` path suppress that very hint. So in
// the exact-match case we emit no alias candidates and let
// the targeted diagnostic surface.
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&partial_prefix)
});
if partial_is_exact_alias {
Vec::new()
} else {
let mut out: Vec<String> = Vec::new();
for binding in resolution_from_scope {
let qualifier =
binding.alias.as_deref().unwrap_or(binding.table.as_str());
if matches_prefix(qualifier)
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
{
out.push(qualifier.to_string());
}
}
out
}
} else {
let alias_candidates: Vec<String> = if has_sql_expr_slot && prefix_qualifier.is_none() {
// Once the partial *exactly* matches an in-scope qualifier,
// discoverability is served — the learner has a whole alias
// in hand and now needs the "add `.column`" hint
// (`diagnostic.alias_used_as_column`), not sibling aliases
// that merely share the prefix. Offering them would also let
// the `typing_over_diag` path suppress that very hint. So in
// the exact-match case we emit no alias candidates and let
// the targeted diagnostic surface.
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&partial_prefix)
});
if partial_is_exact_alias {
Vec::new()
};
} else {
let mut out: Vec<String> = Vec::new();
for binding in resolution_from_scope {
let qualifier = binding.alias.as_deref().unwrap_or(binding.table.as_str());
if matches_prefix(qualifier)
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
{
out.push(qualifier.to_string());
}
}
out
}
} else {
Vec::new()
};
// Source 2: schema identifiers — accumulated across every
// matching schema-listable `Ident { source }` expectation.
@@ -811,9 +816,7 @@ pub fn candidates_at_cursor_with_in_mode(
let mut identifiers: Vec<String> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.flat_map(|source| {
@@ -1007,11 +1010,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Second: table-name match in the active from_scope.
@@ -1026,11 +1025,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Third: direct cte_bindings match (cte_alias.|).
@@ -1038,11 +1033,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(qualifier))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
// Fourth: a bare table name from the schema cache — DSL
// paths reach this for `from <Table>.<col>` shapes where
@@ -1287,7 +1278,13 @@ pub fn invalid_ident_at_cursor_in_mode(
// column. So `select Agx` warns at typing time again, while
// `select sum` does not.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
return None;
@@ -1318,9 +1315,15 @@ pub fn invalid_ident_at_cursor_in_mode(
// schema-column check below would never see it. A partial that
// prefix-matches a known generator is an in-progress name; anything
// else is an unknown generator → flag it `[ERR]` while typing.
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
if crate::seed::is_known_generator_prefix(partial) {
return None;
@@ -1335,9 +1338,7 @@ pub fn invalid_ident_at_cursor_in_mode(
let sources: Vec<IdentSource> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.collect();
@@ -1412,13 +1413,15 @@ mod tests {
use pretty_assertions::assert_eq;
fn cands(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor(input, cursor, &SchemaCache::default())
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
candidates_at_cursor(input, cursor, cache)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
/// Simple-mode completion candidates — the DSL surface
@@ -1429,7 +1432,9 @@ mod tests {
/// Advanced mode surfaces the SQL grammar's completions instead.
fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
.map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cand_kinds_with(
@@ -1438,10 +1443,7 @@ mod tests {
cache: &SchemaCache,
) -> Vec<(String, CandidateKind)> {
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates
.into_iter()
.map(|c| (c.text, c.kind))
.collect()
c.candidates.into_iter().map(|c| (c.text, c.kind)).collect()
})
}
@@ -1503,12 +1505,21 @@ mod tests {
// Simple-only (column, relationship, constraint).
let cs = cands("drop ", 5);
for kw in ["table", "index", "column", "relationship", "constraint"] {
assert!(cs.contains(&kw.to_string()), "`drop ` should offer `{kw}`; got {cs:?}");
assert!(
cs.contains(&kw.to_string()),
"`drop ` should offer `{kw}`; got {cs:?}"
);
}
// Both-mode continuations block before the simple-only ones.
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}");
assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}");
assert!(
pos("table") < pos("column"),
"Both block precedes Simple block: {cs:?}"
);
assert!(
pos("index") < pos("relationship"),
"Both block precedes Simple block: {cs:?}"
);
}
#[test]
@@ -1631,8 +1642,14 @@ mod tests {
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
.expect("a `-` at a flag position offers candidates");
let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect();
assert!(texts.contains(&"--create-fk"), "should offer --create-fk: {texts:?}");
assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}");
assert!(
texts.contains(&"--create-fk"),
"should offer --create-fk: {texts:?}"
);
assert!(
!texts.contains(&"on"),
"must NOT offer `on` after a dash: {texts:?}"
);
assert_eq!(
c.replaced_range,
(input.len() - 1, input.len()),
@@ -1643,13 +1660,9 @@ mod tests {
#[test]
fn double_dash_replaces_both_dashes_on_accept() {
let input = "delete from T --";
let c = candidates_at_cursor_in_mode(
input,
input.len(),
&SchemaCache::default(),
Mode::Simple,
)
.expect("`--` offers the flag");
let c =
candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple)
.expect("`--` offers the flag");
assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
assert_eq!(
c.replaced_range,
@@ -1668,9 +1681,7 @@ mod tests {
s.tables.push("T".into());
s.columns.push("x".into());
let input = "show data T where x = -5";
if let Some(c) =
candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple)
{
if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) {
assert!(
!c.candidates.iter().any(|x| x.text.starts_with("--")),
"no flags at a value position: {:?}",
@@ -1715,8 +1726,8 @@ mod tests {
// App-lifecycle commands now appear alongside DSL
// commands in the entry-keyword set.
for expected in &[
"quit", "help", "rebuild", "save", "new", "load", "export",
"import", "mode", "messages", "undo", "redo", "copy",
"quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode",
"messages", "undo", "redo", "copy",
] {
assert!(
cs.contains(&expected.to_string()),
@@ -1943,7 +1954,10 @@ mod tests {
// opening a sub-shape) becomes a Tab candidate.
let input = "add column to table T";
let cs = cands(input, input.len());
assert!(cs.is_empty(), "trailing-content punct should not surface: {cs:?}");
assert!(
cs.is_empty(),
"trailing-content punct should not surface: {cs:?}"
);
}
#[test]
@@ -1957,10 +1971,7 @@ mod tests {
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
}
fn schema_with_table(
table: &str,
columns: &[(&str, crate::dsl::types::Type)],
) -> SchemaCache {
fn schema_with_table(table: &str, columns: &[(&str, crate::dsl::types::Type)]) -> SchemaCache {
let mut cache = SchemaCache::default();
cache.tables.push(table.to_string());
let cols: Vec<TableColumn> = columns
@@ -2002,8 +2013,14 @@ mod tests {
let cache = two_table_alias_cache();
let input = "select a.id from a o join b z on o.id = z.id group by ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}");
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}");
assert!(
cs.contains(&"o".to_string()),
"alias `o` must be offered; got {cs:?}"
);
assert!(
cs.contains(&"z".to_string()),
"alias `z` must be offered; got {cs:?}"
);
}
#[test]
@@ -2015,8 +2032,14 @@ mod tests {
let cache = two_table_alias_cache();
let input = "select a.id from a aa join b ab on aa.id = ab.id group by a";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}");
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}");
assert!(
cs.contains(&"aa".to_string()),
"alias `aa` must be offered; got {cs:?}"
);
assert!(
cs.contains(&"ab".to_string()),
"alias `ab` must be offered; got {cs:?}"
);
// Exact-alias partial: the alias source steps aside.
let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa";
@@ -2046,19 +2069,20 @@ mod tests {
// SchemaCache.columns has columns from many tables, but
// at `update Customers set ` only Customers' columns
// should appear.
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
// Pretend the global flat list has columns from a second
// table that aren't in Customers.
cache.columns.push("OrderTotal".to_string());
cache.columns.push("Stock".to_string());
cache
.table_columns
.insert("Orders".to_string(), vec![
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false },
]);
cache.table_columns.insert(
"Orders".to_string(),
vec![TableColumn {
name: "OrderTotal".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
}],
);
cache.tables.push("Orders".to_string());
let cs = cands_with("update Customers set ", 21, &cache);
// Customers's columns should appear:
@@ -2079,10 +2103,7 @@ mod tests {
// *before* ORDER BY (the FROM's JOIN options, WHERE /
// GROUP BY / HAVING, set-ops). Those used to shove the
// columns off-screen.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by ";
let cs = cands_with(input, input.len(), &cache);
// The columns the user wants are offered:
@@ -2090,8 +2111,19 @@ mod tests {
assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
// Preceding-clause keywords must not leak in:
for kw in [
"where", "group", "having", "join", "union", "intersect",
"except", "left", "right", "full", "cross", "inner", "as",
"where",
"group",
"having",
"join",
"union",
"intersect",
"except",
"left",
"right",
"full",
"cross",
"inner",
"as",
] {
assert!(
!cs.contains(&kw.to_string()),
@@ -2108,10 +2140,7 @@ mod tests {
// sort item the direction keywords surface as
// continuations (previously discarded at the Repeated
// boundary, so completion offered neither).
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
@@ -2123,10 +2152,7 @@ mod tests {
use crate::dsl::types::Type;
// walk_repeated trailing-optional fix: after a complete
// projection item the `as` alias keyword surfaces.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"as".to_string()), "got {cs:?}");
@@ -2153,16 +2179,13 @@ mod tests {
// ADR-0022 Amendment 2: at an expression position offering
// both column names and keywords, every column precedes
// every keyword so the names stay visible by default.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select * from Things where ";
let cs = cands_with(input, input.len(), &cache);
let pos = |needle: &str| {
cs.iter().position(|c| c == needle).unwrap_or_else(|| {
panic!("{needle:?} not in candidates: {cs:?}")
})
cs.iter()
.position(|c| c == needle)
.unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}"))
};
// Both columns come before any expression-start keyword.
let last_ident = pos("Name").max(pos("Qty"));
@@ -2176,13 +2199,9 @@ mod tests {
#[test]
fn update_where_offers_only_current_table_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("update Customers set Email='x' where ", 37, &cache);
let cs = cands_with("update Customers set Email='x' where ", 37, &cache);
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2208,7 +2227,11 @@ mod tests {
use crate::dsl::types::Type;
let cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)],
&[
("id", Type::Int),
("Email", Type::Text),
("Name", Type::Text),
],
);
let cs = cands_with("insert into Customers (", 23, &cache);
// The user is at Form A's column-list position. All
@@ -2222,10 +2245,7 @@ mod tests {
#[test]
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs = cands_with("insert into Customers (", 23, &cache);
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2239,13 +2259,9 @@ mod tests {
// table's columns. `OrderTotal` belongs to no table in
// this cache's `table_columns`, so it must not leak.
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("drop column from Customers: ", 28, &cache);
let cs = cands_with("drop column from Customers: ", 28, &cache);
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(
@@ -2271,8 +2287,8 @@ mod tests {
#[test]
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (0, 3));
assert_eq!(comp.partial_prefix, "cre");
assert_eq!(comp.candidates.len(), 1);
@@ -2282,8 +2298,8 @@ mod tests {
#[test]
fn cursor_at_word_boundary_has_empty_partial_prefix() {
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (7, 7));
assert_eq!(comp.partial_prefix, "");
}
@@ -2293,7 +2309,7 @@ mod tests {
#[test]
fn type_slot_offers_full_type_vocabulary_when_partial_empty() {
// After `add column to T: Name (` the parser expects
// a column type. With no partial typed, all ten types
// a column type. With no partial typed, all nine types
// from `Type::all()` are offered in declaration order.
let cs = cands("add column to T: Name (", 23);
assert_eq!(
@@ -2306,7 +2322,6 @@ mod tests {
"bool".to_string(),
"date".to_string(),
"datetime".to_string(),
"blob".to_string(),
"serial".to_string(),
"shortid".to_string(),
],
@@ -2517,8 +2532,8 @@ mod tests {
// inside `Name`, and substituting any name there
// produces a complete command. No useful "next after
// name" hint.
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
.expect("should fire");
let t =
typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire");
assert_eq!(t.next_after_name, None);
}
@@ -2534,8 +2549,8 @@ mod tests {
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
// `show data Cust` plus a typo: `show data Custp`. No
// table starts with "Custp" → invalid.
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
.expect("should be invalid");
let invalid =
invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid");
assert_eq!(invalid.range, (10, 15));
assert_eq!(invalid.found, "Custp");
assert_eq!(invalid.source, IdentSource::Tables);
@@ -2600,7 +2615,11 @@ mod tests {
!cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
"NewName slot must not surface schema candidates; got {cs:?}"
);
assert_eq!(cs, vec!["if".to_string()], "only the advanced IF NOT EXISTS keyword");
assert_eq!(
cs,
vec!["if".to_string()],
"only the advanced IF NOT EXISTS keyword"
);
}
fn keyword_cand(text: &str) -> Candidate {
@@ -2791,8 +2810,10 @@ mod tests {
let cands = candidates_at_cursor(input, input.len(), &cache)
.expect("some completion")
.candidates;
let count_entries: Vec<_> =
cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect();
let count_entries: Vec<_> = cands
.iter()
.filter(|c| c.text.eq_ignore_ascii_case("count"))
.collect();
assert_eq!(
count_entries.len(),
1,
@@ -2805,7 +2826,9 @@ mod tests {
);
// A non-colliding function at the same slot is unaffected.
assert!(
cands.iter().any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
cands
.iter()
.any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
"non-colliding functions still surface; got {cands:?}",
);
}
@@ -2875,8 +2898,10 @@ mod tests {
let mut s = SchemaCache::default();
s.tables.push("OrderLines".into());
s.columns.push("count".into());
s.table_columns
.insert("OrderLines".into(), vec![TableColumn::new("count", Type::Int)]);
s.table_columns.insert(
"OrderLines".into(),
vec![TableColumn::new("count", Type::Int)],
);
let input = "select sum(ol.count) from OrderLines ol";
let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
assert!(
@@ -2938,15 +2963,35 @@ mod tests {
s.table_columns.insert(
"a".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
],
);
s.table_columns.insert(
"b".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "total".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
},
],
);
s
@@ -3191,5 +3236,3 @@ mod tests {
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
}
}
+1140 -842
View File
File diff suppressed because it is too large Load Diff
+15 -11
View File
@@ -549,14 +549,16 @@ pub enum AppCommand {
/// word like `insert` / `create` / `show`, or `types`), the
/// focused detail for that command (or command group sharing
/// the entry word).
Help {
topic: Option<String>,
},
Help { topic: Option<String> },
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
/// when submitted, it expands on the most recent runtime error
/// (the buffer is empty post-submit). The live-input surface is
/// the F1 keybinding, handled in `App::handle_key`, not here.
Hint,
/// Print the application version (ADR-0054): the in-app twin of the
/// `--version` / `-V` CLI flag. Emits `CARGO_PKG_VERSION` — the same
/// single source of truth — into the output panel.
Version,
/// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal.
Rebuild,
@@ -576,7 +578,10 @@ pub enum AppCommand {
/// Unpack a zip into a new project and switch to it.
/// `target` overrides the project name (default: taken from
/// the zip).
Import { path: String, target: Option<String> },
Import {
path: String,
target: Option<String>,
},
/// Switch the persistent input mode.
Mode { value: ModeValue },
/// Show or set the messages verbosity.
@@ -787,9 +792,7 @@ impl PartialEq for Operand {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => {
a == b
}
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => a == b,
_ => false,
}
}
@@ -813,7 +816,9 @@ pub enum CompareOp {
/// a single row in the metadata table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelationshipSelector {
Named { name: String },
Named {
name: String,
},
Endpoints {
parent_table: String,
parent_column: String,
@@ -1019,6 +1024,7 @@ impl Command {
AppCommand::Quit => "quit",
AppCommand::Help { .. } => "help",
AppCommand::Hint => "hint",
AppCommand::Version => "version",
AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save",
AppCommand::SaveAs => "save as",
@@ -1151,9 +1157,7 @@ impl Command {
parent_column,
child_table,
child_column,
} => format!(
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
),
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
},
// A constraint command's subject is the dotted
// `<table>.<column>` it acts on (ADR-0029 §2.2).
+52 -29
View File
@@ -9,8 +9,7 @@
use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
Word,
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, Word,
};
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
@@ -60,19 +59,16 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const IMPORT_TARGET: Node = Node::Hinted {
mode: HintMode::ForceProse("hint.ambient_typing_name"),
inner: &IMPORT_TARGET_IDENT,
};
const IMPORT_AS_TARGET: Node = Node::Seq(&[
Node::Word(Word::keyword("as")),
IMPORT_TARGET,
]);
const IMPORT_AS_TARGET: Node = Node::Seq(&[Node::Word(Word::keyword("as")), IMPORT_TARGET]);
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGET_OPT]);
@@ -101,9 +97,9 @@ const MODE_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
@@ -119,9 +115,9 @@ const MESSAGES_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
@@ -174,6 +170,10 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
Ok(Command::App(AppCommand::Rebuild))
}
const fn build_version(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Version))
}
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Undo))
}
@@ -267,7 +267,8 @@ pub static QUIT: CommandNode = CommandNode {
ast_builder: build_quit,
help_id: Some("app.quit"),
hint_ids: &["quit"],
usage_ids: &["parse.usage.quit"],};
usage_ids: &["parse.usage.quit"],
};
pub static HELP: CommandNode = CommandNode {
entry: Word::keyword("help"),
@@ -275,7 +276,8 @@ pub static HELP: CommandNode = CommandNode {
ast_builder: build_help,
help_id: Some("app.help"),
hint_ids: &["help"],
usage_ids: &["parse.usage.help"],};
usage_ids: &["parse.usage.help"],
};
pub static HINT: CommandNode = CommandNode {
entry: Word::keyword("hint"),
@@ -284,7 +286,8 @@ pub static HINT: CommandNode = CommandNode {
help_id: Some("app.hint"),
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
hint_ids: &["hint"],
usage_ids: &["parse.usage.hint"],};
usage_ids: &["parse.usage.hint"],
};
pub static REBUILD: CommandNode = CommandNode {
entry: Word::keyword("rebuild"),
@@ -292,7 +295,17 @@ pub static REBUILD: CommandNode = CommandNode {
ast_builder: build_rebuild,
help_id: Some("app.rebuild"),
hint_ids: &["rebuild"],
usage_ids: &["parse.usage.rebuild"],};
usage_ids: &["parse.usage.rebuild"],
};
pub static VERSION: CommandNode = CommandNode {
entry: Word::keyword("version"),
shape: EMPTY_SEQ,
ast_builder: build_version,
help_id: Some("app.version"),
hint_ids: &["version"],
usage_ids: &["parse.usage.version"],
};
pub static SAVE: CommandNode = CommandNode {
entry: Word::keyword("save"),
@@ -300,7 +313,8 @@ pub static SAVE: CommandNode = CommandNode {
ast_builder: build_save,
help_id: Some("app.save"),
hint_ids: &["save"],
usage_ids: &["parse.usage.save"],};
usage_ids: &["parse.usage.save"],
};
pub static NEW: CommandNode = CommandNode {
entry: Word::keyword("new"),
@@ -308,7 +322,8 @@ pub static NEW: CommandNode = CommandNode {
ast_builder: build_new,
help_id: Some("app.new"),
hint_ids: &["new"],
usage_ids: &["parse.usage.new"],};
usage_ids: &["parse.usage.new"],
};
pub static LOAD: CommandNode = CommandNode {
entry: Word::keyword("load"),
@@ -316,7 +331,8 @@ pub static LOAD: CommandNode = CommandNode {
ast_builder: build_load,
help_id: Some("app.load"),
hint_ids: &["load"],
usage_ids: &["parse.usage.load"],};
usage_ids: &["parse.usage.load"],
};
pub static EXPORT: CommandNode = CommandNode {
entry: Word::keyword("export"),
@@ -324,7 +340,8 @@ pub static EXPORT: CommandNode = CommandNode {
ast_builder: build_export,
help_id: Some("app.export"),
hint_ids: &["export"],
usage_ids: &["parse.usage.export"],};
usage_ids: &["parse.usage.export"],
};
pub static IMPORT: CommandNode = CommandNode {
entry: Word::keyword("import"),
@@ -332,7 +349,8 @@ pub static IMPORT: CommandNode = CommandNode {
ast_builder: build_import,
help_id: Some("app.import"),
hint_ids: &["import"],
usage_ids: &["parse.usage.import"],};
usage_ids: &["parse.usage.import"],
};
pub static MODE: CommandNode = CommandNode {
entry: Word::keyword("mode"),
@@ -340,7 +358,8 @@ pub static MODE: CommandNode = CommandNode {
ast_builder: build_mode,
help_id: Some("app.mode"),
hint_ids: &["mode"],
usage_ids: &["parse.usage.mode"],};
usage_ids: &["parse.usage.mode"],
};
pub static MESSAGES: CommandNode = CommandNode {
entry: Word::keyword("messages"),
@@ -348,7 +367,8 @@ pub static MESSAGES: CommandNode = CommandNode {
ast_builder: build_messages,
help_id: Some("app.messages"),
hint_ids: &["messages"],
usage_ids: &["parse.usage.messages"],};
usage_ids: &["parse.usage.messages"],
};
pub static UNDO: CommandNode = CommandNode {
entry: Word::keyword("undo"),
@@ -356,7 +376,8 @@ pub static UNDO: CommandNode = CommandNode {
ast_builder: build_undo,
help_id: Some("app.undo"),
hint_ids: &["undo"],
usage_ids: &["parse.usage.undo"],};
usage_ids: &["parse.usage.undo"],
};
pub static REDO: CommandNode = CommandNode {
entry: Word::keyword("redo"),
@@ -364,7 +385,8 @@ pub static REDO: CommandNode = CommandNode {
ast_builder: build_redo,
help_id: Some("app.redo"),
hint_ids: &["redo"],
usage_ids: &["parse.usage.redo"],};
usage_ids: &["parse.usage.redo"],
};
pub static COPY: CommandNode = CommandNode {
entry: Word::keyword("copy"),
@@ -372,4 +394,5 @@ pub static COPY: CommandNode = CommandNode {
ast_builder: build_copy,
help_id: Some("app.copy"),
hint_ids: &["copy"],
usage_ids: &["parse.usage.copy"],};
usage_ids: &["parse.usage.copy"],
};
+114 -91
View File
@@ -24,19 +24,17 @@
//! later swap that capture for the same typed slots used here, adding
//! live hints/highlighting.
use crate::dsl::command::{
Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind,
};
use crate::dsl::command::{Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values,
current_column_value, insert_target_columns,
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, current_column_value,
insert_target_columns,
},
sql_delete, sql_insert, sql_select, sql_update,
};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::value::Value;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
// =================================================================
@@ -56,10 +54,10 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Table-name slot variant that populates
@@ -75,10 +73,10 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
@@ -95,10 +93,7 @@ const SHOW_DATA_NODES: &[Node] = &[
];
const SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES);
const SHOW_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const SHOW_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
// `show tables` / `show relationships` / `show indexes` — the
@@ -144,8 +139,7 @@ const SHOW_INDEX_NAME: Node = Node::Ident {
writes_cte_name: false,
writes_projection_alias: false,
};
const SHOW_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES);
const SHOW_CHOICES: &[Node] = &[
@@ -192,9 +186,9 @@ static FORM_A_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: true,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static INSERT_COMMA: Node = Node::Punct(',');
@@ -224,8 +218,7 @@ fn insert_first_paren(ctx: &WalkContext, source: &str, pos: usize) -> Node {
/// or an identifier-shaped token (a column name) returns false.
fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{
consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
};
let p = skip_whitespace(source, pos);
if p >= source.len() {
@@ -281,7 +274,11 @@ fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
return FALLBACK_VALUE_LIST;
};
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if arity_ok {
Node::DynamicSubgrammar(column_value_list)
} else {
@@ -320,8 +317,7 @@ const INSERT_VALUES_KEYWORD_FIRST_NODES: &[Node] = &[
];
const INSERT_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES);
const INSERT_AFTER_TABLE_CHOICES: &[Node] =
&[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
const INSERT_NODES: &[Node] = &[
@@ -349,10 +345,10 @@ const TABLE_NAME_WRITES: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Column-name slot in `set col = …` — resolves the column's
@@ -366,9 +362,9 @@ const SET_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Value slot resolved at walk time from
@@ -376,11 +372,7 @@ writes_projection_alias: false,
/// value-literal choice when no current_column is bound.
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[
SET_COLUMN,
Node::Punct('='),
PER_COLUMN_VALUE,
];
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE];
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
inner: &UPDATE_ASSIGNMENT,
@@ -568,8 +560,7 @@ const SEED_OVERRIDES: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const SEED_SET_CLAUSE_NODES: &[Node] =
&[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE_NODES: &[Node] = &[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES);
const SEED_NODES: &[Node] = &[
@@ -980,7 +971,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("in") => {
*i += 1; // `in`
// `(`
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Punct('('))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Punct('('))
) {
*i += 1;
}
let mut values = Vec::new();
@@ -1001,7 +995,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("between") => {
*i += 1; // `between`
let low = seed_take_value(region, i, column)?;
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Word("and"))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Word("and"))
) {
*i += 1;
}
let high = seed_take_value(region, i, column)?;
@@ -1011,7 +1008,15 @@ fn parse_seed_override_tail(
*i += 1; // `as`
let gen_item = region
.get(*i)
.filter(|t| matches!(t.kind, MatchedKind::Ident { role: "seed_generator", .. }))
.filter(|t| {
matches!(
t.kind,
MatchedKind::Ident {
role: "seed_generator",
..
}
)
})
.ok_or_else(|| seed_set_error(column))?;
*i += 1;
Ok(SeedOverrideKind::Generator(gen_item.text.clone()))
@@ -1085,7 +1090,15 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
let table_idx = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Ident { role: "table_name", .. }))
.position(|i| {
matches!(
&i.kind,
MatchedKind::Ident {
role: "table_name",
..
}
)
})
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing table".to_string())],
@@ -1141,7 +1154,10 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
if columns.is_empty() {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "expected column names in `insert into T (…)`".to_string())],
args: vec![(
"detail",
"expected column names in `insert into T (…)`".to_string(),
)],
});
}
// Find the `values` keyword and the next `(` — the values
@@ -1247,9 +1263,7 @@ fn build_update(path: &MatchedPath, _source: &str) -> Result<Command, Validation
})
}
fn collect_assignments(
path: &MatchedPath,
) -> Result<Vec<(String, Value)>, ValidationError> {
fn collect_assignments(path: &MatchedPath) -> Result<Vec<(String, Value)>, ValidationError> {
let mut out = Vec::new();
let mut iter = path.items.iter();
while let Some(item) = iter.next() {
@@ -1495,9 +1509,7 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
let row_source = path
.items
.iter()
.find(|item| {
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
})
.find(|item| matches!(item.kind, MatchedKind::Word("values" | "select" | "with")))
.map(|item| {
let end = tail_start.unwrap_or(source.len());
source[item.span.0..end]
@@ -1805,7 +1817,8 @@ pub static SHOW: CommandNode = CommandNode {
"parse.usage.show_indexes",
"parse.usage.show_relationship",
"parse.usage.show_index",
],};
],
};
pub static SEED: CommandNode = CommandNode {
entry: Word::keyword("seed"),
@@ -1823,7 +1836,8 @@ pub static INSERT: CommandNode = CommandNode {
help_id: Some("data.insert"),
// ADR-0053 Phase-B exemplar.
hint_ids: &["insert"],
usage_ids: &["parse.usage.insert"],};
usage_ids: &["parse.usage.insert"],
};
pub static UPDATE: CommandNode = CommandNode {
entry: Word::keyword("update"),
@@ -1831,7 +1845,8 @@ pub static UPDATE: CommandNode = CommandNode {
ast_builder: build_update,
help_id: Some("data.update"),
hint_ids: &["update"],
usage_ids: &["parse.usage.update"],};
usage_ids: &["parse.usage.update"],
};
pub static DELETE: CommandNode = CommandNode {
entry: Word::keyword("delete"),
@@ -1839,7 +1854,8 @@ pub static DELETE: CommandNode = CommandNode {
ast_builder: build_delete,
help_id: Some("data.delete"),
hint_ids: &["delete"],
usage_ids: &["parse.usage.delete"],};
usage_ids: &["parse.usage.delete"],
};
pub static REPLAY: CommandNode = CommandNode {
entry: Word::keyword("replay"),
@@ -1847,7 +1863,8 @@ pub static REPLAY: CommandNode = CommandNode {
ast_builder: build_replay,
help_id: Some("data.replay"),
hint_ids: &["replay"],
usage_ids: &["parse.usage.replay"],};
usage_ids: &["parse.usage.replay"],
};
pub static EXPLAIN: CommandNode = CommandNode {
entry: Word::keyword("explain"),
@@ -1855,7 +1872,8 @@ pub static EXPLAIN: CommandNode = CommandNode {
ast_builder: build_explain,
help_id: Some("data.explain"),
hint_ids: &["explain"],
usage_ids: &["parse.usage.explain"],};
usage_ids: &["parse.usage.explain"],
};
/// `explain` over advanced-mode SQL (ADR-0039).
///
@@ -1868,14 +1886,15 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
entry: Word::keyword("explain"),
shape: EXPLAIN_SQL_SHAPE,
ast_builder: build_explain_sql,
// No `help_id` / `usage_ids` — this is the `Advanced` half of the
// shared `explain` entry word, so it defers to the `Simple`
// `EXPLAIN` node's help/usage (which now covers the SQL forms
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
// precedent; otherwise `note_help` would print `explain` twice.
help_id: None,
// Issue #36: its own `help` page (`data.explain_sql`), listed under the
// advanced-mode (SQL) section and shown by `help explain` next to the
// simple `EXPLAIN` form (distinct help_id ⇒ no dedup clash). `usage_ids`
// stays empty — the `Simple` `EXPLAIN` node's usage block already covers
// the shared `explain` entry word.
help_id: Some("data.explain_sql"),
hint_ids: &["explain_sql"],
usage_ids: &[],};
usage_ids: &[],
};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
///
@@ -1883,15 +1902,16 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
/// The shape is the post-`SELECT` portion of a top-level
/// statement; the registry's entry-word dispatch consumes the
/// leading `SELECT` keyword before the shape walks (sub-phase
/// 2c migration). `help_id` is `None` until the `help sql`
/// page lands (ADR-0030 Phase 6).
/// 2c migration). Carries its own `help` page (`data.select`),
/// listed under the advanced-mode (SQL) section (issue #36).
pub static SELECT: CommandNode = CommandNode {
entry: Word::keyword("select"),
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
ast_builder: build_select,
help_id: None,
help_id: Some("data.select"),
hint_ids: &["select"],
usage_ids: &["parse.usage.select"],};
usage_ids: &["parse.usage.select"],
};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
///
@@ -1904,9 +1924,10 @@ pub static WITH: CommandNode = CommandNode {
entry: Word::keyword("with"),
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
ast_builder: build_select,
help_id: None,
help_id: Some("data.with"), // issue #36: own help page, advanced section
hint_ids: &["with"],
usage_ids: &["parse.usage.with"],};
usage_ids: &["parse.usage.with"],
};
/// SQL `INSERT` — the `Advanced`-category node of the shared
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
@@ -1922,7 +1943,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
entry: Word::keyword("insert"),
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
ast_builder: build_sql_insert,
help_id: None,
help_id: Some("data.sql_insert"), // issue #36: own help page, advanced section
hint_ids: &["sql_insert"],
usage_ids: &[],
};
@@ -1936,7 +1957,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
entry: Word::keyword("update"),
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
ast_builder: build_sql_update,
help_id: None,
help_id: Some("data.sql_update"), // issue #36: own help page, advanced section
hint_ids: &["sql_update"],
usage_ids: &[],
};
@@ -1952,7 +1973,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
entry: Word::keyword("delete"),
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
ast_builder: build_sql_delete,
help_id: None,
help_id: Some("data.sql_delete"), // issue #36: own help page, advanced section
hint_ids: &["sql_delete"],
usage_ids: &[],
};
@@ -1993,7 +2014,11 @@ mod explain_tests {
#[test]
fn explain_show_data_carries_where_and_limit_through() {
match explain_inner("explain show data Customers where id = 1 limit 5") {
Command::ShowData { name, filter, limit } => {
Command::ShowData {
name,
filter,
limit,
} => {
assert_eq!(name, "Customers");
assert!(filter.is_some(), "where clause should survive");
assert_eq!(limit, Some(5));
@@ -2052,9 +2077,7 @@ mod explain_tests {
/// Advanced-mode counterpart of `explain_inner`.
fn explain_inner_adv(input: &str) -> Command {
match parse_command_in_mode(input, Mode::Advanced)
.expect("advanced explain should parse")
{
match parse_command_in_mode(input, Mode::Advanced).expect("advanced explain should parse") {
Command::Explain { query } => *query,
other => panic!("expected Command::Explain, got {other:?}"),
}
@@ -2085,7 +2108,9 @@ mod explain_tests {
#[test]
fn explain_sql_insert_wraps_a_sql_insert() {
match explain_inner_adv("explain insert into Customers values (1, 'Bo')") {
Command::SqlInsert { sql, target_table, .. } => {
Command::SqlInsert {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "insert into Customers values (1, 'Bo')");
}
@@ -2096,7 +2121,9 @@ mod explain_tests {
#[test]
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") {
Command::SqlUpdate { sql, target_table, .. } => {
Command::SqlUpdate {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
}
@@ -2107,7 +2134,9 @@ mod explain_tests {
#[test]
fn explain_sql_delete_wraps_a_sql_delete() {
match explain_inner_adv("explain delete from Customers where id = 1") {
Command::SqlDelete { sql, target_table, .. } => {
Command::SqlDelete {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "delete from Customers where id = 1");
}
@@ -2148,11 +2177,7 @@ mod explain_tests {
fn explain_does_not_cover_ddl() {
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
// out of scope); there is no SQL DDL branch under explain.
assert!(parse_command_in_mode(
"explain create table T (id int)",
Mode::Advanced,
)
.is_err());
assert!(parse_command_in_mode("explain create table T (id int)", Mode::Advanced,).is_err());
}
#[test]
@@ -2165,9 +2190,8 @@ mod explain_tests {
use crate::completion::candidates_at_cursor_in_mode;
let schema = crate::completion::SchemaCache::default();
let input = "explain ";
let completion =
candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let names: Vec<&str> = completion
.candidates
.iter()
@@ -2178,4 +2202,3 @@ mod explain_tests {
}
}
}
+299 -172
View File
@@ -16,11 +16,11 @@ use crate::dsl::command::{
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
use crate::dsl::value::Value;
/// `HintMode` annotation shared by every `NewName` ident slot:
/// the user is inventing a name, so the hint panel forces the
@@ -39,12 +39,12 @@ const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const TABLE_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -63,12 +63,12 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME: Node = Node::Ident {
@@ -76,12 +76,12 @@ const COLUMN_NAME: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
@@ -89,12 +89,12 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -106,12 +106,12 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
@@ -119,12 +119,12 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -139,9 +139,9 @@ const INDEX_NAME_EXISTING: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
@@ -152,9 +152,9 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -181,10 +181,7 @@ const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
// drop_table — `drop table <T>`
// =================================================================
const DROP_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
@@ -192,8 +189,10 @@ const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
// `table` keyword (not the Optional) keeps the element/dispatch
// matching honest.
static SQL_DROP_IF_EXISTS_NODES: &[Node] =
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))];
static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[
Node::Word(Word::keyword("if")),
Node::Word(Word::keyword("exists")),
];
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
@@ -257,9 +256,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -270,9 +269,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
@@ -286,9 +285,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -299,9 +298,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
@@ -317,10 +316,7 @@ const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES);
const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
const DROP_RELATIONSHIP_NODES: &[Node] = &[
Node::Word(Word::keyword("relationship")),
DR_SELECTOR,
];
const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR];
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
// =================================================================
@@ -341,18 +337,20 @@ const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
const DROP_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
DI_SELECTOR,
];
const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR];
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
// =================================================================
// drop entry — `drop (table|column|relationship|index) ...`
// =================================================================
const DROP_CHOICES: &[Node] =
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
const DROP_CHOICES: &[Node] = &[
DROP_COLUMN,
DROP_RELATIONSHIP,
DROP_TABLE,
DROP_INDEX,
DROP_CONSTRAINT,
];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// =================================================================
@@ -450,8 +448,7 @@ const AR_CHILD_COL_LIST: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const AR_CHILD_COLS_PAREN_NODES: &[Node] =
&[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES);
const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
@@ -474,18 +471,28 @@ const AR_CHILD_NODES: &[Node] = &[
];
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
const AR_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
RELATIONSHIP_NAME_NEW,
];
const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW];
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
const ADD_RELATIONSHIP_NODES: &[Node] = &[
// The `1:n` cardinality marker, tagged as the
// `cardinality_one_to_many` clause-concept region (issue #37). A
// distinct sub-`Seq` so the concept span covers exactly `1:n`, not
// the whole relationship form (which the per-form `hint.cmd.*` block
// already serves).
const CARDINALITY_1N_NODES: &[Node] = &[
Node::Literal("1"),
Node::Punct(':'),
Node::Word(Word::keyword("n")),
];
const CARDINALITY_1N: Node = Node::Concept {
topic: "cardinality_one_to_many",
inner: &Node::Seq(CARDINALITY_1N_NODES),
};
const ADD_RELATIONSHIP_NODES: &[Node] = &[
CARDINALITY_1N,
Node::Word(Word::keyword("relationship")),
AR_AS_NAME_OPT,
Node::Word(Word::keyword("from")),
@@ -501,10 +508,7 @@ const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
// add_index — `add index [as <name>] on <T> (<col>, …)`
// =================================================================
const AI_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
INDEX_NAME_NEW,
];
const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW];
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
const ADD_INDEX_NODES: &[Node] = &[
@@ -537,9 +541,9 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const NEW_COLUMN_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -563,10 +567,7 @@ const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
// ( <type> ) [--force-conversion | --dont-convert]`
// =================================================================
const CHANGE_FLAG_CHOICES: &[Node] = &[
Node::Flag("force-conversion"),
Node::Flag("dont-convert"),
];
const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")];
const CHANGE_FLAG_OPT: Node = Node::Repeated {
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
separator: None,
@@ -732,8 +733,7 @@ fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationErr
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
let (not_null, unique, default, check) =
collect_column_constraints(path)?;
let (not_null, unique, default, check) = collect_column_constraints(path)?;
Ok(Command::AddColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
@@ -949,7 +949,10 @@ fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, V
} else {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "drop constraint needs a constraint kind".to_string())],
args: vec![(
"detail",
"drop constraint needs a constraint kind".to_string(),
)],
});
};
Ok(Command::DropConstraint {
@@ -981,7 +984,8 @@ pub static DROP: CommandNode = CommandNode {
"parse.usage.drop_relationship",
"parse.usage.drop_index",
"parse.usage.drop_constraint",
],};
],
};
pub static ADD: CommandNode = CommandNode {
entry: Word::keyword("add"),
@@ -1003,7 +1007,8 @@ pub static ADD: CommandNode = CommandNode {
"parse.usage.add_relationship",
"parse.usage.add_index",
"parse.usage.add_constraint",
],};
],
};
pub static RENAME: CommandNode = CommandNode {
entry: Word::keyword("rename"),
@@ -1011,7 +1016,8 @@ pub static RENAME: CommandNode = CommandNode {
ast_builder: build_rename_column,
help_id: Some("ddl.rename"),
hint_ids: &["rename_column"],
usage_ids: &["parse.usage.rename_column"],};
usage_ids: &["parse.usage.rename_column"],
};
pub static CHANGE: CommandNode = CommandNode {
entry: Word::keyword("change"),
@@ -1019,7 +1025,8 @@ pub static CHANGE: CommandNode = CommandNode {
ast_builder: build_change_column,
help_id: Some("ddl.change"),
hint_ids: &["change_column"],
usage_ids: &["parse.usage.change_column"],};
usage_ids: &["parse.usage.change_column"],
};
// =================================================================
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
@@ -1034,9 +1041,9 @@ const COL_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COL_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -1054,7 +1061,14 @@ const NOT_NULL_NODES: &[Node] = &[
];
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);
const UNIQUE_CONSTRAINT: Node = Node::Word(Word::keyword("unique"));
// Tagged as the `unique` clause-concept region (issue #37). Shared
// across `create table … with pk`, `add constraint`, and (as a bare
// keyword) `drop constraint`, so the concept surfaces anywhere a
// unique constraint is being declared or removed.
const UNIQUE_CONSTRAINT: Node = Node::Concept {
topic: "unique",
inner: &Node::Word(Word::keyword("unique")),
};
const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
Node::Word(Word::keyword("default")),
@@ -1072,10 +1086,20 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
Node::Subgrammar(&super::expr::OR_EXPR),
Node::Punct(')'),
];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
// Tagged as the `check` clause-concept region (issue #37). The span
// covers the whole `check ( <expr> )`, so a cursor inside the
// predicate still resolves the concept.
const CHECK_CONSTRAINT: Node = Node::Concept {
topic: "check",
inner: &Node::Seq(CHECK_CONSTRAINT_NODES),
};
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
NOT_NULL_CONSTRAINT,
UNIQUE_CONSTRAINT,
DEFAULT_CONSTRAINT,
CHECK_CONSTRAINT,
];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
/// Zero-or-more constraints — the suffix after a column's
@@ -1114,8 +1138,7 @@ const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);
// `writes_table: true` on the table ident (via `TABLE_NAME_
// EXISTING`) narrows the `.<column>` slot's completion
// candidates to that table's columns.
const CONSTRAINT_TARGET_NODES: &[Node] =
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
const ADD_CONSTRAINT_NODES: &[Node] = &[
@@ -1145,9 +1168,9 @@ const COL_SPEC_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct(')'),
COLUMN_CONSTRAINT_SUFFIX,
@@ -1161,11 +1184,20 @@ const SPEC_LIST: Node = Node::Repeated {
};
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
const WITH_PK_NODES: &[Node] = &[
// The `with pk` keywords, tagged as the `primary_key` clause-concept
// region (issue #37). Wrapping just the keywords (not the column
// `SPEC_LIST` that follows) keeps the concept crisp: a cursor on
// `with pk` teaches primary keys, while a cursor on a column's own
// `unique` / `check` constraint resolves to that narrower concept.
const WITH_PK_MARKER_NODES: &[Node] = &[
Node::Word(Word::keyword("with")),
Node::Word(Word::keyword("pk")),
SPEC_LIST_OPT,
];
const WITH_PK_MARKER: Node = Node::Concept {
topic: "primary_key",
inner: &Node::Seq(WITH_PK_MARKER_NODES),
};
const WITH_PK_NODES: &[Node] = &[WITH_PK_MARKER, SPEC_LIST_OPT];
const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
@@ -1275,10 +1307,14 @@ fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, Vali
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1380,7 +1416,8 @@ pub static CREATE: CommandNode = CommandNode {
ast_builder: build_create_table,
help_id: Some("ddl.create"),
hint_ids: &["create_table"],
usage_ids: &["parse.usage.create_table"],};
usage_ids: &["parse.usage.create_table"],
};
// =================================================================
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
@@ -1422,10 +1459,22 @@ const M2N_T2: Node = Node::Ident {
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
const CREATE_M2N_NODES: &[Node] = &[
// The `m:n` cardinality marker, tagged as the
// `cardinality_many_to_many` clause-concept region (issue #37).
// Mirrors `CARDINALITY_1N` — a distinct sub-`Seq` so the span covers
// exactly `m:n`.
const CARDINALITY_MN_NODES: &[Node] = &[
Node::Literal("m"),
Node::Punct(':'),
Node::Word(Word::keyword("n")),
];
const CARDINALITY_MN: Node = Node::Concept {
topic: "cardinality_many_to_many",
inner: &Node::Seq(CARDINALITY_MN_NODES),
};
const CREATE_M2N_NODES: &[Node] = &[
CARDINALITY_MN,
Node::Word(Word::keyword("relationship")),
Node::Word(Word::keyword("from")),
M2N_T1,
@@ -1506,11 +1555,15 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
while let Some(item) = items.next() {
match &item.kind {
// A column name stashes until its type finalises the spec.
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
// Single-word type — resolve through the SQL alias map.
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1533,7 +1586,9 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
column_open = true;
}
// A table-level `PRIMARY KEY (col, …)` column reference.
MatchedKind::Ident { role: "pk_column", .. } => {
MatchedKind::Ident {
role: "pk_column", ..
} => {
primary_key.push(item.text.clone());
}
// `not null` column constraint (only once a column exists;
@@ -1557,7 +1612,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
let mut cols: Vec<String> = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Ident { role: "unique_column", .. } => {
MatchedKind::Ident {
role: "unique_column",
..
} => {
cols.push(it.text.clone());
items.next();
}
@@ -1575,7 +1633,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// column's flag (round-trips via the single-column
// path); composite (or a name not among the
// columns) becomes a constraint.
match columns.iter_mut().find(|c| cols.len() == 1 && c.name == cols[0]) {
match columns
.iter_mut()
.find(|c| cols.len() == 1 && c.name == cols[0])
{
Some(c) => c.unique = true,
None if !cols.is_empty() => unique_constraints.push(cols),
None => {}
@@ -1588,16 +1649,17 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// the most recent column) or the table-level clause (whose
// `pk_column` idents follow and are collected above).
MatchedKind::Word("primary") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
// Table-level `PRIMARY KEY (…)` is followed by `(`
// (then `pk_column` idents, collected above);
// column-level `PRIMARY KEY` is not, and marks the
// most-recent column.
let table_level = matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Punct('('))
);
let table_level =
matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')));
if !table_level && let Some(last) = columns.last() {
primary_key.push(last.name.clone());
}
@@ -1647,12 +1709,20 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// Inline FK is single-column (the column it sits on);
// a compound FK uses the table-level form (ADR-0043 D4).
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true));
foreign_keys.push(consume_fk_reference(
&mut items,
None,
vec![child_column],
true,
));
}
// Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
MatchedKind::Word("foreign") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next(); // `key`
}
// `( <child column> [, <child column>]* )` — a compound
@@ -1674,7 +1744,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
items.next();
}
// `references <parent> …`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
let fk =
@@ -1859,13 +1932,19 @@ where
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
Some(MatchedKind::Word("set")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
}
ReferentialAction::SetNull
}
Some(MatchedKind::Word("no")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("action"))
) {
items.next();
}
ReferentialAction::NoAction
@@ -1933,11 +2012,12 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
// builder reads `unique` presence via `contains_word("unique")`.
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("unique")),
Node::Word(Word::keyword("index")),
];
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
static SQL_CI_LEAD_CHOICES: &[Node] =
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
@@ -2104,8 +2184,7 @@ static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
NEW_COLUMN_NAME,
];
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
static AT_RENAME_TABLE_TAIL_NODES: &[Node] =
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL];
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
@@ -2132,8 +2211,10 @@ static AT_AC_TYPE_NODES: &[Node] = &[
super::sql_create_table::SQL_TYPE,
];
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
static AT_AC_NOT_NULL_NODES: &[Node] =
&[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))];
static AT_AC_NOT_NULL_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("null")),
];
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
Node::Word(Word::keyword("data")),
@@ -2149,8 +2230,7 @@ static AT_AC_SET_TAIL_CHOICES: &[Node] = &[
const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES);
static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL];
const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
static AT_AC_DROP_TAIL_CHOICES: &[Node] =
&[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES);
static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL];
const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES);
@@ -2258,10 +2338,14 @@ fn build_alter_add_column_spec(
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -2280,7 +2364,10 @@ fn build_alter_add_column_spec(
spec = Some(ColumnSpec::new(name, Type::Real));
}
MatchedKind::Word("not") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
if let Some(s) = spec.as_mut() {
s.not_null = true;
@@ -2326,11 +2413,15 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_type", .. } => {
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?);
MatchedKind::Ident {
role: "col_type", ..
} => {
ty = Some(
Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?,
);
}
MatchedKind::Word("double") => {
if matches!(
@@ -2379,7 +2470,10 @@ fn build_alter_column_attr(
message_key: "parse.error_wrapper",
args: vec![("detail", "set default needs a value".to_string())],
})?;
AlterTableAction::SetColumnDefault { column, default_sql }
AlterTableAction::SetColumnDefault {
column,
default_sql,
}
}
(false, true) => AlterTableAction::DropColumnDefault { column },
(true, false) => AlterTableAction::SetColumnNotNull { column },
@@ -2495,10 +2589,7 @@ fn build_alter_add_table_constraint(
/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
/// `sql_expr` is validate-only, so the expression is captured by byte
/// span — the 4a.2 / 4e mechanism.
fn capture_table_check_sql(
path: &MatchedPath,
source: &str,
) -> Result<String, ValidationError> {
fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result<String, ValidationError> {
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
if matches!(item.kind, MatchedKind::Word("check"))
@@ -2528,7 +2619,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
items.next();
}
items.next(); // `foreign`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
@@ -2548,7 +2642,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
@@ -2626,7 +2723,10 @@ mod constraint_tests {
fn an_unconstrained_create_table_still_parses() {
let cols = create_columns("create table T with pk id(serial), name(text)");
assert_eq!(cols.len(), 2);
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
assert!(
cols.iter()
.all(|c| !c.not_null && !c.unique && c.default.is_none())
);
}
#[test]
@@ -2651,7 +2751,9 @@ mod constraint_tests {
#[test]
fn add_column_parses_a_unique_constraint() {
match parse_command("add column to T: email (text) unique").expect("parse") {
Command::AddColumn { unique, not_null, .. } => {
Command::AddColumn {
unique, not_null, ..
} => {
assert!(unique);
assert!(!not_null);
}
@@ -2682,9 +2784,7 @@ mod constraint_tests {
fn check_with_a_parenthesised_sub_expression_parses() {
// The check's own parens plus a nested group — the
// builder's paren-depth scan must pair them correctly.
let cols = create_columns(
"create table T with pk n(int) check ((n > 0) or (n < -10))",
);
let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))");
assert!(cols[0].check.is_some());
}
@@ -2731,8 +2831,7 @@ mod constraint_tests {
#[test]
fn add_constraint_check_parses() {
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse")
{
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") {
Command::AddConstraint {
column, constraint, ..
} => {
@@ -2826,8 +2925,11 @@ mod sql_drop_table_tests {
Command::DropColumn { .. }
));
assert!(matches!(
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced)
.expect("parses"),
parse_command_in_mode(
"drop relationship Customers_id_to_Orders_CustId",
Mode::Advanced
)
.expect("parses"),
Command::DropRelationship { .. }
));
}
@@ -2932,7 +3034,13 @@ mod sql_create_index_tests {
columns,
unique,
if_not_exists,
} => Ci { name, table, columns, unique, if_not_exists },
} => Ci {
name,
table,
columns,
unique,
if_not_exists,
},
other => panic!("expected SqlCreateIndex, got {other:?}"),
}
}
@@ -3134,7 +3242,9 @@ mod sql_alter_table_tests {
// The target slot carries the `reject_internal_table` validator
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
// before submit — engine-neutral, not a raw engine error.
assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err());
assert!(
parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err()
);
}
#[test]
@@ -3213,7 +3323,10 @@ mod sql_alter_table_tests {
// alias map still applies through the synonym
assert!(matches!(
alter("alter table T alter column n set data type double precision").1,
AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. }
AlterTableAction::AlterColumnType {
ty: crate::dsl::types::Type::Real,
..
}
));
}
@@ -3238,7 +3351,10 @@ mod sql_alter_table_tests {
#[test]
fn alter_column_set_default_captures_raw_expr() {
match alter("alter table T alter column qty set default 0").1 {
AlterTableAction::SetColumnDefault { column, default_sql } => {
AlterTableAction::SetColumnDefault {
column,
default_sql,
} => {
assert_eq!(column, "qty");
assert_eq!(default_sql, "0");
}
@@ -3317,7 +3433,9 @@ mod sql_alter_table_tests {
match alter("alter table T add check (a < b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b"));
assert!(
matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b")
);
}
other => panic!("expected AddTableConstraint/Check, got {other:?}"),
}
@@ -3335,7 +3453,9 @@ mod sql_alter_table_tests {
match alter("alter table T add unique (a, b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()]));
assert!(
matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()])
);
}
other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
}
@@ -3352,7 +3472,9 @@ mod sql_alter_table_tests {
)
.expect_err("a named UNIQUE constraint is refused");
assert!(
err.to_string().to_lowercase().contains("unique constraint cannot be named"),
err.to_string()
.to_lowercase()
.contains("unique constraint cannot be named"),
"expected the builder's named-UNIQUE refusal, got: {err}"
);
}
@@ -3364,7 +3486,9 @@ mod sql_alter_table_tests {
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
.expect_err("ADD PRIMARY KEY is refused");
assert!(
err.to_string().to_lowercase().contains("primary key is fixed at creation"),
err.to_string()
.to_lowercase()
.contains("primary key is fixed at creation"),
"expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
);
}
@@ -3392,7 +3516,10 @@ mod sql_alter_table_tests {
assert_eq!(name.as_deref(), Some("fk_p"));
match *constraint {
TableConstraint::ForeignKey(fk) => {
assert_eq!(fk.parent_columns, None, "bare reference resolves at execution");
assert_eq!(
fk.parent_columns, None,
"bare reference resolves at execution"
);
}
other => panic!("expected ForeignKey, got {other:?}"),
}
+24 -35
View File
@@ -79,9 +79,9 @@ const EXPR_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Operand alternatives. The literal keywords (`null` / `true`
@@ -126,8 +126,7 @@ fn where_rhs_operand(ctx: &WalkContext) -> Node {
// the leak is per distinct column (the walker
// memoizes `DynamicSubgrammar` resolution on
// `current_column`), not per keystroke.
let leaked: &'static str =
Box::leak(col.name.clone().into_boxed_str());
let leaked: &'static str = Box::leak(col.name.clone().into_boxed_str());
Node::TypedValueSlot {
ty: col.user_type,
column_name: Some(leaked),
@@ -260,10 +259,8 @@ static PAREN_GROUP_NODES: &[Node] = &[
Node::Subgrammar(&OR_EXPR),
Node::Punct(')'),
];
static BOOL_PRIMARY_CHOICES: &[Node] = &[
Node::Seq(PAREN_GROUP_NODES),
Node::Subgrammar(&PREDICATE),
];
static BOOL_PRIMARY_CHOICES: &[Node] =
&[Node::Seq(PAREN_GROUP_NODES), Node::Subgrammar(&PREDICATE)];
static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES);
/// `not_expr := NOT not_expr | bool_primary`.
@@ -271,10 +268,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&BOOL_PRIMARY),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&BOOL_PRIMARY)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
/// `and_expr := not_expr ( AND not_expr )*`.
@@ -296,10 +290,7 @@ static AND_EXPR: Node = Node::Seq(AND_EXPR_NODES);
/// `or_expr := and_expr ( OR and_expr )*` — the fragment entry
/// point. `update` / `delete` / `show data` reference this
/// through `Node::Subgrammar(&OR_EXPR)`.
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -534,18 +525,18 @@ impl<'a> ExprParser<'a> {
let span = item.span;
let literal = |value: Value| Operand::Literal { value, span };
match &item.kind {
MatchedKind::Ident { role: "expr_column", .. } => {
Ok(Operand::Column { name: item.text.clone(), span })
}
MatchedKind::Ident {
role: "expr_column",
..
} => Ok(Operand::Column {
name: item.text.clone(),
span,
}),
MatchedKind::Word("null") => Ok(literal(Value::Null)),
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
MatchedKind::NumberLit => {
Ok(literal(Value::Number(item.text.clone())))
}
MatchedKind::StringLit => {
Ok(literal(Value::Text(item.text.clone())))
}
MatchedKind::NumberLit => Ok(literal(Value::Number(item.text.clone()))),
MatchedKind::StringLit => Ok(literal(Value::Text(item.text.clone()))),
_ => Err(drift_error("expected a column or literal operand")),
}
}
@@ -591,8 +582,7 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
match result {
NodeWalkResult::Matched { end, .. } => {
assert!(
@@ -730,8 +720,7 @@ mod tests {
negated: false,
}),
);
let Expr::Predicate(Predicate::Like { negated, .. }) =
parse_expr("Name not like 'A%'")
let Expr::Predicate(Predicate::Like { negated, .. }) = parse_expr("Name not like 'A%'")
else {
panic!("expected a negated Like");
};
@@ -794,16 +783,16 @@ mod tests {
fn nested_parentheses_round_trip() {
// Exercises the Subgrammar recursion a few levels deep.
let expr = parse_expr("((a = 1 and b = 2) or (c = 3))");
assert!(matches!(expr, Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)));
assert!(matches!(
expr,
Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)
));
}
#[test]
fn case_insensitive_keywords() {
// Keywords fold case; the built tree is identical.
assert_eq!(
parse_expr("a = 1 AND b = 2"),
parse_expr("a = 1 and b = 2"),
);
assert_eq!(parse_expr("a = 1 AND b = 2"), parse_expr("a = 1 and b = 2"),);
assert_eq!(
parse_expr("Email IS NOT NULL"),
parse_expr("Email is not null"),
+288 -30
View File
@@ -27,9 +27,9 @@ pub mod data;
pub mod ddl;
pub mod expr;
pub mod shared;
pub mod sql_expr;
pub mod sql_create_table;
pub mod sql_delete;
pub mod sql_expr;
pub mod sql_insert;
pub mod sql_select;
pub mod sql_update;
@@ -328,9 +328,7 @@ pub enum Node {
/// A number literal. The optional `validator` runs against
/// the matched text (used by Phase D value slots to enforce
/// per-type integer/decimal rules).
NumberLit {
validator: Option<NumberValidator>,
},
NumberLit { validator: Option<NumberValidator> },
/// A literal byte sequence at this position — matches
/// bytes verbatim (whitespace-skipped) with a lookahead so
/// `1` doesn't half-match `12` and `n` doesn't half-match
@@ -478,6 +476,26 @@ pub enum Node {
mode: HintMode,
inner: &'static Self,
},
/// Annotates `inner` as a recognized *clause-concept* region
/// (issue #37 / ADR clause-concept-hints, D1). Transparent to
/// matching, highlighting and the expected-set — it walks
/// `inner` and returns its result verbatim — but as a side
/// effect the walker records the byte span the clause covered
/// into `WalkContext::concept_spans`, tagged with `topic` (a
/// `hint.concept.<topic>` catalogue key). The F1 hint surface
/// reads those spans to layer a clause-level teaching block on
/// top of the per-form `hint.cmd.*` block when the cursor sits
/// inside the clause.
///
/// Sibling of `Hinted`, but where `Hinted` records a single
/// *pending* slot mode (cleared on the next match, so it marks
/// the slot the cursor is *about to fill*), `Concept` appends a
/// durable span that survives the clause being fully matched —
/// the "cursor anywhere inside the clause" semantics #37 wants.
Concept {
topic: &'static str,
inner: &'static Self,
},
}
/// Which mode group a registered command belongs to (ADR-0030
@@ -539,8 +557,11 @@ pub struct CommandNode {
/// block). `hint_key_for_input_in_mode` disambiguates by the form
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
/// until a form's tier-3 block is authored (the surface falls back
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
/// `None` on advanced-SQL forms purely to dedup the `help` list).
/// to tier-2 ambient/error text). Parallel to `help_id` but
/// finer-grained: every form (simple and advanced) carries a
/// `hint_id`, whereas the `help <topic>` view groups forms by entry
/// word (so a shared-entry simple + SQL pair both surface under e.g.
/// `help insert`).
pub hint_ids: &'static [&'static str],
/// Catalog keys under `parse.usage.*` to render in the
/// "usage:" block when a parse error fires for this command
@@ -701,7 +722,11 @@ fn selected_nodes_for_input_in_mode(
.filter(|(_, _, c)| *c == CommandCategory::Simple)
.collect()
};
if selected.is_empty() { candidates } else { selected }
if selected.is_empty() {
candidates
} else {
selected
}
}
/// The single usage template most relevant to `source`, when
@@ -724,10 +749,7 @@ pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
/// disambiguates the single most-relevant usage key from the
/// mode-selected key set.
#[must_use]
pub fn usage_key_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<&'static str> {
pub fn usage_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
pick_form_key(source, &keys)
}
@@ -755,7 +777,10 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
}
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it; its key ends `…m2n`.
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
if source[after..]
.get(..3)
.is_some_and(|s| s.eq_ignore_ascii_case("m:n"))
{
return keys.iter().copied().find(|k| k.ends_with("m2n"));
}
// Otherwise the form word is an identifier — `column`, `index`,
@@ -770,8 +795,7 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
/// which read the same data through the legacy `usage::REGISTRY`.
#[must_use]
pub fn entry_words_alphabetised() -> Vec<&'static str> {
let mut words: Vec<&'static str> =
REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
let mut words: Vec<&'static str> = REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
words.sort_unstable();
words.dedup();
words
@@ -791,6 +815,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&app::QUIT, CommandCategory::Simple),
(&app::HELP, CommandCategory::Simple),
(&app::HINT, CommandCategory::Simple),
(&app::VERSION, CommandCategory::Simple),
(&app::REBUILD, CommandCategory::Simple),
(&app::SAVE, CommandCategory::Simple),
(&app::NEW, CommandCategory::Simple),
@@ -904,9 +929,7 @@ pub fn command_for_entry_word(word: &str) -> Option<(usize, &'static CommandNode
/// returns its `Simple` DSL node and `Advanced` SQL node. The
/// dispatcher picks among them by the active input mode.
#[must_use]
pub fn commands_for_entry_word(
word: &str,
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
pub fn commands_for_entry_word(word: &str) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
REGISTRY
.iter()
.enumerate()
@@ -1009,7 +1032,246 @@ mod hint_key_tests {
];
for c in classes {
let key = format!("hint.err.{c}.what");
assert!(cat.get(&key).is_some(), "missing tier-3 error block `{key}`");
assert!(
cat.get(&key).is_some(),
"missing tier-3 error block `{key}`"
);
}
}
/// Semantic-verification guard (handoff-71): every `hint.cmd.<form>`
/// **example** must parse in the mode the form is taught for. This
/// backstops the bug class found in the H2 corpus pass — an example
/// that drifts out of the real grammar (a typo, a removed clause, or
/// an argument the command never accepted, e.g. an inline name on
/// `save as` which opens a modal instead). It cannot police the
/// *semantics* of an example that happens to parse (that is the
/// manual pass), but it locks the syntactic floor so future edits
/// can't ship an unparseable teaching line.
///
/// The mode per form mirrors `hint_key_for_input_in_mode`: the
/// advanced-SQL forms are taught in advanced mode; everything else
/// (DSL + app commands) in simple mode.
#[test]
fn every_cmd_hint_example_parses_in_its_mode() {
use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode;
// Advanced-mode forms — the SQL surface (ADR-00300039). Every
// other form (DSL + app commands) is taught in simple mode. This
// mirrors the mode split `hint_key_for_input_in_mode` resolves.
const ADVANCED: &[&str] = &[
"sql_create_table",
"sql_alter_table",
"sql_create_index",
"sql_drop_index",
"sql_drop_table",
"sql_insert",
"sql_update",
"sql_delete",
"select",
"with",
"explain_sql",
];
// Iterate the *catalog* (the corpus is the source of truth), not the
// REGISTRY: this reaches every `hint.cmd.<id>` block including any
// not owned by a command node, so an orphaned or mis-keyed example
// can't slip past the guard.
let cat = crate::friendly::catalog();
let mut checked = 0usize;
for key in cat.keys() {
let Some(id) = key
.strip_prefix("hint.cmd.")
.and_then(|rest| rest.strip_suffix(".example"))
else {
continue;
};
let example = cat.get(key).expect("key came from the catalog");
let mode = if ADVANCED.contains(&id) {
Mode::Advanced
} else {
Mode::Simple
};
assert!(
parse_command_in_mode(example, mode).is_ok(),
"hint.cmd.{id}.example does not parse in {mode:?} mode: {example:?}",
);
checked += 1;
}
// Floor guard: the corpus had 49 command forms at the time of
// writing (ADR-0053). If this drops, a block (and its example
// coverage) silently vanished.
assert!(
checked >= 49,
"expected at least 49 hint.cmd.* examples, checked {checked}",
);
}
// ── Clause-concept comprehensiveness (issue #37 / clause-concept-
// hints D7) ────────────────────────────────────────────────────
/// A concept topic's mode reachability — drives both the example
/// shape (both-mode topics carry `example.simple`+`.advanced`;
/// single-mode topics a plain `example`) and the mode each example
/// must parse in.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ConceptModes {
Both,
SimpleOnly,
AdvancedOnly,
}
/// Every clause-concept topic + its mode coverage (D4). Source of
/// truth the gates below cross-check against the grammar wrappers
/// and the catalogue, so a wrapper or block can't drift out of sync.
const CONCEPT_TOPICS: &[(&str, ConceptModes)] = &[
("referential_actions", ConceptModes::Both),
("cardinality_one_to_many", ConceptModes::SimpleOnly),
("cardinality_many_to_many", ConceptModes::SimpleOnly),
("primary_key", ConceptModes::Both),
("unique", ConceptModes::Both),
("check", ConceptModes::Both),
("foreign_key", ConceptModes::AdvancedOnly),
];
/// Recursively collect every `Node::Concept` topic reachable from a
/// shape, following the static-child edges **and** `Subgrammar`/
/// `ScopedSubgrammar` references (the SQL `create table` body — and
/// thus the advanced `foreign_key` / constraint wrappers — hangs off
/// the command shape through a `Subgrammar`). Cycles in the
/// expression grammar are broken by a visited-by-address set; every
/// `Node` lives in static memory, so its address is a stable
/// identity. `DynamicSubgrammar` / `Lookahead` build nodes at walk
/// time and can't be traversed statically — no concept wrapper hides
/// behind one (asserted indirectly: every declared topic is reached).
fn collect_concept_topics(
node: &super::Node,
visited: &mut std::collections::BTreeSet<usize>,
out: &mut std::collections::BTreeSet<&'static str>,
) {
use super::Node;
let addr = std::ptr::from_ref(node) as usize;
if !visited.insert(addr) {
return;
}
match node {
Node::Concept { topic, inner } => {
out.insert(topic);
collect_concept_topics(inner, visited, out);
}
Node::Seq(children) | Node::Choice(children) => {
for c in *children {
collect_concept_topics(c, visited, out);
}
}
Node::Optional(inner)
| Node::Repeated { inner, .. }
| Node::Hinted { inner, .. }
| Node::TypedValueSlot { inner, .. }
| Node::Subgrammar(inner)
| Node::ScopedSubgrammar(inner) => {
collect_concept_topics(inner, visited, out);
}
_ => {}
}
}
/// Gate: the set of `Node::Concept` topics wired into the grammar
/// exactly matches `CONCEPT_TOPICS` — neither a wrapper without a
/// declared topic, nor a declared topic without a wrapper.
#[test]
fn concept_wrappers_match_declared_topics() {
let mut found = std::collections::BTreeSet::new();
let mut visited = std::collections::BTreeSet::new();
for (node, _category) in super::REGISTRY {
collect_concept_topics(&node.shape, &mut visited, &mut found);
}
let declared: std::collections::BTreeSet<&str> =
CONCEPT_TOPICS.iter().map(|(t, _)| *t).collect();
for t in &found {
assert!(
declared.contains(t),
"grammar has Node::Concept topic {t:?} missing from CONCEPT_TOPICS",
);
}
for (t, _) in CONCEPT_TOPICS {
assert!(
found.contains(t),
"CONCEPT_TOPICS lists {t:?} but no Node::Concept wrapper reaches it",
);
}
}
/// Gate: every topic resolves to a `hint.concept.<topic>` block with
/// `what` + `concept`, and the example shape matches its mode
/// coverage (mode-keyed for Both, plain for single-mode). `keys.rs`
/// checks referenced keys resolve; this checks every topic *has* a
/// block of the right shape.
#[test]
fn every_concept_topic_has_a_block() {
let cat = crate::friendly::catalog();
for (topic, modes) in CONCEPT_TOPICS {
for part in ["what", "concept"] {
assert!(
cat.get(&format!("hint.concept.{topic}.{part}")).is_some(),
"missing hint.concept.{topic}.{part}",
);
}
let simple = cat.get(&format!("hint.concept.{topic}.example.simple"));
let advanced = cat.get(&format!("hint.concept.{topic}.example.advanced"));
let plain = cat.get(&format!("hint.concept.{topic}.example"));
match modes {
ConceptModes::Both => {
assert!(
simple.is_some() && advanced.is_some() && plain.is_none(),
"{topic} is Both: needs example.simple + example.advanced, no plain example"
);
}
ConceptModes::SimpleOnly | ConceptModes::AdvancedOnly => {
assert!(
plain.is_some() && simple.is_none() && advanced.is_none(),
"{topic} is single-mode: needs a plain example, no mode-keyed variants"
);
}
}
}
}
/// Semantic guard (mirrors `every_cmd_hint_example_parses_in_its_mode`):
/// every clause-concept example parses in the mode it is taught for,
/// so an example can't drift out of the real grammar.
#[test]
fn every_concept_example_parses_in_its_mode() {
use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode;
let cat = crate::friendly::catalog();
let parses = |key: &str, mode: Mode| {
let example = cat.get(key).unwrap_or_else(|| panic!("missing {key}"));
assert!(
parse_command_in_mode(example, mode).is_ok(),
"{key} does not parse in {mode:?} mode: {example:?}",
);
};
for (topic, modes) in CONCEPT_TOPICS {
match modes {
ConceptModes::Both => {
parses(
&format!("hint.concept.{topic}.example.simple"),
Mode::Simple,
);
parses(
&format!("hint.concept.{topic}.example.advanced"),
Mode::Advanced,
);
}
ConceptModes::SimpleOnly => {
parses(&format!("hint.concept.{topic}.example"), Mode::Simple);
}
ConceptModes::AdvancedOnly => {
parses(&format!("hint.concept.{topic}.example"), Mode::Advanced);
}
}
}
}
}
@@ -1028,10 +1290,7 @@ mod usage_key_tests {
let cases = [
("add column to T: c (int)", "parse.usage.add_column"),
("add index on T (c)", "parse.usage.add_index"),
(
"add constraint unique to T.c",
"parse.usage.add_constraint",
),
("add constraint unique to T.c", "parse.usage.add_constraint"),
(
"drop constraint check from T.c",
"parse.usage.drop_constraint",
@@ -1048,10 +1307,7 @@ mod usage_key_tests {
("drop table T", "parse.usage.drop_table"),
("drop column from table T: c", "parse.usage.drop_column"),
("drop index i", "parse.usage.drop_index"),
(
"drop relationship r",
"parse.usage.drop_relationship",
),
("drop relationship r", "parse.usage.drop_relationship"),
("show data T", "parse.usage.show_data"),
("show table T", "parse.usage.show_table"),
// `create` is multi-form (table vs m:n, ADR-0045): each typed
@@ -1090,10 +1346,12 @@ mod usage_key_tests {
#[test]
fn no_two_registered_commands_share_a_help_id() {
// `note_help` emits one help block per `help_id: Some(_)`
// with no dedup, so a duplicate help_id prints the same
// command twice in `help`. Shared-entry-word `Advanced`
// nodes (SQL_INSERT, …, EXPLAIN_SQL) therefore carry
// `help_id: None` and defer to their `Simple` sibling.
// with no dedup, so a duplicate help_id string prints the same
// block twice. Distinct help_ids are fine — a shared-entry-word
// simple + SQL pair (e.g. `data.insert` + `data.sql_insert`,
// issue #36) each get their own block, grouped under one topic
// by `help <topic>` and split across the simple/advanced
// sections of the full list.
let mut seen = std::collections::HashSet::new();
for (command, _category) in super::REGISTRY {
if let Some(id) = command.help_id {
+31 -31
View File
@@ -7,8 +7,8 @@
use crate::completion::TableColumn;
use crate::dsl::grammar::{
HighlightClass, HintMode, IdentSource, IdentValidator, Node,
NumberValidator, ValidationError, Word,
HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError,
Word,
};
use crate::dsl::types::Type;
use crate::dsl::walker::context::WalkContext;
@@ -32,10 +32,7 @@ pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
.join(", ");
Err(ValidationError {
message_key: "parse.custom.unknown_type",
args: vec![
("found", value.to_string()),
("expected", expected),
],
args: vec![("found", value.to_string()), ("expected", expected)],
})
}
}
@@ -51,12 +48,12 @@ pub const TYPE_SLOT: Node = Node::Ident {
role: "type",
validator: Some(TYPE_VALIDATOR),
highlight_override: Some(HighlightClass::Type),
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// --- Qualified column reference (`<Table>.<Column>`) --------------
@@ -70,9 +67,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -83,9 +80,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES);
@@ -136,12 +133,25 @@ pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES);
/// Repeated `on <target> <action>` clauses (0..2 occurrences).
/// Validation of "specified twice" + max=2 lives in the
/// command's AST builder.
pub const REFERENTIAL_CLAUSES: Node = Node::Repeated {
const REFERENTIAL_CLAUSES_INNER: Node = Node::Repeated {
inner: &ON_CLAUSE,
separator: None,
min: 0,
};
/// As [`REFERENTIAL_CLAUSES_INNER`], tagged as the
/// `referential_actions` clause-concept region (issue #37).
///
/// Shared by the simple-mode `add 1:n relationship … on delete/update`
/// and the advanced-mode `references … on delete/update` clause, so the
/// one wrapper surfaces the concept in both modes (the mode-keyed
/// example is chosen by the live mode at F1 time, not by which grammar
/// matched).
pub const REFERENTIAL_CLAUSES: Node = Node::Concept {
topic: "referential_actions",
inner: &REFERENTIAL_CLAUSES_INNER,
};
// =================================================================
// Typed value slots (ADR-0024 §Phase D, §typed-value-slots)
// =================================================================
@@ -266,11 +276,6 @@ const DATETIME_SLOT: Node = Node::TypedValueSlot {
column_name: None,
inner: &TEXT_SLOT_INNER,
};
const BLOB_SLOT: Node = Node::TypedValueSlot {
ty: Type::Blob,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
// shortid columns store base58 text (ADR-0011 fk_target_type
// shortid → text); the slot accepts a quoted-text literal or
// null.
@@ -300,7 +305,6 @@ pub const fn slot_for_type(ty: Type) -> Node {
Type::Text => TEXT_SLOT,
Type::Date => DATE_SLOT,
Type::DateTime => DATETIME_SLOT,
Type::Blob => BLOB_SLOT,
}
}
@@ -313,9 +317,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node {
Type::Real => &REAL_SLOT_INNER,
Type::Decimal => &DECIMAL_SLOT_INNER,
Type::Bool => &BOOL_SLOT_INNER,
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => {
&TEXT_SLOT_INNER
}
Type::Text | Type::Date | Type::DateTime | Type::ShortId => &TEXT_SLOT_INNER,
}
}
@@ -397,9 +399,7 @@ pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated {
/// This is the single source of truth shared by [`column_value_list`]
/// (which builds the typed slots) and the `data.rs` arity gate (which
/// counts them) so the two never disagree (issue #17).
pub fn insert_target_columns<'c>(
ctx: &'c WalkContext<'_>,
) -> Option<Vec<&'c TableColumn>> {
pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option<Vec<&'c TableColumn>> {
let table_cols = ctx.current_table_columns.as_ref()?;
if table_cols.is_empty() {
return None;
+101 -30
View File
@@ -204,16 +204,38 @@ static REFERENCES_NODES: &[Node] = &[
shared::REFERENTIAL_CLAUSES,
];
const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES);
// The `foreign_key` clause-concept region (issue #37) — wraps the
// whole `references …` clause. It contains `shared::REFERENTIAL_CLAUSES`
// (the `referential_actions` concept), so a cursor on `references
// <parent>` resolves to `foreign_key` while a cursor on the inner `on
// delete …` resolves to the narrower `referential_actions` (D2
// innermost-wins).
const REFERENCES_CONCEPT: Node = Node::Concept {
topic: "foreign_key",
inner: &REFERENCES_CLAUSE,
};
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` |
// `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the
// `Choice` never ambiguously commits.
// `Choice` never ambiguously commits. The relational-concept branches
// (`unique`, `primary key`, `check`, `references`) carry a
// `Node::Concept` tag (issue #37); `not null` / `default` deliberately
// do not (D4 omission).
static COL_CONSTRAINT_CHOICES: &[Node] = &[
Node::Seq(NOT_NULL_NODES),
Node::Word(Word::keyword("unique")),
Node::Seq(PRIMARY_KEY_NODES),
Node::Concept {
topic: "unique",
inner: &Node::Word(Word::keyword("unique")),
},
Node::Concept {
topic: "primary_key",
inner: &Node::Seq(PRIMARY_KEY_NODES),
},
Node::Seq(DEFAULT_NODES),
Node::Seq(CHECK_NODES),
REFERENCES_CLAUSE,
Node::Concept {
topic: "check",
inner: &Node::Seq(CHECK_NODES),
},
REFERENCES_CONCEPT,
];
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
/// Zero-or-more column constraints after the type (`min: 0`).
@@ -405,8 +427,14 @@ const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES);
// / `foreign`) that disambiguates it from a column name. (A column
// literally named with one of those keywords is therefore unavailable,
// the same trade real SQL makes with its reserved words.)
static ELEMENT_CHOICES: &[Node] =
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF];
static ELEMENT_CHOICES: &[Node] = &[
TABLE_PK,
TABLE_UNIQUE,
TABLE_CHECK,
TABLE_FK_NAMED,
TABLE_FK,
COLUMN_DEF,
];
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
// Issue #4: wrap the element slot in `IntroProse` so a fresh element
// position (`create table T (` and after every `,`) surfaces a prose
@@ -495,18 +523,31 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_CREATE_TABLE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_CREATE_TABLE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
fn good(input: &str) {
assert!(walks(input), "{input:?} should be a valid CREATE TABLE tail");
assert!(
walks(input),
"{input:?} should be a valid CREATE TABLE tail"
);
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete CREATE TABLE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete CREATE TABLE tail"
);
}
#[test]
@@ -548,7 +589,7 @@ mod tests {
fn standard_sql_type_aliases() {
good("table t (a integer, b varchar, c boolean, d timestamp)");
good("table t (e bigint, f smallint, g char, h numeric)");
good("table t (i binary, j varbinary, k float)");
good("table t (k float)");
}
#[test]
@@ -638,7 +679,9 @@ mod tests {
good("table t (id int, ref int references other(id))");
good("table t (id int, ref int references other)"); // bare ref
good("table t (id int, ref int references other(id) on delete cascade)");
good("table t (id int, ref int references other(id) on update set null on delete restrict)");
good(
"table t (id int, ref int references other(id) on update set null on delete restrict)",
);
good("table t (id int, ref int, foreign key (ref) references other(id))");
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
good(
@@ -691,7 +734,10 @@ mod builder_tests {
assert_eq!(name, "t");
assert_eq!(
cols,
vec![("id".to_string(), Type::Int), ("name".to_string(), Type::Text)]
vec![
("id".to_string(), Type::Int),
("name".to_string(), Type::Text)
]
);
assert!(pk.is_empty(), "no PK declared");
assert!(!ine);
@@ -710,7 +756,7 @@ mod builder_tests {
fn standard_sql_aliases_map_to_playground_types() {
let (_, cols, _, _) = sct(
"create table t (a bigint, b varchar, c boolean, d timestamp, \
e numeric, f float, g binary)",
e numeric, f float)",
);
assert_eq!(
cols,
@@ -721,7 +767,6 @@ mod builder_tests {
("d".to_string(), Type::DateTime),
("e".to_string(), Type::Decimal),
("f".to_string(), Type::Real),
("g".to_string(), Type::Blob),
]
);
}
@@ -740,7 +785,10 @@ mod builder_tests {
let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))");
assert_eq!(
cols,
vec![("a".to_string(), Type::Text), ("b".to_string(), Type::Decimal)]
vec![
("a".to_string(), Type::Text),
("b".to_string(), Type::Decimal)
]
);
}
@@ -780,8 +828,7 @@ mod builder_tests {
fn redundant_constraints_deduped_off_sole_pk_column() {
// ADR-0035 §6.5: advanced mode accepts the redundant spelling
// and silently drops the flags off the sole PK column.
match parse_command("create table t (id int primary key not null unique)")
.expect("parses")
match parse_command("create table t (id int primary key not null unique)").expect("parses")
{
Command::SqlCreateTable {
columns,
@@ -944,8 +991,7 @@ mod builder_tests {
// depth 2, not an element boundary, so the following `check`
// is still column-level. A naive "reset on any comma" would
// misclassify it as table-level (the §4.2 probe).
let (cols, checks) =
parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
let (cols, checks) = parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
assert_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0"));
assert!(checks.is_empty(), "no table-level CHECK was produced");
}
@@ -977,8 +1023,7 @@ mod builder_tests {
fn table_check_before_a_later_column_is_table_level() {
// A CHECK element that appears between columns (not after a
// column's type) is table-level even though more columns follow.
let (cols, checks) =
parse_sct_checks("create table t (a int, check (a > 0), b int)");
let (cols, checks) = parse_sct_checks("create table t (a int, check (a > 0), b int)");
assert_eq!(checks, vec!["a > 0".to_string()]);
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
}
@@ -1004,7 +1049,10 @@ mod builder_tests {
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
assert_eq!(fk.on_update, ReferentialAction::NoAction);
assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)");
assert!(
fk.inline,
"a column-level `references` is an inline FK (ADR-0043 D4)"
);
}
#[test]
@@ -1012,14 +1060,19 @@ mod builder_tests {
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
// carry a multi-column reference and never triggers the inline
// "use the table-level form" hint (ADR-0043 D4).
let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
}
#[test]
fn bare_inline_reference_has_no_parent_column() {
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
assert_eq!(fks[0].parent_columns, None, "bare REFERENCES — resolved at execution");
assert_eq!(
fks[0].parent_columns, None,
"bare REFERENCES — resolved at execution"
);
assert_eq!(fks[0].parent_table, "parent");
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
}
@@ -1047,8 +1100,9 @@ mod builder_tests {
#[test]
fn table_level_foreign_key_captured() {
let fks =
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert_eq!(fks.len(), 1);
assert_eq!(fks[0].name, None);
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
@@ -1073,8 +1127,20 @@ mod builder_tests {
foreign key (a) references p(id), foreign key (b) references q(id))",
);
assert_eq!(fks.len(), 2);
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p"));
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q"));
assert_eq!(
(
fks[0].child_columns[0].as_str(),
fks[0].parent_table.as_str()
),
("a", "p")
);
assert_eq!(
(
fks[1].child_columns[0].as_str(),
fks[1].parent_table.as_str()
),
("b", "q")
);
}
#[test]
@@ -1108,7 +1174,12 @@ mod builder_tests {
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
// the column-level CHECK still attaches to `pid`
assert_eq!(
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
columns
.iter()
.find(|c| c.name == "pid")
.unwrap()
.check_sql
.as_deref(),
Some("pid > 0")
);
// the table-level CHECK is captured separately
+12 -2
View File
@@ -82,7 +82,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_DELETE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_DELETE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -93,7 +100,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete DELETE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete DELETE tail"
);
}
#[test]
+31 -63
View File
@@ -82,19 +82,16 @@ const EXPR_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
// or_expr := and_expr ( OR and_expr )* — the fragment entry point
// =================================================================
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static SQL_OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -140,10 +137,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&PREDICATE),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&PREDICATE)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// =================================================================
@@ -156,10 +150,7 @@ static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// needs. ADR-0026's DSL grammar made the tail mandatory because it
// forbade a bare column as a boolean; SQL does not.
static PREDICATE_NODES: &[Node] = &[
Node::Subgrammar(&ADDITIVE),
Node::Optional(&PREDICATE_TAIL),
];
static PREDICATE_NODES: &[Node] = &[Node::Subgrammar(&ADDITIVE), Node::Optional(&PREDICATE_TAIL)];
static PREDICATE: Node = Node::Seq(PREDICATE_NODES);
// ---- cmp_op := <= | <> | >= | != | < | > | = --------------------
@@ -181,10 +172,7 @@ static CMP_OP_CHOICES: &[Node] = &[
// ---- predicate_tail branches ------------------------------------
/// `cmp_op additive`.
static COMPARE_FORM_NODES: &[Node] = &[
Node::Choice(CMP_OP_CHOICES),
Node::Subgrammar(&ADDITIVE),
];
static COMPARE_FORM_NODES: &[Node] = &[Node::Choice(CMP_OP_CHOICES), Node::Subgrammar(&ADDITIVE)];
/// `IS [NOT] NULL`.
static IS_NULL_NODES: &[Node] = &[
@@ -265,11 +253,7 @@ static PREDICATE_TAIL: Node = Node::Choice(PREDICATE_TAIL_CHOICES);
// additive := multiplicative ( ( + | - | || ) multiplicative )*
// =================================================================
static ADD_OP_CHOICES: &[Node] = &[
Node::Punct('+'),
Node::Punct('-'),
Node::Literal("||"),
];
static ADD_OP_CHOICES: &[Node] = &[Node::Punct('+'), Node::Punct('-'), Node::Literal("||")];
static ADD_TAIL_NODES: &[Node] = &[
Node::Choice(ADD_OP_CHOICES),
Node::Subgrammar(&MULTIPLICATIVE),
@@ -289,15 +273,8 @@ static ADDITIVE: Node = Node::Seq(ADDITIVE_NODES);
// multiplicative := unary ( ( * | / | % ) unary )*
// =================================================================
static MUL_OP_CHOICES: &[Node] = &[
Node::Punct('*'),
Node::Punct('/'),
Node::Punct('%'),
];
static MUL_TAIL_NODES: &[Node] = &[
Node::Choice(MUL_OP_CHOICES),
Node::Subgrammar(&UNARY),
];
static MUL_OP_CHOICES: &[Node] = &[Node::Punct('*'), Node::Punct('/'), Node::Punct('%')];
static MUL_TAIL_NODES: &[Node] = &[Node::Choice(MUL_OP_CHOICES), Node::Subgrammar(&UNARY)];
static MUL_TAIL: Node = Node::Seq(MUL_TAIL_NODES);
static MULTIPLICATIVE_NODES: &[Node] = &[
Node::Subgrammar(&UNARY),
@@ -314,14 +291,8 @@ static MULTIPLICATIVE: Node = Node::Seq(MULTIPLICATIVE_NODES);
// =================================================================
static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')];
static UNARY_SIGN_NODES: &[Node] = &[
Node::Choice(SIGN_CHOICES),
Node::Subgrammar(&UNARY),
];
static UNARY_CHOICES: &[Node] = &[
Node::Seq(UNARY_SIGN_NODES),
Node::Subgrammar(&PRIMARY),
];
static UNARY_SIGN_NODES: &[Node] = &[Node::Choice(SIGN_CHOICES), Node::Subgrammar(&UNARY)];
static UNARY_CHOICES: &[Node] = &[Node::Seq(UNARY_SIGN_NODES), Node::Subgrammar(&PRIMARY)];
static UNARY: Node = Node::Choice(UNARY_CHOICES);
// =================================================================
@@ -402,10 +373,7 @@ static SIMPLE_CASE_NODES: &[Node] = &[
Node::Optional(&ELSE_CLAUSE),
Node::Word(Word::keyword("end")),
];
static CASE_BODY_CHOICES: &[Node] = &[
Node::Seq(SEARCHED_CASE_NODES),
Node::Seq(SIMPLE_CASE_NODES),
];
static CASE_BODY_CHOICES: &[Node] = &[Node::Seq(SEARCHED_CASE_NODES), Node::Seq(SIMPLE_CASE_NODES)];
static CASE_NODES: &[Node] = &[
Node::Word(Word::keyword("case")),
Node::Choice(CASE_BODY_CHOICES),
@@ -467,14 +435,11 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[
Node::Punct('.'),
QUALIFIED_REF_IDENT,
];
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[Node::Punct('.'), QUALIFIED_REF_IDENT];
static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[
Node::Seq(QUALIFIED_REF_TAIL_NODES),
@@ -531,7 +496,10 @@ mod tests {
/// Assert `input` is *not* a complete SQL expression.
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete expression");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete expression"
);
}
#[test]
@@ -643,13 +611,13 @@ mod tests {
#[test]
fn malformed_expressions_do_not_walk() {
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
}
#[test]
@@ -680,9 +648,9 @@ mod tests {
// The optional tail dispatches `.identifier` (qualified
// ref) vs `(args)` (function call) by first token — a
// bare ident remains a column ref.
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
}
#[test]
+31 -8
View File
@@ -120,7 +120,10 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
listed
.iter()
.filter_map(|name| {
table_cols.iter().find(|c| c.name.eq_ignore_ascii_case(name)).cloned()
table_cols
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.cloned()
})
.collect()
},
@@ -148,7 +151,11 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
let cols = target_value_columns(ctx);
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if !cols.is_empty() && arity_ok {
Node::DynamicSubgrammar(sql_value_list)
} else {
@@ -304,8 +311,10 @@ static DO_UPDATE_NODES: &[Node] = &[
/// the enclosing Seq, each branch's FIRST token (`nothing` vs
/// `update`) disambiguates, so a non-match of branch 0 is a clean
/// `NoMatch` that falls through to branch 1.
static DO_ACTION_CHOICES: &[Node] =
&[Node::Word(Word::keyword("nothing")), Node::Seq(DO_UPDATE_NODES)];
static DO_ACTION_CHOICES: &[Node] = &[
Node::Word(Word::keyword("nothing")),
Node::Seq(DO_UPDATE_NODES),
];
// `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`.
const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES);
@@ -361,7 +370,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_INSERT_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_INSERT_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -372,7 +388,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete INSERT tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete INSERT tail"
);
}
#[test]
@@ -418,8 +437,12 @@ mod tests {
// 3h: ON CONFLICT … DO NOTHING / DO UPDATE (ADR-0033 §9).
good("into t (id, name) values (1, 'x') on conflict (id) do nothing");
good("into t (id, name) values (1, 'x') on conflict do nothing");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0");
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
);
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0",
);
// Multi-column conflict target + multi-assignment DO UPDATE.
good("into t (a, b) values (1, 2) on conflict (a, b) do update set b = excluded.b, a = 9");
// ON CONFLICT composes with RETURNING (order: row source,
+62 -97
View File
@@ -141,8 +141,15 @@ static EMPTY_NOMATCH: Node = Node::Choice(&[]);
/// suffix keywords. `as` is not listed — the AS-form alias is a
/// separate `Choice` branch that fires before the lookahead.
const PROJECTION_FOLLOW_SET: &[&str] = &[
"from", "where", "group", "order", "having", "limit",
"union", "intersect", "except",
"from",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
// projection item's bare alias — so a no-FROM SELECT row source
@@ -158,9 +165,21 @@ const PROJECTION_FOLLOW_SET: &[&str] = &[
/// only when `b` has no alias — `on` is not a base-table name a
/// learner would type as an alias.
const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
"where", "group", "order", "having", "limit",
"union", "intersect", "except",
"inner", "left", "right", "full", "cross", "join", "on",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
"inner",
"left",
"right",
"full",
"cross",
"join",
"on",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
// SELECT row source must not read it as table `t`'s bare alias.
@@ -172,15 +191,9 @@ fn peek_next_ident_lower(source: &str, pos: usize) -> Option<String> {
consume_ident(source, p).map(|(s, e)| source[s..e].to_ascii_lowercase())
}
fn projection_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => PROJECTION_BARE_ALIAS_IDENT,
@@ -188,15 +201,9 @@ fn projection_bare_alias_factory(
}
}
fn table_source_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn table_source_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
@@ -237,14 +244,12 @@ const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: true,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static PROJECTION_AS_ALIAS_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
PROJECTION_BARE_ALIAS_IDENT,
];
static PROJECTION_AS_ALIAS_NODES: &[Node] =
&[Node::Word(Word::keyword("as")), PROJECTION_BARE_ALIAS_IDENT];
static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES);
static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[
@@ -258,17 +263,14 @@ static PROJECTION_ALIAS_CHOICES: &[Node] = &[
Node::Lookahead(projection_bare_alias_factory),
];
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
static PROJECTION_ALIAS_OPTIONAL: Node =
Node::Optional(&PROJECTION_ALIAS_CHOICE);
static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
Node::Lookahead(table_source_bare_alias_factory),
];
static TABLE_SOURCE_ALIAS_CHOICE: Node =
Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node =
Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICE: Node = Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node = Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
// =================================================================
// Projection item
@@ -282,16 +284,13 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_STAR_NODES: &[Node] = &[
QUALIFIED_STAR_QUALIFIER,
Node::Punct('.'),
Node::Punct('*'),
];
static QUALIFIED_STAR_NODES: &[Node] =
&[QUALIFIED_STAR_QUALIFIER, Node::Punct('.'), Node::Punct('*')];
static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES);
static PROJECTION_EXPR_ITEM_NODES: &[Node] = &[
@@ -310,11 +309,7 @@ static PROJECTION_EXPR_ITEM: Node = Node::Seq(PROJECTION_EXPR_ITEM_NODES);
/// ambiguity between `t.*` and `sql_expr` (which can match a
/// bare `t`), since the walker's `Choice` doesn't backtrack on
/// a committed match.
fn projection_item_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_item_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
let p = skip_whitespace(source, pos);
let bytes = source.as_bytes();
if bytes.get(p) == Some(&b'*') {
@@ -363,8 +358,7 @@ static DISTINCT_OR_ALL_CHOICES: &[Node] = &[
Node::Word(Word::keyword("all")),
];
static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES);
static DISTINCT_OR_ALL_OPTIONAL: Node =
Node::Optional(&DISTINCT_OR_ALL_CHOICE);
static DISTINCT_OR_ALL_OPTIONAL: Node = Node::Optional(&DISTINCT_OR_ALL_CHOICE);
// =================================================================
// Table source (FROM / JOIN target)
@@ -379,8 +373,8 @@ const TABLE_NAME_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static TABLE_SOURCE_NODES: &[Node] = &[
@@ -395,8 +389,7 @@ static TABLE_SOURCE: Node = Node::Seq(TABLE_SOURCE_NODES);
const JOIN_WORD: Node = Node::Word(Word::keyword("join"));
const ON_WORD: Node = Node::Word(Word::keyword("on"));
static OUTER_OPTIONAL: Node =
Node::Optional(&Node::Word(Word::keyword("outer")));
static OUTER_OPTIONAL: Node = Node::Optional(&Node::Word(Word::keyword("outer")));
// `INNER JOIN` and bare `JOIN` are split into two Choice
// branches so each branch has a distinct leading keyword
@@ -585,8 +578,7 @@ static SET_OP_CHOICES: &[Node] = &[
];
static SET_OP: Node = Node::Choice(SET_OP_CHOICES);
static SET_OP_TAIL_NODES: &[Node] =
&[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL_NODES: &[Node] = &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES);
static PLAIN_COMPOUND_NODES: &[Node] = &[
@@ -619,8 +611,7 @@ static WITH_PREFIXED_COMPOUND_NODES: &[Node] = &[
Node::Subgrammar(&WITH_CLAUSE),
Node::Subgrammar(&PLAIN_COMPOUND),
];
static WITH_PREFIXED_COMPOUND: Node =
Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static WITH_PREFIXED_COMPOUND: Node = Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static COMPOUND_CHOICES: &[Node] = &[
Node::Subgrammar(&WITH_PREFIXED_COMPOUND),
@@ -659,9 +650,9 @@ const CTE_COLUMN_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static CTE_COLUMN_LIST_NODES: &[Node] = &[
@@ -674,18 +665,13 @@ static CTE_COLUMN_LIST_NODES: &[Node] = &[
RPAREN,
];
static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES);
static CTE_COLUMN_LIST_OPTIONAL: Node =
Node::Optional(&CTE_COLUMN_LIST_SEQ);
static CTE_COLUMN_LIST_OPTIONAL: Node = Node::Optional(&CTE_COLUMN_LIST_SEQ);
// CTE body recursion pushes a fresh lexical scope frame (ADR-
// 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same;
// the top-level statement's own COMPOUND embedding does not
// (it shares the implicit bottom frame).
static CTE_BODY_NODES: &[Node] = &[
LPAREN,
Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND),
RPAREN,
];
static CTE_BODY_NODES: &[Node] = &[LPAREN, Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), RPAREN];
static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES);
static CTE_DEF_NODES: &[Node] = &[
@@ -807,9 +793,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) {
NodeWalkResult::Matched { end, .. } => {
input[end..].trim().is_empty()
}
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
@@ -819,10 +803,7 @@ mod tests {
}
fn good(input: &str) {
assert!(
walks(input),
"{input:?} should be a valid SELECT statement"
);
assert!(walks(input), "{input:?} should be a valid SELECT statement");
}
fn bad(input: &str) {
@@ -1051,16 +1032,12 @@ mod tests {
#[test]
fn set_op_chain() {
good(
"select a from t union select b from u intersect select c from v",
);
good("select a from t union select b from u intersect select c from v");
}
#[test]
fn set_op_with_outer_order_by_and_limit() {
good(
"select a from t union select b from u order by a limit 10",
);
good("select a from t union select b from u order by a limit 10");
}
// ----- ORDER BY / LIMIT / OFFSET -----
@@ -1126,16 +1103,12 @@ mod tests {
#[test]
fn recursive_cte() {
good(
"with recursive r as (select 1 union all select 2) select * from r",
);
good("with recursive r as (select 1 union all select 2) select * from r");
}
#[test]
fn multiple_ctes() {
good(
"with a as (select 1), b as (select 2) select * from a union select * from b",
);
good("with a as (select 1), b as (select 2) select * from a union select * from b");
}
// ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) -----
@@ -1147,9 +1120,7 @@ mod tests {
#[test]
fn nested_cte_body_with_union() {
good(
"with x as (select 1 union select 2) select * from x",
);
good("with x as (select 1 union select 2) select * from x");
}
// ----- case insensitivity / spacing -----
@@ -1363,9 +1334,7 @@ mod tests {
#[test]
fn in_subquery_in_where_clause() {
good("select * from t where id in (select user_id from orders)");
good(
"select * from customers where id not in (select customer_id from blocklist)",
);
good("select * from customers where id not in (select customer_id from blocklist)");
}
#[test]
@@ -1378,9 +1347,7 @@ mod tests {
#[test]
fn nested_subqueries() {
good(
"select * from t where x in (select y from u where y in (select z from v))",
);
good("select * from t where x in (select y from u where y in (select z from v))");
}
#[test]
@@ -1393,8 +1360,6 @@ mod tests {
#[test]
fn cte_body_references_qualified_columns() {
good(
"with x as (select t.name, t.age from t) select x.name from x",
);
good("with x as (select t.name, t.age from t) select x.name from x");
}
}
+12 -2
View File
@@ -119,7 +119,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_UPDATE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_UPDATE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -130,7 +137,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete UPDATE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete UPDATE tail"
);
}
#[test]
+3 -3
View File
@@ -21,9 +21,9 @@ pub mod walker;
pub use action::ReferentialAction;
pub use command::{
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
ShowListKind, SqlForeignKey,
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope,
Expr, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector,
RowFilter, ShowListKind, SqlForeignKey,
};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+18 -25
View File
@@ -55,10 +55,9 @@ pub enum ParseError {
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid { message, .. } => f.write_str(&crate::t!(
"parse.error_wrapper",
detail = message,
)),
Self::Invalid { message, .. } => {
f.write_str(&crate::t!("parse.error_wrapper", detail = message,))
}
Self::Empty => f.write_str(&crate::t!("parse.empty")),
}
}
@@ -125,10 +124,7 @@ pub fn parse_command_with_schema(
/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
/// the walker gates SQL-only commands and produces the
/// "this is SQL" hint instead of executing them.
pub fn parse_command_in_mode(
input: &str,
mode: Mode,
) -> Result<Command, ParseError> {
pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result<Command, ParseError> {
parse_command_inner(input, None, mode)
}
@@ -185,10 +181,8 @@ fn unknown_command_error(source: &str) -> ParseError {
.collect();
let joined = oxford_join(&entries);
let start = skip_whitespace(source, 0);
let (position, found_word) = consume_ident(source, start).map_or_else(
|| (start, None),
|(s, e)| (s, Some(&source[s..e])),
);
let (position, found_word) = consume_ident(source, start)
.map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e])));
let message = found_word.map_or_else(
|| format!("expected one of {joined}"),
|w| format!("expected one of {joined}, found `{w}`"),
@@ -1034,19 +1028,22 @@ mod tests {
false,
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"
),
expected
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"
),
expected
);
}
#[test]
fn add_relationship_repeated_clause_errors() {
let e =
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}");
@@ -1073,7 +1070,9 @@ mod tests {
#[test]
fn add_relationship_with_name_actions_and_flag() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
ok(
"add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"
),
rel(
Some("cust_orders"),
("Customers", "Id"),
@@ -1300,10 +1299,7 @@ mod tests {
#[test]
fn advanced_ambiguous_update_routes_to_sql() {
assert!(matches!(
parse_command_in_mode(
"update Orders set total = 0 where id = 1",
Mode::Advanced,
),
parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,),
Ok(Command::SqlUpdate { .. })
));
}
@@ -1399,10 +1395,7 @@ mod tests {
// in advanced mode)" pointer is added at the hint layer
// (input_render), not in the parsed command/error here.
assert!(matches!(
parse_command_in_mode(
"delete from Orders where id = 1 returning *",
Mode::Simple,
),
parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,),
Err(ParseError::Invalid { .. })
));
}
+1 -2
View File
@@ -9,8 +9,7 @@ use rand::RngExt;
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
/// because they are easily confused in print.
const ALPHABET: &[u8; 58] =
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const DEFAULT_LEN: usize = 10;
+4 -26
View File
@@ -43,29 +43,9 @@
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
"abs",
"avg",
"coalesce",
"count",
"date",
"datetime",
"hex",
"ifnull",
"instr",
"length",
"lower",
"max",
"min",
"nullif",
"random",
"replace",
"round",
"strftime",
"substr",
"sum",
"trim",
"typeof",
"upper",
"abs", "avg", "coalesce", "count", "date", "datetime", "hex", "ifnull", "instr", "length",
"lower", "max", "min", "nullif", "random", "replace", "round", "strftime", "substr", "sum",
"trim", "typeof", "upper",
];
/// Whether `partial` is a case-insensitive prefix of at least one
@@ -80,9 +60,7 @@ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
#[must_use]
pub fn is_known_function_prefix(partial: &str) -> bool {
let lowered = partial.to_lowercase();
KNOWN_SQL_FUNCTIONS
.iter()
.any(|f| f.starts_with(&lowered))
KNOWN_SQL_FUNCTIONS.iter().any(|f| f.starts_with(&lowered))
}
#[cfg(test)]
+19 -40
View File
@@ -1,7 +1,8 @@
//! User-facing column types and their mapping to SQLite STRICT.
//!
//! Implements the full ten-type vocabulary committed to in
//! ADR-0005. Storage choices for the text-backed types
//! Implements the nine-type vocabulary committed to in ADR-0005
//! (ten as originally decided; `blob` dropped by Amendment 2).
//! Storage choices for the text-backed types
//! (`decimal`, `date`, `datetime`) preserve precision and ISO
//! readability; comparisons rely on lexicographic ordering or
//! explicit casts at query time, which is acceptable for a
@@ -27,8 +28,6 @@ pub enum Type {
Date,
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
DateTime,
/// Arbitrary binary data.
Blob,
/// Auto-incrementing integer; intended as a default primary key.
Serial,
/// 1012 character base58 random identifier (no ambiguous chars).
@@ -47,7 +46,6 @@ impl Type {
Self::Bool => "bool",
Self::Date => "date",
Self::DateTime => "datetime",
Self::Blob => "blob",
Self::Serial => "serial",
Self::ShortId => "shortid",
}
@@ -59,14 +57,9 @@ impl Type {
#[must_use]
pub const fn sqlite_strict_type(self) -> &'static str {
match self {
Self::Text
| Self::ShortId
| Self::Decimal
| Self::Date
| Self::DateTime => "TEXT",
Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL",
Self::Blob => "BLOB",
}
}
@@ -83,8 +76,8 @@ impl Type {
/// All types known in this iteration, in stable order.
/// Ordering groups numeric types together, then boolean,
/// then temporal, then binary, then identity-flavoured
/// auto-generated types.
/// then temporal, then identity-flavoured auto-generated
/// types.
#[must_use]
pub const fn all() -> &'static [Self] {
&[
@@ -95,22 +88,18 @@ impl Type {
Self::Bool,
Self::Date,
Self::DateTime,
Self::Blob,
Self::Serial,
Self::ShortId,
]
}
/// True for the numeric types — `int`, `real`, `decimal`,
/// `serial`. `bool` (stored 0/1) and the text- / blob-backed
/// types are not numeric. Used to flag a `LIKE` text-pattern
/// `serial`. `bool` (stored 0/1) and the text-backed types
/// are not numeric. Used to flag a `LIKE` text-pattern
/// match against a numeric column (ADR-0027, Amendment 1).
#[must_use]
pub const fn is_numeric(self) -> bool {
matches!(
self,
Self::Int | Self::Real | Self::Decimal | Self::Serial
)
matches!(self, Self::Int | Self::Real | Self::Decimal | Self::Serial)
}
/// The user-facing type that an FK column should use to
@@ -136,7 +125,7 @@ impl Type {
}
/// Resolve a type name from the **advanced-mode SQL** type slot
/// (ADR-0035 §3). Accepts the ten playground keywords *and* the
/// (ADR-0035 §3). Accepts the nine playground keywords *and* the
/// standard-SQL aliases mapped onto them. Case-insensitive;
/// internal whitespace is collapsed so `double precision` resolves
/// regardless of spacing. Returns `None` for an unrecognised name —
@@ -144,7 +133,7 @@ impl Type {
/// diagnostic.
///
/// Deliberately distinct from [`FromStr`](std::str::FromStr), which
/// is the *simple-mode* parser and accepts only the ten keywords
/// is the *simple-mode* parser and accepts only the nine keywords
/// (no aliases), so simple mode teaches the playground's own
/// vocabulary. A length/precision argument (`varchar(255)`) is
/// stripped by the grammar before the name reaches this resolver.
@@ -164,8 +153,7 @@ impl Type {
"timestamp" => Some(Self::DateTime),
"numeric" => Some(Self::Decimal),
"float" | "double precision" => Some(Self::Real),
"binary" | "varbinary" => Some(Self::Blob),
// Fall through to the canonical ten keywords.
// Fall through to the canonical nine keywords.
other => other.parse::<Self>().ok(),
}
}
@@ -277,20 +265,19 @@ mod tests {
Type::Bool,
Type::Date,
Type::DateTime,
Type::Blob,
] {
assert_eq!(ty.fk_target_type(), ty);
}
}
#[test]
fn all_ten_types_are_present_and_distinct() {
fn all_nine_types_are_present_and_distinct() {
let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect();
assert_eq!(kws.len(), 10);
assert_eq!(kws.len(), 9);
let mut sorted = kws.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 10, "keywords must be unique");
assert_eq!(sorted.len(), 9, "keywords must be unique");
}
#[test]
@@ -301,16 +288,10 @@ mod tests {
}
#[test]
fn blob_type_maps_to_blob_storage() {
assert_eq!(Type::Blob.sqlite_strict_type(), "BLOB");
}
#[test]
fn unknown_type_message_lists_all_ten() {
fn unknown_type_message_lists_all_nine() {
let err = "varchar".parse::<Type>().unwrap_err();
for kw in [
"text", "int", "real", "decimal", "bool", "date", "datetime", "blob", "serial",
"shortid",
"text", "int", "real", "decimal", "bool", "date", "datetime", "serial", "shortid",
] {
assert!(
err.expected.contains(kw),
@@ -321,12 +302,12 @@ mod tests {
}
// --- ADR-0035 §3: advanced-mode SQL type-name resolution ---
// `from_sql_name` accepts the ten playground keywords *and* the
// `from_sql_name` accepts the nine playground keywords *and* the
// standard-SQL aliases. Simple-mode `FromStr` is unchanged (it
// still rejects aliases — see `unknown_type_lists_expected_alternatives`).
#[test]
fn sql_resolver_accepts_the_ten_canonical_keywords() {
fn sql_resolver_accepts_the_nine_canonical_keywords() {
for &ty in Type::all() {
assert_eq!(Type::from_sql_name(ty.keyword()), Some(ty));
}
@@ -345,8 +326,6 @@ mod tests {
("numeric", Type::Decimal),
("float", Type::Real),
("double precision", Type::Real),
("binary", Type::Blob),
("varbinary", Type::Blob),
] {
assert_eq!(
Type::from_sql_name(alias),
+37 -29
View File
@@ -101,10 +101,6 @@ impl Value {
Type::Bool => self.bind_bool(column),
Type::Date => self.bind_date(column),
Type::DateTime => self.bind_datetime(column),
Type::Blob => Err(ValueError::Format {
column: column.to_string(),
message: "literal `blob` values are not supported in DSL yet".to_string(),
}),
}
}
@@ -129,13 +125,14 @@ impl Value {
fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
match self {
Self::Number(n) => n
.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
}),
Self::Number(n) => {
n.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
})
}
other => Err(ValueError::TypeMismatch {
column: column.to_string(),
expected_human: format!("a whole number for `{ty}`"),
@@ -241,9 +238,7 @@ pub(crate) fn validate_date(s: &str) -> Result<(), String> {
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
let bytes = s.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return Err(format!(
"`{s}` is not a date in `YYYY-MM-DD` form"
));
return Err(format!("`{s}` is not a date in `YYYY-MM-DD` form"));
}
let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?;
let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?;
@@ -272,7 +267,9 @@ pub(crate) fn validate_datetime(s: &str) -> Result<(), String> {
validate_date(date_part)?;
let bytes = s.as_bytes();
if bytes[10] != b'T' {
return Err(format!("`{s}`: missing `T` separator between date and time"));
return Err(format!(
"`{s}`: missing `T` separator between date and time"
));
}
if bytes[13] != b':' || bytes[16] != b':' {
return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
@@ -319,15 +316,20 @@ mod tests {
#[test]
fn null_binds_to_null_for_any_type() {
for ty in Type::all() {
// Skip blob — null still works there too.
assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null);
}
}
#[test]
fn integer_for_int_column() {
assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42));
assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7));
assert_eq!(
n("42").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(42)
);
assert_eq!(
n("-7").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(-7)
);
}
#[test]
@@ -355,7 +357,9 @@ mod tests {
#[test]
fn shortid_validation_runs_on_text_for_shortid_column() {
let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err();
let err = t("toolong_xyz_more")
.bind_for_column("c", Type::ShortId)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
// Well-formed shortid binds fine.
@@ -367,8 +371,14 @@ mod tests {
#[test]
fn bool_for_bool_column_maps_to_zero_or_one() {
assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1));
assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0));
assert_eq!(
Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(1)
);
assert_eq!(
Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(0)
);
}
#[test]
@@ -377,13 +387,17 @@ mod tests {
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
Bound::Text("2025-01-15".to_string())
);
let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025/01/15")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
}
#[test]
fn date_range_check() {
let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025-13-01")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month")));
}
@@ -410,10 +424,4 @@ mod tests {
let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
}
#[test]
fn blob_inserts_are_explicitly_unsupported_for_now() {
let err = t("0xdead").bind_for_column("c", Type::Blob).unwrap_err();
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("blob")));
}
}
+27
View File
@@ -134,6 +134,18 @@ pub struct WalkContext<'a> {
/// resolver reads this directly instead of inferring the
/// slot kind from the shape of the expected set.
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
/// Byte spans of the clause-concept regions the walk passed
/// through (issue #37 / ADR clause-concept-hints, D1).
///
/// A `Node::Concept { topic, inner }` wrapper pushes one entry
/// per *committed* inner walk (Matched / Incomplete / Failed —
/// never on NoMatch). Unlike `pending_hint_mode`, this is
/// **append-only and never cleared on match**, so a fully-typed
/// clause keeps its span — that is what lets the F1
/// clause-concept resolver report a concept for a cursor parked
/// *inside* already-typed clause text, not only at the slot
/// boundary.
pub concept_spans: Vec<ConceptSpan>,
/// An `IntroProse` hint captured from an *optional* slot that
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
/// (cleared on the very next match — including the empty match
@@ -227,6 +239,19 @@ pub struct PendingCteHarvest {
pub cte_name_span: (usize, usize),
}
/// A clause-concept region recorded during a walk (issue #37).
///
/// `topic` is the `hint.concept.<topic>` catalogue key; `[start,
/// end]` is the inclusive byte range the clause covered as typed so
/// far (`end` is the matched end on a full match, or the stop
/// position on an incomplete / failed inner).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConceptSpan {
pub topic: &'static str,
pub start: usize,
pub end: usize,
}
impl<'a> WalkContext<'a> {
/// Schemaless walk context — the legacy default used by
/// pre-Phase-D callers and tests that don't care about
@@ -243,6 +268,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
concept_spans: Vec::new(),
surviving_intro_hint: None,
user_listed_columns: None,
subgrammar_depth: 0,
@@ -266,6 +292,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
concept_spans: Vec::new(),
surviving_intro_hint: None,
user_listed_columns: None,
subgrammar_depth: 0,
+146 -179
View File
@@ -28,12 +28,10 @@ use crate::completion::TableColumn;
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
};
use crate::dsl::walker::outcome::{
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath};
/// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1).
///
@@ -77,10 +75,7 @@ static DYNAMIC_CACHE: LazyLock<Mutex<HashMap<DynamicKey, &'static Node>>> =
/// Resolve a `DynamicSubgrammar` factory to a `&'static Node`,
/// reusing a previously-leaked Node when the factory's inputs
/// match a cached entry.
fn resolve_dynamic(
factory: fn(&WalkContext) -> Node,
ctx: &WalkContext,
) -> &'static Node {
fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node {
let key = DynamicKey {
factory: factory as usize,
current_table_columns: ctx.current_table_columns.clone(),
@@ -123,10 +118,7 @@ pub enum NodeWalkResult {
expected: Vec<Expectation>,
},
/// Committed and hit a hard mismatch or validator failure.
Failed {
position: usize,
kind: FailureKind,
},
Failed { position: usize, kind: FailureKind },
}
const fn matched(end: usize) -> NodeWalkResult {
@@ -218,9 +210,7 @@ fn walk_node_inner(
kind: FailureKind::Mismatch { expected: vec![] },
}
}
Node::Subgrammar(inner) => {
walk_subgrammar(source, pos, inner, ctx, path, per_byte)
}
Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte),
Node::ScopedSubgrammar(inner) => {
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
}
@@ -247,8 +237,7 @@ fn walk_node_inner(
// DynamicSubgrammar wrapper that delegates to the
// memoized `column_value_list`), so the per-walk
// leak is a few bytes, not a whole typed tree.
let resolved: &'static Node =
Box::leak(Box::new(factory(ctx, source, pos)));
let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos)));
walk_node(source, pos, resolved, ctx, path, per_byte)
}
Node::SetColumn(col) => {
@@ -262,7 +251,10 @@ fn walk_node_inner(
let col: &crate::completion::TableColumn = col;
ctx.current_column = Some(col.clone());
ctx.pending_value_column = Some(col.name.clone());
NodeWalkResult::Matched { end: pos, skipped: Vec::new() }
NodeWalkResult::Matched {
end: pos,
skipped: Vec::new(),
}
}
Node::TypedValueSlot {
ty,
@@ -298,6 +290,33 @@ fn walk_node_inner(
ctx.pending_hint_mode = Some(*mode);
walk_node(source, pos, inner, ctx, path, per_byte)
}
Node::Concept { topic, inner } => {
// Issue #37 / ADR clause-concept-hints D1. Walk the
// inner clause, then record its covered byte span in
// `ctx.concept_spans` iff the inner *committed* — i.e.
// matched, ran out mid-clause (Incomplete), or hit a
// hard failure after engaging (Failed). On `NoMatch`
// the node never engaged (e.g. a `Choice` tried this
// branch and it didn't apply), so record nothing — no
// stale span. `start` is the post-whitespace position
// the walk wrapper already resolved.
let result = walk_node(source, pos, inner, ctx, path, per_byte);
let end = match &result {
NodeWalkResult::Matched { end, .. } => Some(*end),
NodeWalkResult::Incomplete { position, .. }
| NodeWalkResult::Failed { position, .. } => Some(*position),
NodeWalkResult::NoMatch { .. } => None,
};
if let Some(end) = end {
ctx.concept_spans
.push(crate::dsl::walker::context::ConceptSpan {
topic,
start: pos,
end,
});
}
result
}
Node::Flag(name) => walk_flag(source, pos, name, path, per_byte),
Node::Repeated {
inner,
@@ -342,7 +361,10 @@ fn walk_word(
// Amendment 4). Plain keywords leave it `None`.
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} else {
NodeWalkResult::NoMatch {
position,
@@ -477,9 +499,7 @@ fn walk_ident(
// ScopedSubgrammar (which is structurally guaranteed to be
// the CTE body — no intervening scoped subgrammar in CTE
// syntax) runs the harvest at body-frame exit.
if writes_cte_name
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame
.cte_bindings
.push(crate::dsl::walker::context::CteBinding {
@@ -487,13 +507,12 @@ fn walk_ident(
columns: Vec::new(),
});
let placeholder_index = frame.cte_bindings.len() - 1;
ctx.pending_cte_harvest =
Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
}
// ADR-0032 §10.3: the optional `(c1, c2, …)` rename list
// between the cte name and `AS`. Each `cte_column` ident
@@ -507,9 +526,7 @@ fn walk_ident(
}
// ADR-0032 §10.4: projection-list alias accumulator for
// ORDER BY completion candidates.
if writes_projection_alias
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame.projection_aliases.push(text.clone());
}
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
@@ -529,9 +546,7 @@ fn walk_ident(
.map(|c| c.name.clone())
.or_else(|| Some(text.clone()));
}
if writes_user_listed_column
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
{
if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
// Form A: `insert into <T> (col1, col2, …)`. Append the
// matched column name to user_listed_columns so the
// inner `values (…)` slot list mirrors the user's
@@ -564,7 +579,10 @@ fn walk_ident(
// (issue #8 / ADR-0022 Amendment 4).
class: highlight_override.unwrap_or(HighlightClass::Identifier),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_string_lit(
@@ -648,7 +666,10 @@ fn walk_literal(
end,
class,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_number_lit(
@@ -683,7 +704,10 @@ fn walk_number_lit(
end,
class: HighlightClass::Number,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_flag(
@@ -717,7 +741,10 @@ fn walk_flag(
end,
class: HighlightClass::Flag,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
#[allow(clippy::too_many_arguments)]
@@ -784,7 +811,10 @@ fn walk_repeated(
count += 1;
last_item_skipped = skipped;
}
NodeWalkResult::NoMatch { expected, position: inner_pos } => {
NodeWalkResult::NoMatch {
expected,
position: inner_pos,
} => {
// Mid-typing-the-next-item recovery: if the
// separator just consumed and the inner failed
// at EOF, the user is partway through typing the
@@ -860,7 +890,10 @@ fn walk_bare_path(
end,
class: HighlightClass::String,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_choice(
@@ -1031,7 +1064,10 @@ fn walk_optional(
skipped: expected,
}
}
NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => {
NodeWalkResult::Incomplete {
position: p,
expected,
} if !inner_committed => {
// Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's
// perspective. Roll back and skip.
@@ -1156,9 +1192,7 @@ fn walk_scoped_subgrammar(
// walks that NoMatch / Incomplete / Fail leave the placeholder
// empty (the outer-frame state is also discarded in the
// speculative path, so this is correct).
if let (Some(req), NodeWalkResult::Matched { end, .. }) =
(pending_cte, &result)
{
if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) {
run_cte_harvest(ctx, path, source, pos, *end, &req);
}
@@ -1240,9 +1274,8 @@ fn run_cte_harvest(
select_idx = Some(i + 1); // start of projection list
}
MatchedKind::Word(
"from" | "where" | "group" | "having" | "order"
| "limit" | "offset" | "union" | "intersect"
| "except",
"from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
| "intersect" | "except",
) if select_idx.is_some() => {
end_idx = i;
break;
@@ -1281,12 +1314,7 @@ fn run_cte_harvest(
// Classify each projection item per ADR-0032 §10.3.
let mut derived: Vec<CteColumn> = Vec::new();
for slice in item_slices {
classify_projection_item(
slice,
body_frame,
&ctx.from_scope_stack,
&mut derived,
);
classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived);
}
// Apply (c1, c2, …) positional rename if provided. Types
@@ -1339,8 +1367,7 @@ fn run_cte_harvest(
let stack_len = ctx.from_scope_stack.len();
if stack_len >= 2
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
&& let Some(placeholder) =
outer.cte_bindings.get_mut(req.placeholder_index)
&& let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index)
{
placeholder.columns = derived;
}
@@ -1368,9 +1395,7 @@ fn classify_projection_item(
// empty because it wasn't a base-table lookup), resolve
// through to the in-scope CteBinding so nested CTEs project
// correctly.
if expr_slice.len() == 1
&& matches!(expr_slice[0].kind, MatchedKind::Punct('*'))
{
if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) {
for binding in &body_frame.from_scope {
for col in expand_binding(binding, scope_stack) {
out.push(col);
@@ -1383,7 +1408,10 @@ fn classify_projection_item(
if expr_slice.len() == 3
&& matches!(
expr_slice[0].kind,
MatchedKind::Ident { role: "qualified_star_qualifier", .. }
MatchedKind::Ident {
role: "qualified_star_qualifier",
..
}
)
&& matches!(expr_slice[1].kind, MatchedKind::Punct('.'))
&& matches!(expr_slice[2].kind, MatchedKind::Punct('*'))
@@ -1413,11 +1441,7 @@ fn classify_projection_item(
)
{
let col_text = &expr_slice[0].text;
let resolved_type = resolve_bare_column_type_in_frame(
body_frame,
scope_stack,
col_text,
);
let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1447,12 +1471,7 @@ fn classify_projection_item(
{
let qual = &expr_slice[0].text;
let col_text = &expr_slice[2].text;
let resolved_type = resolve_qualified_column_type(
body_frame,
scope_stack,
qual,
col_text,
);
let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1493,16 +1512,8 @@ fn strip_trailing_alias<'a>(
}
) {
// Optional preceding `AS` keyword.
if slice.len() >= 2
&& matches!(
slice[slice.len() - 2].kind,
MatchedKind::Word("as")
)
{
return (
&slice[..slice.len() - 2],
Some(last.text.clone()),
);
if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) {
return (&slice[..slice.len() - 2], Some(last.text.clone()));
}
return (&slice[..slice.len() - 1], Some(last.text.clone()));
}
@@ -1613,8 +1624,8 @@ fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
#[cfg(test)]
mod tests {
use super::{
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult,
resolve_dynamic, walk_node,
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic,
walk_node,
};
use crate::dsl::grammar::{Node, Word};
use crate::dsl::walker::context::WalkContext;
@@ -1629,18 +1640,14 @@ mod tests {
Node::Subgrammar(&NESTED),
Node::Punct(')'),
];
static NESTED_CHOICES: &[Node] = &[
Node::Seq(NESTED_GROUP),
Node::Word(Word::keyword("x")),
];
static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))];
static NESTED: Node = Node::Choice(NESTED_CHOICES);
fn walk_nested(input: &str) -> NodeWalkResult {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1726,14 +1733,8 @@ mod tests {
fn resolve_dynamic_cache_is_populated() {
let ctx = WalkContext::new();
let _ = resolve_dynamic(const_factory, &ctx);
let populated = !DYNAMIC_CACHE
.lock()
.expect("cache lock")
.is_empty();
assert!(
populated,
"resolve_dynamic should populate the memo cache",
);
let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty();
assert!(populated, "resolve_dynamic should populate the memo cache",);
}
// ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
@@ -1758,14 +1759,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let baseline_frames = ctx.from_scope_stack.len();
let result = walk_node(
input,
0,
&SCOPED_NESTED,
&mut ctx,
&mut path,
&mut per_byte,
);
let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1801,9 +1795,9 @@ mod tests {
kind: FailureKind::Validation(err),
..
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
other => panic!(
"expected expression_too_deep on pathological scoped nesting, got {other:?}",
),
other => {
panic!("expected expression_too_deep on pathological scoped nesting, got {other:?}",)
}
}
}
@@ -1822,9 +1816,7 @@ mod tests {
/// Walk a top-level SQL SELECT and return the bottom frame's
/// `from_scope` after the walk completes. Used to verify that
/// `writes_table` / `writes_table_alias` populate bindings.
fn from_scope_after_walk(
input: &str,
) -> Vec<crate::dsl::walker::context::TableBinding> {
fn from_scope_after_walk(input: &str) -> Vec<crate::dsl::walker::context::TableBinding> {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1871,9 +1863,7 @@ mod tests {
#[test]
fn join_pushes_a_second_binding() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y",
);
let bindings = from_scope_after_walk("select * from a join b on x = y");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1881,9 +1871,7 @@ mod tests {
#[test]
fn join_with_aliases() {
let bindings = from_scope_after_walk(
"select * from a as x join b as y on x.id = y.id",
);
let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[0].alias, Some("x".to_string()));
@@ -1893,9 +1881,8 @@ mod tests {
#[test]
fn three_way_join_pushes_three_bindings() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y left join c on y = z",
);
let bindings =
from_scope_after_walk("select * from a join b on x = y left join c on y = z");
assert_eq!(bindings.len(), 3);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1908,9 +1895,8 @@ mod tests {
// binding into the inner scope frame; on exit, the frame
// pops and the inner binding is gone. The outer scope's
// from_scope still contains only `outer_t`.
let bindings = from_scope_after_walk(
"select * from outer_t where id in (select id from inner_t)",
);
let bindings =
from_scope_after_walk("select * from outer_t where id in (select id from inner_t)");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "outer_t");
}
@@ -1921,9 +1907,8 @@ mod tests {
// body's scope frame; on body-frame exit, the inner
// binding goes away. The outer scope contains only
// the CTE-name reference `cte_x`.
let bindings = from_scope_after_walk(
"with cte_x as (select * from base_table) select * from cte_x",
);
let bindings =
from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "cte_x");
}
@@ -1940,10 +1925,7 @@ mod tests {
/// `cte_bindings` and `projection_aliases` after the walk.
fn frame_state_after_walk(
input: &str,
) -> (
Vec<crate::dsl::walker::context::CteBinding>,
Vec<String>,
) {
) -> (Vec<crate::dsl::walker::context::CteBinding>, Vec<String>) {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1968,9 +1950,7 @@ mod tests {
#[test]
fn cte_name_pushes_placeholder_binding() {
let (ctes, _) = frame_state_after_walk(
"with cte_x as (select 1) select * from cte_x",
);
let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x");
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].name, "cte_x");
// §10.3 stage-2 harvest produces one CteColumn per
@@ -1984,9 +1964,8 @@ mod tests {
#[test]
fn multiple_ctes_push_in_order() {
let (ctes, _) = frame_state_after_walk(
"with a as (select 1), b as (select 2) select * from b",
);
let (ctes, _) =
frame_state_after_walk("with a as (select 1), b as (select 2) select * from b");
assert_eq!(ctes.len(), 2);
assert_eq!(ctes[0].name, "a");
assert_eq!(ctes[1].name, "b");
@@ -2006,25 +1985,20 @@ mod tests {
#[test]
fn projection_aliases_captured_via_as_form() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b as beta from t",
);
let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_captured_via_bare_form() {
let (_, aliases) = frame_state_after_walk(
"select a alpha, b beta from t",
);
let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_mixed_forms() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b beta, c, d as delta from t",
);
let (_, aliases) =
frame_state_after_walk("select a as alpha, b beta, c, d as delta from t");
assert_eq!(
aliases,
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
@@ -2033,8 +2007,7 @@ mod tests {
#[test]
fn projection_aliases_empty_when_no_aliases() {
let (_, aliases) =
frame_state_after_walk("select a, b from t");
let (_, aliases) = frame_state_after_walk("select a, b from t");
assert!(aliases.is_empty());
}
@@ -2088,9 +2061,24 @@ mod tests {
s.table_columns.insert(
"users".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn { name: "age".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
TableColumn {
name: "age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
],
);
s
@@ -2108,10 +2096,7 @@ mod tests {
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("name"));
assert_eq!(
ctes[0].columns[1].type_,
@@ -2161,10 +2146,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 1);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
}
#[test]
@@ -2259,15 +2241,9 @@ mod tests {
.expect("outer_cte binding");
assert_eq!(outer.columns.len(), 2);
assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
assert_eq!(
outer.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(outer.columns[1].name.as_deref(), Some("name"));
assert_eq!(
outer.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2287,15 +2263,9 @@ mod tests {
let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
assert_eq!(b.columns.len(), 2);
assert_eq!(b.columns[0].name.as_deref(), Some("id"));
assert_eq!(
b.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(b.columns[1].name.as_deref(), Some("name"));
assert_eq!(
b.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2310,10 +2280,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("b"));
assert_eq!(
ctes[0].columns[1].type_,
+41 -28
View File
@@ -24,8 +24,8 @@
use crate::dsl::grammar::HighlightClass;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, WalkBound};
@@ -47,16 +47,11 @@ pub fn highlight_runs(source: &str) -> Vec<ByteClass> {
/// token, producing the keyword classes the renderer needs to
/// colour `select` / `from` / `where` / `union` / `case` / etc.
#[must_use]
pub fn highlight_runs_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Vec<ByteClass> {
pub fn highlight_runs_in_mode(source: &str, mode: crate::mode::Mode) -> Vec<ByteClass> {
let mut ctx = WalkContext::new();
ctx.mode = mode;
let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx);
let mut classes: Vec<ByteClass> = result
.map(|r| r.per_byte_class)
.unwrap_or_default();
let mut classes: Vec<ByteClass> = result.map(|r| r.per_byte_class).unwrap_or_default();
let scan_start = classes.last().map_or(0, |c| c.end);
scan_remainder(source, scan_start, &mut classes);
@@ -133,9 +128,7 @@ fn scan_remainder(source: &str, start: usize, classes: &mut Vec<ByteClass>) {
.get(pos + 1)
.copied()
.is_some_and(|c| c.is_ascii_digit()));
if looks_like_number
&& let Some((s, e)) = consume_number_literal(source, pos)
{
if looks_like_number && let Some((s, e)) = consume_number_literal(source, pos) {
classes.push(ByteClass {
start: s,
end: e,
@@ -222,8 +215,14 @@ mod tests {
"no Error highlight on a valid m:n line: {runs:?}"
);
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
assert!(
kinds.contains(&HighlightClass::Keyword),
"keywords highlighted: {runs:?}"
);
assert!(
kinds.contains(&HighlightClass::Identifier),
"table names highlighted: {runs:?}"
);
}
#[test]
@@ -276,10 +275,7 @@ mod tests {
#[test]
fn flag_classified_via_fallback() {
// Walker doesn't engage for a bare `--all-rows`.
assert_eq!(
run("--all-rows"),
vec![(0, 10, HighlightClass::Flag)],
);
assert_eq!(run("--all-rows"), vec![(0, 10, HighlightClass::Flag)],);
}
#[test]
@@ -445,15 +441,13 @@ mod tests {
// dispatcher, so only the entry word would highlight).
let runs = run_advanced("select * from t");
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (0, 6)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (0, 6) }),
"expected `select` keyword span 0..6; got {runs:?}",
);
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (9, 13)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (9, 13) }),
"expected `from` keyword span 9..13; got {runs:?}",
);
}
@@ -514,18 +508,37 @@ mod tests {
let insert = keywords_of(
"insert into t (a) values (1) on conflict (a) do update set a = excluded.a returning a",
);
for kw in ["insert", "into", "values", "on", "conflict", "do", "update", "set", "returning"] {
assert!(insert.contains(&kw), "INSERT/UPSERT: missing `{kw}`; got {insert:?}");
for kw in [
"insert",
"into",
"values",
"on",
"conflict",
"do",
"update",
"set",
"returning",
] {
assert!(
insert.contains(&kw),
"INSERT/UPSERT: missing `{kw}`; got {insert:?}"
);
}
let update = keywords_of("update t set a = 1 where id = 2 returning a");
for kw in ["update", "set", "where", "returning"] {
assert!(update.contains(&kw), "UPDATE: missing `{kw}`; got {update:?}");
assert!(
update.contains(&kw),
"UPDATE: missing `{kw}`; got {update:?}"
);
}
let delete = keywords_of("delete from t where id = 1 returning *");
for kw in ["delete", "from", "where", "returning"] {
assert!(delete.contains(&kw), "DELETE: missing `{kw}`; got {delete:?}");
assert!(
delete.contains(&kw),
"DELETE: missing `{kw}`; got {delete:?}"
);
}
}
}
+1 -3
View File
@@ -110,9 +110,7 @@ pub fn consume_number_literal(source: &str, start: usize) -> Option<(usize, usiz
return None;
}
let mut i = start;
let leading_minus = bytes[i] == b'-'
&& i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit();
let leading_minus = bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit();
if leading_minus {
i += 1;
}
+616 -562
View File
File diff suppressed because it is too large Load Diff
+110 -38
View File
@@ -14,12 +14,12 @@
//! advanced effective mode (ADR-0037).
use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction;
use crate::dsl::types::Type;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
};
use crate::dsl::types::Type;
use crate::dsl::value::Value;
/// The dimmed `Executing SQL:` prefix on a teaching-echo line
@@ -79,7 +79,12 @@ pub fn echo_for_query(
name,
filter,
limit,
} => Some(vec![render_show_data(name, filter.as_ref(), *limit, primary_key)]),
} => Some(vec![render_show_data(
name,
filter.as_ref(),
*limit,
primary_key,
)]),
_ => None,
}
}
@@ -150,12 +155,12 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
column,
kind,
} => match kind {
ConstraintKind::NotNull => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"))
}
ConstraintKind::Default => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"))
}
ConstraintKind::NotNull => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"
)),
ConstraintKind::Default => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"
)),
// A column-level UNIQUE / CHECK is anonymous in our model —
// no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
// ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
@@ -169,7 +174,10 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
table,
assignments,
filter: RowFilter::AllRows,
} => Some(format!("UPDATE {table} SET {}", render_assignments(assignments))),
} => Some(format!(
"UPDATE {table} SET {}",
render_assignments(assignments)
)),
Command::Delete {
table,
filter: RowFilter::AllRows,
@@ -199,7 +207,13 @@ fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String
// The same column-constraint suffix `add column` emits (ADR-0029):
// simple-mode `create table` can carry `default` / `check` too, so
// the echo must render them or it is not equivalent (§1 contract).
append_constraints(&mut s, c.not_null, c.unique, c.default.as_ref(), c.check.as_ref());
append_constraints(
&mut s,
c.not_null,
c.unique,
c.default.as_ref(),
c.check.as_ref(),
);
s
})
.collect();
@@ -299,8 +313,10 @@ pub(crate) fn render_create_m2n(
primary_key: &[String],
foreign_keys: &[(Vec<String>, String, Vec<String>)],
) -> String {
let mut parts: Vec<String> =
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
let mut parts: Vec<String> = columns
.iter()
.map(|(n, ty)| format!("{n} {}", ty.keyword()))
.collect();
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
for (child_columns, parent_table, parent_columns) in foreign_keys {
parts.push(format!(
@@ -368,7 +384,12 @@ pub(crate) fn render_add_relationship_create_fk(
) -> Vec<String> {
let mut lines: Vec<String> = new_columns
.iter()
.map(|(col, ty)| format!("ALTER TABLE {child_table} ADD COLUMN {col} {}", ty.keyword()))
.map(|(col, ty)| {
format!(
"ALTER TABLE {child_table} ADD COLUMN {col} {}",
ty.keyword()
)
})
.collect();
lines.push(render_add_relationship(
name,
@@ -461,7 +482,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
negated,
} => {
let not = if *negated { "NOT " } else { "" };
format!("{} {not}LIKE {}", operand_to_sql(target), operand_to_sql(pattern))
format!(
"{} {not}LIKE {}",
operand_to_sql(target),
operand_to_sql(pattern)
)
}
Predicate::Between {
target,
@@ -484,7 +509,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
} => {
let not = if *negated { "NOT " } else { "" };
let rendered: Vec<String> = items.iter().map(operand_to_sql).collect();
format!("{} {not}IN ({})", operand_to_sql(target), rendered.join(", "))
format!(
"{} {not}IN ({})",
operand_to_sql(target),
rendered.join(", ")
)
}
Predicate::IsNull { target, negated } => {
let not = if *negated { "NOT " } else { "" };
@@ -562,7 +591,10 @@ mod tests {
fn create_table_compound_pk_renders_table_level() {
let cmd = create_table(
"T",
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
],
&["a", "b"],
);
assert_eq!(
@@ -594,7 +626,11 @@ mod tests {
default: Some(Value::Text("A".to_string())),
..ColumnSpec::new("grade", Type::Text)
};
let cmd = create_table("T", vec![ColumnSpec::new("id", Type::Serial), age, grade], &["id"]);
let cmd = create_table(
"T",
vec![ColumnSpec::new("id", Type::Serial), age, grade],
&["id"],
);
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(
sql,
@@ -625,11 +661,11 @@ mod tests {
check: None,
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'");
assert!(matches!(
reparse(&sql),
Ok(Command::SqlAlterTable { .. })
));
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
#[test]
@@ -657,7 +693,10 @@ mod tests {
})),
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)");
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
@@ -1031,7 +1070,10 @@ mod tests {
let lines = render_drop_column_cascade(
"Orders",
"CustId",
&["Orders_CustId_idx".to_string(), "Orders_CustId_Day_idx".to_string()],
&[
"Orders_CustId_idx".to_string(),
"Orders_CustId_Day_idx".to_string(),
],
);
assert_eq!(
lines.as_slice(),
@@ -1043,9 +1085,18 @@ mod tests {
);
// Each line is itself runnable advanced-mode SQL (the §1 contract
// holds per line for category 2).
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[2]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1054,7 +1105,10 @@ mod tests {
// plain `DROP COLUMN` — still semantically equivalent.
let lines = render_drop_column_cascade("T", "c", &[]);
assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN c"]);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1078,8 +1132,14 @@ mod tests {
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE",
]
);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1116,8 +1176,16 @@ mod tests {
],
&["Students_id".to_string(), "Courses_id".to_string()],
&[
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
(
vec!["Students_id".to_string()],
"Students".to_string(),
vec!["id".to_string()],
),
(
vec!["Courses_id".to_string()],
"Courses".to_string(),
vec!["id".to_string()],
),
],
);
assert_eq!(
@@ -1172,8 +1240,14 @@ mod tests {
#[test]
fn value_literal_renders_null_uppercase_and_quotes_text() {
assert_eq!(value_to_sql_literal(&Value::Null), "NULL");
assert_eq!(value_to_sql_literal(&Value::Text("O'Hara".to_string())), "'O''Hara'");
assert_eq!(value_to_sql_literal(&Value::Number("3.14".to_string())), "3.14");
assert_eq!(
value_to_sql_literal(&Value::Text("O'Hara".to_string())),
"'O''Hara'"
);
assert_eq!(
value_to_sql_literal(&Value::Number("3.14".to_string())),
"3.14"
);
assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false");
}
@@ -1258,9 +1332,7 @@ mod tests {
"Command::App({app:?}) is Bucket C — no echo"
);
// Also confirm echo_for gates the same in advanced mode.
assert!(
echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),
);
assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),);
}
}
+10 -5
View File
@@ -8,9 +8,8 @@
use crossterm::event::KeyEvent;
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription,
UpdateResult,
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, DropColumnResult,
InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, UpdateResult,
};
use crate::dsl::Command;
@@ -73,10 +72,16 @@ pub enum AppEvent {
},
/// An `explain …` command succeeded (ADR-0028). `plan`
/// carries the captured query plan; nothing was executed.
DslExplainSucceeded { command: Command, plan: QueryPlan },
DslExplainSucceeded {
command: Command,
plan: QueryPlan,
},
/// A `show <kind>` list command (V5) — carries pre-formatted
/// display lines (tables / relationships / indexes).
DslShowListSucceeded { command: Command, lines: Vec<String> },
DslShowListSucceeded {
command: Command,
lines: Vec<String>,
},
/// `show relationship <name>` (ADR-0044) — structured data for the
/// diagram, rendered App-side; `None` when no such relationship.
DslShowRelationshipSucceeded {
+3 -11
View File
@@ -43,17 +43,11 @@ impl Catalog {
}
}
fn flatten(
value: &serde_norway::Value,
prefix: String,
out: &mut HashMap<String, String>,
) {
fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
match value {
serde_norway::Value::Mapping(map) => {
for (k, v) in map {
let k_str = k
.as_str()
.expect("catalog keys must be strings");
let k_str = k.as_str().expect("catalog keys must be strings");
let next = if prefix.is_empty() {
k_str.to_string()
} else {
@@ -85,9 +79,7 @@ pub fn catalog() -> &'static Catalog {
/// See module docs for failure modes.
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
let template = catalog().get(key).unwrap_or_else(|| {
panic!(
"missing catalog key: `{key}` (the validator should have caught this)"
);
panic!("missing catalog key: `{key}` (the validator should have caught this)");
});
substitute(template, args, key)
}
+69 -31
View File
@@ -41,8 +41,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("diagnostic.alias_used_as_column", &["name"]),
("diagnostic.ambiguous_column", &["column", "qualifiers"]),
("diagnostic.auto_column_overridden", &["column", "type"]),
("diagnostic.compound_arity_mismatch", &["op", "left_n", "right_n"]),
("diagnostic.cte_arity_mismatch", &["cte", "declared", "actual"]),
(
"diagnostic.compound_arity_mismatch",
&["op", "left_n", "right_n"],
),
(
"diagnostic.cte_arity_mismatch",
&["cte", "declared", "actual"],
),
("diagnostic.duplicate_cte", &["name"]),
("diagnostic.eq_null", &[]),
("diagnostic.insert_arity_mismatch", &["expected", "actual"]),
@@ -63,7 +69,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
),
("diagnostic.not_null_missing", &["column"]),
("diagnostic.like_numeric", &["column", "type"]),
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
(
"diagnostic.projection_alias_misplaced",
&["alias", "clause"],
),
("diagnostic.table_used_as_column", &["name"]),
("diagnostic.type_mismatch", &["column", "type"]),
("diagnostic.unknown_column", &["name", "table"]),
@@ -149,10 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
"error.type_mismatch.change_column.headline",
&["table", "column", "src_type", "target_type"],
),
(
"error.type_mismatch.change_column.hint",
&["target_type"],
),
("error.type_mismatch.change_column.hint", &["target_type"]),
(
"error.type_mismatch.insert.headline",
&["value", "expected_type"],
@@ -174,13 +180,15 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
// In-app `help` — framing + per-command entries keyed by
// each CommandNode's `help_id` (ADR-0024 §help_id).
("help.intro", &[]),
("help.dsl_section", &[]),
("help.simple_section", &[]),
("help.advanced_section", &[]),
("help.types_reference", &[]),
("help.detail_hint", &[]),
("help.unknown_topic", &["topic"]),
("help.app.quit", &[]),
("help.app.help", &[]),
("help.app.hint", &[]),
("help.app.version", &[]),
("help.app.rebuild", &[]),
("help.app.save", &[]),
("help.app.new", &[]),
@@ -216,18 +224,52 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.data.delete", &[]),
("help.data.replay", &[]),
("help.data.explain", &[]),
// Issue #36: advanced-mode (SQL) help pages.
("help.data.select", &[]),
("help.data.with", &[]),
("help.data.sql_insert", &[]),
("help.data.sql_update", &[]),
("help.data.sql_delete", &[]),
("help.data.explain_sql", &[]),
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
("hint.ambient_complete", &[]),
(
"hint.ambient_error_with_usage",
&["message", "usage"],
),
("hint.ambient_error_with_usage", &["message", "usage"]),
("hint.ambient_expected", &["expected"]),
("hint.getting_started", &[]),
("hint.block.heading", &[]),
("hint.block.concept_heading", &[]),
("hint.block.what", &[]),
("hint.block.example", &[]),
("hint.block.concept", &[]),
// Clause-concept blocks (issue #37 / clause-concept-hints D5).
// Both-mode topics carry a mode-keyed `example` (simple + advanced);
// single-mode topics carry a plain `example`. The comprehensiveness
// test (CONCEPT_TOPICS) enforces this shape per topic.
("hint.concept.referential_actions.what", &[]),
("hint.concept.referential_actions.example.simple", &[]),
("hint.concept.referential_actions.example.advanced", &[]),
("hint.concept.referential_actions.concept", &[]),
("hint.concept.cardinality_one_to_many.what", &[]),
("hint.concept.cardinality_one_to_many.example", &[]),
("hint.concept.cardinality_one_to_many.concept", &[]),
("hint.concept.cardinality_many_to_many.what", &[]),
("hint.concept.cardinality_many_to_many.example", &[]),
("hint.concept.cardinality_many_to_many.concept", &[]),
("hint.concept.primary_key.what", &[]),
("hint.concept.primary_key.example.simple", &[]),
("hint.concept.primary_key.example.advanced", &[]),
("hint.concept.primary_key.concept", &[]),
("hint.concept.unique.what", &[]),
("hint.concept.unique.example.simple", &[]),
("hint.concept.unique.example.advanced", &[]),
("hint.concept.unique.concept", &[]),
("hint.concept.check.what", &[]),
("hint.concept.check.example.simple", &[]),
("hint.concept.check.example.advanced", &[]),
("hint.concept.check.concept", &[]),
("hint.concept.foreign_key.what", &[]),
("hint.concept.foreign_key.example", &[]),
("hint.concept.foreign_key.concept", &[]),
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
("hint.cmd.insert.what", &[]),
("hint.cmd.insert.example", &[]),
@@ -272,11 +314,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.cmd.help.concept", &[]),
("hint.cmd.hint.what", &[]),
("hint.cmd.hint.example", &[]),
("hint.cmd.version.what", &[]),
("hint.cmd.version.example", &[]),
("hint.cmd.rebuild.what", &[]),
("hint.cmd.rebuild.example", &[]),
("hint.cmd.rebuild.concept", &[]),
("hint.cmd.save.what", &[]),
("hint.cmd.save.example", &[]),
("hint.cmd.save.concept", &[]),
("hint.cmd.new.what", &[]),
("hint.cmd.new.example", &[]),
("hint.cmd.load.what", &[]),
@@ -400,10 +445,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.cmd.explain_sql.what", &[]),
("hint.cmd.explain_sql.example", &[]),
("hint.cmd.explain_sql.concept", &[]),
(
"hint.ambient_invalid_ident",
&["kind", "found"],
),
("hint.ambient_invalid_ident", &["kind", "found"]),
("hint.ambient_typing_name", &[]),
// Issue #4: introduce the advanced-mode CREATE TABLE element
// slot (`create table T (`) so the otherwise-invisible
@@ -411,12 +453,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.create_table_element", &[]),
("hint.seed_count", &[]),
("hint.value_literal_slot", &[]),
(
"hint.ambient_typing_name_then",
&["next"],
),
("hint.ambient_typing_name_then", &["next"]),
// Per-column-type value-slot hints (ADR-0024 §Phase D).
("hint.value_slot_blob", &[]),
("hint.value_slot_bool", &[]),
("hint.value_slot_date", &[]),
("hint.value_slot_datetime", &[]),
@@ -437,7 +475,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.custom.alter_named_unique", &[]),
("parse.custom.bind_type_mismatch", &["found", "expected"]),
("parse.custom.change_column_flags_exclusive", &[]),
("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]),
(
"parse.custom.constraint_redundant_on_pk",
&["column", "constraint"],
),
("parse.custom.create_table_needs_pk", &[]),
("parse.custom.expression_too_deep", &[]),
("parse.custom.insert_form_a_missing_values", &["columns"]),
@@ -485,6 +526,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.mode", &[]),
("parse.usage.new", &[]),
("parse.usage.quit", &[]),
("parse.usage.version", &[]),
("parse.usage.rebuild", &[]),
("parse.usage.redo", &[]),
("parse.usage.replay", &[]),
@@ -571,14 +613,12 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["table", "col_count", "col_list", "supplied", "non_auto_csv"],
),
("select.internal_table", &["table"]),
(
"cli.invalid_value",
&["flag", "value", "expected"],
),
("cli.invalid_value", &["flag", "value", "expected"]),
("cli.missing_value", &["flag"]),
("cli.multiple_paths", &["first", "second"]),
("cli.resume_with_path", &[]),
("cli.unknown_argument", &["arg"]),
("cli.version_line", &["version"]),
(
"archive.export_sequence_exhausted",
&["project", "target_dir", "limit"],
@@ -861,8 +901,7 @@ mod tests {
}
}
let declared: HashSet<&str> =
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
let declared: HashSet<&str> = KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
for key in cat.keys() {
if key.starts_with("_test.") {
continue;
@@ -884,9 +923,8 @@ mod tests {
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
/// duplicated here so the catalog validator is self-contained
/// (no dependency on the integration-test binary).
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA",
];
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] =
&["SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA"];
/// Detect a `{name:...}` format-specifier placeholder.
/// Doubled braces `{{` / `}}` are escapes — must skip them.
+2 -2
View File
@@ -34,8 +34,8 @@ pub mod keys;
pub mod translate;
pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog};
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
pub use format::{Catalog, catalog};
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity, error_hint_class};
// `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError
+96 -12
View File
@@ -162,6 +162,10 @@ error:
# ---- Help text (CLI banner + in-app `help` command) ------------------
# ---- CLI argument-parsing errors (stderr before TUI starts) ---------
cli:
# Version line for `--version` / `-V` and the in-app `version` command
# (ADR-0054). `{version}` is `CARGO_PKG_VERSION` — the single source of
# truth, equal to the `v*` release tag (release CI guards the match).
version_line: "rdbms-playground {version}"
missing_value: "missing value for --{flag}"
invalid_value: "invalid value for --{flag}: {value} (expected one of: {expected})"
unknown_argument: "unknown argument: {arg}"
@@ -186,6 +190,7 @@ help:
Options:
-h, --help Print this help and exit.
-V, --version Print the version and exit.
--theme <light|dark> Override theme (default: auto-detect).
--data-dir <PATH> Use PATH as the data root instead of
the OS-standard location for this run.
@@ -210,6 +215,7 @@ help:
App-level commands (typed inside the app, available in both modes):
quit Exit cleanly.
version Print the application version.
mode simple|advanced Switch input mode.
help Show this list of commands in-app.
save Save the current temp project under a
@@ -240,7 +246,13 @@ help:
# are multi-line-capable — the renderer emits one output row
# per line so scroll math stays accurate.
intro: "Supported commands:"
dsl_section: "DSL data commands (in simple mode):"
# Issue #36: the command list groups by mode. App-lifecycle commands list
# first (unlabelled, under the intro — they work in either mode); the rest
# split into these two sections by command category. (Replaces the old
# single "DSL data commands (in simple mode):" header, which used the banned
# "DSL" term and mis-labelled the advanced SQL forms it already contained.)
simple_section: "Simple-mode commands:"
advanced_section: "Advanced-mode (SQL) commands:"
# H3: footer on the full `help` list, and the not-found note
# for `help <topic>`. `{topic}` is the word the user typed.
detail_hint: "Type `help <command>` for detail on one command (e.g. `help insert`), or `help types` for the type reference."
@@ -258,6 +270,8 @@ help:
help <command> — detailed help for one command (e.g. `help insert`)
hint: |-
hint — explain the most recent error (press F1 for a hint on what you're typing)
version: |-
version — print the application version (same as the `--version` command-line flag)
rebuild: |-
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
save: |-
@@ -360,11 +374,28 @@ help:
explain show data <T> | explain update <T> ... | explain delete from <T> ...
— show how the database would run a query, without
running it (safe even for update / delete)
explain <select|with|insert|update|delete …> (advanced mode)
— the same plan for the SQL you wrote
# Issue #36: advanced-mode (SQL) forms. Each has its own help page, listed
# under the "Advanced-mode (SQL) commands:" section and shown by
# `help <topic>` alongside its simple-mode sibling — so `help insert` shows
# both the simple form and `sql_insert` (like `help create` already does).
select: |-
select <cols> | * from <T> [where …] [group by …] [order by …] [limit n]
— query rows (advanced SQL)
with: |-
with <name> as (<select>) [, …] <select> — query through a named
sub-query / CTE (advanced SQL)
sql_insert: |-
insert into <T> (col, …) values (val, …) — add a row (advanced SQL)
sql_update: |-
update <T> set <col> = <val>, … where <expr> — change matching rows (advanced SQL)
sql_delete: |-
delete from <T> where <expr> — remove matching rows (advanced SQL)
explain_sql: |-
explain <select|with|insert|update|delete …> — show the plan for a SQL statement,
without running it (advanced SQL)
# Type reference, appended after the command list.
types_reference: |
Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid
Types: text, int, real, decimal, bool, date, datetime, serial, shortid
Auto-generated types (serial, shortid):
serial — integer that auto-fills with the next sequence value
(MAX(col)+1) on insert. Outside a primary key it carries
@@ -395,9 +426,58 @@ hint:
# `what` / `example` / `concept` parts render under.
block:
heading: "Hint"
# Sub-heading for the clause-concept block layered beneath the
# per-form block when the cursor sits inside a recognized clause
# (issue #37 / clause-concept-hints D6).
concept_heading: "About this clause"
what: "What"
example: "Example"
concept: "Concept"
# ── Tier-3 clause-concept blocks (issue #37 / clause-concept-hints) ─
# Surfaced by F1 when the cursor sits inside a recognized clause,
# layered on top of the per-form `hint.cmd.*` block. Same
# `what`/`example`/`concept` shape; topics reachable in BOTH modes
# carry a mode-keyed `example` (`simple` + `advanced`) so the example
# is syntax-correct for the mode the user is actually in (ADR-0053 D6).
# Single-mode topics carry one plain `example`. Copy rules: no engine
# name; "simple mode"/"advanced mode", never "DSL".
concept:
referential_actions:
what: "Decide what happens to a child row when the parent it points at is deleted, or its key changes."
example:
simple: "add 1:n relationship from Customers.id to Orders.customer_id on delete cascade"
advanced: "create table Orders (id int primary key, customer_id int references Customers(id) on delete cascade)"
concept: "A foreign key forbids orphans by default (restrict — the delete is refused). `cascade` deletes the children along with the parent; `set null` keeps the children but clears their link. The action you pick encodes a real rule about your data."
cardinality_one_to_many:
what: "Link two tables so one parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "\"1:n\" is one parent, many children — one customer, many orders. The child table holds a foreign key column pointing back at the parent; that shared value is what groups a parent's children together."
cardinality_many_to_many:
what: "Link two tables when rows on each side can relate to many rows on the other."
example: "create m:n relationship from Students to Courses"
concept: "\"m:n\" — many students take many courses — can't be stored as one foreign key. A junction table sits between them, holding one row per pairing; it is created for you, turning the m:n into two 1:n links."
primary_key:
what: "Mark the column (or columns) that uniquely identify each row."
example:
simple: "create table Customers with pk id(serial), Name(text)"
advanced: "create table Customers (id int primary key, Name text)"
concept: "A primary key is the row's identity: every row must have one, and no two rows may share it. Other tables point at this key to reference the row. List several columns for a compound key when one column isn't unique on its own."
unique:
what: "Require that no two rows share the same value in this column."
example:
simple: "create table Customers with pk id(serial), Email(text) unique"
advanced: "create table Customers (id int primary key, Email text unique)"
concept: "Like a primary key, `unique` forbids duplicates — but a table has one primary key (its identity) and may have many `unique` columns (e.g. email, username). Unlike the primary key, a unique column may usually be left empty."
check:
what: "Attach a rule each row must satisfy before it can be saved."
example:
simple: "create table Products with pk id(serial), Price(decimal) check (Price >= 0)"
advanced: "create table Products (id int primary key, Price decimal check (Price >= 0))"
concept: "A check guards the data at write time: any insert or update that breaks the rule is refused, so invalid rows never reach the table. Use it to enforce real-world constraints — a non-negative price, a rating between 1 and 5."
foreign_key:
what: "Make this column point at a row in another table."
example: "create table Orders (id int primary key, customer_id int references Customers(id))"
concept: "A foreign key is a promise that every value here matches a real row in the parent table — no orphaned references. The parent must exist before the child can point at it; an `on delete`/`on update` action decides what happens when that parent later changes."
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
# Per-form command hints (`hint.cmd.<form>`) and per-class error
# hints (`hint.err.<class>`), each a `what` (12 sentences) / `example`
@@ -425,13 +505,17 @@ hint:
hint:
what: "Explain the most recent error — or, pressing F1 while typing, the command you're building."
example: "hint"
version:
what: "Print the application version."
example: "version"
rebuild:
what: "Rebuild the project database from its saved text files."
example: "rebuild"
concept: "The text files (project.yaml + the data folder) are the source of truth; the database is derived and can always be rebuilt from them."
save:
what: "Save the current project under a name; `save as` copies it to a new one."
example: "save as my-shop"
what: "Save the current project; `save as` copies it to a new name or location."
example: "save as"
concept: "On a temporary project, `save` opens a prompt to give it a permanent name; a named project auto-saves as you work, so `save` on one is already done. `save as` always prompts for a new name or path — use it to copy a project."
new:
what: "Close the current project and start a fresh temporary one."
example: "new"
@@ -444,7 +528,7 @@ hint:
concept: "The zip carries the schema and data as text, so anyone can rebuild the very same database from it."
import:
what: "Unpack a project zip into a new project and switch to it."
example: "import my-shop.zip as shop-copy"
example: "import my-shop.zip as shop_copy"
mode:
what: "Switch between simple mode (the guided teaching commands) and advanced mode (raw SQL)."
example: "mode advanced"
@@ -465,9 +549,9 @@ hint:
example: "copy last"
# DDL — schema-shaping commands (Phase C batch 2).
create_table:
what: "Create a new table — its columns, their types, and a primary key."
example: "create table Customers with pk id(serial), name(text), email(text)"
concept: "A table is a set of rows that share the same columns. The primary key uniquely identifies each row; a `serial` key numbers the rows for you."
what: "Create a new table and declare its primary key."
example: "create table Customers with pk id(serial)"
concept: "A table is a set of rows sharing the same columns. `with pk` declares the primary key — one column, or several for a compound key; add the other columns afterwards with `add column`. A `serial` key numbers the rows for you."
create_m2n:
what: "Create a junction table linking two tables many-to-many."
example: "create m:n relationship from Students to Courses"
@@ -606,7 +690,7 @@ hint:
child_side:
what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at."
example: "First insert the parent (insert into Customers …), then the child that references it."
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`."
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist before a child can reference it. (`on delete` actions like `cascade` or `set null` govern the other direction — what happens to children when their parent is removed — not this one.)"
parent_side:
what: "You're deleting or changing a row that other rows point at, which would orphan those children."
example: "Delete the child rows first, or set the relationship's `on delete` to `cascade` (remove them too) or `set null` (keep them, unlinked)."
@@ -679,7 +763,6 @@ hint:
value_slot_text: "Type a quoted string (e.g. 'Alice') or null"
value_slot_date: "Type a quoted date as 'YYYY-MM-DD' or null"
value_slot_datetime: "Type a quoted datetime as 'YYYY-MM-DD HH:MM:SS' or null"
value_slot_blob: "Type a quoted blob literal or null"
# Serial / shortid in `values (…)` form: the user must enter
# something at this position (no "skip column" syntax). `null`
# is the auto-fill path (ADR-0018: serial / shortid columns
@@ -873,6 +956,7 @@ parse:
quit: "quit"
help: "help [<command>]"
hint: "hint"
version: "version"
rebuild: "rebuild"
save: "save | save as"
new: "new"

Some files were not shown because too many files have changed in this diff Show More