Compare commits
34
Commits
website
...
44b495dfed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b495dfed | ||
|
|
78272e2fd0 | ||
|
|
e1d7419a72 | ||
|
|
6e28e58e45 | ||
|
|
5c51a00e72 | ||
|
|
ec7a8ff3be | ||
|
|
f06ee3b460 | ||
|
|
279ef98ab8 | ||
|
|
4570c4e1ea | ||
|
|
3585cca5ea | ||
|
|
63d11402ae | ||
|
|
8ccecc4245 | ||
|
|
b5c848efcb | ||
|
|
6d4364666a | ||
|
|
48fc5e8202 | ||
|
|
9812463620 | ||
|
|
208da81108 | ||
|
|
e845a6ee72 | ||
|
|
c9d6660ba6 | ||
|
|
6b4c4dcea4 | ||
|
|
1a2002dbf6 | ||
|
|
64818c08f6 | ||
|
|
3ad4affef2 | ||
|
|
e88fa79f09 | ||
|
|
07575da983 | ||
|
|
010dbf8e9e | ||
|
|
35ca108fa1 | ||
|
|
1ffe11cb1f | ||
|
|
c1f24599da | ||
|
|
a8ebbfba26 | ||
|
|
f06cbbc788 | ||
|
|
88204f25c5 | ||
|
|
fd63de3441 | ||
|
|
65eab71439 |
@@ -14,11 +14,16 @@
|
|||||||
name: ci
|
name: ci
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# Branch pushes only — a tag push hits the same commit the branch push
|
# Only `main` (the post-merge gate + canonical branch). Feature branches
|
||||||
# already gated, so `branches: ['**']` drops the redundant tag-triggered
|
# are gated via `pull_request` instead. Running both on a push to a PR'd
|
||||||
# run (the release workflow owns tags). Pushing commits + a tag together
|
# branch was pure duplication on Gitea: unlike GitHub it has no merge-
|
||||||
# still gates the commits via the branch push.
|
# preview ref — its `pull_request` checks out `refs/pull/N/head`, the same
|
||||||
branches: ['**']
|
# 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,
|
# 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
|
# and the website subproject (it has its own workflow, website.yaml, that
|
||||||
# builds + publishes it). A push touching crate code *and* these still runs
|
# builds + publishes it). A push touching crate code *and* these still runs
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -58,8 +58,8 @@ Current decisions at a glance (each backed by an ADR):
|
|||||||
`[temp]`-marker / contents-allowlist guards. Anything
|
`[temp]`-marker / contents-allowlist guards. Anything
|
||||||
unexpected → refuse, never delete the wrong thing.
|
unexpected → refuse, never delete the wrong thing.
|
||||||
- **Types:** `text`, `int`, `real`, `decimal`, `bool`, `date`,
|
- **Types:** `text`, `int`, `real`, `decimal`, `bool`, `date`,
|
||||||
`datetime`, `blob`, `serial`, `shortid`. Compound primary keys
|
`datetime`, `serial`, `shortid`. Compound primary keys
|
||||||
supported. No real UUIDs (ADR-0005). FK column type
|
supported. No real UUIDs; `blob` dropped (ADR-0005 Amendment 2). FK column type
|
||||||
compatibility via `Type::fk_target_type()` — `serial → int`,
|
compatibility via `Type::fk_target_type()` — `serial → int`,
|
||||||
`shortid → text`, others identity (ADR-0011).
|
`shortid → text`, others identity (ADR-0011).
|
||||||
- **Safety:** append-only `history.log` for replay and scripting
|
- **Safety:** append-only `history.log` for replay and scripting
|
||||||
@@ -115,15 +115,23 @@ Current decisions at a glance (each backed by an ADR):
|
|||||||
a self-hosted
|
a self-hosted
|
||||||
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
|
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
|
||||||
`1.95.0` — one source of toolchain for dev *and* CI) plus a
|
`1.95.0` — one source of toolchain for dev *and* CI) plus a
|
||||||
prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` +
|
prebuilt CI image. **Gate** (`ci.yaml`, every step run via `nix
|
||||||
`cargo test` on every branch push / PR. **Release** on a `v*` tag
|
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
|
(`release.yaml`): the four non-macOS **D1** targets cross-built
|
||||||
with `cargo-zigbuild` (Linux musl static + standalone Windows
|
with `cargo-zigbuild` (Linux musl static + standalone Windows
|
||||||
`.exe`); the two macOS targets via the **dispatched**
|
`.exe`); the two macOS targets via the **dispatched**
|
||||||
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
|
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
|
||||||
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
|
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
|
||||||
release with `.sha256`s. **`fmt` is intentionally not gated yet**
|
release with `.sha256`s. **`fmt` is gated** (`cargo fmt --check`,
|
||||||
(the tree isn't stock-`rustfmt`-clean). Now that this is on `main`,
|
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
|
`release-macos` is dispatchable (`workflow_dispatch` is
|
||||||
Gitea-default-branch-only) — **dispatched and verified working**: the
|
Gitea-default-branch-only) — **dispatched and verified working**: the
|
||||||
macOS build + de-nix/re-sign + upload runs end-to-end and the binaries
|
macOS build + de-nix/re-sign + upload runs end-to-end and the binaries
|
||||||
@@ -246,10 +254,64 @@ Key invariants in the code:
|
|||||||
the specific product (SQLite, STRICT, rusqlite, PRAGMA).
|
the specific product (SQLite, STRICT, rusqlite, PRAGMA).
|
||||||
ADR-internal prose and code comments may name it where
|
ADR-internal prose and code comments may name it where
|
||||||
technically necessary for precision.
|
technically necessary for precision.
|
||||||
|
- **Changelog discipline.** `CHANGELOG.md` (repo root) tracks
|
||||||
|
notable **user-facing** changes (Keep a Changelog + SemVer).
|
||||||
|
Update it in the **same change** that introduces a user-facing
|
||||||
|
behaviour change: add or amend a bullet under `[Unreleased]`
|
||||||
|
in the right category (Added / Changed / Deprecated / Removed /
|
||||||
|
Fixed / Security), phrased for end users under the two copy
|
||||||
|
rules above (no engine name; no "DSL" — say "simple mode" /
|
||||||
|
"advanced mode"). A change with no user-visible effect (pure
|
||||||
|
refactor, internal tests, CI plumbing) gets **no** entry — and
|
||||||
|
the judgement of "user-facing" includes scripted / pasted /
|
||||||
|
power-user paths, not just the interactive happy path. At
|
||||||
|
release time, rename `[Unreleased]` to the new version + date,
|
||||||
|
add the compare link, and **sweep the commits/handoffs since
|
||||||
|
the last tag** as a backstop for anything missed.
|
||||||
- **Confirm commits.** Per the user's global rules, every
|
- **Confirm commits.** Per the user's global rules, every
|
||||||
`git commit` is preceded by an explicit message proposal
|
`git commit` is preceded by an explicit message proposal
|
||||||
and user approval. No AI attribution in commit messages.
|
and user approval. No AI attribution in commit messages.
|
||||||
|
|
||||||
|
## 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`
|
## Issue tracking — Gitea via `tea`
|
||||||
|
|
||||||
Extends (does not replace) the generic Gitea/`tea` safety rules in
|
Extends (does not replace) the generic Gitea/`tea` safety rules in
|
||||||
|
|||||||
@@ -7,6 +7,67 @@ open pull requests there. It's approaching its first public release, so
|
|||||||
the most useful contributions right now are bug reports and rough edges
|
the most useful contributions right now are bug reports and rough edges
|
||||||
you hit while learning.
|
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
|
## License of contributions
|
||||||
|
|
||||||
Unless you explicitly state otherwise, any contribution you intentionally
|
Unless you explicitly state otherwise, any contribution you intentionally
|
||||||
|
|||||||
Generated
+113
-3
@@ -64,6 +64,12 @@ dependencies = [
|
|||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic"
|
name = "atomic"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -164,6 +170,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -481,6 +493,12 @@ dependencies = [
|
|||||||
"litrs",
|
"litrs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downcast-rs"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -976,7 +994,7 @@ version = "1.1.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nix",
|
"nix 0.29.0",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1038,6 +1056,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases 0.1.1",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -1046,7 +1076,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
@@ -1360,6 +1390,27 @@ version = "1.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-pty"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"downcast-rs",
|
||||||
|
"filedescriptor",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"nix 0.28.0",
|
||||||
|
"serial2",
|
||||||
|
"shared_library",
|
||||||
|
"shell-words",
|
||||||
|
"winapi",
|
||||||
|
"winreg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1548,6 +1599,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"gethostname",
|
"gethostname",
|
||||||
"insta",
|
"insta",
|
||||||
|
"portable-pty",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -1559,6 +1611,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"vt100",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1738,6 +1791,17 @@ dependencies = [
|
|||||||
"unsafe-libyaml-norway",
|
"unsafe-libyaml-norway",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial2"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -1758,6 +1822,22 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shared_library"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1968,7 +2048,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"memmem",
|
"memmem",
|
||||||
"nix",
|
"nix 0.29.0",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
@@ -2240,6 +2320,27 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vt100"
|
||||||
|
version = "0.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"unicode-width",
|
||||||
|
"vte",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vtparse"
|
name = "vtparse"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2639,6 +2740,15 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = { version = "1.47.2", features = ["yaml"] }
|
insta = { version = "1.47.2", features = ["yaml"] }
|
||||||
|
portable-pty = "0.9"
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
vt100 = "0.16"
|
||||||
|
|
||||||
# Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test`
|
# Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test`
|
||||||
# links ~25 separate integration-test binaries, each statically
|
# links ~25 separate integration-test binaries, each statically
|
||||||
|
|||||||
@@ -45,18 +45,23 @@ ADR numbers are a single global sequence, so two branches can each grab
|
|||||||
the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by
|
the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by
|
||||||
renumbering the former to ADR-0044.) To prevent it:
|
renumbering the former to ADR-0044.) To prevent it:
|
||||||
|
|
||||||
**Assign an ADR's number at merge-to-`main`, not at creation.** While the
|
**Reserve the number up front, via `scripts/adr-reserve.sh`** (ADR-0059,
|
||||||
work lives on a non-`main` branch, draft the ADR under a placeholder — an
|
which superseded the earlier placeholder-until-merge default). The moment
|
||||||
`ADR-XXXX` title and a `draft-<slug>.md` filename — and reference it that
|
you know an ADR is needed — at branch start or mid-branch — run
|
||||||
way from any plan or notes. Give it the next free number only when the
|
`scripts/adr-reserve.sh <slug> "<title>"`. It atomically claims the next
|
||||||
branch merges to `main`, renaming the file and updating its references in
|
free number against `main` (the remote ref is the registry; a `git push`
|
||||||
the same step.
|
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" only once it appears in `main`'s `docs/adr/README.md`,
|
A number is "taken" once its ledger line (or `NNNN-*.md` file) is on
|
||||||
which is the single source of truth for the next free number — never
|
`main`; the script reads both to compute the next free number — never
|
||||||
compute "next" from a feature branch. A branch that genuinely needs a real
|
compute "next" by hand from a feature branch. The full rationale (why
|
||||||
number up front may instead reserve one by landing a stub index entry on
|
reserve-first beats number-on-merge, issue-number ids, or an allocator bot)
|
||||||
`main` first, but placeholder-until-merge is the default.
|
is in **ADR-0059**.
|
||||||
|
|
||||||
### Subproject ADR namespaces
|
### Subproject ADR namespaces
|
||||||
|
|
||||||
|
|||||||
@@ -132,3 +132,145 @@ verbatim, trailing zeros and all (`100.10`). Exact decimal
|
|||||||
require rewriting the user's standard-SQL operators into function
|
require rewriting the user's standard-SQL operators into function
|
||||||
calls, defeating both the "validated SQL runs verbatim" model and
|
calls, defeating both the "validated SQL runs verbatim" model and
|
||||||
the goal of teaching ordinary SQL.
|
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.
|
||||||
|
|||||||
@@ -147,3 +147,56 @@ and on a nightly schedule for any extended coverage.
|
|||||||
only slow tier and is kept narrow.
|
only slow tier and is kept narrow.
|
||||||
- Adding a feature implies adding tests at the appropriate tier
|
- Adding a feature implies adding tests at the appropriate tier
|
||||||
(or tiers); coverage is not retrofitted later.
|
(or tiers); coverage is not retrofitted later.
|
||||||
|
|
||||||
|
## Amendment 1 — 2026-06-22: Tier 4 realized (TT4)
|
||||||
|
|
||||||
|
Tier 4 was a specification only (no PTY deps, no tests) until this
|
||||||
|
amendment. It is now implemented in `tests/e2e_pty.rs`, and the realized
|
||||||
|
shape refines the original decision in three ways.
|
||||||
|
|
||||||
|
**Tooling — `expectrl` dropped.** The decision named
|
||||||
|
`portable-pty` + `expectrl` + `vt100`. We kept **`portable-pty`** (0.9, to
|
||||||
|
spawn the real binary in a PTY at a fixed size) and **`vt100`** (0.16, to
|
||||||
|
parse the output stream into an inspectable cell grid), both current and
|
||||||
|
maintained. We **dropped `expectrl`**: it bundles its own PTY abstraction
|
||||||
|
(which conflicts with `portable-pty`) and matches line-by-line, a poor fit
|
||||||
|
for a full-screen TUI. A small hand-rolled `wait_for(predicate, timeout)`
|
||||||
|
that polls the vt100 screen replaces it — fewer dependencies, and assertions
|
||||||
|
read the actual rendered grid.
|
||||||
|
|
||||||
|
**Harness shape.** Each test spawns the binary
|
||||||
|
(`env!("CARGO_BIN_EXE_rdbms-playground")`) under a fresh PTY at **100×30**
|
||||||
|
(the wide three-region layout, ADR-0046), with its **own temp `--data-dir`**
|
||||||
|
so it never touches real projects or resume state, and `--theme dark` for
|
||||||
|
deterministic styling. Tests run **serially** (one PTY + child at a time)
|
||||||
|
via a poison-tolerant global lock, so timing stays predictable on the
|
||||||
|
low-parallelism self-hosted runner. Waits are **tight and fail-fast** (3 s):
|
||||||
|
a slow wait is a genuine hang, and a timeout panics with a full screen dump
|
||||||
|
for debuggability.
|
||||||
|
|
||||||
|
Two non-obvious robustness rules the harness encodes:
|
||||||
|
- **Read table presence from the Tables *sidebar* region, not the whole
|
||||||
|
screen** — the Output panel echoes every command, so a table name typed
|
||||||
|
in a command would pollute a whole-screen match.
|
||||||
|
- **Pace each command to completion before the next** (real-user / cast
|
||||||
|
cadence). A command submitted while the previous one's worker rebuild is
|
||||||
|
still in flight can be misread against a stale schema cache (issue #39,
|
||||||
|
discovered here — interactive use is unaffected).
|
||||||
|
|
||||||
|
**The four flows** mirror this ADR's Tier-4 scope: (1) cold launch → first
|
||||||
|
DDL → graceful quit (Ctrl-C); (2) create → quit → reopen via `--resume`,
|
||||||
|
asserting the *column* (not just the table name) round-trips; (3) export →
|
||||||
|
import into a fresh project → schema **and** data rebuilt; (4) `undo` after
|
||||||
|
`DROP TABLE`, through the Y/N confirm modal.
|
||||||
|
|
||||||
|
**CI.** The target runs by default in `cargo test`, so the existing Linux
|
||||||
|
gate exercises it on every push — this **advances TT5** (Tier 4 now runs in
|
||||||
|
CI on Linux). PTY-in-container was validated (openpty + child spawn work
|
||||||
|
under the Docker runtime the Gitea runner uses); the first real CI run is
|
||||||
|
the final confirmation. **Windows execution remains out of scope** (no
|
||||||
|
Windows runner), and the original Tier-4 note about a *nightly broader
|
||||||
|
coverage* schedule is **not** implemented — the focused four flows are the
|
||||||
|
committed TT4 scope; broader coverage is deferred.
|
||||||
|
|
||||||
|
**Piggybacked NFR measurement.** The same harness measures NFR-1 (startup
|
||||||
|
to first frame) and NFR-3 (idle RSS, Linux) — see ADR-0057.
|
||||||
@@ -824,6 +824,63 @@ of issue #4; no `AmbientHint` / renderer change. Covered by
|
|||||||
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
|
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
|
||||||
seed_count_hint_also_fires_after_a_column_fill_target}`.
|
seed_count_hint_also_fires_after_a_column_fill_target}`.
|
||||||
|
|
||||||
|
## Amendment 8 — submission gate: hold input until the schema-cache refresh lands (2026-06-22)
|
||||||
|
|
||||||
|
§9 established the schema cache as the source of truth for the
|
||||||
|
walker's schema-aware dispatch, "refreshed by the runtime … after
|
||||||
|
successful DDL". That refresh is **asynchronous**: the runtime applies
|
||||||
|
a command on the worker thread (ADR-0010) and only afterwards posts a
|
||||||
|
`SchemaCacheRefreshed` event back through the **same FIFO channel that
|
||||||
|
carries key events**. Between dispatching a DDL command and that event
|
||||||
|
landing, `schema_cache` is stale w.r.t. the command just run.
|
||||||
|
|
||||||
|
Validation in `App::update` is pure-sync (a core invariant — it cannot
|
||||||
|
do a DB round-trip), so it can only consult that cache. Under
|
||||||
|
**faster-than-human input** (paste, scripted input, an unpaced PTY
|
||||||
|
driver), the next submission's Enter is already queued *ahead* of the
|
||||||
|
refresh event, so it is validated against the stale cache. A simple-mode
|
||||||
|
**Form-B insert** (`insert into T values (…)`, columns derived from the
|
||||||
|
cache) submitted right after `add column` then sees the pre-DDL columns,
|
||||||
|
its value arity can't match, and the friendly layer tags it *"trying to
|
||||||
|
write SQL?"* — even though the identical line succeeds at human speed
|
||||||
|
(issue **#39**).
|
||||||
|
|
||||||
|
**Decision — gate submission on the pending refresh.** `App` carries
|
||||||
|
`awaiting_schema_refresh: bool` + a `held_submissions` FIFO queue.
|
||||||
|
`dispatch_dsl` arms the flag on **every** `ExecuteDsl` dispatch; while
|
||||||
|
armed, a new DSL submission is **held** (queued in submission order)
|
||||||
|
rather than validated. The `SchemaCacheRefreshed` handler clears the
|
||||||
|
flag and **drains** the queue against the now-fresh cache, stopping as
|
||||||
|
soon as a drained command re-arms the gate (so the remainder wait for
|
||||||
|
*its* refresh — order preserved). A held command that doesn't dispatch
|
||||||
|
(parse error / pre-flight rejection) leaves the gate open and the loop
|
||||||
|
continues. App-lifecycle commands (`quit` / `help` / `load` / `undo` /
|
||||||
|
`rebuild`) route through `dispatch_app_command` *before* `dispatch_dsl`
|
||||||
|
and so are never held.
|
||||||
|
|
||||||
|
**Why arm on every dispatch, not just DDL.** It keeps at most one DSL
|
||||||
|
command in flight, so refreshes are strictly one-per-dispatch and in
|
||||||
|
order — making the gate a provably-correct boolean. Arming only on
|
||||||
|
schema-mutating commands would let a *preceding* non-DDL command's
|
||||||
|
refresh clear the gate early and drain a held Form-B insert against a
|
||||||
|
cache that predates the DDL — reintroducing the bug in a corner case.
|
||||||
|
(App-lifecycle commands also refresh but bypass the gate; they are
|
||||||
|
modal/picker-gated and so cannot overlap a rapid DSL paste, the only
|
||||||
|
thing this guards.)
|
||||||
|
|
||||||
|
**Scope.** Interactive input only. The `replay` / history-log / startup
|
||||||
|
rebuild-from-text batch path already re-snapshots the schema
|
||||||
|
**synchronously, inline, before every line** (`run_replay`,
|
||||||
|
`build_schema_cache`), so it has always had this ordering guarantee;
|
||||||
|
this amendment brings the interactive path in line with it. No
|
||||||
|
interactive-user impact (the gate clears in milliseconds); held input is
|
||||||
|
never lost because the runtime sends a `SchemaCacheRefreshed` after
|
||||||
|
*every* dispatch, success or failure. Covered by
|
||||||
|
`app::tests::form_b_insert_after_ddl_is_held_until_refresh_then_dispatched`
|
||||||
|
(Tier-1, deterministic event ordering) and the Tier-4 PTY regression
|
||||||
|
`e2e_pty::back_to_back_insert_after_ddl_still_succeeds` (the unpaced
|
||||||
|
inverse of flow 3).
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
Deliberately deferred to keep this ADR shippable as a single
|
Deliberately deferred to keep this ADR shippable as a single
|
||||||
|
|||||||
@@ -706,6 +706,56 @@ documentation is still hand-curated for round 1.
|
|||||||
table-ident from new-name-ident visually is a future
|
table-ident from new-name-ident visually is a future
|
||||||
enhancement.
|
enhancement.
|
||||||
|
|
||||||
|
## Amendment 1 — advanced-mode SQL forms get their own `help` pages, and the list groups by mode (2026-06-22, issue #36)
|
||||||
|
|
||||||
|
`help_id` (§Node taxonomy) drives two surfaces: the `help` **list**
|
||||||
|
(`note_help` emits one `help.<id>` block per `Some` id) and the
|
||||||
|
`help <topic>` **lookup** (`note_help_topic` shows the block of every
|
||||||
|
node whose entry word matches the topic). The dedup rule is that
|
||||||
|
`help_id` *strings* are unique (one block ⇒ printed once).
|
||||||
|
|
||||||
|
Originally the six advanced-mode SQL **DML/query** forms — `SELECT`,
|
||||||
|
`WITH`, `SQL_INSERT`, `SQL_UPDATE`, `SQL_DELETE`, `EXPLAIN_SQL` —
|
||||||
|
carried `help_id: None`. That was a list-formatting shortcut (avoid a
|
||||||
|
second `insert` entry), but it had a side effect: `help select` /
|
||||||
|
`help with` resolved to **nothing** (the unknown-topic note), and
|
||||||
|
`help insert` showed only the simple form — even though the SQL surface
|
||||||
|
is genuinely different and advanced mode exists precisely for learners
|
||||||
|
moving to raw SQL. (The advanced SQL **DDL** forms — `sql_create_table`
|
||||||
|
etc. — already had their own `help.ddl.sql_*` pages, so the gap was
|
||||||
|
inconsistent as well as a pedagogy hole.)
|
||||||
|
|
||||||
|
**Decision.** Give every advanced SQL form its **own** `help_id`
|
||||||
|
(`data.select`, `data.with`, `data.sql_insert`, `data.sql_update`,
|
||||||
|
`data.sql_delete`, `data.explain_sql`) with a hand-curated
|
||||||
|
`help.data.*` page (the catalog body stays hand-written, per "What's
|
||||||
|
out of scope" above — this amendment doesn't change that). Because the
|
||||||
|
ids are **distinct strings**, the dedup invariant
|
||||||
|
(`no_two_registered_commands_share_a_help_id`) is untouched, and:
|
||||||
|
|
||||||
|
- **`help <topic>` shows every form sharing the entry word** — so
|
||||||
|
`help insert` shows the simple block *and* the `sql_insert` block
|
||||||
|
(exactly as `help create` already showed the simple + SQL create
|
||||||
|
forms). Advanced-only `help select` / `help with` now resolve.
|
||||||
|
- **The list groups by mode.** `note_help` now splits the REGISTRY by
|
||||||
|
[`CommandCategory`]: app-lifecycle commands (ids in the `app.*`
|
||||||
|
namespace, usable in either mode) list first, unlabelled, under the
|
||||||
|
intro; then a **`help.simple_section`** ("Simple-mode commands:")
|
||||||
|
group and a **`help.advanced_section`** ("Advanced-mode (SQL)
|
||||||
|
commands:") group. This replaces the single `help.dsl_section` header
|
||||||
|
("DSL data commands (in simple mode):"), which both used the banned
|
||||||
|
"DSL" term (ADR-0002 user-facing posture) and wrongly claimed simple
|
||||||
|
mode for the advanced SQL forms the section already held.
|
||||||
|
|
||||||
|
This partially realises ADR-0030 §6's "Polish" item (a `help sql`
|
||||||
|
page): rather than one combined page, each form has its own, reached
|
||||||
|
through the normal `help <topic>` surface and discoverable in the
|
||||||
|
advanced-mode list section. `note_help_topic` needed **no** change —
|
||||||
|
the new `help_id`s make the forms resolve automatically. Covered by
|
||||||
|
`help_command::{help_select_renders_the_sql_select_block,
|
||||||
|
help_with_renders_the_cte_block, help_insert_shows_both_simple_and_sql_forms,
|
||||||
|
help_list_splits_simple_and_advanced_sections}`.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- ADR-0023 — Unified declarative grammar tree (Proposed direction). Superseded by this ADR for execution detail.
|
- ADR-0023 — Unified declarative grammar tree (Proposed direction). Superseded by this ADR for execution detail.
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ representation; the SQL surface does not round-trip through it.
|
|||||||
|
|
||||||
### 5. Type vocabulary — the playground's, not the engine's
|
### 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
|
vocabulary (ADR-0005). There is **no fallback to engine
|
||||||
storage types**: a column created in advanced mode is a
|
storage types**: a column created in advanced mode is a
|
||||||
first-class `serial` / `decimal` / `date` / … exactly as 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
|
- ADR-0002 — the engine is an implementation detail; "no
|
||||||
engine name in user-facing strings" — §7 extends it.
|
engine name in user-facing strings" — §7 extends it.
|
||||||
- ADR-0003 — the simple / advanced mode model this builds on.
|
- 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
|
- ADR-0009 — the DSL conventions; the DSL stays usable in
|
||||||
advanced mode.
|
advanced mode.
|
||||||
- ADR-0012 / ADR-0013 — the metadata tables the `Command` core
|
- ADR-0012 / ADR-0013 — the metadata tables the `Command` core
|
||||||
|
|||||||
@@ -1649,7 +1649,7 @@ more.
|
|||||||
|
|
||||||
## See also
|
## 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-0006 — the auto-snapshot before destructive ops.
|
||||||
- ADR-0014 — the DSL DML model + cascade-summary precedent.
|
- ADR-0014 — the DSL DML model + cascade-summary precedent.
|
||||||
- ADR-0015 — the persistence write-through path.
|
- ADR-0015 — the persistence write-through path.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Two things from the earlier phases shape this one:
|
|||||||
executed as-is, the engine would make the table, but the
|
executed as-is, the engine would make the table, but the
|
||||||
playground would lose what the user meant: that `id` is `serial`,
|
playground would lose what the user meant: that `id` is `serial`,
|
||||||
that a `REFERENCES` clause is a *named relationship*, that `STRICT`
|
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.
|
needs the parsed statement either way.
|
||||||
|
|
||||||
ADR-0030 §4 said "DDL → a `Command` … run the typed executor." That
|
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
|
the handler reads the parsed structure and performs the schema change
|
||||||
through the playground's metadata-maintaining machinery — writing
|
through the playground's metadata-maintaining machinery — writing
|
||||||
`__rdbms_playground_columns` / `__rdbms_playground_relationships`,
|
`__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,
|
advanced-mode-created object is a first-class playground object,
|
||||||
identical to a simple-mode-created one (ADR-0030 §5).
|
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-0017** — the column type-change classification §7 shares.
|
||||||
- **ADR-0029** — column constraints; **ADR-0025** — indexes;
|
- **ADR-0029** — column constraints; **ADR-0025** — indexes;
|
||||||
**ADR-0011** — FK column-type compatibility; **ADR-0005** — the
|
**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).
|
- **ADR-0006** — undo; each DDL statement is one undo step (§10).
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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 0001–0057 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 `0001–0057` 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.
|
||||||
+7
-4
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
@@ -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`").
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Plan — TT4 (Tier-4 PTY tests) + NFR verification + CHANGELOG
|
||||||
|
|
||||||
|
Status: **approved 2026-06-22**, implementation pending. Supersedes the
|
||||||
|
transient harness plan-mode copy. This is the persistent, version-controlled
|
||||||
|
plan per project convention (`docs/plans/`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
RDBMS Playground now ships public binaries (v0.2.0 on crates.io, plus
|
||||||
|
Scoop/Homebrew/winget). With real users pulling updates, the priority is
|
||||||
|
**regression protection before the next release**. Three tracked gaps are taken
|
||||||
|
up together:
|
||||||
|
|
||||||
|
- **TT4** (`requirements.md`, ADR-0008 Tier 4): the four critical end-to-end
|
||||||
|
flows are specified but **no PTY harness exists** — no deps, no tests. Tiers
|
||||||
|
1–3 never exercise the *real built binary* through a terminal.
|
||||||
|
- **NFR-1..7**: quality bars (startup, latency, memory, contrast, distinctive
|
||||||
|
design, parity) that were **never formally verified** despite shipping.
|
||||||
|
- **CHANGELOG**: none exists; useful now that releases are public (and lets the
|
||||||
|
CI `winget` job pass `--release-notes-url` later).
|
||||||
|
|
||||||
|
Baseline (Phase 1): `cargo test` = **2509 passed / 0 failed / 1 ignored**;
|
||||||
|
clippy + fmt clean. This is the number Phase 5 verifies against (new tests add
|
||||||
|
to it; nothing may regress).
|
||||||
|
|
||||||
|
User decisions (Phase 3):
|
||||||
|
- **TT4 in CI:** own `e2e_pty` test target, runs by default in `cargo test`
|
||||||
|
(so the Linux gate exercises it every push). **Tight, fail-fast timeouts** —
|
||||||
|
the runner is self-hosted, low-parallelism, low expected flakiness (so a
|
||||||
|
failure surfaces fast rather than hanging on a generous timeout).
|
||||||
|
- **NFR:** WCAG contrast = hard gated test; startup + idle-memory = measured via
|
||||||
|
the PTY harness against *generous* bounds + numbers recorded; NFR-2/4/6 =
|
||||||
|
verify-by-argument + reviewer note. Each requirement → `[x]`/`[/]` with
|
||||||
|
evidence.
|
||||||
|
- **CHANGELOG:** Keep a Changelog + SemVer; `[Unreleased]` + a single
|
||||||
|
consolidated `[0.2.0]` only (0.1.0 treated as pre-history).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream 1 — TT4: Tier-4 PTY harness + 4 flows
|
||||||
|
|
||||||
|
### Tooling (refines ADR-0008's stated `portable-pty`+`expectrl`+`vt100`)
|
||||||
|
- **`portable-pty`** (wezterm, actively maintained) — spawn the real binary in a
|
||||||
|
PTY with a fixed window size.
|
||||||
|
- **`vt100`** — parse the PTY output stream into an inspectable cell grid.
|
||||||
|
- **Drop `expectrl`** — it bundles its *own* PTY abstraction (conflicts with
|
||||||
|
portable-pty) and is line-oriented, a poor fit for a full-screen TUI. Replace
|
||||||
|
it with a small hand-rolled `wait_for(predicate, timeout)` polling the vt100
|
||||||
|
screen. This deviation is recorded in the ADR-0008 amendment.
|
||||||
|
- **Checkpoint at implementation:** verify `vt100` is still maintained/current
|
||||||
|
(global currency rule). If stale, evaluate `avt` as the screen parser before
|
||||||
|
committing the dep. `cargo add` picks latest; pin sensibly.
|
||||||
|
|
||||||
|
### Harness (`tests/e2e_pty.rs` — new `[[test]]` target)
|
||||||
|
A `support` module providing:
|
||||||
|
- `struct PtyApp` — opens a PTY (e.g. **100×30**, the wide three-region
|
||||||
|
layout), spawns `env!("CARGO_BIN_EXE_rdbms-playground")` with a **per-test
|
||||||
|
temp `--data-dir`** (`tempfile::TempDir`), `--theme dark`, `TERM=xterm-256color`;
|
||||||
|
a reader thread feeds bytes into a `vt100::Parser`.
|
||||||
|
- `send(bytes)` (raw; `\r` = Enter, `\x03` = Ctrl-C), `send_line(s)` =
|
||||||
|
`s` + `\r`.
|
||||||
|
- `wait_for(&str, Duration)` — poll the vt100 screen text until the substring
|
||||||
|
appears or the (tight, ~**3 s**) timeout fires; on timeout **panic with a full
|
||||||
|
screen dump** (fail-fast + debuggable). A `screen_text()` helper for assertions.
|
||||||
|
- `quit()` — `send("\x03")`, wait for child exit, assert success.
|
||||||
|
- Run **serially** (`serial_test` dep, or a shared `Mutex`/single-threaded
|
||||||
|
target) — PTYs + temp dirs shouldn't race; also keeps timing stable.
|
||||||
|
- **Instrument** with `eprintln!`/screen dumps on every wait so a CI failure is
|
||||||
|
diagnosable from logs (per the project's logging discipline).
|
||||||
|
|
||||||
|
### The four flows (one `#[test]` each, mirroring ADR-0008 §Tier-4)
|
||||||
|
1. **Cold launch → DDL → quit.** Launch fresh; `wait_for("SIMPLE")` +
|
||||||
|
`wait_for("(none yet)")`; `send_line("create table Customers with pk
|
||||||
|
id(serial)")`; `wait_for("Customers")` in the Tables panel; `quit()`.
|
||||||
|
2. **Save → restart → reopen.** Launch project path P under the temp data-dir;
|
||||||
|
create a table; `quit()` (autosave already persisted per-command); relaunch
|
||||||
|
**same P**; `wait_for("Customers")` — schema restored from text/rebuild.
|
||||||
|
3. **Export → import → rebuild.** Project A: create table + insert a row;
|
||||||
|
`send_line("export A.zip")` (explicit path under the temp dir);
|
||||||
|
`wait_for` export-success note; `quit()`. Fresh project B:
|
||||||
|
`send_line("import <path>/A.zip")`; `wait_for` import note (rebuild auto-runs
|
||||||
|
on missing `.db`); assert the table + row are present (`show data` /
|
||||||
|
Tables panel).
|
||||||
|
4. **Undo after DROP.** Create table; `send_line("drop table Customers")`;
|
||||||
|
confirm gone; `send_line("undo")`; **`wait_for("Restore that earlier
|
||||||
|
state?")`** (the real modal); `send("y")`; `wait_for("Customers")` restored.
|
||||||
|
|
||||||
|
### CI
|
||||||
|
No workflow edit needed for execution: `e2e_pty` is a default target, so the
|
||||||
|
existing `gate` job's `cargo test --no-fail-fast` runs it on the Linux
|
||||||
|
container. **Validate locally first** that a PTY opens inside the
|
||||||
|
`rdbms-playground-ci` image (container `/dev/ptmx`); if not, add `--init`/pts
|
||||||
|
mount notes to the ADR. Advances **TT5** (Tier-4-in-CI on Linux); Windows
|
||||||
|
execution runner stays out of scope (no runner).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream 2 — NFR verification
|
||||||
|
|
||||||
|
### Gated: WCAG contrast (NFR-5, NFR-7) — `src/theme.rs` `#[cfg(test)]`
|
||||||
|
- Add a `relative_luminance(Color) -> f64` + `contrast_ratio(a,b) -> f64`
|
||||||
|
helper (WCAG 2.x sRGB formula) in the test module; a `match Color::Rgb` channel
|
||||||
|
extractor (panics on non-RGB — also a guard against a future named-colour
|
||||||
|
regression).
|
||||||
|
- For **both** `Theme::light()` and `Theme::dark()`, assert **≥ 4.5:1** for
|
||||||
|
normal-text foregrounds on `bg`: `fg`, `error`, `warning`, `system`,
|
||||||
|
`plan_efficient`, `mode_simple`, `mode_advanced`, and the info-carrying syntax
|
||||||
|
tokens (`tok_keyword/identifier/type/number/string/flag/function/error`).
|
||||||
|
- **`muted`/`tok_punct`** (deliberately dim secondary text) and **borders**
|
||||||
|
(`border`/`border_advanced`, non-text UI): assert the WCAG **non-text 3:1**
|
||||||
|
threshold, with a comment citing the WCAG large-text/non-text allowance. If
|
||||||
|
any *fails even 3:1*, that's a real finding → surface to the user, don't
|
||||||
|
silently relax. (Supersedes the existing inequality-only theme tests.)
|
||||||
|
- This runs in the existing gate — permanent regression protection on the palette.
|
||||||
|
|
||||||
|
### Measured + documented: startup (NFR-1) + idle memory (NFR-3)
|
||||||
|
Two measurement tests in `e2e_pty.rs`:
|
||||||
|
- **Startup:** time from spawn to `wait_for("SIMPLE")` (first rendered frame);
|
||||||
|
assert a **generous** bound (e.g. < 1500 ms locally) and `eprintln!` the actual.
|
||||||
|
- **Idle memory:** after the app is idle, read the child's `/proc/<pid>/status`
|
||||||
|
`VmRSS` (Linux); assert a generous bound and print actual. (Linux-gated via
|
||||||
|
`#[cfg(target_os = "linux")]`.)
|
||||||
|
The point is gross-regression detection, not a tight SLA gate (user decision).
|
||||||
|
|
||||||
|
### Verify-by-argument + reviewer note (NFR-2, NFR-4, NFR-6)
|
||||||
|
- **NFR-2** (input off-thread): already architecturally true — DB runs on the
|
||||||
|
worker thread (ADR-0010) and input on a separate Tokio task (confirmed in
|
||||||
|
`runtime.rs`). Record the evidence; optionally a test that a query doesn't
|
||||||
|
block a subsequent keystroke render.
|
||||||
|
- **NFR-4** (distinctive design) / **NFR-6** (cross-platform parity): qualitative
|
||||||
|
/ partly-CI. Written reviewer verification (DA-hat) in the NFR verification doc;
|
||||||
|
parity partly evidenced by the CI matrix (Linux+macOS execute, Windows builds).
|
||||||
|
|
||||||
|
### Requirements bookkeeping
|
||||||
|
Move NFR-5/7 → `[x]` (gated test), NFR-1/3 → `[x]` (measured + bounded test +
|
||||||
|
recorded numbers), NFR-2 → `[x]` (architectural evidence), NFR-4/6 → `[/]` with
|
||||||
|
the reviewer note (honest: not fully automatable). Each with a commit/test ref.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workstream 3 — CHANGELOG
|
||||||
|
|
||||||
|
- New **`CHANGELOG.md`** at repo root, **Keep a Changelog** + SemVer.
|
||||||
|
- `[Unreleased]` (empty/seeded) + one consolidated **`[0.2.0] - <release date>`**
|
||||||
|
with Added/Changed/Fixed of the notable user-facing changes (install methods,
|
||||||
|
packaging, `--version`/`version`, seed improvements, hint feature, readline
|
||||||
|
keys, cross-mode history) mined from ADRs 0053–0056 + handoffs 73–75. 0.1.0 =
|
||||||
|
pre-history (a one-line note).
|
||||||
|
- Optional follow-up (flag, don't auto-do): wire `--release-notes-url` into the
|
||||||
|
CI `winget`/release path — out of scope unless you want it now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docs / ADR updates (project discipline)
|
||||||
|
|
||||||
|
- **Amend ADR-0008** (Tier-4 section): record the realized tooling
|
||||||
|
(`portable-pty` + `vt100` + hand-rolled `wait_for`, `expectrl` dropped),
|
||||||
|
serial execution, tight-timeout/fail-fast stance, the default-target CI wiring,
|
||||||
|
and the four implemented flows. Update `docs/adr/README.md` in the same edit.
|
||||||
|
- **New ADR-0057 — NFR verification strategy** (next free number; assigned at
|
||||||
|
merge per ADR-0000): the contrast-gate + thresholds (incl. the 3:1
|
||||||
|
muted/border allowance), the measured-not-tightly-gated stance for
|
||||||
|
startup/memory, and the verify-by-argument treatment of NFR-2/4/6. Add a
|
||||||
|
short **NFR verification record** (the measured numbers + reviewer notes) —
|
||||||
|
either in the ADR or a `docs/` companion. Update the README index.
|
||||||
|
- Update `docs/requirements.md`: TT4 `[~]`→`[x]`, TT5 `[/]` note (Tier-4 now in
|
||||||
|
Linux CI; Windows-exec still pending), NFR rows as above.
|
||||||
|
- A handoff note (next sequence number) per project convention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification (Phase 5)
|
||||||
|
|
||||||
|
1. `cargo test` — full suite green; new e2e_pty + contrast tests pass; **2509 +
|
||||||
|
new, 0 failed, ≤1 ignored**; no regressions vs baseline.
|
||||||
|
2. `cargo clippy --all-targets -- -D warnings` + `cargo fmt --check` clean.
|
||||||
|
3. Run `e2e_pty` repeatedly (e.g. 5–10×) locally to confirm non-flaky under the
|
||||||
|
tight timeouts before relying on the gate.
|
||||||
|
4. Confirm a PTY opens inside the CI image (or document the fix).
|
||||||
|
5. DA-hat pass over each workstream + the NFR reviewer judgements, written down.
|
||||||
|
6. `cargo sweep` at the milestone (CLAUDE.md build hygiene).
|
||||||
|
|
||||||
|
## Risks / checkpoints
|
||||||
|
- **vt100 currency** — verify before adding; fall back to `avt` if stale.
|
||||||
|
- **PTY-in-container** — validate early; the whole TT4 CI value depends on it.
|
||||||
|
- **muted/border contrast** — may fail even 3:1; if so it's a finding to escalate,
|
||||||
|
not silently relax (could mean a small palette tweak — touches a decided area,
|
||||||
|
so escalate).
|
||||||
|
- **Commits** — confirm each message; no AI attribution; append-only; never push.
|
||||||
+88
-32
@@ -404,7 +404,7 @@ since ADR-0027.)
|
|||||||
referenced column), then `ALTER TABLE … ALTER COLUMN TYPE` (4f —
|
referenced column), then `ALTER TABLE … ALTER COLUMN TYPE` (4f —
|
||||||
runtime-decomposed to `change_column_type` with `ForceConversion`, the
|
runtime-decomposed to `change_column_type` with `ForceConversion`, the
|
||||||
§7 advanced policy: lossy converts with a note, incompatible + static
|
§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`),
|
the internal-`__rdbms_*` guard folded into `do_change_column_type`),
|
||||||
then `ALTER TABLE` add/drop constraint + add FK (4g — `ADD [CONSTRAINT
|
then `ALTER TABLE` add/drop constraint + add FK (4g — `ADD [CONSTRAINT
|
||||||
<name>] (CHECK | UNIQUE | FOREIGN KEY)` + `DROP CONSTRAINT <name>`;
|
<name>] (CHECK | UNIQUE | FOREIGN KEY)` + `DROP CONSTRAINT <name>`;
|
||||||
@@ -430,7 +430,7 @@ since ADR-0027.)
|
|||||||
refused with an engine-neutral message naming the construct.
|
refused with an engine-neutral message naming the construct.
|
||||||
Implementation pending.)*
|
Implementation pending.)*
|
||||||
- [x] **Q3** User-facing simplified types map transparently to
|
- [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.)*
|
and tested.)*
|
||||||
- [x] **Q4** Supported SQL subset specification — **ADR-0030**.
|
- [x] **Q4** Supported SQL subset specification — **ADR-0030**.
|
||||||
Advanced mode is a standard-SQL surface, engine-neutral; the
|
Advanced mode is a standard-SQL surface, engine-neutral; the
|
||||||
@@ -462,10 +462,11 @@ since ADR-0027.)
|
|||||||
|
|
||||||
## Type system (per ADR-0005)
|
## Type system (per ADR-0005)
|
||||||
|
|
||||||
- [x] **T1** All ten user-facing types implemented: `text`,
|
- [x] **T1** All nine user-facing types implemented: `text`,
|
||||||
`int`, `real`, `decimal`, `bool`, `date`, `datetime`, `blob`,
|
`int`, `real`, `decimal`, `bool`, `date`, `datetime`,
|
||||||
`serial`, `shortid`. *(Mapping to SQLite STRICT covered by
|
`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, 10–12 characters,
|
- [x] **T2** `shortid` generation: base58, 10–12 characters,
|
||||||
omits ambiguous characters; generated client-side at insert.
|
omits ambiguous characters; generated client-side at insert.
|
||||||
*(Implemented per ADR-0014; auto-fills omitted shortid
|
*(Implemented per ADR-0014; auto-fills omitted shortid
|
||||||
@@ -918,28 +919,44 @@ since ADR-0027.)
|
|||||||
for representative views.
|
for representative views.
|
||||||
- [x] **TT3** Tier 3: synthetic event-loop integration tests
|
- [x] **TT3** Tier 3: synthetic event-loop integration tests
|
||||||
covering the user-facing flows in this checklist.
|
covering the user-facing flows in this checklist.
|
||||||
- [~] **TT4** Tier 4: PTY-based end-to-end for the four critical
|
- [x] **TT4** Tier 4: PTY-based end-to-end for the four critical
|
||||||
flows named in ADR-0008 (cold launch → DDL → quit; save →
|
flows named in ADR-0008 (cold launch → DDL → quit; save →
|
||||||
reopen; export → import → rebuild; undo after DROP).
|
reopen; export → import → rebuild; undo after DROP).
|
||||||
*(Verified 2026-06-07: **nothing is wired** — no
|
*(Implemented 2026-06-22 — ADR-0008 **Amendment 1**;
|
||||||
`portable-pty` / `expectrl` / `vt100` dependencies, no PTY test
|
`tests/e2e_pty.rs` (commit `fd63de3`). Drives the actual built
|
||||||
files; ADR-0008 §Tier-4 is a specification only. The Tier-3
|
binary in a real pseudo-terminal: `portable-pty` + `vt100`
|
||||||
`tests/it/*_e2e.rs` files are synthetic event-loop tests, not
|
(**`expectrl` dropped** — conflicting PTY layer, replaced by a
|
||||||
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is
|
hand-rolled `wait_for` on the vt100 grid). Fixed 100×30, per-test
|
||||||
wired only for the listed critical flows" — it was not wired at
|
temp `--data-dir`, serial, tight fail-fast 3 s waits; table
|
||||||
all. Genuinely deferred.)*
|
presence read from the Tables sidebar region (Output echoes
|
||||||
|
commands); commands paced to completion (stale-schema-cache
|
||||||
|
misparse on faster-than-human input → issue #39). All four flows
|
||||||
|
green (flow 2 asserts a **column** round-trips, not just the
|
||||||
|
table name). Was previously a spec-only deferral; the earlier
|
||||||
|
2026-06-07 "nothing is wired" note is now resolved.)*
|
||||||
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
|
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
|
||||||
stable Rust.
|
stable Rust.
|
||||||
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea
|
*(Partial, updated 2026-06-22. **CI is live** on the self-hosted
|
||||||
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` +
|
Gitea Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings`
|
||||||
`cargo test` (Tiers 1–3) on the **Linux** runner for every branch
|
+ `cargo test` (now Tiers **1–4** — the `e2e_pty` target runs by
|
||||||
push / PR, and `release-macos` runs the suite natively on the
|
default) on the **Linux** runner for every branch push / PR.
|
||||||
**macOS** runner. **Windows is build-only** — cross-compiled, not
|
**macOS is fully covered**: `release-macos` runs the whole suite
|
||||||
executed (no Windows runner). **Tier 4** (PTY, TT4) is still
|
natively on the macOS runner, and Tier 4 was confirmed green on
|
||||||
unwired, so "all tiers" is not yet fully met. "Stable Rust" is
|
macOS (2026-06-22 — the 4 flows + startup; the Linux-only RSS probe
|
||||||
satisfied by the flake's pinned `1.95.0` (a stable release, not
|
is `#[cfg]`-skipped). **Tier 4 in CI on Linux is met** (TT4);
|
||||||
nightly). Remaining for full TT5: a Windows execution runner and
|
PTY-in-container was validated (openpty + spawn under Docker), with
|
||||||
Tier-4 PTY in CI.)*
|
the first real gate run the final confirmation. "Stable Rust" is
|
||||||
|
satisfied by the flake's pinned `1.95.0`.
|
||||||
|
**Windows — automated execution is the one open piece.** Windows
|
||||||
|
binaries are first-class: cross-built and shipped for every release
|
||||||
|
(D1/D2), and we verify them by **running the suite on Windows by
|
||||||
|
hand from time to time** so the Windows experience stays sound
|
||||||
|
between releases. A standing Windows CI runner is more involved to
|
||||||
|
operate than the Linux and macOS runners already in use; we intend
|
||||||
|
to keep looking for a clean solution but make no promises on timing.
|
||||||
|
Until then, Windows users get exactly the same builds, verified
|
||||||
|
manually rather than on every push. So TT5 stays `[/]` by deliberate
|
||||||
|
scope, not neglect.)*
|
||||||
|
|
||||||
## Cross-cutting
|
## Cross-cutting
|
||||||
|
|
||||||
@@ -1015,41 +1032,80 @@ target is measurable, it is stated numerically; where it is
|
|||||||
necessarily qualitative, the criterion is named and the bar is
|
necessarily qualitative, the criterion is named and the bar is
|
||||||
"reviewer judgement against the criterion."
|
"reviewer judgement against the criterion."
|
||||||
|
|
||||||
- [ ] **NFR-1 Performance — startup.** Cold launch to first
|
All seven were formally verified 2026-06-22 (ADR-0057). Approach:
|
||||||
|
contrast/distinctness **gated** by tests, startup/memory **measured**
|
||||||
|
against the targets, the rest **verified by argument / reviewer note**.
|
||||||
|
|
||||||
|
- [x] **NFR-1 Performance — startup.** Cold launch to first
|
||||||
rendered frame under 500ms on commodity hardware (developer
|
rendered frame under 500ms on commodity hardware (developer
|
||||||
laptop, mid-range desktop). Measured in CI on the Linux runner
|
laptop, mid-range desktop).
|
||||||
as a regression gate.
|
*(Verified 2026-06-22, ADR-0057 — measured via the Tier-4 PTY
|
||||||
- [ ] **NFR-2 Performance — input latency.** Keystroke-to-render
|
harness; release-binary median **~29 ms** to first frame (5
|
||||||
|
trials, this dev machine), ~17× under target. The harness test
|
||||||
|
`startup_to_first_frame_is_reasonable` gates a generous
|
||||||
|
debug-binary bound for gross-regression detection; a tight 500 ms
|
||||||
|
CI gate was declined as flaky on a shared runner — user decision.)*
|
||||||
|
- [x] **NFR-2 Performance — input latency.** Keystroke-to-render
|
||||||
latency under 16ms during normal editing; long-running queries
|
latency under 16ms during normal editing; long-running queries
|
||||||
must execute off the UI thread so the interface remains
|
must execute off the UI thread so the interface remains
|
||||||
responsive (typing, scrolling, mode switching) while a query is
|
responsive (typing, scrolling, mode switching) while a query is
|
||||||
running.
|
running.
|
||||||
- [ ] **NFR-3 Performance — resource footprint.** Idle memory
|
*(Verified 2026-06-22, ADR-0057 — by architecture: database work
|
||||||
|
runs on the dedicated worker thread (ADR-0010) and terminal input
|
||||||
|
on a separate Tokio task (`spawn_event_reader`, `runtime.rs`), so
|
||||||
|
a running query cannot block input/render. No automated sub-16 ms
|
||||||
|
latency test — it would be flaky and low-value.)*
|
||||||
|
- [x] **NFR-3 Performance — resource footprint.** Idle memory
|
||||||
under 50MB on the smallest target platform; no busy-loops; CPU
|
under 50MB on the smallest target platform; no busy-loops; CPU
|
||||||
near zero when waiting for input.
|
near zero when waiting for input.
|
||||||
- [ ] **NFR-4 Visual quality — distinctive design.** Colour
|
*(Verified 2026-06-22, ADR-0057 — release-binary idle RSS
|
||||||
|
**~10 MB** (5 trials, Linux), ~5× under target. The event loop
|
||||||
|
blocks on `recv` (no busy-loop). The harness test
|
||||||
|
`idle_memory_footprint_is_reasonable` gates a generous
|
||||||
|
debug-binary RSS bound on Linux.)*
|
||||||
|
- [/] **NFR-4 Visual quality — distinctive design.** Colour
|
||||||
palette and typography are deliberate and consistent across
|
palette and typography are deliberate and consistent across
|
||||||
views; layout uses Unicode box-drawing and symbols where they
|
views; layout uses Unicode box-drawing and symbols where they
|
||||||
add clarity; rendering avoids the generic flat-default look
|
add clarity; rendering avoids the generic flat-default look
|
||||||
that ships with most TUI frameworks. Criterion: a reviewer can
|
that ships with most TUI frameworks. Criterion: a reviewer can
|
||||||
identify the app from a screenshot of any view.
|
identify the app from a screenshot of any view.
|
||||||
- [ ] **NFR-5 Visual quality — colour use.** Colour conveys
|
*(Reviewer note 2026-06-22, ADR-0057 — satisfied by the bespoke
|
||||||
|
two-theme palette, the three-region framed layout, and the
|
||||||
|
annotated plan/relationship rendering; qualitative, not
|
||||||
|
automatable.)*
|
||||||
|
- [x] **NFR-5 Visual quality — colour use.** Colour conveys
|
||||||
information rather than decoration: mode indication, query
|
information rather than decoration: mode indication, query
|
||||||
result types (numeric vs text vs null), error severity,
|
result types (numeric vs text vs null), error severity,
|
||||||
syntax highlighting categories. Foreground/background
|
syntax highlighting categories. Foreground/background
|
||||||
combinations meet WCAG-AA contrast (4.5:1 for normal text)
|
combinations meet WCAG-AA contrast (4.5:1 for normal text)
|
||||||
even though we have not committed to broader accessibility.
|
even though we have not committed to broader accessibility.
|
||||||
- [ ] **NFR-6 Cross-platform parity.** Behaviour and visual
|
*(Verified 2026-06-22, ADR-0057 — **gated** in `src/theme.rs`:
|
||||||
|
every text foreground clears 4.5:1 on both themes; the
|
||||||
|
advanced-mode border clears 3:1 (plain border decorative-exempt);
|
||||||
|
token pairs clear ΔE2000 ≥ 15. Writing the gate caught + fixed two
|
||||||
|
shipped light-theme defects (`tok_string` 4.42:1, `tok_flag`
|
||||||
|
3.15:1) and two near-duplicate dark pairs, `65eab71`.)*
|
||||||
|
- [/] **NFR-6 Cross-platform parity.** Behaviour and visual
|
||||||
quality are equivalent across Linux, macOS, and Windows on
|
quality are equivalent across Linux, macOS, and Windows on
|
||||||
crossterm-supported terminals. Platform-specific divergence
|
crossterm-supported terminals. Platform-specific divergence
|
||||||
(e.g. font fallbacks) is documented, not silently tolerated.
|
(e.g. font fallbacks) is documented, not silently tolerated.
|
||||||
- [ ] **NFR-7 Light and dark background support.** The colour
|
*(Reviewer note 2026-06-22, ADR-0057 — partly evidenced by the CI
|
||||||
|
matrix (Linux + macOS execute the suite incl. Tier 4; Windows is
|
||||||
|
build-only). Documented divergence: on terminals/multiplexers
|
||||||
|
without true-colour passthrough the 24-bit palette is quantised to
|
||||||
|
256 colours and near hues can collide (a terminal-capability
|
||||||
|
issue). Full parity awaits a Windows execution runner — see TT5.)*
|
||||||
|
- [x] **NFR-7 Light and dark background support.** The colour
|
||||||
scheme remains legible and visually coherent on both light and
|
scheme remains legible and visually coherent on both light and
|
||||||
dark terminal backgrounds. The mechanism (auto-detect via
|
dark terminal backgrounds. The mechanism (auto-detect via
|
||||||
terminal query, explicit user setting, or both) is an
|
terminal query, explicit user setting, or both) is an
|
||||||
implementation choice, but the outcome is non-negotiable: no
|
implementation choice, but the outcome is non-negotiable: no
|
||||||
dark-on-dark or light-on-light readability failures on either
|
dark-on-dark or light-on-light readability failures on either
|
||||||
background.
|
background.
|
||||||
|
*(Verified 2026-06-22, ADR-0057 — the WCAG-AA contrast gate
|
||||||
|
(NFR-5) runs over **both** themes, so the legibility outcome is
|
||||||
|
enforced on light and dark. Selection mechanism is `--theme` +
|
||||||
|
`COLORFGBG` auto-detect (OSC-11 querying deferred).)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Executable
+141
@@ -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?"
|
||||||
Executable
+168
@@ -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()
|
||||||
Executable
+82
@@ -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 ]
|
||||||
Executable
+87
@@ -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 ]
|
||||||
Executable
+60
@@ -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"
|
||||||
Executable
+68
@@ -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"
|
||||||
+350
-25
@@ -377,6 +377,31 @@ pub struct App {
|
|||||||
/// by default; refreshed by the runtime on project load
|
/// by default; refreshed by the runtime on project load
|
||||||
/// and after successful DDL.
|
/// and after successful DDL.
|
||||||
pub schema_cache: crate::completion::SchemaCache,
|
pub schema_cache: crate::completion::SchemaCache,
|
||||||
|
/// Issue #39: `true` while a dispatched DSL command's async
|
||||||
|
/// schema-cache refresh is still in flight. The runtime applies a
|
||||||
|
/// command on the worker thread and only *afterwards* sends back a
|
||||||
|
/// `SchemaCacheRefreshed` event; until that lands, `schema_cache` is
|
||||||
|
/// stale w.r.t. the command just dispatched. Validating a follow-up
|
||||||
|
/// submission against that stale cache is the issue #39 bug (a Form-B
|
||||||
|
/// insert after `add column` sees the pre-DDL columns and is wrongly
|
||||||
|
/// rejected as "trying to write SQL?"). While this flag is set, new
|
||||||
|
/// `dispatch_dsl` submissions are *held* in `held_submissions` rather
|
||||||
|
/// than validated, then drained when the refresh arrives. Armed on
|
||||||
|
/// every `ExecuteDsl` dispatch, so at most one *DSL* command is ever in
|
||||||
|
/// flight — refreshes are then strictly one-per-dispatch and in order,
|
||||||
|
/// keeping the gate a simple, provably-correct boolean. (App-lifecycle
|
||||||
|
/// commands — `load` / `undo` / `rebuild` — also refresh the cache but
|
||||||
|
/// bypass this gate; they are modal/picker-gated and so cannot overlap a
|
||||||
|
/// rapid DSL paste, the only thing this guards.) Cleared in the
|
||||||
|
/// `SchemaCacheRefreshed` handler.
|
||||||
|
awaiting_schema_refresh: bool,
|
||||||
|
/// Issue #39: submissions deferred while `awaiting_schema_refresh` is
|
||||||
|
/// set, in submission order. Each is the canonical input line plus the
|
||||||
|
/// effective mode it was submitted under; drained through
|
||||||
|
/// `dispatch_dsl` (re-validated against the now-fresh cache) when the
|
||||||
|
/// pending refresh lands. `push_history` already ran for these at
|
||||||
|
/// `submit` time, so draining re-enters at `dispatch_dsl`, not `submit`.
|
||||||
|
held_submissions: std::collections::VecDeque<(String, EffectiveMode)>,
|
||||||
/// Whether the undo/snapshot machinery is active this session
|
/// Whether the undo/snapshot machinery is active this session
|
||||||
/// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI
|
/// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI
|
||||||
/// flag; the `undo` / `redo` commands then report undo is off
|
/// flag; the `undo` / `redo` commands then report undo is off
|
||||||
@@ -596,6 +621,10 @@ impl App {
|
|||||||
modal: None,
|
modal: None,
|
||||||
last_completion: None,
|
last_completion: None,
|
||||||
schema_cache: crate::completion::SchemaCache::default(),
|
schema_cache: crate::completion::SchemaCache::default(),
|
||||||
|
// Issue #39: no command is in flight at construction; the
|
||||||
|
// schema-refresh gate starts open with an empty hold queue.
|
||||||
|
awaiting_schema_refresh: false,
|
||||||
|
held_submissions: std::collections::VecDeque::new(),
|
||||||
// Undo is on by default; the runtime flips this off for
|
// Undo is on by default; the runtime flips this off for
|
||||||
// a `--no-undo` session (ADR-0006 Amendment 1).
|
// a `--no-undo` session (ADR-0006 Amendment 1).
|
||||||
undo_enabled: true,
|
undo_enabled: true,
|
||||||
@@ -912,7 +941,24 @@ impl App {
|
|||||||
"schema cache refreshed",
|
"schema cache refreshed",
|
||||||
);
|
);
|
||||||
self.schema_cache = cache;
|
self.schema_cache = cache;
|
||||||
Vec::new()
|
// Issue #39: the in-flight command's refresh has landed, so
|
||||||
|
// the gate opens. Drain any submissions held while it was in
|
||||||
|
// flight, re-validating each against the now-fresh cache.
|
||||||
|
// Stop as soon as a drained command dispatches (re-arming the
|
||||||
|
// gate via its own `ExecuteDsl`): the remaining held commands
|
||||||
|
// then wait for *its* refresh, preserving submission order. A
|
||||||
|
// held command that does not dispatch (parse error / pre-flight
|
||||||
|
// rejection) leaves the gate open, so the loop continues to the
|
||||||
|
// next held submission.
|
||||||
|
self.awaiting_schema_refresh = false;
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
while !self.awaiting_schema_refresh {
|
||||||
|
let Some((input, submission_mode)) = self.held_submissions.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
actions.extend(self.dispatch_dsl(&input, submission_mode));
|
||||||
|
}
|
||||||
|
actions
|
||||||
}
|
}
|
||||||
AppEvent::RelationshipsRefreshed(relationships) => {
|
AppEvent::RelationshipsRefreshed(relationships) => {
|
||||||
trace!(count = relationships.len(), "relationships refreshed");
|
trace!(count = relationships.len(), "relationships refreshed");
|
||||||
@@ -1981,6 +2027,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec<Action> {
|
fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec<Action> {
|
||||||
|
// Issue #39: if a previously-dispatched command's schema-cache
|
||||||
|
// refresh is still in flight, `schema_cache` is stale w.r.t. that
|
||||||
|
// command. Validating this submission now would race it (the bug:
|
||||||
|
// a Form-B insert after `add column` rejected against the pre-DDL
|
||||||
|
// schema). Hold it in submission order; the `SchemaCacheRefreshed`
|
||||||
|
// handler drains the queue once the fresh schema lands. App-level
|
||||||
|
// commands (`quit`, `help`, `load`, …) route through
|
||||||
|
// `dispatch_app_command` *before* here, so they are never held.
|
||||||
|
if self.awaiting_schema_refresh {
|
||||||
|
debug!(input, "holding submission until schema cache refresh lands");
|
||||||
|
self.held_submissions
|
||||||
|
.push_back((input.to_string(), submission_mode));
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
// The two-way mode the walker + the `[mode]` render tag read; the
|
// The two-way mode the walker + the `[mode]` render tag read; the
|
||||||
// three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for
|
// three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for
|
||||||
// the runtime's echo gate (ADR-0038).
|
// the runtime's echo gate (ADR-0038).
|
||||||
@@ -2069,6 +2129,14 @@ impl App {
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
self.push_output(OutputLine::echo(input, mode));
|
self.push_output(OutputLine::echo(input, mode));
|
||||||
|
// Issue #39: a command is now in flight; its schema-cache
|
||||||
|
// refresh will land asynchronously. Arm the gate so any
|
||||||
|
// follow-up submission waits for the fresh schema rather
|
||||||
|
// than racing the stale cache. Armed on every dispatch (not
|
||||||
|
// just DDL) so only one command is ever in flight, which
|
||||||
|
// keeps the gate a simple boolean — refreshes are then
|
||||||
|
// strictly one-per-dispatch and in order.
|
||||||
|
self.awaiting_schema_refresh = true;
|
||||||
vec![Action::ExecuteDsl {
|
vec![Action::ExecuteDsl {
|
||||||
command: cmd,
|
command: cmd,
|
||||||
source: input.to_string(),
|
source: input.to_string(),
|
||||||
@@ -2979,34 +3047,52 @@ impl App {
|
|||||||
/// output panel.
|
/// output panel.
|
||||||
///
|
///
|
||||||
/// Assembled from the command REGISTRY (ADR-0024 §help_id):
|
/// Assembled from the command REGISTRY (ADR-0024 §help_id):
|
||||||
/// the framing (`help.intro`, `help.dsl_section`,
|
/// the framing (`help.intro`, `help.simple_section`,
|
||||||
/// `help.types_reference`) comes from the catalog, and each
|
/// `help.advanced_section`, `help.types_reference`) comes from
|
||||||
/// command's body is the catalog entry named by its
|
/// the catalog, and each command's body is the catalog entry
|
||||||
/// `help_id`. A newly-registered command appears here
|
/// named by its `help_id`. A newly-registered command appears
|
||||||
/// automatically — no edit to this function or a hand-kept
|
/// here automatically — no edit to this function or a hand-kept
|
||||||
/// list. Each catalog line becomes its own `OutputLine` so
|
/// list. Each catalog line becomes its own `OutputLine` so
|
||||||
/// the scroll-position math (one logical line = one display
|
/// the scroll-position math (one logical line = one display
|
||||||
/// row) stays accurate per the renderer's invariant.
|
/// row) stays accurate per the renderer's invariant.
|
||||||
|
///
|
||||||
|
/// Issue #36: the commands group by mode. App-lifecycle commands
|
||||||
|
/// (`help_id` in the `app.*` namespace) work in either mode and
|
||||||
|
/// list first, unlabelled, under the intro. The rest split by
|
||||||
|
/// [`CommandCategory`] into a simple-mode group and an
|
||||||
|
/// advanced-mode (SQL) group — replacing the old single "DSL data
|
||||||
|
/// commands (in simple mode)" header, which both used the banned
|
||||||
|
/// "DSL" term and wrongly claimed simple mode for the advanced SQL
|
||||||
|
/// forms the section already contained.
|
||||||
fn note_help(&mut self) {
|
fn note_help(&mut self) {
|
||||||
use crate::dsl::grammar::REGISTRY;
|
use crate::dsl::grammar::{CommandCategory, REGISTRY};
|
||||||
|
|
||||||
let mut lines: Vec<String> = Vec::new();
|
let mut lines: Vec<String> = Vec::new();
|
||||||
lines.push(crate::t!("help.intro"));
|
lines.push(crate::t!("help.intro"));
|
||||||
// REGISTRY is ordered app-commands first; emit the
|
|
||||||
// "DSL data commands" sub-header at the first command
|
let mut simple: Vec<String> = Vec::new();
|
||||||
// whose help_id leaves the `app.` namespace.
|
let mut advanced: Vec<String> = Vec::new();
|
||||||
let mut dsl_header_done = false;
|
for (command, category) in REGISTRY {
|
||||||
for (command, _category) in REGISTRY {
|
|
||||||
let Some(help_id) = command.help_id else {
|
let Some(help_id) = command.help_id else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !dsl_header_done && !help_id.starts_with("app.") {
|
let body = crate::friendly::translate(&format!("help.{help_id}"), &[]);
|
||||||
lines.push(crate::t!("help.dsl_section"));
|
let block: Vec<String> = body.lines().map(str::to_string).collect();
|
||||||
dsl_header_done = true;
|
if help_id.starts_with("app.") {
|
||||||
|
lines.extend(block);
|
||||||
|
} else if matches!(category, CommandCategory::Advanced) {
|
||||||
|
advanced.extend(block);
|
||||||
|
} else {
|
||||||
|
simple.extend(block);
|
||||||
}
|
}
|
||||||
let key = format!("help.{help_id}");
|
}
|
||||||
let body = crate::friendly::translate(&key, &[]);
|
if !simple.is_empty() {
|
||||||
lines.extend(body.lines().map(str::to_string));
|
lines.push(crate::t!("help.simple_section"));
|
||||||
|
lines.extend(simple);
|
||||||
|
}
|
||||||
|
if !advanced.is_empty() {
|
||||||
|
lines.push(crate::t!("help.advanced_section"));
|
||||||
|
lines.extend(advanced);
|
||||||
}
|
}
|
||||||
lines.extend(
|
lines.extend(
|
||||||
crate::t!("help.types_reference")
|
crate::t!("help.types_reference")
|
||||||
@@ -3119,8 +3205,24 @@ impl App {
|
|||||||
let probe = view.to_string();
|
let probe = view.to_string();
|
||||||
let mode = self.effective_mode().as_mode();
|
let mode = self.effective_mode().as_mode();
|
||||||
if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode)
|
if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode)
|
||||||
&& self.emit_tier3_block(&format!("hint.cmd.{id}"))
|
&& self.emit_tier3_block(&format!("hint.cmd.{id}"), "hint.block.heading")
|
||||||
{
|
{
|
||||||
|
// Clause-concept layer (issue #37 / clause-concept-hints D6):
|
||||||
|
// if the cursor sits inside a recognized clause, append its
|
||||||
|
// `hint.concept.<topic>` block beneath the per-form block.
|
||||||
|
// Only when the per-form block rendered — the concept layer
|
||||||
|
// never appears alone.
|
||||||
|
if let Some(topic) = crate::dsl::walker::concept_topic_at_cursor(
|
||||||
|
&probe,
|
||||||
|
cursor,
|
||||||
|
Some(&self.schema_cache),
|
||||||
|
mode,
|
||||||
|
) {
|
||||||
|
self.emit_tier3_block(
|
||||||
|
&format!("hint.concept.{topic}"),
|
||||||
|
"hint.block.concept_heading",
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Tier-2 fallback: surface the ambient prose as a persistent
|
// Tier-2 fallback: surface the ambient prose as a persistent
|
||||||
@@ -3156,7 +3258,7 @@ impl App {
|
|||||||
/// (ADR-0053 D2/D5).
|
/// (ADR-0053 D2/D5).
|
||||||
fn note_hint_for_recent_error(&mut self) {
|
fn note_hint_for_recent_error(&mut self) {
|
||||||
if let Some(class) = self.last_error_hint_key.clone()
|
if let Some(class) = self.last_error_hint_key.clone()
|
||||||
&& self.emit_tier3_block(&format!("hint.err.{class}"))
|
&& self.emit_tier3_block(&format!("hint.err.{class}"), "hint.block.heading")
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3172,28 +3274,51 @@ impl App {
|
|||||||
/// absent so the caller can fall back to tier 2. `what` is
|
/// absent so the caller can fall back to tier 2. `what` is
|
||||||
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
|
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
|
||||||
/// polish (the framed block) lands with the corpus.
|
/// polish (the framed block) lands with the corpus.
|
||||||
fn emit_tier3_block(&mut self, stem: &str) -> bool {
|
/// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
|
||||||
|
/// under `heading_key` (ADR-0053 D4; clause-concept-hints D6).
|
||||||
|
/// Returns `false` if the `what` part is absent so the caller can
|
||||||
|
/// fall back. `heading_key` lets the clause-concept block carry a
|
||||||
|
/// distinct sub-heading ("About this clause") beneath the per-form
|
||||||
|
/// `Hint` block. The example lookup is **mode-keyed**
|
||||||
|
/// (clause-concept-hints D5/D6): it prefers `<stem>.example.<mode>`
|
||||||
|
/// (`simple`/`advanced` from the live effective mode) and falls
|
||||||
|
/// back to a plain `<stem>.example` — so `hint.cmd.*` / `hint.err.*`
|
||||||
|
/// (plain example) are unaffected, while a both-mode
|
||||||
|
/// `hint.concept.*` topic shows the mode-correct example.
|
||||||
|
fn emit_tier3_block(&mut self, stem: &str, heading_key: &str) -> bool {
|
||||||
let cat = crate::friendly::catalog();
|
let cat = crate::friendly::catalog();
|
||||||
let what_key = format!("{stem}.what");
|
let what_key = format!("{stem}.what");
|
||||||
if cat.get(&what_key).is_none() {
|
if cat.get(&what_key).is_none() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Labelled block (ADR-0053 D4): a `Hint` heading, then aligned
|
// Labelled block (ADR-0053 D4): the heading, then aligned
|
||||||
// `What:` / `Example:` / `Concept:` lines. `concept` renders
|
// `What:` / `Example:` / `Concept:` lines. `concept` renders
|
||||||
// muted (`OutputStyleClass::Hint`); the rest are plain system.
|
// muted (`OutputStyleClass::Hint`); the rest are plain system.
|
||||||
let labelled = |label: &str, value: &str| {
|
let labelled = |label: &str, value: &str| {
|
||||||
// Pad `<Label>:` to a common width so the values align.
|
// Pad `<Label>:` to a common width so the values align.
|
||||||
format!(" {:<9}{value}", format!("{label}:"))
|
format!(" {:<9}{value}", format!("{label}:"))
|
||||||
};
|
};
|
||||||
self.note_system(crate::t!("hint.block.heading"));
|
let mode_str = match self.effective_mode().as_mode() {
|
||||||
|
crate::mode::Mode::Simple => "simple",
|
||||||
|
crate::mode::Mode::Advanced => "advanced",
|
||||||
|
};
|
||||||
|
let example_key = {
|
||||||
|
let keyed = format!("{stem}.example.{mode_str}");
|
||||||
|
if cat.get(&keyed).is_some() {
|
||||||
|
keyed
|
||||||
|
} else {
|
||||||
|
format!("{stem}.example")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.note_system(crate::friendly::translate(heading_key, &[]));
|
||||||
self.note_system(labelled(
|
self.note_system(labelled(
|
||||||
&crate::t!("hint.block.what"),
|
&crate::t!("hint.block.what"),
|
||||||
&crate::friendly::translate(&what_key, &[]),
|
&crate::friendly::translate(&what_key, &[]),
|
||||||
));
|
));
|
||||||
if cat.get(&format!("{stem}.example")).is_some() {
|
if cat.get(&example_key).is_some() {
|
||||||
self.note_system(labelled(
|
self.note_system(labelled(
|
||||||
&crate::t!("hint.block.example"),
|
&crate::t!("hint.block.example"),
|
||||||
&crate::friendly::translate(&format!("{stem}.example"), &[]),
|
&crate::friendly::translate(&example_key, &[]),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if cat.get(&format!("{stem}.concept")).is_some() {
|
if cat.get(&format!("{stem}.concept")).is_some() {
|
||||||
@@ -4614,6 +4739,100 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn form_b_insert_after_ddl_is_held_until_refresh_then_dispatched() {
|
||||||
|
// Issue #39: a simple-mode Form-B insert (`insert into T values
|
||||||
|
// (…)`, no column list) submitted faster than the post-DDL
|
||||||
|
// schema-cache refresh must NOT be validated against the *stale*
|
||||||
|
// cache — doing so wrongly rejects it as "trying to write SQL?".
|
||||||
|
//
|
||||||
|
// This reproduces, deterministically, the event ordering the async
|
||||||
|
// runtime produces under fast input: a schema-mutating command is
|
||||||
|
// dispatched (arming the gate while its cache refresh is in flight),
|
||||||
|
// a follow-up insert is submitted before the refresh lands, and only
|
||||||
|
// then does the `SchemaCacheRefreshed` event arrive. Pre-fix the
|
||||||
|
// insert is validated against the pre-DDL schema and rejected; with
|
||||||
|
// the gate it is held and dispatched against the fresh schema.
|
||||||
|
use crate::completion::{SchemaCache, TableColumn};
|
||||||
|
use crate::dsl::types::Type;
|
||||||
|
|
||||||
|
// Pre-DDL schema: `Customers` has only the auto `id` (serial). This
|
||||||
|
// is the stale cache a racing Form-B insert would otherwise see —
|
||||||
|
// zero user-fillable columns, so `values ('Alice')` can't match.
|
||||||
|
let mut app = App::new();
|
||||||
|
let mut stale = SchemaCache::default();
|
||||||
|
stale.tables.push("Customers".to_string());
|
||||||
|
stale.columns.push("id".to_string());
|
||||||
|
stale.table_columns.insert(
|
||||||
|
"Customers".to_string(),
|
||||||
|
vec![TableColumn {
|
||||||
|
name: "id".to_string(),
|
||||||
|
user_type: Type::Serial,
|
||||||
|
not_null: true,
|
||||||
|
has_default: false,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
app.schema_cache = stale;
|
||||||
|
|
||||||
|
// 1. Submit the DDL. It dispatches and arms the gate: a schema
|
||||||
|
// refresh is now (conceptually) in flight.
|
||||||
|
type_str(&mut app, "add column to Customers: Name (text)");
|
||||||
|
let ddl_actions = submit(&mut app);
|
||||||
|
assert!(
|
||||||
|
matches!(ddl_actions.as_slice(), [Action::ExecuteDsl { .. }]),
|
||||||
|
"the DDL should dispatch; got {ddl_actions:?}",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Submit the Form-B insert *before* the refresh lands. It must be
|
||||||
|
// held — not dispatched, and crucially not rejected against the
|
||||||
|
// stale cache (no error note such as "trying to write SQL?").
|
||||||
|
type_str(&mut app, "insert into Customers values ('Alice')");
|
||||||
|
let held_actions = submit(&mut app);
|
||||||
|
assert!(
|
||||||
|
held_actions.is_empty(),
|
||||||
|
"the insert must be held while the refresh is in flight, \
|
||||||
|
not dispatched or rejected; got {held_actions:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!app.output.iter().any(|l| l.kind == OutputKind::Error),
|
||||||
|
"a held insert must produce no error note (e.g. the \
|
||||||
|
'trying to write SQL?' pointer); errors:\n{}",
|
||||||
|
error_lines(&app),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. The DDL's schema refresh lands carrying the fresh schema
|
||||||
|
// (`Customers` now has `id` + `Name`). The held insert drains and
|
||||||
|
// dispatches, validated against the up-to-date cache.
|
||||||
|
let mut fresh = SchemaCache::default();
|
||||||
|
fresh.tables.push("Customers".to_string());
|
||||||
|
fresh.columns.push("id".to_string());
|
||||||
|
fresh.columns.push("Name".to_string());
|
||||||
|
fresh.table_columns.insert(
|
||||||
|
"Customers".to_string(),
|
||||||
|
vec![
|
||||||
|
TableColumn {
|
||||||
|
name: "id".to_string(),
|
||||||
|
user_type: Type::Serial,
|
||||||
|
not_null: true,
|
||||||
|
has_default: false,
|
||||||
|
},
|
||||||
|
TableColumn::new("Name", Type::Text),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let drained = app.update(AppEvent::SchemaCacheRefreshed(fresh));
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
drained.as_slice(),
|
||||||
|
[Action::ExecuteDsl {
|
||||||
|
command: Command::Insert { .. },
|
||||||
|
..
|
||||||
|
}]
|
||||||
|
),
|
||||||
|
"the held insert must dispatch once the fresh schema lands; \
|
||||||
|
got {drained:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn simple_mode_submit_of_pure_dsl_error_has_no_advanced_pointer() {
|
fn simple_mode_submit_of_pure_dsl_error_has_no_advanced_pointer() {
|
||||||
// A DSL error that is *not* valid SQL either (unknown command)
|
// A DSL error that is *not* valid SQL either (unknown command)
|
||||||
@@ -5970,6 +6189,112 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Clause-concept hints (issue #37) ────────────────────────
|
||||||
|
|
||||||
|
/// Move the input cursor left `n` times (into already-typed text).
|
||||||
|
fn cursor_left_n(app: &mut App, n: usize) {
|
||||||
|
for _ in 0..n {
|
||||||
|
app.update(key(KeyCode::Left));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_inside_on_delete_layers_the_referential_actions_concept() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(
|
||||||
|
&mut app,
|
||||||
|
"add 1:n relationship from Customers.id to Orders.cid on delete cascade",
|
||||||
|
);
|
||||||
|
// Park the cursor inside the already-typed `cascade` — the
|
||||||
|
// cursor-inside case (#37), not merely the slot boundary.
|
||||||
|
cursor_left_n(&mut app, 3);
|
||||||
|
let buffer_before = app.input.clone();
|
||||||
|
let cursor_before = app.input_cursor;
|
||||||
|
f1(&mut app);
|
||||||
|
// Per-form block still renders…
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "one parent, many children"),
|
||||||
|
"the per-form relationship block must still render",
|
||||||
|
);
|
||||||
|
// …layered with the clause-concept block under its sub-heading.
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "About this clause"),
|
||||||
|
"expected the clause-concept sub-heading",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "forbids orphans"),
|
||||||
|
"expected the referential-actions concept block",
|
||||||
|
);
|
||||||
|
// F1 is a read-only overlay (ADR-0053 D1): buffer + cursor intact.
|
||||||
|
assert_eq!(app.input, buffer_before, "F1 must not alter the buffer");
|
||||||
|
assert_eq!(
|
||||||
|
app.input_cursor, cursor_before,
|
||||||
|
"F1 must not move the cursor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_concept_example_is_mode_correct() {
|
||||||
|
// Advanced mode: the referential-actions concept is reachable
|
||||||
|
// via `references … on delete`, and its example must be the
|
||||||
|
// advanced (SQL) one, never the simple-mode `add 1:n …` line
|
||||||
|
// (ADR-0053 D6 / clause-concept-hints D5).
|
||||||
|
let mut app = App::new();
|
||||||
|
app.mode = crate::mode::Mode::Advanced;
|
||||||
|
type_str(
|
||||||
|
&mut app,
|
||||||
|
"create table Orders (cid int references Customers(id) on delete cascade)",
|
||||||
|
);
|
||||||
|
cursor_left_n(&mut app, 3); // inside `cascade`
|
||||||
|
f1(&mut app);
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "references Customers(id) on delete cascade"),
|
||||||
|
"expected the advanced-mode example",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output_contains(&app, "add 1:n relationship"),
|
||||||
|
"must NOT show the simple-mode example in advanced mode",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_outside_a_concept_clause_shows_no_concept_block() {
|
||||||
|
// Cursor at the end of a plain `insert` — not inside any
|
||||||
|
// recognized clause. The per-form block renders; no concept
|
||||||
|
// layer, no sub-heading.
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "insert into Customers ");
|
||||||
|
f1(&mut app);
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "Add one or more rows to a table"),
|
||||||
|
"the per-form block must render",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!output_contains(&app, "About this clause"),
|
||||||
|
"no clause-concept block when the cursor is outside any clause",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locks the rendered shape of the per-form block + the layered
|
||||||
|
/// clause-concept block (clause-concept-hints D6).
|
||||||
|
#[test]
|
||||||
|
fn hint_block_with_concept_renders_layered() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(
|
||||||
|
&mut app,
|
||||||
|
"add 1:n relationship from Customers.id to Orders.cid on delete cascade",
|
||||||
|
);
|
||||||
|
cursor_left_n(&mut app, 3);
|
||||||
|
f1(&mut app);
|
||||||
|
let block = app
|
||||||
|
.output
|
||||||
|
.iter()
|
||||||
|
.map(|l| l.text.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
insta::assert_snapshot!("hint_block_with_concept", block);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn f1_on_add_column_does_not_render_the_relationship_block() {
|
fn f1_on_add_column_does_not_render_the_relationship_block() {
|
||||||
// Per-form disambiguation (ADR-0053 D3): `add column` resolves
|
// Per-form disambiguation (ADR-0053 D3): `add column` resolves
|
||||||
|
|||||||
+2
-3
@@ -631,7 +631,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
|||||||
// Source 1.5: type-name candidates when the walker expects
|
// Source 1.5: type-name candidates when the walker expects
|
||||||
// a column-type slot. Type names are a closed set sourced
|
// a column-type slot. Type names are a closed set sourced
|
||||||
// from `Type::all()` (ADR-0005 declaration order:
|
// 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
|
// shortid). The walker surfaces this as
|
||||||
// `Expectation::Ident { source: Types }`.
|
// `Expectation::Ident { source: Types }`.
|
||||||
let type_names: Vec<String> = if expected.iter().any(|e| {
|
let type_names: Vec<String> = if expected.iter().any(|e| {
|
||||||
@@ -2309,7 +2309,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn type_slot_offers_full_type_vocabulary_when_partial_empty() {
|
fn type_slot_offers_full_type_vocabulary_when_partial_empty() {
|
||||||
// After `add column to T: Name (` the parser expects
|
// 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.
|
// from `Type::all()` are offered in declaration order.
|
||||||
let cs = cands("add column to T: Name (", 23);
|
let cs = cands("add column to T: Name (", 23);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2322,7 +2322,6 @@ mod tests {
|
|||||||
"bool".to_string(),
|
"bool".to_string(),
|
||||||
"date".to_string(),
|
"date".to_string(),
|
||||||
"datetime".to_string(),
|
"datetime".to_string(),
|
||||||
"blob".to_string(),
|
|
||||||
"serial".to_string(),
|
"serial".to_string(),
|
||||||
"shortid".to_string(),
|
"shortid".to_string(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3169,7 +3169,10 @@ fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result<CellValue, D
|
|||||||
ValueRef::Integer(n) => CellValue::Integer(n),
|
ValueRef::Integer(n) => CellValue::Integer(n),
|
||||||
ValueRef::Real(f) => CellValue::Real(f),
|
ValueRef::Real(f) => CellValue::Real(f),
|
||||||
ValueRef::Text(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()),
|
ValueRef::Text(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()),
|
||||||
ValueRef::Blob(bytes) => CellValue::Blob(bytes.to_vec()),
|
// `blob` was removed from the vocabulary (ADR-0005 Amendment 2), so no
|
||||||
|
// column is BLOB-typed any more and a blob value-ref is not expected.
|
||||||
|
// Surface any stray bytes as lossy text rather than panicking.
|
||||||
|
ValueRef::Blob(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3627,11 +3630,6 @@ fn do_drop_table(
|
|||||||
/// rows receive a generated value (1..N for serial, fresh
|
/// rows receive a generated value (1..N for serial, fresh
|
||||||
/// shortids for shortid) and the column gains UNIQUE +
|
/// shortids for shortid) and the column gains UNIQUE +
|
||||||
/// NOT NULL.
|
/// NOT NULL.
|
||||||
///
|
|
||||||
/// `blob` is statically refused as a column-add target by the
|
|
||||||
/// existing infrastructure (no DSL literal, downstream INSERT
|
|
||||||
/// would fail), but the path itself is allowed via the plain
|
|
||||||
/// branch — same as today.
|
|
||||||
fn do_add_column(
|
fn do_add_column(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
persistence: Option<&Persistence>,
|
persistence: Option<&Persistence>,
|
||||||
@@ -5043,7 +5041,7 @@ fn do_rename_table(
|
|||||||
///
|
///
|
||||||
/// 1. Target is `serial` (auto-increment is create-table-only).
|
/// 1. Target is `serial` (auto-increment is create-table-only).
|
||||||
/// 2. Source ↔ target is statically refused per the matrix
|
/// 2. Source ↔ target is statically refused per the matrix
|
||||||
/// (same-type, blob, date↔datetime, undefined cross-domain).
|
/// (same-type, date↔datetime, undefined cross-domain).
|
||||||
/// 3. Column is the *child* side of any relationship (outbound
|
/// 3. Column is the *child* side of any relationship (outbound
|
||||||
/// FK) — drop the relationship first.
|
/// FK) — drop the relationship first.
|
||||||
/// 4. Column has any inbound FK (parent side) and the new type
|
/// 4. Column has any inbound FK (parent side) and the new type
|
||||||
@@ -8641,9 +8639,7 @@ fn sample_parent_key_tuples(
|
|||||||
///
|
///
|
||||||
/// Foreign-key columns are filled by sampling existing parent rows
|
/// Foreign-key columns are filled by sampling existing parent rows
|
||||||
/// (D14); a compound FK reads all its child columns from one sampled
|
/// (D14); a compound FK reads all its child columns from one sampled
|
||||||
/// parent row. An empty parent is refused with a friendly error. A
|
/// parent row. An empty parent is refused with a friendly error.
|
||||||
/// `NOT NULL blob` column (which seed cannot generate) is refused by
|
|
||||||
/// the block guard (D1); a nullable blob is omitted (→ NULL).
|
|
||||||
///
|
///
|
||||||
/// **Phase 2 (SD2):** when `target_column` is `Some`, this delegates to
|
/// **Phase 2 (SD2):** when `target_column` is `Some`, this delegates to
|
||||||
/// [`do_seed_column_fill`] (fill one column across existing rows, D1
|
/// [`do_seed_column_fill`] (fill one column across existing rows, D1
|
||||||
@@ -8719,17 +8715,6 @@ fn do_seed(
|
|||||||
if matches!(ty, Type::Serial) {
|
if matches!(ty, Type::Serial) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// blob has no DSL value path: refuse if required (D1), else omit.
|
|
||||||
if matches!(ty, Type::Blob) {
|
|
||||||
if c.notnull {
|
|
||||||
return Err(DbError::Unsupported(format!(
|
|
||||||
"cannot seed `{table}`: column `{}` is `NOT NULL` but has type `blob`, \
|
|
||||||
which seed cannot generate. Add the rows another way or make it nullable.",
|
|
||||||
c.name,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
col_names.push(c.name.clone());
|
col_names.push(c.name.clone());
|
||||||
if let Some(&(fk_idx, pos)) = fk_child_pos.get(c.name.as_str()) {
|
if let Some(&(fk_idx, pos)) = fk_child_pos.get(c.name.as_str()) {
|
||||||
plans.push(SeedColPlan::ForeignKey { fk_idx, pos });
|
plans.push(SeedColPlan::ForeignKey { fk_idx, pos });
|
||||||
@@ -9122,7 +9107,7 @@ fn seed_override_literal(value: &Value, column: &str) -> Result<String, DbError>
|
|||||||
/// Column-fill (ADR-0048 D1 form 2): fill one column across the table's
|
/// Column-fill (ADR-0048 D1 form 2): fill one column across the table's
|
||||||
/// **existing** rows (an UPDATE), the natural follow-up to `add column`.
|
/// **existing** rows (an UPDATE), the natural follow-up to `add column`.
|
||||||
///
|
///
|
||||||
/// Refuses PK and auto-generated (`serial`/`shortid`/`blob`) targets;
|
/// Refuses PK and auto-generated (`serial`/`shortid`) targets;
|
||||||
/// an empty table is a friendly no-op. The `set` clause may only adjust
|
/// an empty table is a friendly no-op. The `set` clause may only adjust
|
||||||
/// the column being filled (the rest of the per-column heuristics do not
|
/// the column being filled (the rest of the per-column heuristics do not
|
||||||
/// apply — there is exactly one column). A UNIQUE / identifier target
|
/// apply — there is exactly one column). A UNIQUE / identifier target
|
||||||
@@ -9178,12 +9163,6 @@ fn do_seed_column_fill(
|
|||||||
ty.keyword(),
|
ty.keyword(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if matches!(ty, Type::Blob) {
|
|
||||||
return Err(DbError::Unsupported(format!(
|
|
||||||
"cannot fill `{table}.{canonical_col}`: seed cannot generate `blob` values."
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The `set` clause may only adjust the filled column (user decision).
|
// The `set` clause may only adjust the filled column (user decision).
|
||||||
for ov in overrides {
|
for ov in overrides {
|
||||||
if !ov.column.eq_ignore_ascii_case(&canonical_col) {
|
if !ov.column.eq_ignore_ascii_case(&canonical_col) {
|
||||||
@@ -11230,7 +11209,6 @@ fn cell_value_to_sqlite(cell: &CellValue) -> rusqlite::types::Value {
|
|||||||
CellValue::Integer(n) => Value::Integer(*n),
|
CellValue::Integer(n) => Value::Integer(*n),
|
||||||
CellValue::Real(f) => Value::Real(*f),
|
CellValue::Real(f) => Value::Real(*f),
|
||||||
CellValue::Text(s) => Value::Text(s.clone()),
|
CellValue::Text(s) => Value::Text(s.clone()),
|
||||||
CellValue::Blob(b) => Value::Blob(b.clone()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12912,30 +12890,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn change_column_type_blob_target_refused_statically() {
|
|
||||||
let db = db();
|
|
||||||
make_id_table(&db, "T").await;
|
|
||||||
db.add_column(
|
|
||||||
"T".to_string(),
|
|
||||||
ColumnSpec::new("Note".to_string(), Type::Text),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let err = db
|
|
||||||
.change_column_type(
|
|
||||||
"T".to_string(),
|
|
||||||
"Note".to_string(),
|
|
||||||
Type::Blob,
|
|
||||||
ChangeColumnMode::Default,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn change_column_type_outbound_fk_refused() {
|
async fn change_column_type_outbound_fk_refused() {
|
||||||
// Child-side FK column: §4.2 says always refuse,
|
// Child-side FK column: §4.2 says always refuse,
|
||||||
|
|||||||
+13
-13
@@ -1886,12 +1886,12 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("explain"),
|
entry: Word::keyword("explain"),
|
||||||
shape: EXPLAIN_SQL_SHAPE,
|
shape: EXPLAIN_SQL_SHAPE,
|
||||||
ast_builder: build_explain_sql,
|
ast_builder: build_explain_sql,
|
||||||
// No `help_id` / `usage_ids` — this is the `Advanced` half of the
|
// Issue #36: its own `help` page (`data.explain_sql`), listed under the
|
||||||
// shared `explain` entry word, so it defers to the `Simple`
|
// advanced-mode (SQL) section and shown by `help explain` next to the
|
||||||
// `EXPLAIN` node's help/usage (which now covers the SQL forms
|
// simple `EXPLAIN` form (distinct help_id ⇒ no dedup clash). `usage_ids`
|
||||||
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
|
// stays empty — the `Simple` `EXPLAIN` node's usage block already covers
|
||||||
// precedent; otherwise `note_help` would print `explain` twice.
|
// the shared `explain` entry word.
|
||||||
help_id: None,
|
help_id: Some("data.explain_sql"),
|
||||||
hint_ids: &["explain_sql"],
|
hint_ids: &["explain_sql"],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
@@ -1902,13 +1902,13 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
|||||||
/// The shape is the post-`SELECT` portion of a top-level
|
/// The shape is the post-`SELECT` portion of a top-level
|
||||||
/// statement; the registry's entry-word dispatch consumes the
|
/// statement; the registry's entry-word dispatch consumes the
|
||||||
/// leading `SELECT` keyword before the shape walks (sub-phase
|
/// leading `SELECT` keyword before the shape walks (sub-phase
|
||||||
/// 2c migration). `help_id` is `None` until the `help sql`
|
/// 2c migration). Carries its own `help` page (`data.select`),
|
||||||
/// page lands (ADR-0030 Phase 6).
|
/// listed under the advanced-mode (SQL) section (issue #36).
|
||||||
pub static SELECT: CommandNode = CommandNode {
|
pub static SELECT: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("select"),
|
entry: Word::keyword("select"),
|
||||||
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
||||||
ast_builder: build_select,
|
ast_builder: build_select,
|
||||||
help_id: None,
|
help_id: Some("data.select"),
|
||||||
hint_ids: &["select"],
|
hint_ids: &["select"],
|
||||||
usage_ids: &["parse.usage.select"],
|
usage_ids: &["parse.usage.select"],
|
||||||
};
|
};
|
||||||
@@ -1924,7 +1924,7 @@ pub static WITH: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("with"),
|
entry: Word::keyword("with"),
|
||||||
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
||||||
ast_builder: build_select,
|
ast_builder: build_select,
|
||||||
help_id: None,
|
help_id: Some("data.with"), // issue #36: own help page, advanced section
|
||||||
hint_ids: &["with"],
|
hint_ids: &["with"],
|
||||||
usage_ids: &["parse.usage.with"],
|
usage_ids: &["parse.usage.with"],
|
||||||
};
|
};
|
||||||
@@ -1943,7 +1943,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("insert"),
|
entry: Word::keyword("insert"),
|
||||||
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
||||||
ast_builder: build_sql_insert,
|
ast_builder: build_sql_insert,
|
||||||
help_id: None,
|
help_id: Some("data.sql_insert"), // issue #36: own help page, advanced section
|
||||||
hint_ids: &["sql_insert"],
|
hint_ids: &["sql_insert"],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
@@ -1957,7 +1957,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("update"),
|
entry: Word::keyword("update"),
|
||||||
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||||
ast_builder: build_sql_update,
|
ast_builder: build_sql_update,
|
||||||
help_id: None,
|
help_id: Some("data.sql_update"), // issue #36: own help page, advanced section
|
||||||
hint_ids: &["sql_update"],
|
hint_ids: &["sql_update"],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
@@ -1973,7 +1973,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
|
|||||||
entry: Word::keyword("delete"),
|
entry: Word::keyword("delete"),
|
||||||
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||||
ast_builder: build_sql_delete,
|
ast_builder: build_sql_delete,
|
||||||
help_id: None,
|
help_id: Some("data.sql_delete"), // issue #36: own help page, advanced section
|
||||||
hint_ids: &["sql_delete"],
|
hint_ids: &["sql_delete"],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
|
|||||||
+53
-6
@@ -476,10 +476,23 @@ 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 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::Literal("1"),
|
||||||
Node::Punct(':'),
|
Node::Punct(':'),
|
||||||
Node::Word(Word::keyword("n")),
|
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")),
|
Node::Word(Word::keyword("relationship")),
|
||||||
AR_AS_NAME_OPT,
|
AR_AS_NAME_OPT,
|
||||||
Node::Word(Word::keyword("from")),
|
Node::Word(Word::keyword("from")),
|
||||||
@@ -1048,7 +1061,14 @@ const NOT_NULL_NODES: &[Node] = &[
|
|||||||
];
|
];
|
||||||
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);
|
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] = &[
|
const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
|
||||||
Node::Word(Word::keyword("default")),
|
Node::Word(Word::keyword("default")),
|
||||||
@@ -1066,7 +1086,13 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
|
|||||||
Node::Subgrammar(&super::expr::OR_EXPR),
|
Node::Subgrammar(&super::expr::OR_EXPR),
|
||||||
Node::Punct(')'),
|
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] = &[
|
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
|
||||||
NOT_NULL_CONSTRAINT,
|
NOT_NULL_CONSTRAINT,
|
||||||
@@ -1158,11 +1184,20 @@ const SPEC_LIST: Node = Node::Repeated {
|
|||||||
};
|
};
|
||||||
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
|
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("with")),
|
||||||
Node::Word(Word::keyword("pk")),
|
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: Node = Node::Seq(WITH_PK_NODES);
|
||||||
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
|
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
|
||||||
|
|
||||||
@@ -1424,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_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 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::Literal("m"),
|
||||||
Node::Punct(':'),
|
Node::Punct(':'),
|
||||||
Node::Word(Word::keyword("n")),
|
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("relationship")),
|
||||||
Node::Word(Word::keyword("from")),
|
Node::Word(Word::keyword("from")),
|
||||||
M2N_T1,
|
M2N_T1,
|
||||||
|
|||||||
+198
-6
@@ -476,6 +476,26 @@ pub enum Node {
|
|||||||
mode: HintMode,
|
mode: HintMode,
|
||||||
inner: &'static Self,
|
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
|
/// Which mode group a registered command belongs to (ADR-0030
|
||||||
@@ -537,8 +557,11 @@ pub struct CommandNode {
|
|||||||
/// block). `hint_key_for_input_in_mode` disambiguates by the form
|
/// block). `hint_key_for_input_in_mode` disambiguates by the form
|
||||||
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
|
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
|
||||||
/// until a form's tier-3 block is authored (the surface falls back
|
/// until a form's tier-3 block is authored (the surface falls back
|
||||||
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
|
/// to tier-2 ambient/error text). Parallel to `help_id` but
|
||||||
/// `None` on advanced-SQL forms purely to dedup the `help` list).
|
/// finer-grained: every form (simple and advanced) carries a
|
||||||
|
/// `hint_id`, whereas the `help <topic>` view groups forms by entry
|
||||||
|
/// word (so a shared-entry simple + SQL pair both surface under e.g.
|
||||||
|
/// `help insert`).
|
||||||
pub hint_ids: &'static [&'static str],
|
pub hint_ids: &'static [&'static str],
|
||||||
/// Catalog keys under `parse.usage.*` to render in the
|
/// Catalog keys under `parse.usage.*` to render in the
|
||||||
/// "usage:" block when a parse error fires for this command
|
/// "usage:" block when a parse error fires for this command
|
||||||
@@ -1084,6 +1107,173 @@ mod hint_key_tests {
|
|||||||
"expected at least 49 hint.cmd.* examples, checked {checked}",
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1156,10 +1346,12 @@ mod usage_key_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn no_two_registered_commands_share_a_help_id() {
|
fn no_two_registered_commands_share_a_help_id() {
|
||||||
// `note_help` emits one help block per `help_id: Some(_)`
|
// `note_help` emits one help block per `help_id: Some(_)`
|
||||||
// with no dedup, so a duplicate help_id prints the same
|
// with no dedup, so a duplicate help_id string prints the same
|
||||||
// command twice in `help`. Shared-entry-word `Advanced`
|
// block twice. Distinct help_ids are fine — a shared-entry-word
|
||||||
// nodes (SQL_INSERT, …, EXPLAIN_SQL) therefore carry
|
// simple + SQL pair (e.g. `data.insert` + `data.sql_insert`,
|
||||||
// `help_id: None` and defer to their `Simple` sibling.
|
// issue #36) each get their own block, grouped under one topic
|
||||||
|
// by `help <topic>` and split across the simple/advanced
|
||||||
|
// sections of the full list.
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
for (command, _category) in super::REGISTRY {
|
for (command, _category) in super::REGISTRY {
|
||||||
if let Some(id) = command.help_id {
|
if let Some(id) = command.help_id {
|
||||||
|
|||||||
@@ -133,12 +133,25 @@ pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES);
|
|||||||
/// Repeated `on <target> <action>` clauses (0..2 occurrences).
|
/// Repeated `on <target> <action>` clauses (0..2 occurrences).
|
||||||
/// Validation of "specified twice" + max=2 lives in the
|
/// Validation of "specified twice" + max=2 lives in the
|
||||||
/// command's AST builder.
|
/// command's AST builder.
|
||||||
pub const REFERENTIAL_CLAUSES: Node = Node::Repeated {
|
const REFERENTIAL_CLAUSES_INNER: Node = Node::Repeated {
|
||||||
inner: &ON_CLAUSE,
|
inner: &ON_CLAUSE,
|
||||||
separator: None,
|
separator: None,
|
||||||
min: 0,
|
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)
|
// Typed value slots (ADR-0024 §Phase D, §typed-value-slots)
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -263,11 +276,6 @@ const DATETIME_SLOT: Node = Node::TypedValueSlot {
|
|||||||
column_name: None,
|
column_name: None,
|
||||||
inner: &TEXT_SLOT_INNER,
|
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 columns store base58 text (ADR-0011 fk_target_type
|
||||||
// shortid → text); the slot accepts a quoted-text literal or
|
// shortid → text); the slot accepts a quoted-text literal or
|
||||||
// null.
|
// null.
|
||||||
@@ -297,7 +305,6 @@ pub const fn slot_for_type(ty: Type) -> Node {
|
|||||||
Type::Text => TEXT_SLOT,
|
Type::Text => TEXT_SLOT,
|
||||||
Type::Date => DATE_SLOT,
|
Type::Date => DATE_SLOT,
|
||||||
Type::DateTime => DATETIME_SLOT,
|
Type::DateTime => DATETIME_SLOT,
|
||||||
Type::Blob => BLOB_SLOT,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +317,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node {
|
|||||||
Type::Real => &REAL_SLOT_INNER,
|
Type::Real => &REAL_SLOT_INNER,
|
||||||
Type::Decimal => &DECIMAL_SLOT_INNER,
|
Type::Decimal => &DECIMAL_SLOT_INNER,
|
||||||
Type::Bool => &BOOL_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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,16 +204,38 @@ static REFERENCES_NODES: &[Node] = &[
|
|||||||
shared::REFERENTIAL_CLAUSES,
|
shared::REFERENTIAL_CLAUSES,
|
||||||
];
|
];
|
||||||
const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES);
|
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>` |
|
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` |
|
||||||
// `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the
|
// `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] = &[
|
static COL_CONSTRAINT_CHOICES: &[Node] = &[
|
||||||
Node::Seq(NOT_NULL_NODES),
|
Node::Seq(NOT_NULL_NODES),
|
||||||
Node::Word(Word::keyword("unique")),
|
Node::Concept {
|
||||||
Node::Seq(PRIMARY_KEY_NODES),
|
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(DEFAULT_NODES),
|
||||||
Node::Seq(CHECK_NODES),
|
Node::Concept {
|
||||||
REFERENCES_CLAUSE,
|
topic: "check",
|
||||||
|
inner: &Node::Seq(CHECK_NODES),
|
||||||
|
},
|
||||||
|
REFERENCES_CONCEPT,
|
||||||
];
|
];
|
||||||
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
|
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
|
||||||
/// Zero-or-more column constraints after the type (`min: 0`).
|
/// Zero-or-more column constraints after the type (`min: 0`).
|
||||||
@@ -567,7 +589,7 @@ mod tests {
|
|||||||
fn standard_sql_type_aliases() {
|
fn standard_sql_type_aliases() {
|
||||||
good("table t (a integer, b varchar, c boolean, d timestamp)");
|
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 (e bigint, f smallint, g char, h numeric)");
|
||||||
good("table t (i binary, j varbinary, k float)");
|
good("table t (k float)");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -734,7 +756,7 @@ mod builder_tests {
|
|||||||
fn standard_sql_aliases_map_to_playground_types() {
|
fn standard_sql_aliases_map_to_playground_types() {
|
||||||
let (_, cols, _, _) = sct(
|
let (_, cols, _, _) = sct(
|
||||||
"create table t (a bigint, b varchar, c boolean, d timestamp, \
|
"create table t (a bigint, b varchar, c boolean, d timestamp, \
|
||||||
e numeric, f float, g binary)",
|
e numeric, f float)",
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cols,
|
cols,
|
||||||
@@ -745,7 +767,6 @@ mod builder_tests {
|
|||||||
("d".to_string(), Type::DateTime),
|
("d".to_string(), Type::DateTime),
|
||||||
("e".to_string(), Type::Decimal),
|
("e".to_string(), Type::Decimal),
|
||||||
("f".to_string(), Type::Real),
|
("f".to_string(), Type::Real),
|
||||||
("g".to_string(), Type::Blob),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-31
@@ -1,7 +1,8 @@
|
|||||||
//! User-facing column types and their mapping to SQLite STRICT.
|
//! User-facing column types and their mapping to SQLite STRICT.
|
||||||
//!
|
//!
|
||||||
//! Implements the full ten-type vocabulary committed to in
|
//! Implements the nine-type vocabulary committed to in ADR-0005
|
||||||
//! ADR-0005. Storage choices for the text-backed types
|
//! (ten as originally decided; `blob` dropped by Amendment 2).
|
||||||
|
//! Storage choices for the text-backed types
|
||||||
//! (`decimal`, `date`, `datetime`) preserve precision and ISO
|
//! (`decimal`, `date`, `datetime`) preserve precision and ISO
|
||||||
//! readability; comparisons rely on lexicographic ordering or
|
//! readability; comparisons rely on lexicographic ordering or
|
||||||
//! explicit casts at query time, which is acceptable for a
|
//! explicit casts at query time, which is acceptable for a
|
||||||
@@ -27,8 +28,6 @@ pub enum Type {
|
|||||||
Date,
|
Date,
|
||||||
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
|
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
|
||||||
DateTime,
|
DateTime,
|
||||||
/// Arbitrary binary data.
|
|
||||||
Blob,
|
|
||||||
/// Auto-incrementing integer; intended as a default primary key.
|
/// Auto-incrementing integer; intended as a default primary key.
|
||||||
Serial,
|
Serial,
|
||||||
/// 10–12 character base58 random identifier (no ambiguous chars).
|
/// 10–12 character base58 random identifier (no ambiguous chars).
|
||||||
@@ -47,7 +46,6 @@ impl Type {
|
|||||||
Self::Bool => "bool",
|
Self::Bool => "bool",
|
||||||
Self::Date => "date",
|
Self::Date => "date",
|
||||||
Self::DateTime => "datetime",
|
Self::DateTime => "datetime",
|
||||||
Self::Blob => "blob",
|
|
||||||
Self::Serial => "serial",
|
Self::Serial => "serial",
|
||||||
Self::ShortId => "shortid",
|
Self::ShortId => "shortid",
|
||||||
}
|
}
|
||||||
@@ -62,7 +60,6 @@ impl Type {
|
|||||||
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::Int | Self::Serial | Self::Bool => "INTEGER",
|
||||||
Self::Real => "REAL",
|
Self::Real => "REAL",
|
||||||
Self::Blob => "BLOB",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +76,8 @@ impl Type {
|
|||||||
|
|
||||||
/// All types known in this iteration, in stable order.
|
/// All types known in this iteration, in stable order.
|
||||||
/// Ordering groups numeric types together, then boolean,
|
/// Ordering groups numeric types together, then boolean,
|
||||||
/// then temporal, then binary, then identity-flavoured
|
/// then temporal, then identity-flavoured auto-generated
|
||||||
/// auto-generated types.
|
/// types.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn all() -> &'static [Self] {
|
pub const fn all() -> &'static [Self] {
|
||||||
&[
|
&[
|
||||||
@@ -91,15 +88,14 @@ impl Type {
|
|||||||
Self::Bool,
|
Self::Bool,
|
||||||
Self::Date,
|
Self::Date,
|
||||||
Self::DateTime,
|
Self::DateTime,
|
||||||
Self::Blob,
|
|
||||||
Self::Serial,
|
Self::Serial,
|
||||||
Self::ShortId,
|
Self::ShortId,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True for the numeric types — `int`, `real`, `decimal`,
|
/// True for the numeric types — `int`, `real`, `decimal`,
|
||||||
/// `serial`. `bool` (stored 0/1) and the text- / blob-backed
|
/// `serial`. `bool` (stored 0/1) and the text-backed types
|
||||||
/// types are not numeric. Used to flag a `LIKE` text-pattern
|
/// are not numeric. Used to flag a `LIKE` text-pattern
|
||||||
/// match against a numeric column (ADR-0027, Amendment 1).
|
/// match against a numeric column (ADR-0027, Amendment 1).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn is_numeric(self) -> bool {
|
pub const fn is_numeric(self) -> bool {
|
||||||
@@ -129,7 +125,7 @@ impl Type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a type name from the **advanced-mode SQL** type slot
|
/// 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;
|
/// standard-SQL aliases mapped onto them. Case-insensitive;
|
||||||
/// internal whitespace is collapsed so `double precision` resolves
|
/// internal whitespace is collapsed so `double precision` resolves
|
||||||
/// regardless of spacing. Returns `None` for an unrecognised name —
|
/// regardless of spacing. Returns `None` for an unrecognised name —
|
||||||
@@ -137,7 +133,7 @@ impl Type {
|
|||||||
/// diagnostic.
|
/// diagnostic.
|
||||||
///
|
///
|
||||||
/// Deliberately distinct from [`FromStr`](std::str::FromStr), which
|
/// 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
|
/// (no aliases), so simple mode teaches the playground's own
|
||||||
/// vocabulary. A length/precision argument (`varchar(255)`) is
|
/// vocabulary. A length/precision argument (`varchar(255)`) is
|
||||||
/// stripped by the grammar before the name reaches this resolver.
|
/// stripped by the grammar before the name reaches this resolver.
|
||||||
@@ -157,8 +153,7 @@ impl Type {
|
|||||||
"timestamp" => Some(Self::DateTime),
|
"timestamp" => Some(Self::DateTime),
|
||||||
"numeric" => Some(Self::Decimal),
|
"numeric" => Some(Self::Decimal),
|
||||||
"float" | "double precision" => Some(Self::Real),
|
"float" | "double precision" => Some(Self::Real),
|
||||||
"binary" | "varbinary" => Some(Self::Blob),
|
// Fall through to the canonical nine keywords.
|
||||||
// Fall through to the canonical ten keywords.
|
|
||||||
other => other.parse::<Self>().ok(),
|
other => other.parse::<Self>().ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,20 +265,19 @@ mod tests {
|
|||||||
Type::Bool,
|
Type::Bool,
|
||||||
Type::Date,
|
Type::Date,
|
||||||
Type::DateTime,
|
Type::DateTime,
|
||||||
Type::Blob,
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(ty.fk_target_type(), ty);
|
assert_eq!(ty.fk_target_type(), ty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
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();
|
let mut sorted = kws.clone();
|
||||||
sorted.sort_unstable();
|
sorted.sort_unstable();
|
||||||
sorted.dedup();
|
sorted.dedup();
|
||||||
assert_eq!(sorted.len(), 10, "keywords must be unique");
|
assert_eq!(sorted.len(), 9, "keywords must be unique");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -294,16 +288,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blob_type_maps_to_blob_storage() {
|
fn unknown_type_message_lists_all_nine() {
|
||||||
assert_eq!(Type::Blob.sqlite_strict_type(), "BLOB");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_type_message_lists_all_ten() {
|
|
||||||
let err = "varchar".parse::<Type>().unwrap_err();
|
let err = "varchar".parse::<Type>().unwrap_err();
|
||||||
for kw in [
|
for kw in [
|
||||||
"text", "int", "real", "decimal", "bool", "date", "datetime", "blob", "serial",
|
"text", "int", "real", "decimal", "bool", "date", "datetime", "serial", "shortid",
|
||||||
"shortid",
|
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
err.expected.contains(kw),
|
err.expected.contains(kw),
|
||||||
@@ -314,12 +302,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- ADR-0035 §3: advanced-mode SQL type-name resolution ---
|
// --- 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
|
// standard-SQL aliases. Simple-mode `FromStr` is unchanged (it
|
||||||
// still rejects aliases — see `unknown_type_lists_expected_alternatives`).
|
// still rejects aliases — see `unknown_type_lists_expected_alternatives`).
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sql_resolver_accepts_the_ten_canonical_keywords() {
|
fn sql_resolver_accepts_the_nine_canonical_keywords() {
|
||||||
for &ty in Type::all() {
|
for &ty in Type::all() {
|
||||||
assert_eq!(Type::from_sql_name(ty.keyword()), Some(ty));
|
assert_eq!(Type::from_sql_name(ty.keyword()), Some(ty));
|
||||||
}
|
}
|
||||||
@@ -338,8 +326,6 @@ mod tests {
|
|||||||
("numeric", Type::Decimal),
|
("numeric", Type::Decimal),
|
||||||
("float", Type::Real),
|
("float", Type::Real),
|
||||||
("double precision", Type::Real),
|
("double precision", Type::Real),
|
||||||
("binary", Type::Blob),
|
|
||||||
("varbinary", Type::Blob),
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Type::from_sql_name(alias),
|
Type::from_sql_name(alias),
|
||||||
|
|||||||
@@ -101,10 +101,6 @@ impl Value {
|
|||||||
Type::Bool => self.bind_bool(column),
|
Type::Bool => self.bind_bool(column),
|
||||||
Type::Date => self.bind_date(column),
|
Type::Date => self.bind_date(column),
|
||||||
Type::DateTime => self.bind_datetime(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(),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +316,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn null_binds_to_null_for_any_type() {
|
fn null_binds_to_null_for_any_type() {
|
||||||
for ty in Type::all() {
|
for ty in Type::all() {
|
||||||
// Skip blob — null still works there too.
|
|
||||||
assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null);
|
assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,10 +424,4 @@ mod tests {
|
|||||||
let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err();
|
let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err();
|
||||||
assert!(matches!(err, ValueError::Format { .. }));
|
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")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ pub struct WalkContext<'a> {
|
|||||||
/// resolver reads this directly instead of inferring the
|
/// resolver reads this directly instead of inferring the
|
||||||
/// slot kind from the shape of the expected set.
|
/// slot kind from the shape of the expected set.
|
||||||
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
|
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
|
/// An `IntroProse` hint captured from an *optional* slot that
|
||||||
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
|
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
|
||||||
/// (cleared on the very next match — including the empty match
|
/// (cleared on the very next match — including the empty match
|
||||||
@@ -227,6 +239,19 @@ pub struct PendingCteHarvest {
|
|||||||
pub cte_name_span: (usize, usize),
|
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> {
|
impl<'a> WalkContext<'a> {
|
||||||
/// Schemaless walk context — the legacy default used by
|
/// Schemaless walk context — the legacy default used by
|
||||||
/// pre-Phase-D callers and tests that don't care about
|
/// 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_type: None,
|
||||||
pending_value_column: None,
|
pending_value_column: None,
|
||||||
pending_hint_mode: None,
|
pending_hint_mode: None,
|
||||||
|
concept_spans: Vec::new(),
|
||||||
surviving_intro_hint: None,
|
surviving_intro_hint: None,
|
||||||
user_listed_columns: None,
|
user_listed_columns: None,
|
||||||
subgrammar_depth: 0,
|
subgrammar_depth: 0,
|
||||||
@@ -266,6 +292,7 @@ impl<'a> WalkContext<'a> {
|
|||||||
pending_value_type: None,
|
pending_value_type: None,
|
||||||
pending_value_column: None,
|
pending_value_column: None,
|
||||||
pending_hint_mode: None,
|
pending_hint_mode: None,
|
||||||
|
concept_spans: Vec::new(),
|
||||||
surviving_intro_hint: None,
|
surviving_intro_hint: None,
|
||||||
user_listed_columns: None,
|
user_listed_columns: None,
|
||||||
subgrammar_depth: 0,
|
subgrammar_depth: 0,
|
||||||
|
|||||||
@@ -290,6 +290,33 @@ fn walk_node_inner(
|
|||||||
ctx.pending_hint_mode = Some(*mode);
|
ctx.pending_hint_mode = Some(*mode);
|
||||||
walk_node(source, pos, inner, ctx, path, per_byte)
|
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::Flag(name) => walk_flag(source, pos, name, path, per_byte),
|
||||||
Node::Repeated {
|
Node::Repeated {
|
||||||
inner,
|
inner,
|
||||||
|
|||||||
+186
-3
@@ -25,7 +25,7 @@ use crate::dsl::walker::driver::{FailureKind, NodeWalkResult, walk_node};
|
|||||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||||
use crate::dsl::walker::outcome::{Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult};
|
use crate::dsl::walker::outcome::{Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult};
|
||||||
|
|
||||||
pub use context::ColumnInfo;
|
pub use context::{ColumnInfo, ConceptSpan};
|
||||||
pub use highlight::{highlight_runs, highlight_runs_in_mode};
|
pub use highlight::{highlight_runs, highlight_runs_in_mode};
|
||||||
pub use outcome::{Diagnostic, Severity};
|
pub use outcome::{Diagnostic, Severity};
|
||||||
|
|
||||||
@@ -167,6 +167,46 @@ pub fn hint_resolution_at_input_in_mode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the clause-concept topic the cursor sits inside, if any
|
||||||
|
/// (issue #37 / ADR clause-concept-hints, D2).
|
||||||
|
///
|
||||||
|
/// Walks the **full** `source` (not a cursor-trimmed prefix — the
|
||||||
|
/// dormant `WalkBound::Position` path is deliberately not used, so
|
||||||
|
/// the whole parse and every clause span is available), collects the
|
||||||
|
/// `Node::Concept` spans recorded in `ctx.concept_spans`, and returns
|
||||||
|
/// the `topic` of the **innermost** span containing `cursor`. The F1
|
||||||
|
/// hint surface layers `hint.concept.<topic>` on top of the per-form
|
||||||
|
/// block when this is `Some`.
|
||||||
|
///
|
||||||
|
/// "Innermost" = the containing span with the latest `start`, tie-
|
||||||
|
/// broken by the earliest `end` (narrowest): when a `references`
|
||||||
|
/// clause (`foreign_key`) wraps an `on delete` clause
|
||||||
|
/// (`referential_actions`), a cursor on `on delete` resolves to the
|
||||||
|
/// more specific inner concept. Containment is inclusive at both ends
|
||||||
|
/// so the boundary case (cursor exactly at the clause start) also
|
||||||
|
/// resolves.
|
||||||
|
#[must_use]
|
||||||
|
pub fn concept_topic_at_cursor(
|
||||||
|
source: &str,
|
||||||
|
cursor: usize,
|
||||||
|
schema: Option<&crate::completion::SchemaCache>,
|
||||||
|
mode: crate::mode::Mode,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
if source.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut ctx = schema.map_or_else(context::WalkContext::new, |s| {
|
||||||
|
context::WalkContext::with_schema(s)
|
||||||
|
});
|
||||||
|
ctx.mode = mode;
|
||||||
|
let _ = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
|
||||||
|
ctx.concept_spans
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.start <= cursor && cursor <= s.end)
|
||||||
|
.max_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)))
|
||||||
|
.map(|s| s.topic)
|
||||||
|
}
|
||||||
|
|
||||||
/// Auto-generated columns a Form B insert skips from its value
|
/// Auto-generated columns a Form B insert skips from its value
|
||||||
/// list — but only when the cursor sits at the *first* value
|
/// list — but only when the cursor sits at the *first* value
|
||||||
/// slot, so the pedagogical note fires once per command rather
|
/// slot, so the pedagogical note fires once per command rather
|
||||||
@@ -240,7 +280,6 @@ const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str
|
|||||||
Type::Text => "hint.value_slot_text",
|
Type::Text => "hint.value_slot_text",
|
||||||
Type::Date => "hint.value_slot_date",
|
Type::Date => "hint.value_slot_date",
|
||||||
Type::DateTime => "hint.value_slot_datetime",
|
Type::DateTime => "hint.value_slot_datetime",
|
||||||
Type::Blob => "hint.value_slot_blob",
|
|
||||||
Type::Serial => "hint.value_slot_serial",
|
Type::Serial => "hint.value_slot_serial",
|
||||||
Type::ShortId => "hint.value_slot_shortid",
|
Type::ShortId => "hint.value_slot_shortid",
|
||||||
}
|
}
|
||||||
@@ -4745,7 +4784,6 @@ mod tests {
|
|||||||
(Type::Text, "hint.value_slot_text"),
|
(Type::Text, "hint.value_slot_text"),
|
||||||
(Type::Date, "hint.value_slot_date"),
|
(Type::Date, "hint.value_slot_date"),
|
||||||
(Type::DateTime, "hint.value_slot_datetime"),
|
(Type::DateTime, "hint.value_slot_datetime"),
|
||||||
(Type::Blob, "hint.value_slot_blob"),
|
|
||||||
] {
|
] {
|
||||||
let schema = schema_with("T", &[("c", ty)]);
|
let schema = schema_with("T", &[("c", ty)]);
|
||||||
let mode = hint_mode_at_input_with_schema("insert into T values (", &schema);
|
let mode = hint_mode_at_input_with_schema("insert into T values (", &schema);
|
||||||
@@ -7030,3 +7068,148 @@ mod order_by_expected_set_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod concept_hint_tests {
|
||||||
|
//! Issue #37 / ADR clause-concept-hints — `concept_topic_at_cursor`.
|
||||||
|
//!
|
||||||
|
//! The cursor is positioned by `find`-ing a substring so the
|
||||||
|
//! tests don't hard-code byte offsets. "inside" means the cursor
|
||||||
|
//! sits within already-typed clause text (the cursor-inside
|
||||||
|
//! semantics, not merely the slot boundary).
|
||||||
|
use super::concept_topic_at_cursor;
|
||||||
|
use crate::mode::Mode;
|
||||||
|
|
||||||
|
/// Cursor at the START of `needle` within `src`.
|
||||||
|
fn at(src: &str, needle: &str) -> usize {
|
||||||
|
src.find(needle).expect("needle present")
|
||||||
|
}
|
||||||
|
/// Cursor in the MIDDLE of `needle` within `src` (inside typed text).
|
||||||
|
fn inside(src: &str, needle: &str) -> usize {
|
||||||
|
at(src, needle) + needle.len() / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topic(src: &str, cursor: usize, mode: Mode) -> Option<&'static str> {
|
||||||
|
concept_topic_at_cursor(src, cursor, None, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn referential_actions_at_on_delete_boundary() {
|
||||||
|
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, at(s, "on delete"), Mode::Simple),
|
||||||
|
Some("referential_actions"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn referential_actions_inside_already_typed_action() {
|
||||||
|
// Cursor parked inside the finished `cascade` — the
|
||||||
|
// cursor-inside case the slot-boundary mechanism can't serve.
|
||||||
|
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, inside(s, "cascade"), Mode::Simple),
|
||||||
|
Some("referential_actions"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cardinality_one_to_many_on_marker() {
|
||||||
|
let s = "add 1:n relationship from Customers.id to Orders.cid";
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, inside(s, "1:n"), Mode::Simple),
|
||||||
|
Some("cardinality_one_to_many"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cardinality_many_to_many_on_marker() {
|
||||||
|
let s = "create m:n relationship from Students to Courses";
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, inside(s, "m:n"), Mode::Simple),
|
||||||
|
Some("cardinality_many_to_many"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn primary_key_in_with_pk_simple() {
|
||||||
|
let s = "create table Orders with pk Code(text)";
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, inside(s, "with pk"), Mode::Simple),
|
||||||
|
Some("primary_key"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn primary_key_in_create_table_constraint_advanced() {
|
||||||
|
let s = "create table Orders (id int primary key)";
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, inside(s, "primary key"), Mode::Advanced),
|
||||||
|
Some("primary_key"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_constraint_simple() {
|
||||||
|
let s = "create table Ages with pk age(int) unique";
|
||||||
|
assert_eq!(topic(s, inside(s, "unique"), Mode::Simple), Some("unique"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_constraint_advanced() {
|
||||||
|
let s = "create table Orders (Code text unique)";
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, inside(s, "unique"), Mode::Advanced),
|
||||||
|
Some("unique")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_constraint_simple() {
|
||||||
|
let s = "create table Ages with pk age(int) check (age > 0)";
|
||||||
|
assert_eq!(topic(s, inside(s, "check"), Mode::Simple), Some("check"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_constraint_advanced() {
|
||||||
|
let s = "create table Orders (Qty int check (Qty > 0))";
|
||||||
|
assert_eq!(topic(s, inside(s, "check"), Mode::Advanced), Some("check"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_key_references_advanced() {
|
||||||
|
let s = "create table Orders (cid int references Customers(id))";
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, inside(s, "references"), Mode::Advanced),
|
||||||
|
Some("foreign_key"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_on_delete_inside_references_resolves_innermost() {
|
||||||
|
// `references … on delete cascade` nests referential_actions
|
||||||
|
// inside foreign_key; cursor on `on delete` → inner concept.
|
||||||
|
let s = "create table Orders (cid int references Customers(id) on delete cascade)";
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, at(s, "on delete"), Mode::Advanced),
|
||||||
|
Some("referential_actions"),
|
||||||
|
);
|
||||||
|
// …but a cursor on `references` → the outer foreign_key.
|
||||||
|
assert_eq!(
|
||||||
|
topic(s, inside(s, "references"), Mode::Advanced),
|
||||||
|
Some("foreign_key"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_outside_any_clause_is_none() {
|
||||||
|
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
|
||||||
|
// On the entry word `add` — no concept clause there.
|
||||||
|
assert_eq!(topic(s, at(s, "add"), Mode::Simple), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_input_is_none() {
|
||||||
|
assert_eq!(topic("", 0, Mode::Simple), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+39
-2
@@ -180,7 +180,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
// In-app `help` — framing + per-command entries keyed by
|
// In-app `help` — framing + per-command entries keyed by
|
||||||
// each CommandNode's `help_id` (ADR-0024 §help_id).
|
// each CommandNode's `help_id` (ADR-0024 §help_id).
|
||||||
("help.intro", &[]),
|
("help.intro", &[]),
|
||||||
("help.dsl_section", &[]),
|
("help.simple_section", &[]),
|
||||||
|
("help.advanced_section", &[]),
|
||||||
("help.types_reference", &[]),
|
("help.types_reference", &[]),
|
||||||
("help.detail_hint", &[]),
|
("help.detail_hint", &[]),
|
||||||
("help.unknown_topic", &["topic"]),
|
("help.unknown_topic", &["topic"]),
|
||||||
@@ -223,15 +224,52 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("help.data.delete", &[]),
|
("help.data.delete", &[]),
|
||||||
("help.data.replay", &[]),
|
("help.data.replay", &[]),
|
||||||
("help.data.explain", &[]),
|
("help.data.explain", &[]),
|
||||||
|
// Issue #36: advanced-mode (SQL) help pages.
|
||||||
|
("help.data.select", &[]),
|
||||||
|
("help.data.with", &[]),
|
||||||
|
("help.data.sql_insert", &[]),
|
||||||
|
("help.data.sql_update", &[]),
|
||||||
|
("help.data.sql_delete", &[]),
|
||||||
|
("help.data.explain_sql", &[]),
|
||||||
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
|
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
|
||||||
("hint.ambient_complete", &[]),
|
("hint.ambient_complete", &[]),
|
||||||
("hint.ambient_error_with_usage", &["message", "usage"]),
|
("hint.ambient_error_with_usage", &["message", "usage"]),
|
||||||
("hint.ambient_expected", &["expected"]),
|
("hint.ambient_expected", &["expected"]),
|
||||||
("hint.getting_started", &[]),
|
("hint.getting_started", &[]),
|
||||||
("hint.block.heading", &[]),
|
("hint.block.heading", &[]),
|
||||||
|
("hint.block.concept_heading", &[]),
|
||||||
("hint.block.what", &[]),
|
("hint.block.what", &[]),
|
||||||
("hint.block.example", &[]),
|
("hint.block.example", &[]),
|
||||||
("hint.block.concept", &[]),
|
("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.
|
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
|
||||||
("hint.cmd.insert.what", &[]),
|
("hint.cmd.insert.what", &[]),
|
||||||
("hint.cmd.insert.example", &[]),
|
("hint.cmd.insert.example", &[]),
|
||||||
@@ -417,7 +455,6 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("hint.value_literal_slot", &[]),
|
("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).
|
// Per-column-type value-slot hints (ADR-0024 §Phase D).
|
||||||
("hint.value_slot_blob", &[]),
|
|
||||||
("hint.value_slot_bool", &[]),
|
("hint.value_slot_bool", &[]),
|
||||||
("hint.value_slot_date", &[]),
|
("hint.value_slot_date", &[]),
|
||||||
("hint.value_slot_datetime", &[]),
|
("hint.value_slot_datetime", &[]),
|
||||||
|
|||||||
@@ -246,7 +246,13 @@ help:
|
|||||||
# are multi-line-capable — the renderer emits one output row
|
# are multi-line-capable — the renderer emits one output row
|
||||||
# per line so scroll math stays accurate.
|
# per line so scroll math stays accurate.
|
||||||
intro: "Supported commands:"
|
intro: "Supported commands:"
|
||||||
dsl_section: "DSL data commands (in simple mode):"
|
# Issue #36: the command list groups by mode. App-lifecycle commands list
|
||||||
|
# first (unlabelled, under the intro — they work in either mode); the rest
|
||||||
|
# split into these two sections by command category. (Replaces the old
|
||||||
|
# single "DSL data commands (in simple mode):" header, which used the banned
|
||||||
|
# "DSL" term and mis-labelled the advanced SQL forms it already contained.)
|
||||||
|
simple_section: "Simple-mode commands:"
|
||||||
|
advanced_section: "Advanced-mode (SQL) commands:"
|
||||||
# H3: footer on the full `help` list, and the not-found note
|
# H3: footer on the full `help` list, and the not-found note
|
||||||
# for `help <topic>`. `{topic}` is the word the user typed.
|
# for `help <topic>`. `{topic}` is the word the user typed.
|
||||||
detail_hint: "Type `help <command>` for detail on one command (e.g. `help insert`), or `help types` for the type reference."
|
detail_hint: "Type `help <command>` for detail on one command (e.g. `help insert`), or `help types` for the type reference."
|
||||||
@@ -368,11 +374,28 @@ help:
|
|||||||
explain show data <T> | explain update <T> ... | explain delete from <T> ...
|
explain show data <T> | explain update <T> ... | explain delete from <T> ...
|
||||||
— show how the database would run a query, without
|
— show how the database would run a query, without
|
||||||
running it (safe even for update / delete)
|
running it (safe even for update / delete)
|
||||||
explain <select|with|insert|update|delete …> (advanced mode)
|
# Issue #36: advanced-mode (SQL) forms. Each has its own help page, listed
|
||||||
— the same plan for the SQL you wrote
|
# under the "Advanced-mode (SQL) commands:" section and shown by
|
||||||
|
# `help <topic>` alongside its simple-mode sibling — so `help insert` shows
|
||||||
|
# both the simple form and `sql_insert` (like `help create` already does).
|
||||||
|
select: |-
|
||||||
|
select <cols> | * from <T> [where …] [group by …] [order by …] [limit n]
|
||||||
|
— query rows (advanced SQL)
|
||||||
|
with: |-
|
||||||
|
with <name> as (<select>) [, …] <select> — query through a named
|
||||||
|
sub-query / CTE (advanced SQL)
|
||||||
|
sql_insert: |-
|
||||||
|
insert into <T> (col, …) values (val, …) — add a row (advanced SQL)
|
||||||
|
sql_update: |-
|
||||||
|
update <T> set <col> = <val>, … where <expr> — change matching rows (advanced SQL)
|
||||||
|
sql_delete: |-
|
||||||
|
delete from <T> where <expr> — remove matching rows (advanced SQL)
|
||||||
|
explain_sql: |-
|
||||||
|
explain <select|with|insert|update|delete …> — show the plan for a SQL statement,
|
||||||
|
without running it (advanced SQL)
|
||||||
# Type reference, appended after the command list.
|
# Type reference, appended after the command list.
|
||||||
types_reference: |
|
types_reference: |
|
||||||
Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid
|
Types: text, int, real, decimal, bool, date, datetime, serial, shortid
|
||||||
Auto-generated types (serial, shortid):
|
Auto-generated types (serial, shortid):
|
||||||
serial — integer that auto-fills with the next sequence value
|
serial — integer that auto-fills with the next sequence value
|
||||||
(MAX(col)+1) on insert. Outside a primary key it carries
|
(MAX(col)+1) on insert. Outside a primary key it carries
|
||||||
@@ -403,9 +426,58 @@ hint:
|
|||||||
# `what` / `example` / `concept` parts render under.
|
# `what` / `example` / `concept` parts render under.
|
||||||
block:
|
block:
|
||||||
heading: "Hint"
|
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"
|
what: "What"
|
||||||
example: "Example"
|
example: "Example"
|
||||||
concept: "Concept"
|
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) ──────────────────────────
|
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
|
||||||
# Per-form command hints (`hint.cmd.<form>`) and per-class error
|
# Per-form command hints (`hint.cmd.<form>`) and per-class error
|
||||||
# hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example`
|
# hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example`
|
||||||
@@ -691,7 +763,6 @@ hint:
|
|||||||
value_slot_text: "Type a quoted string (e.g. 'Alice') or null"
|
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_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_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
|
# Serial / shortid in `values (…)` form: the user must enter
|
||||||
# something at this position (no "skip column" syntax). `null`
|
# something at this position (no "skip column" syntax). `null`
|
||||||
# is the auto-fill path (ADR-0018: serial / shortid columns
|
# is the auto-fill path (ADR-0018: serial / shortid columns
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ const fn alignment_for(ty: Option<Type>) -> Alignment {
|
|||||||
match ty {
|
match ty {
|
||||||
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
|
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
|
||||||
Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime)
|
Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime)
|
||||||
| Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left,
|
| Some(Type::ShortId) | None => Alignment::Left,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1334,7 +1334,6 @@ mod tests {
|
|||||||
sqlite_type: match ty {
|
sqlite_type: match ty {
|
||||||
Type::Int | Type::Serial | Type::Bool => "INTEGER".to_string(),
|
Type::Int | Type::Serial | Type::Bool => "INTEGER".to_string(),
|
||||||
Type::Real => "REAL".to_string(),
|
Type::Real => "REAL".to_string(),
|
||||||
Type::Blob => "BLOB".to_string(),
|
|
||||||
_ => "TEXT".to_string(),
|
_ => "TEXT".to_string(),
|
||||||
},
|
},
|
||||||
notnull,
|
notnull,
|
||||||
@@ -1361,7 +1360,6 @@ mod tests {
|
|||||||
Type::Bool,
|
Type::Bool,
|
||||||
Type::Date,
|
Type::Date,
|
||||||
Type::DateTime,
|
Type::DateTime,
|
||||||
Type::Blob,
|
|
||||||
Type::ShortId,
|
Type::ShortId,
|
||||||
] {
|
] {
|
||||||
assert_eq!(alignment_for(Some(ty)), Alignment::Left, "{ty:?}");
|
assert_eq!(alignment_for(Some(ty)), Alignment::Left, "{ty:?}");
|
||||||
|
|||||||
@@ -25,8 +25,6 @@
|
|||||||
|
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
|
||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
|
|
||||||
use super::{CellValue, TableSnapshot};
|
use super::{CellValue, TableSnapshot};
|
||||||
@@ -149,12 +147,6 @@ fn encode_cell(ty: Type, value: &CellValue) -> Result<Cell, String> {
|
|||||||
CellValue::Text(s) => Ok(Cell::Plain(s.clone())),
|
CellValue::Text(s) => Ok(Cell::Plain(s.clone())),
|
||||||
other => Err(format!("expected date/datetime (text), got {other:?}")),
|
other => Err(format!("expected date/datetime (text), got {other:?}")),
|
||||||
},
|
},
|
||||||
Type::Blob => match value {
|
|
||||||
CellValue::Blob(bytes) => Ok(Cell::Plain(
|
|
||||||
base64::engine::general_purpose::STANDARD.encode(bytes),
|
|
||||||
)),
|
|
||||||
other => Err(format!("expected blob, got {other:?}")),
|
|
||||||
},
|
|
||||||
Type::Serial => match value {
|
Type::Serial => match value {
|
||||||
CellValue::Integer(n) => Ok(Cell::Plain(n.to_string())),
|
CellValue::Integer(n) => Ok(Cell::Plain(n.to_string())),
|
||||||
other => Err(format!("expected serial (int), got {other:?}")),
|
other => Err(format!("expected serial (int), got {other:?}")),
|
||||||
@@ -362,10 +354,6 @@ pub(crate) fn decode_cell(ty: Type, cell: &RawCell) -> Result<CellValue, String>
|
|||||||
"false" => Ok(CellValue::Integer(0)),
|
"false" => Ok(CellValue::Integer(0)),
|
||||||
other => Err(format!("expected `true` or `false`, got `{other}`")),
|
other => Err(format!("expected `true` or `false`, got `{other}`")),
|
||||||
},
|
},
|
||||||
Type::Blob => base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(cell.content.as_bytes())
|
|
||||||
.map(CellValue::Blob)
|
|
||||||
.map_err(|e| format!("invalid base64 blob: {e}")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,18 +455,6 @@ mod tests {
|
|||||||
assert_eq!(s, "b\ntrue\nfalse\n");
|
assert_eq!(s, "b\ntrue\nfalse\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blobs_use_base64() {
|
|
||||||
let body = serialize_table(&TableSnapshot {
|
|
||||||
name: "T".to_string(),
|
|
||||||
columns: vec![col("blob", Type::Blob)],
|
|
||||||
rows: vec![vec![CellValue::Blob(b"hello".to_vec())]],
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
let s = String::from_utf8(body).unwrap();
|
|
||||||
assert!(s.contains("aGVsbG8="));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dates_and_datetimes_pass_through() {
|
fn dates_and_datetimes_pass_through() {
|
||||||
let body = serialize_table(&TableSnapshot {
|
let body = serialize_table(&TableSnapshot {
|
||||||
@@ -548,13 +524,11 @@ mod tests {
|
|||||||
col("n", Type::Int),
|
col("n", Type::Int),
|
||||||
col("r", Type::Real),
|
col("r", Type::Real),
|
||||||
col("b", Type::Bool),
|
col("b", Type::Bool),
|
||||||
col("blob", Type::Blob),
|
|
||||||
],
|
],
|
||||||
rows: vec![vec![
|
rows: vec![vec![
|
||||||
CellValue::Integer(42),
|
CellValue::Integer(42),
|
||||||
CellValue::Real(std::f64::consts::PI),
|
CellValue::Real(std::f64::consts::PI),
|
||||||
CellValue::Integer(1),
|
CellValue::Integer(1),
|
||||||
CellValue::Blob(b"hi".to_vec()),
|
|
||||||
]],
|
]],
|
||||||
};
|
};
|
||||||
let body = serialize_table(&table).unwrap();
|
let body = serialize_table(&table).unwrap();
|
||||||
@@ -572,9 +546,6 @@ mod tests {
|
|||||||
decode_cell(Type::Bool, &row[2]).unwrap(),
|
decode_cell(Type::Bool, &row[2]).unwrap(),
|
||||||
CellValue::Integer(1)
|
CellValue::Integer(1)
|
||||||
));
|
));
|
||||||
assert!(
|
|
||||||
matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+103
-16
@@ -44,6 +44,35 @@ use serde::Deserialize;
|
|||||||
/// migrator's job is purely the format transformation.
|
/// migrator's job is purely the format transformation.
|
||||||
pub type MigrateFn = fn(&str) -> Result<String, MigrateError>;
|
pub type MigrateFn = fn(&str) -> Result<String, MigrateError>;
|
||||||
|
|
||||||
|
/// The newest `project.yaml` format version this build writes and reads.
|
||||||
|
///
|
||||||
|
/// Must equal `MigratorRegistry::production().latest_version()` (asserted
|
||||||
|
/// in tests) and is what the YAML serializer stamps and the parser
|
||||||
|
/// accepts. Bumped 1 → 2 by **ADR-0005 Amendment 2** (drop `blob`).
|
||||||
|
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
|
||||||
|
|
||||||
|
/// v1 → v2 migrator (ADR-0005 Amendment 2). `blob` was dropped from the
|
||||||
|
/// type vocabulary, so any `type: blob` column is rewritten to
|
||||||
|
/// `type: text` (the chosen conversion target — see the ADR). A pure,
|
||||||
|
/// line-oriented text transform: only column-definition lines (the
|
||||||
|
/// `- { name: …, type: … }` shape the serializer emits) are touched, so a
|
||||||
|
/// `blob` substring inside a CHECK expression or a default value is left
|
||||||
|
/// alone. Also bumps the `version:` field to 2 (the framework asserts the
|
||||||
|
/// advertised version, so this must stay in step).
|
||||||
|
fn migrate_v1_to_v2(body: &str) -> Result<String, MigrateError> {
|
||||||
|
let mut out = String::with_capacity(body.len() + 1);
|
||||||
|
for line in body.split_inclusive('\n') {
|
||||||
|
if line.trim_end() == "version: 1" {
|
||||||
|
out.push_str(&line.replacen("version: 1", "version: 2", 1));
|
||||||
|
} else if line.contains("{ name:") {
|
||||||
|
out.push_str(&line.replace(", type: blob", ", type: text"));
|
||||||
|
} else {
|
||||||
|
out.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// Ordered list of migrators. `migrators[i]` runs from
|
/// Ordered list of migrators. `migrators[i]` runs from
|
||||||
/// version `i + 1` to version `i + 2` (so index 0 is v1→v2,
|
/// version `i + 1` to version `i + 2` (so index 0 is v1→v2,
|
||||||
/// index 1 is v2→v3, etc.).
|
/// index 1 is v2→v3, etc.).
|
||||||
@@ -56,13 +85,12 @@ pub struct MigratorRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MigratorRegistry {
|
impl MigratorRegistry {
|
||||||
/// Production-default registry: empty. As new versions
|
/// Production-default registry. Migrators are listed in
|
||||||
/// land, register the migrators here in source-version
|
/// source-version order (index 0 = v1→v2, …).
|
||||||
/// order.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn production() -> Self {
|
pub fn production() -> Self {
|
||||||
Self {
|
Self {
|
||||||
migrators: Vec::new(),
|
migrators: vec![migrate_v1_to_v2],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +184,20 @@ pub struct MigrationOutcome {
|
|||||||
pub migrated_from: Option<u32>,
|
pub migrated_from: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `body` declares at least one `type: blob` column.
|
||||||
|
///
|
||||||
|
/// The runtime uses this as the signal to force a `.db` rebuild after the
|
||||||
|
/// v1→v2 migration: the migration rewrites the YAML, but the derived
|
||||||
|
/// `.db` keeps a `STRICT … BLOB` engine column + `"blob"` metadata that
|
||||||
|
/// load otherwise uses as-is (ADR-0005 Amendment 2). Scoped to
|
||||||
|
/// column-definition lines, mirroring [`migrate_v1_to_v2`], so a `blob`
|
||||||
|
/// substring inside a CHECK expression or default doesn't trip it.
|
||||||
|
#[must_use]
|
||||||
|
pub fn body_declares_blob_column(body: &str) -> bool {
|
||||||
|
body.lines()
|
||||||
|
.any(|l| l.contains("{ name:") && l.contains(", type: blob"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Detect the version of `body` and migrate it to the
|
/// Detect the version of `body` and migrate it to the
|
||||||
/// registry's `latest_version()`.
|
/// registry's `latest_version()`.
|
||||||
///
|
///
|
||||||
@@ -304,22 +346,67 @@ mod tests {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A project at the current (latest) version — nothing to migrate.
|
||||||
|
fn v2_body() -> String {
|
||||||
|
"version: 2\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn production_registry_latest_version_is_1() {
|
fn production_registry_latest_version_matches_current_schema_version() {
|
||||||
let r = MigratorRegistry::production();
|
let r = MigratorRegistry::production();
|
||||||
assert_eq!(r.latest_version(), 1);
|
assert_eq!(r.latest_version(), CURRENT_SCHEMA_VERSION);
|
||||||
|
assert_eq!(r.latest_version(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_migration_runs_when_body_already_latest() {
|
fn no_migration_runs_when_body_already_latest() {
|
||||||
let tmp = tempdir();
|
let tmp = tempdir();
|
||||||
let outcome =
|
let outcome =
|
||||||
migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
|
migrate_to_latest(&v2_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
|
||||||
assert_eq!(outcome.body, v1_body());
|
assert_eq!(outcome.body, v2_body());
|
||||||
assert_eq!(outcome.migrated_from, None);
|
assert_eq!(outcome.migrated_from, None);
|
||||||
// No .bak written when nothing migrated.
|
// No .bak written when nothing migrated.
|
||||||
let bak = tmp.path().join("project.yaml.v1.bak");
|
assert!(
|
||||||
assert!(!bak.exists(), "no .bak when no migration");
|
!tmp.path().join("project.yaml.v2.bak").exists(),
|
||||||
|
"no .bak when no migration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0005 Amendment 2: the real production v1→v2 migrator rewrites a
|
||||||
|
/// `blob` column to `text` and bumps the version, leaving everything
|
||||||
|
/// else (incl. a column literally named `blob`, and a `blob` substring
|
||||||
|
/// inside a CHECK) untouched.
|
||||||
|
#[test]
|
||||||
|
fn production_v1_to_v2_converts_blob_columns_to_text() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let body = concat!(
|
||||||
|
"version: 1\n",
|
||||||
|
"project:\n created_at: x\n",
|
||||||
|
"tables:\n",
|
||||||
|
" - name: Files\n",
|
||||||
|
" primary_key: [id]\n",
|
||||||
|
" columns:\n",
|
||||||
|
" - { name: id, type: serial }\n",
|
||||||
|
" - { name: payload, type: blob }\n",
|
||||||
|
" - { name: blob, type: text }\n",
|
||||||
|
" check_constraints:\n",
|
||||||
|
" - \"note <> 'type: blob'\"\n",
|
||||||
|
"relationships: []\n",
|
||||||
|
);
|
||||||
|
let outcome = migrate_to_latest(body, &MigratorRegistry::production(), tmp.path()).unwrap();
|
||||||
|
assert_eq!(outcome.migrated_from, Some(1));
|
||||||
|
assert!(outcome.body.contains("version: 2"));
|
||||||
|
// The blob column became text.
|
||||||
|
assert!(
|
||||||
|
outcome.body.contains("{ name: payload, type: text }"),
|
||||||
|
"blob column converted: {}",
|
||||||
|
outcome.body
|
||||||
|
);
|
||||||
|
// A column NAMED blob is untouched; the CHECK string is untouched.
|
||||||
|
assert!(outcome.body.contains("{ name: blob, type: text }"));
|
||||||
|
assert!(outcome.body.contains("note <> 'type: blob'"));
|
||||||
|
assert!(!outcome.body.contains("type: blob }"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -332,7 +419,7 @@ mod tests {
|
|||||||
err,
|
err,
|
||||||
MigrateError::NewerThanSupported {
|
MigrateError::NewerThanSupported {
|
||||||
file: 99,
|
file: 99,
|
||||||
latest: 1
|
latest: 2
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
"got: {err:?}",
|
"got: {err:?}",
|
||||||
@@ -393,17 +480,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ensure_yaml_migrated_no_op_on_v1_with_empty_registry() {
|
fn ensure_yaml_migrated_no_op_when_already_latest() {
|
||||||
let tmp = tempdir();
|
let tmp = tempdir();
|
||||||
let yaml_path = tmp.path().join("project.yaml");
|
let yaml_path = tmp.path().join("project.yaml");
|
||||||
std::fs::write(&yaml_path, v1_body()).unwrap();
|
std::fs::write(&yaml_path, v2_body()).unwrap();
|
||||||
let outcome =
|
let outcome =
|
||||||
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
|
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
|
||||||
assert_eq!(outcome.migrated_from, None);
|
assert_eq!(outcome.migrated_from, None);
|
||||||
// File unchanged.
|
// File unchanged.
|
||||||
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
|
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
|
||||||
assert_eq!(on_disk, v1_body());
|
assert_eq!(on_disk, v2_body());
|
||||||
assert!(!tmp.path().join("project.yaml.v1.bak").exists());
|
assert!(!tmp.path().join("project.yaml.v2.bak").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -280,7 +280,6 @@ pub enum CellValue {
|
|||||||
Integer(i64),
|
Integer(i64),
|
||||||
Real(f64),
|
Real(f64),
|
||||||
Text(String),
|
Text(String),
|
||||||
Blob(Vec<u8>),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Persistence {
|
impl Persistence {
|
||||||
@@ -561,7 +560,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
p.write_schema(&schema).unwrap();
|
p.write_schema(&schema).unwrap();
|
||||||
let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
|
let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
|
||||||
assert!(body.contains("version: 1"));
|
assert!(body.contains("version: 2"));
|
||||||
assert!(body.contains("created_at:"));
|
assert!(body.contains("created_at:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-14
@@ -32,7 +32,11 @@ use super::{
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String {
|
pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let _ = writeln!(out, "version: 1");
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"version: {}",
|
||||||
|
crate::persistence::migrations::CURRENT_SCHEMA_VERSION
|
||||||
|
);
|
||||||
let _ = writeln!(out, "project:");
|
let _ = writeln!(out, "project:");
|
||||||
let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at));
|
let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at));
|
||||||
// ADR-0015 mode-restore amendment (issue #14): the input mode
|
// ADR-0015 mode-restore amendment (issue #14): the input mode
|
||||||
@@ -283,7 +287,11 @@ const fn is_safe_yaml_char(c: char) -> bool {
|
|||||||
pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
||||||
let raw: RawProject =
|
let raw: RawProject =
|
||||||
serde_norway::from_str(body).map_err(|e| YamlError::Syntax(e.to_string()))?;
|
serde_norway::from_str(body).map_err(|e| YamlError::Syntax(e.to_string()))?;
|
||||||
if raw.version != 1 {
|
// The migration framework (ADR-0015) upgrades older project files to
|
||||||
|
// `CURRENT_SCHEMA_VERSION` before `parse_schema` ever runs, so the
|
||||||
|
// parser only accepts the current version (a stale version reaching
|
||||||
|
// here means migration was skipped — a bug worth surfacing).
|
||||||
|
if raw.version != crate::persistence::migrations::CURRENT_SCHEMA_VERSION {
|
||||||
return Err(YamlError::UnsupportedVersion(raw.version));
|
return Err(YamlError::UnsupportedVersion(raw.version));
|
||||||
}
|
}
|
||||||
let mut tables: Vec<TableSchema> = Vec::with_capacity(raw.tables.len());
|
let mut tables: Vec<TableSchema> = Vec::with_capacity(raw.tables.len());
|
||||||
@@ -617,7 +625,7 @@ mod tests {
|
|||||||
let body = serialize_schema(&snapshot());
|
let body = serialize_schema(&snapshot());
|
||||||
// Spot-check structural lines rather than asserting on
|
// Spot-check structural lines rather than asserting on
|
||||||
// the whole blob — easier to read in failure output.
|
// the whole blob — easier to read in failure output.
|
||||||
assert!(body.contains("version: 1"));
|
assert!(body.contains("version: 2"));
|
||||||
assert!(body.contains("created_at: 2026-05-07T14:30:12Z"));
|
assert!(body.contains("created_at: 2026-05-07T14:30:12Z"));
|
||||||
assert!(body.contains("- name: Customers"));
|
assert!(body.contains("- name: Customers"));
|
||||||
assert!(body.contains("primary_key: [id]"));
|
assert!(body.contains("primary_key: [id]"));
|
||||||
@@ -752,7 +760,7 @@ mod tests {
|
|||||||
// Older project files (written before unique indexes) omit the
|
// Older project files (written before unique indexes) omit the
|
||||||
// `unique` field; the `#[serde(default)]` makes it `false`.
|
// `unique` field; the `#[serde(default)]` makes it `false`.
|
||||||
let body = "\
|
let body = "\
|
||||||
version: 1
|
version: 2
|
||||||
project:
|
project:
|
||||||
created_at: 2026-05-25T00:00:00Z
|
created_at: 2026-05-25T00:00:00Z
|
||||||
tables:
|
tables:
|
||||||
@@ -922,7 +930,7 @@ indexes:
|
|||||||
// Back-compat: a project file written before §4g (bare-string
|
// Back-compat: a project file written before §4g (bare-string
|
||||||
// check_constraints) parses with name = None.
|
// check_constraints) parses with name = None.
|
||||||
let body = "\
|
let body = "\
|
||||||
version: 1
|
version: 2
|
||||||
project:
|
project:
|
||||||
created_at: \"2026-05-25T00:00:00Z\"
|
created_at: \"2026-05-25T00:00:00Z\"
|
||||||
tables:
|
tables:
|
||||||
@@ -949,7 +957,7 @@ indexes: []
|
|||||||
// A project file written before table-level CHECK existed (no
|
// A project file written before table-level CHECK existed (no
|
||||||
// `check_constraints:` key) parses with an empty list.
|
// `check_constraints:` key) parses with an empty list.
|
||||||
let body = "\
|
let body = "\
|
||||||
version: 1
|
version: 2
|
||||||
project:
|
project:
|
||||||
created_at: 2026-05-25T00:00:00Z
|
created_at: 2026-05-25T00:00:00Z
|
||||||
tables:
|
tables:
|
||||||
@@ -966,7 +974,7 @@ relationships: []
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_minimal_yaml_with_no_tables() {
|
fn parses_minimal_yaml_with_no_tables() {
|
||||||
let body = "\
|
let body = "\
|
||||||
version: 1
|
version: 2
|
||||||
project:
|
project:
|
||||||
created_at: 2026-05-07T14:30:12Z
|
created_at: 2026-05-07T14:30:12Z
|
||||||
tables: []
|
tables: []
|
||||||
@@ -993,7 +1001,7 @@ relationships: []
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_column_type() {
|
fn rejects_unknown_column_type() {
|
||||||
let body = "\
|
let body = "\
|
||||||
version: 1
|
version: 2
|
||||||
project:
|
project:
|
||||||
created_at: x
|
created_at: x
|
||||||
tables:
|
tables:
|
||||||
@@ -1012,7 +1020,7 @@ relationships: []
|
|||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_action() {
|
fn rejects_unknown_action() {
|
||||||
let body = "\
|
let body = "\
|
||||||
version: 1
|
version: 2
|
||||||
project:
|
project:
|
||||||
created_at: x
|
created_at: x
|
||||||
tables: []
|
tables: []
|
||||||
@@ -1090,7 +1098,7 @@ relationships:
|
|||||||
fn parse_schema_defaults_mode_to_simple_when_field_absent() {
|
fn parse_schema_defaults_mode_to_simple_when_field_absent() {
|
||||||
// A pre-#14 project file carries no `mode:` field; it must
|
// A pre-#14 project file carries no `mode:` field; it must
|
||||||
// parse with the default mode, not fail.
|
// parse with the default mode, not fail.
|
||||||
let body = "version: 1\nproject:\n created_at: x\ntables: []\nrelationships: []\n";
|
let body = "version: 2\nproject:\n created_at: x\ntables: []\nrelationships: []\n";
|
||||||
let parsed = parse_schema(body).expect("legacy file parses");
|
let parsed = parse_schema(body).expect("legacy file parses");
|
||||||
assert_eq!(parsed.mode, Mode::Simple);
|
assert_eq!(parsed.mode, Mode::Simple);
|
||||||
}
|
}
|
||||||
@@ -1100,13 +1108,13 @@ relationships:
|
|||||||
// `None` (no stored preference) must be distinct from an
|
// `None` (no stored preference) must be distinct from an
|
||||||
// explicit `simple`, so restore-on-open precedence can tell
|
// explicit `simple`, so restore-on-open precedence can tell
|
||||||
// "fall back to default" from "the user chose simple".
|
// "fall back to default" from "the user chose simple".
|
||||||
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n";
|
let absent = "version: 2\nproject:\n created_at: x\ntables: []\n";
|
||||||
assert_eq!(parse_stored_mode(absent), None);
|
assert_eq!(parse_stored_mode(absent), None);
|
||||||
|
|
||||||
let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
|
let explicit_simple = "version: 2\nproject:\n created_at: x\n mode: simple\ntables: []\n";
|
||||||
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
|
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
|
||||||
|
|
||||||
let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
|
let advanced = "version: 2\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
|
||||||
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
|
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,7 +1122,7 @@ relationships:
|
|||||||
fn parse_stored_mode_falls_back_to_none_on_unknown_value() {
|
fn parse_stored_mode_falls_back_to_none_on_unknown_value() {
|
||||||
// An unrecognised mode keyword degrades to "no preference"
|
// An unrecognised mode keyword degrades to "no preference"
|
||||||
// rather than rejecting the whole file over a UI hint.
|
// rather than rejecting the whole file over a UI hint.
|
||||||
let body = "version: 1\nproject:\n created_at: x\n mode: expert\ntables: []\n";
|
let body = "version: 2\nproject:\n created_at: x\n mode: expert\ntables: []\n";
|
||||||
assert_eq!(parse_stored_mode(body), None);
|
assert_eq!(parse_stored_mode(body), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -384,7 +384,7 @@ impl Project {
|
|||||||
/// Build the on-disk skeleton for a fresh project: the
|
/// Build the on-disk skeleton for a fresh project: the
|
||||||
/// directory itself, an empty `data/`, an empty
|
/// directory itself, an empty `data/`, an empty
|
||||||
/// `history.log`, a placeholder `project.yaml` with just
|
/// `history.log`, a placeholder `project.yaml` with just
|
||||||
/// `version: 1` and `created_at`, and a `.gitignore`.
|
/// the current schema `version` and `created_at`, and a `.gitignore`.
|
||||||
///
|
///
|
||||||
/// `playground.db` is not created here; it's created the
|
/// `playground.db` is not created here; it's created the
|
||||||
/// first time `Database::open` runs against the path
|
/// first time `Database::open` runs against the path
|
||||||
@@ -401,7 +401,8 @@ impl Project {
|
|||||||
// schema mutation; for now we just ensure the file
|
// schema mutation; for now we just ensure the file
|
||||||
// exists and carries the version + creation timestamp.
|
// exists and carries the version + creation timestamp.
|
||||||
let yaml = format!(
|
let yaml = format!(
|
||||||
"version: 1\nproject:\n created_at: {}\ntables: []\nrelationships: []\n",
|
"version: {}\nproject:\n created_at: {}\ntables: []\nrelationships: []\n",
|
||||||
|
crate::persistence::migrations::CURRENT_SCHEMA_VERSION,
|
||||||
iso8601_now(),
|
iso8601_now(),
|
||||||
);
|
);
|
||||||
write_if_missing(&path.join(PROJECT_YAML), &yaml)?;
|
write_if_missing(&path.join(PROJECT_YAML), &yaml)?;
|
||||||
@@ -830,7 +831,7 @@ mod tests {
|
|||||||
|
|
||||||
// YAML carries version + created_at.
|
// YAML carries version + created_at.
|
||||||
let yaml = fs::read_to_string(path.join(PROJECT_YAML)).unwrap();
|
let yaml = fs::read_to_string(path.join(PROJECT_YAML)).unwrap();
|
||||||
assert!(yaml.contains("version: 1"));
|
assert!(yaml.contains("version: 2"));
|
||||||
assert!(yaml.contains("created_at:"));
|
assert!(yaml.contains("created_at:"));
|
||||||
|
|
||||||
// .gitignore matches ADR-0015.
|
// .gitignore matches ADR-0015.
|
||||||
|
|||||||
+26
-3
@@ -176,6 +176,14 @@ pub async fn run(args: Args) -> Result<()> {
|
|||||||
// `project.yaml.v<N>.bak` breadcrumb on disk; that's
|
// `project.yaml.v<N>.bak` breadcrumb on disk; that's
|
||||||
// sufficient v1 UX and lets us defer dedicated event
|
// sufficient v1 UX and lets us defer dedicated event
|
||||||
// plumbing until a real migrator demands it.
|
// plumbing until a real migrator demands it.
|
||||||
|
// ADR-0005 Amendment 2: note a pre-migration `blob` column so we can
|
||||||
|
// force a `.db` rebuild after the v1→v2 migration converts it to text
|
||||||
|
// (the stale `.db` keeps a `STRICT … BLOB` engine column + `"blob"`
|
||||||
|
// metadata that load otherwise uses as-is).
|
||||||
|
let had_blob_column =
|
||||||
|
std::fs::read_to_string(project.path().join(crate::project::PROJECT_YAML))
|
||||||
|
.map(|b| crate::persistence::migrations::body_declares_blob_column(&b))
|
||||||
|
.unwrap_or(false);
|
||||||
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
|
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
|
||||||
let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
|
let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
|
||||||
project.path(),
|
project.path(),
|
||||||
@@ -235,7 +243,12 @@ pub async fn run(args: Args) -> Result<()> {
|
|||||||
Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled)
|
Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled)
|
||||||
.context("open database")?;
|
.context("open database")?;
|
||||||
let mut initial_events: Vec<AppEvent> = Vec::new();
|
let mut initial_events: Vec<AppEvent> = Vec::new();
|
||||||
if !db_existed {
|
// ADR-0005 Amendment 2: a blob→text migration leaves the existing
|
||||||
|
// `.db` with a stale `STRICT … BLOB` engine column, so rebuild it from
|
||||||
|
// the migrated text. A pure version bump with no schema change does not.
|
||||||
|
let force_rebuild_after_migration =
|
||||||
|
migration_outcome.migrated_from.is_some() && had_blob_column;
|
||||||
|
if !db_existed || force_rebuild_after_migration {
|
||||||
match database.rebuild_from_text(project_path.clone(), None).await {
|
match database.rebuild_from_text(project_path.clone(), None).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Surface the silent rebuild as a system note
|
// Surface the silent rebuild as a system note
|
||||||
@@ -927,7 +940,13 @@ async fn perform_switch(
|
|||||||
// state momentarily, but the next user action will
|
// state momentarily, but the next user action will
|
||||||
// surface the error and they can retry).
|
// surface the error and they can retry).
|
||||||
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
|
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
|
||||||
crate::persistence::migrations::ensure_project_yaml_migrated(
|
// ADR-0005 Amendment 2: see the matching note in `run()` — a blob→text
|
||||||
|
// migration needs the `.db` rebuilt from the migrated text.
|
||||||
|
let had_blob_column =
|
||||||
|
std::fs::read_to_string(new_project.path().join(crate::project::PROJECT_YAML))
|
||||||
|
.map(|b| crate::persistence::migrations::body_declares_blob_column(&b))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
|
||||||
new_project.path(),
|
new_project.path(),
|
||||||
&migrate_registry,
|
&migrate_registry,
|
||||||
)
|
)
|
||||||
@@ -949,7 +968,11 @@ async fn perform_switch(
|
|||||||
let new_database =
|
let new_database =
|
||||||
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
|
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !db_existed && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await {
|
let force_rebuild_after_migration =
|
||||||
|
migration_outcome.migrated_from.is_some() && had_blob_column;
|
||||||
|
if (!db_existed || force_rebuild_after_migration)
|
||||||
|
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
|
||||||
|
{
|
||||||
return Err(e.friendly_message());
|
return Err(e.friendly_message());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-12
@@ -138,7 +138,7 @@ fn range_value(low: &str, high: &str, ty: Type, rng: &mut SeedRng) -> Value {
|
|||||||
Type::DateTime => parse_datetime_range(low, high)
|
Type::DateTime => parse_datetime_range(low, high)
|
||||||
.map(|(lo, hi)| Value::Text(random_datetime_between(rng, lo, hi)))
|
.map(|(lo, hi)| Value::Text(random_datetime_between(rng, lo, hi)))
|
||||||
.unwrap_or_else(|| generic_for_type(ty, rng)),
|
.unwrap_or_else(|| generic_for_type(ty, rng)),
|
||||||
// text / bool / blob / shortid have no range meaning.
|
// text / bool / shortid have no range meaning.
|
||||||
_ => generic_for_type(ty, rng),
|
_ => generic_for_type(ty, rng),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,8 +155,8 @@ pub fn range_bounds_reason(ty: Type, low: &str, high: &str) -> Option<String> {
|
|||||||
Type::Real | Type::Decimal => parse_real_range(low, high).is_some(),
|
Type::Real | Type::Decimal => parse_real_range(low, high).is_some(),
|
||||||
Type::Date => parse_date_range(low, high).is_some(),
|
Type::Date => parse_date_range(low, high).is_some(),
|
||||||
Type::DateTime => parse_datetime_range(low, high).is_some(),
|
Type::DateTime => parse_datetime_range(low, high).is_some(),
|
||||||
// text / bool / blob / shortid have no range meaning.
|
// text / bool / shortid have no range meaning.
|
||||||
Type::Text | Type::Bool | Type::Blob | Type::ShortId => false,
|
Type::Text | Type::Bool | Type::ShortId => false,
|
||||||
};
|
};
|
||||||
if ok {
|
if ok {
|
||||||
return None;
|
return None;
|
||||||
@@ -169,7 +169,7 @@ pub fn range_bounds_reason(ty: Type, low: &str, high: &str) -> Option<String> {
|
|||||||
"expected two quoted datetimes, e.g. `between '2023-01-01T00:00:00' and '2024-12-31T23:59:59'`"
|
"expected two quoted datetimes, e.g. `between '2023-01-01T00:00:00' and '2024-12-31T23:59:59'`"
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
Type::Text | Type::Bool | Type::Blob | Type::ShortId => {
|
Type::Text | Type::Bool | Type::ShortId => {
|
||||||
"a `between` range only applies to numeric and date/datetime columns".to_string()
|
"a `between` range only applies to numeric and date/datetime columns".to_string()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -242,9 +242,8 @@ fn random_datetime_between(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Type-based fallback generation (D8). Never produces NULL for a
|
/// Type-based fallback generation (D8). Never produces NULL for a
|
||||||
/// generatable type; `blob`/`serial`/`shortid` are handled by the
|
/// generatable type; `serial`/`shortid` are handled by the executor
|
||||||
/// executor (autogen / block guard) and yield NULL here only as a
|
/// (autogen) and yield NULL here only as a last resort.
|
||||||
/// last resort.
|
|
||||||
fn generic_for_type(ty: Type, rng: &mut SeedRng) -> Value {
|
fn generic_for_type(ty: Type, rng: &mut SeedRng) -> Value {
|
||||||
use fake::faker::lorem::en as lorem;
|
use fake::faker::lorem::en as lorem;
|
||||||
match ty {
|
match ty {
|
||||||
@@ -267,7 +266,6 @@ fn generic_for_type(ty: Type, rng: &mut SeedRng) -> Value {
|
|||||||
Type::Bool => Value::Bool(rng.random_range(0..2) == 1),
|
Type::Bool => Value::Bool(rng.random_range(0..2) == 1),
|
||||||
Type::Date => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
|
Type::Date => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
|
||||||
Type::DateTime => Value::Text(random_recent_datetime(rng)),
|
Type::DateTime => Value::Text(random_recent_datetime(rng)),
|
||||||
Type::Blob => Value::Null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,10 +703,6 @@ mod tests {
|
|||||||
generate_value(&Generator::Generic, Type::Bool, &mut rng),
|
generate_value(&Generator::Generic, Type::Bool, &mut rng),
|
||||||
Value::Bool(_)
|
Value::Bool(_)
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
|
||||||
generate_value(&Generator::Generic, Type::Blob, &mut rng),
|
|
||||||
Value::Null
|
|
||||||
));
|
|
||||||
// shortid fallback is a valid base58 id.
|
// shortid fallback is a valid base58 id.
|
||||||
let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
|
let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
|
||||||
panic!("shortid not text")
|
panic!("shortid not text")
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
source: src/app.rs
|
||||||
|
expression: block
|
||||||
|
---
|
||||||
|
Hint
|
||||||
|
What: Link two tables so a parent row can own many child rows.
|
||||||
|
Example: add 1:n relationship from Customers.id to Orders.customer_id
|
||||||
|
Concept: The "1:n" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet.
|
||||||
|
About this clause
|
||||||
|
What: Decide what happens to a child row when the parent it points at is deleted, or its key changes.
|
||||||
|
Example: add 1:n relationship from Customers.id to Orders.customer_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.
|
||||||
+275
-4
@@ -106,13 +106,13 @@ impl Theme {
|
|||||||
// distinct from the mode-banner blue.
|
// distinct from the mode-banner blue.
|
||||||
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
|
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
|
||||||
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
|
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
|
||||||
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
|
tok_type: Color::Rgb(0xF5, 0x8A, 0xAE), // rose-pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier (ADR-0057: widened ΔE from keyword 14.5→18.4)
|
||||||
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
|
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
|
||||||
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
|
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
|
||||||
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
|
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
|
||||||
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
|
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
|
||||||
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
|
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
|
||||||
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
|
tok_function: Color::Rgb(0x6C, 0xB2, 0xFF), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + rose type (ADR-0057: widened ΔE from identifier 14.3→19.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +139,9 @@ impl Theme {
|
|||||||
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
|
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
|
||||||
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
|
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
|
||||||
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
|
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
|
||||||
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
|
tok_string: Color::Rgb(0x1B, 0x7A, 0x33), // forest green (ADR-0057: darkened for WCAG-AA, 4.42→5.18:1)
|
||||||
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
|
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
|
||||||
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
|
tok_flag: Color::Rgb(0x7A, 0x5C, 0x00), // dark mustard/olive (ADR-0057: darkened for WCAG-AA, 3.15→5.98:1)
|
||||||
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
|
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
|
||||||
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
|
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
|
||||||
}
|
}
|
||||||
@@ -264,4 +264,275 @@ mod tests {
|
|||||||
assert_ne!(t.tok_function, t.tok_type);
|
assert_ne!(t.tok_function, t.tok_type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- NFR-5 / NFR-7 (ADR-0057): WCAG-AA contrast gate ----
|
||||||
|
//
|
||||||
|
// Every text-bearing foreground must clear WCAG-AA 4.5:1 against
|
||||||
|
// the panel background, in BOTH themes, so the colour scheme stays
|
||||||
|
// legible on light and dark terminals (no dark-on-dark / light-on-
|
||||||
|
// light failures). Borders are non-text structural chrome and are
|
||||||
|
// handled by `advanced_mode_border_meets_ui_contrast` below; the
|
||||||
|
// plain `border` is intentionally exempt (decorative — see ADR-0057).
|
||||||
|
//
|
||||||
|
// These checks rely on every `Theme` colour being `Color::Rgb`
|
||||||
|
// (true-colour). `rgb_channels` panics on any other variant, which
|
||||||
|
// also guards against a future regression to a named palette colour
|
||||||
|
// (whose real contrast can't be known).
|
||||||
|
|
||||||
|
fn rgb_channels(c: Color) -> (f64, f64, f64) {
|
||||||
|
match c {
|
||||||
|
Color::Rgb(r, g, b) => (f64::from(r), f64::from(g), f64::from(b)),
|
||||||
|
other => panic!("theme colours must be Color::Rgb for contrast checks; got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG 2.x relative luminance of an sRGB colour.
|
||||||
|
// The luminance/Lab/CIEDE2000 maths below are published standards; we
|
||||||
|
// keep them in canonical `a*x + b*y` form (verified against reference
|
||||||
|
// vectors) rather than refactoring into `mul_add`, which would obscure
|
||||||
|
// the formula for a negligible test-only gain.
|
||||||
|
#[allow(clippy::suboptimal_flops)]
|
||||||
|
fn relative_luminance(c: Color) -> f64 {
|
||||||
|
let chan = |v: f64| {
|
||||||
|
let v = v / 255.0;
|
||||||
|
if v <= 0.03928 {
|
||||||
|
v / 12.92
|
||||||
|
} else {
|
||||||
|
((v + 0.055) / 1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (r, g, b) = rgb_channels(c);
|
||||||
|
0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG 2.x contrast ratio between two colours (1.0 ..= 21.0).
|
||||||
|
fn contrast_ratio(a: Color, b: Color) -> f64 {
|
||||||
|
let (la, lb) = (relative_luminance(a), relative_luminance(b));
|
||||||
|
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||||
|
(hi + 0.05) / (lo + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_label(t: &Theme) -> &'static str {
|
||||||
|
match t.background {
|
||||||
|
Background::Dark => "dark",
|
||||||
|
Background::Light => "light",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every text foreground (incl. dimmed `muted`/`tok_punct` and the
|
||||||
|
/// syntax-token classes) on `bg`, both themes.
|
||||||
|
fn text_foregrounds(t: &Theme) -> [(&'static str, Color); 17] {
|
||||||
|
[
|
||||||
|
("fg", t.fg),
|
||||||
|
("muted", t.muted),
|
||||||
|
("mode_simple", t.mode_simple),
|
||||||
|
("mode_advanced", t.mode_advanced),
|
||||||
|
("system", t.system),
|
||||||
|
("error", t.error),
|
||||||
|
("warning", t.warning),
|
||||||
|
("plan_efficient", t.plan_efficient),
|
||||||
|
("tok_keyword", t.tok_keyword),
|
||||||
|
("tok_identifier", t.tok_identifier),
|
||||||
|
("tok_type", t.tok_type),
|
||||||
|
("tok_number", t.tok_number),
|
||||||
|
("tok_string", t.tok_string),
|
||||||
|
("tok_punct", t.tok_punct),
|
||||||
|
("tok_flag", t.tok_flag),
|
||||||
|
("tok_error", t.tok_error),
|
||||||
|
("tok_function", t.tok_function),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_text_colours_meet_wcag_aa_contrast() {
|
||||||
|
for t in [Theme::dark(), Theme::light()] {
|
||||||
|
let label = theme_label(&t);
|
||||||
|
for (name, c) in text_foregrounds(&t) {
|
||||||
|
let ratio = contrast_ratio(c, t.bg);
|
||||||
|
assert!(
|
||||||
|
ratio >= 4.5,
|
||||||
|
"{label} theme: {name} contrast {ratio:.2}:1 on bg is below WCAG-AA 4.5:1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_mode_border_meets_ui_contrast() {
|
||||||
|
// The advanced-mode border carries a mode-warning signal, so it
|
||||||
|
// meets WCAG's 3:1 non-text / UI-component threshold in both
|
||||||
|
// themes. The plain `border` is decorative structural chrome and
|
||||||
|
// is intentionally exempt (recorded in ADR-0057).
|
||||||
|
for t in [Theme::dark(), Theme::light()] {
|
||||||
|
let label = theme_label(&t);
|
||||||
|
let ratio = contrast_ratio(t.border_advanced, t.bg);
|
||||||
|
assert!(
|
||||||
|
ratio >= 3.0,
|
||||||
|
"{label} theme: border_advanced contrast {ratio:.2}:1 below the 3:1 UI bar",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NFR-5 perceptual distinctness (ADR-0057) ----
|
||||||
|
//
|
||||||
|
// Contrast-against-background is necessary but not sufficient: two
|
||||||
|
// token colours can each clear 4.5:1 yet be hard to tell APART from
|
||||||
|
// each other (a real pain reported on high-quality screens). We lock
|
||||||
|
// that in with a CIEDE2000 (ΔE2000) floor between every pair of
|
||||||
|
// syntax-token classes that can share a line. The metric itself is
|
||||||
|
// validated against the Sharma et al. reference vectors.
|
||||||
|
|
||||||
|
/// sRGB `Color` → CIELAB (D65), for perceptual-difference checks.
|
||||||
|
#[allow(clippy::suboptimal_flops)]
|
||||||
|
fn rgb_to_lab(c: Color) -> (f64, f64, f64) {
|
||||||
|
let lin = |v: f64| {
|
||||||
|
let v = v / 255.0;
|
||||||
|
if v <= 0.03928 {
|
||||||
|
v / 12.92
|
||||||
|
} else {
|
||||||
|
((v + 0.055) / 1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (r0, g0, b0) = rgb_channels(c);
|
||||||
|
let (r, g, b) = (lin(r0), lin(g0), lin(b0));
|
||||||
|
let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.950_47;
|
||||||
|
let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||||
|
let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.088_83;
|
||||||
|
let f = |t: f64| {
|
||||||
|
if t > 0.008_856 {
|
||||||
|
t.cbrt()
|
||||||
|
} else {
|
||||||
|
7.787 * t + 16.0 / 116.0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (fx, fy, fz) = (f(x), f(y), f(z));
|
||||||
|
(116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CIEDE2000 colour difference between two CIELAB values.
|
||||||
|
#[allow(clippy::suboptimal_flops)]
|
||||||
|
fn delta_e_2000(lab1: (f64, f64, f64), lab2: (f64, f64, f64)) -> f64 {
|
||||||
|
let (l1, a1, b1) = lab1;
|
||||||
|
let (l2, a2, b2) = lab2;
|
||||||
|
let pow7 = |v: f64| v.powi(7);
|
||||||
|
let k_l = pow7(25.0);
|
||||||
|
|
||||||
|
let avg_lp = (l1 + l2) / 2.0;
|
||||||
|
let c1 = a1.hypot(b1);
|
||||||
|
let c2 = a2.hypot(b2);
|
||||||
|
let avg_c = (c1 + c2) / 2.0;
|
||||||
|
let g = if avg_c > 0.0 {
|
||||||
|
0.5 * (1.0 - (pow7(avg_c) / (pow7(avg_c) + k_l)).sqrt())
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let a1p = (1.0 + g) * a1;
|
||||||
|
let a2p = (1.0 + g) * a2;
|
||||||
|
let c1p = a1p.hypot(b1);
|
||||||
|
let c2p = a2p.hypot(b2);
|
||||||
|
let avg_cp = (c1p + c2p) / 2.0;
|
||||||
|
|
||||||
|
let hp = |ap: f64, b: f64| -> f64 {
|
||||||
|
if ap == 0.0 && b == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let ang = b.atan2(ap).to_degrees();
|
||||||
|
if ang < 0.0 { ang + 360.0 } else { ang }
|
||||||
|
};
|
||||||
|
let h1p = hp(a1p, b1);
|
||||||
|
let h2p = hp(a2p, b2);
|
||||||
|
|
||||||
|
let dlp = l2 - l1;
|
||||||
|
let dcp = c2p - c1p;
|
||||||
|
let dhp = if c1p * c2p == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else if (h2p - h1p).abs() <= 180.0 {
|
||||||
|
h2p - h1p
|
||||||
|
} else if h2p - h1p > 180.0 {
|
||||||
|
h2p - h1p - 360.0
|
||||||
|
} else {
|
||||||
|
h2p - h1p + 360.0
|
||||||
|
};
|
||||||
|
let d_big_hp = 2.0 * (c1p * c2p).sqrt() * (dhp.to_radians() / 2.0).sin();
|
||||||
|
|
||||||
|
let avg_hp = if c1p * c2p == 0.0 {
|
||||||
|
h1p + h2p
|
||||||
|
} else if (h1p - h2p).abs() <= 180.0 {
|
||||||
|
(h1p + h2p) / 2.0
|
||||||
|
} else if h1p + h2p < 360.0 {
|
||||||
|
(h1p + h2p + 360.0) / 2.0
|
||||||
|
} else {
|
||||||
|
(h1p + h2p - 360.0) / 2.0
|
||||||
|
};
|
||||||
|
let t = 1.0 - 0.17 * (avg_hp - 30.0).to_radians().cos()
|
||||||
|
+ 0.24 * (2.0 * avg_hp).to_radians().cos()
|
||||||
|
+ 0.32 * (3.0 * avg_hp + 6.0).to_radians().cos()
|
||||||
|
- 0.20 * (4.0 * avg_hp - 63.0).to_radians().cos();
|
||||||
|
let d_ro = 30.0 * (-((avg_hp - 275.0) / 25.0).powi(2)).exp();
|
||||||
|
let rc = if avg_cp > 0.0 {
|
||||||
|
2.0 * (pow7(avg_cp) / (pow7(avg_cp) + k_l)).sqrt()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let sl = 1.0 + (0.015 * (avg_lp - 50.0).powi(2)) / (20.0 + (avg_lp - 50.0).powi(2)).sqrt();
|
||||||
|
let sc = 1.0 + 0.045 * avg_cp;
|
||||||
|
let sh = 1.0 + 0.015 * avg_cp * t;
|
||||||
|
let rt = -(2.0 * d_ro.to_radians()).sin() * rc;
|
||||||
|
|
||||||
|
((dlp / sl).powi(2)
|
||||||
|
+ (dcp / sc).powi(2)
|
||||||
|
+ (d_big_hp / sh).powi(2)
|
||||||
|
+ rt * (dcp / sc) * (d_big_hp / sh))
|
||||||
|
.sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delta_e_2000_matches_reference_vectors() {
|
||||||
|
// Sharma, Wu & Dalal (2005) CIEDE2000 test data — validates the
|
||||||
|
// metric so the distinctness gate below rests on a correct base.
|
||||||
|
let cases = [
|
||||||
|
((50.0, 2.6772, -79.7751), (50.0, 0.0, -82.7485), 2.0425),
|
||||||
|
((50.0, 3.1571, -77.2803), (50.0, 0.0, -82.7485), 2.8615),
|
||||||
|
((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0009), 7.1792),
|
||||||
|
((50.0, -1.0, 2.0), (50.0, 0.0, 0.0), 2.3669),
|
||||||
|
];
|
||||||
|
for (l1, l2, exp) in cases {
|
||||||
|
let got = delta_e_2000(l1, l2);
|
||||||
|
assert!(
|
||||||
|
(got - exp).abs() < 0.01,
|
||||||
|
"ΔE2000 {got:.4} != reference {exp:.4}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syntax_token_colours_are_perceptually_distinct() {
|
||||||
|
// Every pair of syntax-token classes that can appear together on
|
||||||
|
// one line must be perceptually separable, not merely unequal in
|
||||||
|
// hex. Floor at ΔE2000 >= 15 (post-ADR-0057 minimum is ~18).
|
||||||
|
// Excludes tok_punct (== muted) and tok_error (== error), which
|
||||||
|
// deliberately alias other roles.
|
||||||
|
const MIN_DE: f64 = 15.0;
|
||||||
|
for t in [Theme::dark(), Theme::light()] {
|
||||||
|
let label = theme_label(&t);
|
||||||
|
let toks = [
|
||||||
|
("tok_keyword", t.tok_keyword),
|
||||||
|
("tok_identifier", t.tok_identifier),
|
||||||
|
("tok_type", t.tok_type),
|
||||||
|
("tok_number", t.tok_number),
|
||||||
|
("tok_string", t.tok_string),
|
||||||
|
("tok_flag", t.tok_flag),
|
||||||
|
("tok_function", t.tok_function),
|
||||||
|
];
|
||||||
|
for (i, (name_a, ca)) in toks.iter().enumerate() {
|
||||||
|
for (name_b, cb) in toks.iter().skip(i + 1) {
|
||||||
|
let de = delta_e_2000(rgb_to_lab(*ca), rgb_to_lab(*cb));
|
||||||
|
assert!(
|
||||||
|
de >= MIN_DE,
|
||||||
|
"{label} theme: {name_a} vs {name_b} ΔE2000 {de:.1} below {MIN_DE} — too similar",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-21
@@ -17,8 +17,8 @@
|
|||||||
//!
|
//!
|
||||||
//! Pairs not present in the matrix are statically refused via
|
//! Pairs not present in the matrix are statically refused via
|
||||||
//! [`static_refusal`] before any per-cell pass runs. Same-type
|
//! [`static_refusal`] before any per-cell pass runs. Same-type
|
||||||
//! identity, anything → `serial`, anything ↔ `blob`, and
|
//! identity, anything → `serial`, and `date` ↔ `datetime` direct
|
||||||
//! `date` ↔ `datetime` direct are all statically refused.
|
//! are all statically refused.
|
||||||
|
|
||||||
use rusqlite::types::Value;
|
use rusqlite::types::Value;
|
||||||
|
|
||||||
@@ -46,9 +46,8 @@ pub enum CellOutcome {
|
|||||||
/// refused; `None` when the per-cell matrix should be consulted.
|
/// refused; `None` when the per-cell matrix should be consulted.
|
||||||
///
|
///
|
||||||
/// Static refusals cover: same-type identity, anything →
|
/// Static refusals cover: same-type identity, anything →
|
||||||
/// `serial`, anything ↔ `blob`, `date` ↔ `datetime` direct, and
|
/// `serial`, `date` ↔ `datetime` direct, and any cross-domain
|
||||||
/// any cross-domain pair not present in the matrix
|
/// pair not present in the matrix (e.g. `bool` → `date`).
|
||||||
/// (e.g. `bool` → `date`).
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn static_refusal(src: Type, target: Type) -> Option<String> {
|
pub fn static_refusal(src: Type, target: Type) -> Option<String> {
|
||||||
if src == target {
|
if src == target {
|
||||||
@@ -63,12 +62,6 @@ pub fn static_refusal(src: Type, target: Type) -> Option<String> {
|
|||||||
first; only `int → serial` is supported directly."
|
first; only `int → serial` is supported directly."
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if matches!(src, Type::Blob) || matches!(target, Type::Blob) {
|
|
||||||
return Some(format!(
|
|
||||||
"conversion between `{src}` and `{target}` is not supported \
|
|
||||||
in this version."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if matches!(
|
if matches!(
|
||||||
(src, target),
|
(src, target),
|
||||||
(Type::Date, Type::DateTime) | (Type::DateTime, Type::Date)
|
(Type::Date, Type::DateTime) | (Type::DateTime, Type::Date)
|
||||||
@@ -601,16 +594,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn anything_involving_blob_is_statically_refused() {
|
|
||||||
for &other in Type::all() {
|
|
||||||
if other != Type::Blob {
|
|
||||||
assert!(static_refusal(Type::Blob, other).is_some(), "{other:?}");
|
|
||||||
assert!(static_refusal(other, Type::Blob).is_some(), "{other:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn date_to_datetime_direct_is_statically_refused() {
|
fn date_to_datetime_direct_is_statically_refused() {
|
||||||
assert!(static_refusal(Type::Date, Type::DateTime).is_some());
|
assert!(static_refusal(Type::Date, Type::DateTime).is_some());
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
//! Tier-4 PTY-based end-to-end tests (ADR-0008 §Tier-4, requirements TT4).
|
||||||
|
//!
|
||||||
|
//! These drive the **actual built binary** in a real pseudo-terminal and
|
||||||
|
//! assert on what a user would see on screen — catching what the lower
|
||||||
|
//! tiers can't: TTY setup, raw-mode / alternate-screen transitions, real
|
||||||
|
//! I/O timing, and graceful quit. The four flows mirror ADR-0008's
|
||||||
|
//! initial Tier-4 scope exactly:
|
||||||
|
//!
|
||||||
|
//! 1. cold launch → first DDL command → graceful quit
|
||||||
|
//! 2. project save → reopen → identical state
|
||||||
|
//! 3. project export → import into a fresh project → rebuilt state
|
||||||
|
//! 4. `undo` immediately after `DROP TABLE` (incl. the confirm modal)
|
||||||
|
//!
|
||||||
|
//! Tooling (ADR-0008, refined): `portable-pty` to spawn the binary in a
|
||||||
|
//! PTY at a fixed window size, `vt100` to parse the output stream into an
|
||||||
|
//! inspectable screen grid. ADR-0008 also named `expectrl`; we dropped it
|
||||||
|
//! — it bundles its own PTY abstraction (conflicts with portable-pty) and
|
||||||
|
//! is line-oriented, a poor fit for a full-screen TUI. A small hand-rolled
|
||||||
|
//! `wait_for` polling the vt100 screen replaces it.
|
||||||
|
//!
|
||||||
|
//! Determinism: each test gets its own temp `--data-dir` (no contact with
|
||||||
|
//! the user's real projects / resume state), a fixed 100×30 terminal (the
|
||||||
|
//! wide three-region layout, ADR-0046), and `--theme dark`. Tests run
|
||||||
|
//! **serially** (one PTY + child process at a time) so timing stays
|
||||||
|
//! predictable on the low-parallelism self-hosted CI runner, with tight,
|
||||||
|
//! fail-fast timeouts: a slow wait means a real hang, not contention.
|
||||||
|
|
||||||
|
// `PtyApp` deliberately holds a serial `MutexGuard` for its whole lifetime
|
||||||
|
// (to run PTY tests one at a time), and the screen-reading helpers hold the
|
||||||
|
// vt100 parser lock for the duration of a read. `significant_drop_tightening`
|
||||||
|
// flags both as droppable-earlier, but tightening them is either impossible
|
||||||
|
// (the guard *is* the serialization) or pointless here.
|
||||||
|
#![allow(clippy::significant_drop_tightening)]
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
const COLS: u16 = 100;
|
||||||
|
const ROWS: u16 = 30;
|
||||||
|
/// Tight, fail-fast wait. The self-hosted runner has little parallelism,
|
||||||
|
/// so anything slower than this is a genuine hang worth failing on.
|
||||||
|
const WAIT: Duration = Duration::from_secs(3);
|
||||||
|
const POLL: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
/// Tier-4 tests share one global lock so only one PTY + child runs at a
|
||||||
|
/// time (predictable timing; no resource races). Poison-tolerant so a
|
||||||
|
/// panicking test doesn't cascade-fail the rest.
|
||||||
|
static SERIAL: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
fn lock_serial() -> MutexGuard<'static, ()> {
|
||||||
|
SERIAL
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running instance of the app under a pseudo-terminal.
|
||||||
|
struct PtyApp {
|
||||||
|
_serial: MutexGuard<'static, ()>,
|
||||||
|
/// Present when this instance owns its data dir (single-launch
|
||||||
|
/// flows); `None` when the test owns it (multi-launch flows that
|
||||||
|
/// reopen the same dir).
|
||||||
|
_owned_dir: Option<TempDir>,
|
||||||
|
_master: Box<dyn MasterPty + Send>,
|
||||||
|
writer: Box<dyn Write + Send>,
|
||||||
|
parser: Arc<Mutex<vt100::Parser>>,
|
||||||
|
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||||
|
_reader: thread::JoinHandle<()>,
|
||||||
|
/// Spawn → first rendered frame (the `SIMPLE` mode label). Measured
|
||||||
|
/// for NFR-1; note this is the *debug* binary, so it's a generous
|
||||||
|
/// gross-regression signal, not the release startup figure.
|
||||||
|
startup: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PtyApp {
|
||||||
|
/// Launch against a fresh, owned temp data dir.
|
||||||
|
fn launch(args: &[&str]) -> Self {
|
||||||
|
let dir = TempDir::new().expect("create temp data dir");
|
||||||
|
let mut app = Self::launch_in(dir.path(), args);
|
||||||
|
app._owned_dir = Some(dir);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch against a caller-owned data dir (so a later launch can
|
||||||
|
/// reopen the same projects — flow 2).
|
||||||
|
fn launch_in(data_dir: &Path, args: &[&str]) -> Self {
|
||||||
|
let serial = lock_serial();
|
||||||
|
let pty = native_pty_system();
|
||||||
|
let pair = pty
|
||||||
|
.openpty(PtySize {
|
||||||
|
rows: ROWS,
|
||||||
|
cols: COLS,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.expect("open pty");
|
||||||
|
|
||||||
|
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_rdbms-playground"));
|
||||||
|
cmd.arg("--data-dir");
|
||||||
|
cmd.arg(data_dir);
|
||||||
|
cmd.arg("--theme");
|
||||||
|
cmd.arg("dark");
|
||||||
|
for a in args {
|
||||||
|
cmd.arg(a);
|
||||||
|
}
|
||||||
|
cmd.env("TERM", "xterm-256color");
|
||||||
|
cmd.env("RDBMS_PLAYGROUND_DEMO", "0");
|
||||||
|
cmd.cwd(data_dir);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let child = pair.slave.spawn_command(cmd).expect("spawn binary");
|
||||||
|
drop(pair.slave); // we never write to the slave directly
|
||||||
|
|
||||||
|
let parser = Arc::new(Mutex::new(vt100::Parser::new(ROWS, COLS, 0)));
|
||||||
|
let mut reader = pair.master.try_clone_reader().expect("clone pty reader");
|
||||||
|
let writer = pair.master.take_writer().expect("take pty writer");
|
||||||
|
|
||||||
|
let parser_for_reader = Arc::clone(&parser);
|
||||||
|
let reader_handle = thread::spawn(move || {
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buf) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => parser_for_reader
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.process(&buf[..n]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut app = Self {
|
||||||
|
_serial: serial,
|
||||||
|
_owned_dir: None,
|
||||||
|
_master: pair.master,
|
||||||
|
writer,
|
||||||
|
parser,
|
||||||
|
child,
|
||||||
|
_reader: reader_handle,
|
||||||
|
startup: Duration::ZERO,
|
||||||
|
};
|
||||||
|
// Every flow needs a booted, idle app; block on the first frame.
|
||||||
|
app.wait_for("SIMPLE");
|
||||||
|
app.startup = started.elapsed();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current visible screen as text (one line per row, trailing blanks
|
||||||
|
/// trimmed) — what a human would read.
|
||||||
|
fn screen_text(&self) -> String {
|
||||||
|
self.parser
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.screen()
|
||||||
|
.contents()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text of just the left **Tables sidebar** region (first
|
||||||
|
/// [`SIDEBAR_W`] columns). The Output panel echoes every command, so
|
||||||
|
/// a table name typed in a command pollutes a whole-screen match —
|
||||||
|
/// table presence/absence must be read from the sidebar, where the
|
||||||
|
/// items list actually lives.
|
||||||
|
fn sidebar(&self) -> String {
|
||||||
|
const SIDEBAR_W: u16 = 28;
|
||||||
|
let parser = self
|
||||||
|
.parser
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let screen = parser.screen();
|
||||||
|
let mut out = String::new();
|
||||||
|
for row in 0..ROWS {
|
||||||
|
for col in 0..SIDEBAR_W {
|
||||||
|
if let Some(cell) = screen.cell(row, col) {
|
||||||
|
out.push_str(cell.contents());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll a predicate to the tight timeout, panicking with a screen dump.
|
||||||
|
fn wait_until(&self, desc: &str, pred: impl Fn(&Self) -> bool) {
|
||||||
|
let deadline = Instant::now() + WAIT;
|
||||||
|
loop {
|
||||||
|
if pred(self) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"timed out after {WAIT:?} waiting for {desc}\n\
|
||||||
|
----- screen -----\n{}\n------------------",
|
||||||
|
self.screen_text(),
|
||||||
|
);
|
||||||
|
thread::sleep(POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for `needle` anywhere on screen (Output-panel messages, modals).
|
||||||
|
fn wait_for(&self, needle: &str) {
|
||||||
|
self.wait_until(&format!("{needle:?} on screen"), |a| {
|
||||||
|
a.screen_text().contains(needle)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for a table `name` to appear in the Tables sidebar.
|
||||||
|
fn wait_for_table(&self, name: &str) {
|
||||||
|
self.wait_until(&format!("table {name:?} in sidebar"), |a| {
|
||||||
|
a.sidebar().contains(name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for the Tables sidebar to be empty (the `(none yet)` placeholder).
|
||||||
|
fn wait_for_no_tables(&self) {
|
||||||
|
self.wait_until("empty Tables sidebar", |a| {
|
||||||
|
a.sidebar().contains("(none yet)")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&mut self, bytes: &[u8]) {
|
||||||
|
self.writer.write_all(bytes).expect("write to pty");
|
||||||
|
self.writer.flush().expect("flush pty");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type a command and submit it (Enter == carriage return in raw mode).
|
||||||
|
fn submit(&mut self, line: &str) {
|
||||||
|
self.send(line.as_bytes());
|
||||||
|
self.send(b"\r");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resident set size of the child in KiB (Linux only). `process_id`
|
||||||
|
/// is read inline here rather than via a helper so the method has no
|
||||||
|
/// non-Linux callers to go dead (the CI clippy gate runs on Linux,
|
||||||
|
/// where such dead code wouldn't surface).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn rss_kib(&self) -> Option<u64> {
|
||||||
|
let pid = self.child.process_id()?;
|
||||||
|
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||||
|
status.lines().find_map(|l| {
|
||||||
|
let rest = l.strip_prefix("VmRSS:")?;
|
||||||
|
rest.split_whitespace().next()?.parse::<u64>().ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send Ctrl-C and assert the process exits cleanly within the wait.
|
||||||
|
fn quit(mut self) {
|
||||||
|
self.send(b"\x03");
|
||||||
|
let deadline = Instant::now() + WAIT;
|
||||||
|
loop {
|
||||||
|
match self.child.try_wait() {
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
assert!(status.success(), "app exited unsuccessfully: {status:?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => panic!("waiting on child failed: {e}"),
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
let _ = self.child.kill();
|
||||||
|
panic!(
|
||||||
|
"app did not exit within {WAIT:?} of Ctrl-C\n\
|
||||||
|
----- screen -----\n{}\n------------------",
|
||||||
|
self.screen_text(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
thread::sleep(POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PtyApp {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Never leak a child process, even if a test panicked mid-flow.
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ the four flows ===========================
|
||||||
|
|
||||||
|
/// Flow 1 — cold launch → first DDL command → graceful quit.
|
||||||
|
#[test]
|
||||||
|
fn cold_launch_ddl_and_quit() {
|
||||||
|
let mut app = PtyApp::launch(&[]);
|
||||||
|
app.wait_for_no_tables(); // fresh project: empty sidebar
|
||||||
|
app.submit("create table Customers with pk id(serial)");
|
||||||
|
app.wait_for_table("Customers"); // table appears in the sidebar
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flow 2 — create, quit, reopen the same project (`--resume`), identical
|
||||||
|
/// state. Persistence is per-command autosave; the kept temp is recorded
|
||||||
|
/// as the resume target on quit (ADR-0015).
|
||||||
|
#[test]
|
||||||
|
fn save_quit_and_reopen_restores_state() {
|
||||||
|
let dir = TempDir::new().expect("shared data dir");
|
||||||
|
|
||||||
|
let mut app = PtyApp::launch_in(dir.path(), &[]);
|
||||||
|
app.submit("create table Customers with pk id(serial)");
|
||||||
|
app.wait_for_table("Customers");
|
||||||
|
app.submit("add column to Customers: Name (text)");
|
||||||
|
app.wait_for("Name (text) ✓");
|
||||||
|
app.quit();
|
||||||
|
|
||||||
|
// Reopen the most-recently-used project from the same data root, then
|
||||||
|
// assert the *column* survived — not just the table name — so "identical
|
||||||
|
// state" means the schema, not merely that some table exists. In this
|
||||||
|
// fresh process "Name" appears only from the show-data header.
|
||||||
|
let mut reopened = PtyApp::launch_in(dir.path(), &["--resume"]);
|
||||||
|
reopened.wait_for_table("Customers");
|
||||||
|
reopened.submit("show data Customers");
|
||||||
|
reopened.wait_for("Name"); // the added column round-tripped through restart
|
||||||
|
reopened.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flow 3 — export a project, import it into a fresh project, confirm the
|
||||||
|
/// rebuilt database carries the schema and data.
|
||||||
|
#[test]
|
||||||
|
fn export_then_import_into_fresh_project() {
|
||||||
|
let zip_dir = TempDir::new().expect("zip dir");
|
||||||
|
let zip_path = zip_dir.path().join("export.zip");
|
||||||
|
let zip = zip_path.to_str().expect("utf-8 zip path");
|
||||||
|
|
||||||
|
// Source project: a table with one data row. `Name` is a regular
|
||||||
|
// column (added separately) — putting it in the `with pk` clause would
|
||||||
|
// make a compound PK and block the serial auto-fill.
|
||||||
|
let mut source = PtyApp::launch(&[]);
|
||||||
|
source.submit("create table Customers with pk id(serial)");
|
||||||
|
source.wait_for_table("Customers");
|
||||||
|
// Pace each command to completion before the next — driving a TUI, like
|
||||||
|
// a real user (or the cast driver), means waiting for each result. A
|
||||||
|
// command sent while the previous one's worker rebuild is still in
|
||||||
|
// flight can be misread against a stale schema cache (issue #39).
|
||||||
|
source.submit("add column to Customers: Name (text)");
|
||||||
|
source.wait_for("Name (text) ✓");
|
||||||
|
source.submit("insert into Customers values ('Alice')");
|
||||||
|
// The insert's OWN success echo (value + ✓) — not a bare "✓", which the
|
||||||
|
// create already painted — so export runs only once the row is in CSV.
|
||||||
|
source.wait_for("('Alice') ✓");
|
||||||
|
source.submit(&format!("export {zip}"));
|
||||||
|
source.wait_for("[ok] export");
|
||||||
|
source.quit();
|
||||||
|
|
||||||
|
// Fresh project (new data dir): import the zip. Import switches to the
|
||||||
|
// imported project, rebuilding its db from text since the export omits
|
||||||
|
// the .db (ADR-0004/0015). "Alice" is unambiguous here — it appears in
|
||||||
|
// no command typed into this instance, only in a rebuilt data row.
|
||||||
|
let mut target = PtyApp::launch(&[]);
|
||||||
|
target.submit(&format!("import {zip}"));
|
||||||
|
target.wait_for("now editing"); // switched to the imported project
|
||||||
|
target.wait_for_table("Customers"); // schema rebuilt
|
||||||
|
target.submit("show data Customers");
|
||||||
|
target.wait_for("Alice"); // data rebuilt from the CSV
|
||||||
|
target.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flow 4 — `undo` immediately after `DROP TABLE`, including the
|
||||||
|
/// confirmation modal.
|
||||||
|
#[test]
|
||||||
|
fn undo_after_drop_table_restores_it() {
|
||||||
|
let mut app = PtyApp::launch(&[]);
|
||||||
|
app.submit("create table Customers with pk id(serial)");
|
||||||
|
app.wait_for_table("Customers");
|
||||||
|
|
||||||
|
app.submit("drop table Customers");
|
||||||
|
app.wait_for_no_tables(); // gone from the sidebar
|
||||||
|
|
||||||
|
app.submit("undo");
|
||||||
|
app.wait_for("Restore that earlier state?"); // the confirm modal
|
||||||
|
app.send(b"y"); // confirm
|
||||||
|
app.wait_for_table("Customers"); // table restored in the sidebar
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flow 5 — issue #39 regression: commands submitted **back-to-back**, with
|
||||||
|
/// no wait between them, must still execute correctly. This is the inverse of
|
||||||
|
/// flow 3 (which paces each command on purpose). Pre-fix, a Form-B insert sent
|
||||||
|
/// immediately after `add column` was validated against the *stale* schema
|
||||||
|
/// cache — the worker hadn't yet refreshed it — and wrongly rejected as
|
||||||
|
/// "trying to write SQL?", so the row never landed. The schema-refresh gate
|
||||||
|
/// (`App::awaiting_schema_refresh`) now holds each submission until the prior
|
||||||
|
/// command's refresh arrives, making the outcome independent of input speed.
|
||||||
|
#[test]
|
||||||
|
fn back_to_back_insert_after_ddl_still_succeeds() {
|
||||||
|
let mut app = PtyApp::launch(&[]);
|
||||||
|
app.wait_for_no_tables(); // fresh project
|
||||||
|
|
||||||
|
// Fire all three with no readiness wait in between — the faster-than-human
|
||||||
|
// input that triggered issue #39 (paste / script / unpaced driver).
|
||||||
|
app.submit("create table Customers with pk id(serial)");
|
||||||
|
app.submit("add column to Customers: Name (text)");
|
||||||
|
app.submit("insert into Customers values ('Alice')");
|
||||||
|
|
||||||
|
// The insert's OWN success echo (value + ✓) — proof the row reached the
|
||||||
|
// database, not the "trying to write SQL?" rejection. If the gate
|
||||||
|
// regressed, the insert would misparse against the stale schema and this
|
||||||
|
// would time out.
|
||||||
|
app.wait_for("('Alice') ✓");
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flow 6 — ADR-0005 Amendment 2 backward-compat: opening a **legacy v1
|
||||||
|
/// project that declares a `blob` column** migrates it to `text` and opens
|
||||||
|
/// cleanly. This drives the real runtime's migrate-on-open + rebuild path
|
||||||
|
/// end-to-end — the post-removal binary would otherwise reject the v1/blob
|
||||||
|
/// project file. (The integration test covers the migrator + rebuild via
|
||||||
|
/// direct calls; this covers the runtime glue that decides to run them.)
|
||||||
|
#[test]
|
||||||
|
fn opens_a_legacy_v1_blob_project_by_migrating_to_text() {
|
||||||
|
let dir = TempDir::new().expect("data dir");
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
// Seed a v1 project (the old format) with a `blob` column, under the
|
||||||
|
// data root's projects/ dir, and point `--resume` at it. No
|
||||||
|
// playground.db is shipped, so the open rebuilds from the migrated text.
|
||||||
|
let proj = root.join("projects").join("Legacy");
|
||||||
|
fs::create_dir_all(proj.join("data")).expect("create project dirs");
|
||||||
|
fs::write(
|
||||||
|
proj.join("project.yaml"),
|
||||||
|
concat!(
|
||||||
|
"version: 1\n",
|
||||||
|
"project:\n created_at: 2026-01-01T00:00:00Z\n mode: simple\n",
|
||||||
|
"tables:\n",
|
||||||
|
" - name: Files\n",
|
||||||
|
" primary_key: [id]\n",
|
||||||
|
" columns:\n",
|
||||||
|
" - { name: id, type: serial }\n",
|
||||||
|
" - { name: payload, type: blob }\n",
|
||||||
|
"relationships: []\n",
|
||||||
|
"indexes: []\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write project.yaml");
|
||||||
|
fs::write(
|
||||||
|
proj.join("data").join("Files.csv"),
|
||||||
|
"id,payload\n1,aGVsbG8=\n",
|
||||||
|
)
|
||||||
|
.expect("write csv");
|
||||||
|
fs::write(root.join("last_project"), format!("{}\n", proj.display())).expect("write resume");
|
||||||
|
|
||||||
|
// Open via --resume: the runtime migrates blob→text and rebuilds, so
|
||||||
|
// the project opens and the table shows in the sidebar.
|
||||||
|
let mut app = PtyApp::launch_in(root, &["--resume"]);
|
||||||
|
app.wait_for_table("Files");
|
||||||
|
// The former blob column is now a normal text column — `show data`
|
||||||
|
// renders it (header + the base64 value preserved as text).
|
||||||
|
app.submit("show data Files");
|
||||||
|
app.wait_for("payload");
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== NFR perf (measured, generous) ===================
|
||||||
|
//
|
||||||
|
// These run against the DEBUG binary, so the bounds are loose
|
||||||
|
// gross-regression catches, not the NFR targets. The real NFR-1 (startup
|
||||||
|
// < 500 ms) and NFR-3 (idle RSS < 50 MB) figures are measured on a
|
||||||
|
// --release build and recorded in the NFR verification doc (ADR-0057).
|
||||||
|
|
||||||
|
/// NFR-1 — startup to first rendered frame. Generous debug-binary bound.
|
||||||
|
#[test]
|
||||||
|
fn startup_to_first_frame_is_reasonable() {
|
||||||
|
let app = PtyApp::launch(&[]);
|
||||||
|
let ms = app.startup.as_millis();
|
||||||
|
eprintln!("NFR-1 startup (debug binary, under test harness): {ms} ms");
|
||||||
|
assert!(
|
||||||
|
app.startup < Duration::from_millis(2000),
|
||||||
|
"startup {ms} ms exceeds the generous 2000 ms debug bound — likely a real regression",
|
||||||
|
);
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NFR-3 — idle resident memory. Generous debug-binary bound (Linux only;
|
||||||
|
/// reads the child's /proc VmRSS).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn idle_memory_footprint_is_reasonable() {
|
||||||
|
let app = PtyApp::launch(&[]);
|
||||||
|
// Already idle after the readiness wait; let it settle a moment.
|
||||||
|
thread::sleep(Duration::from_millis(200));
|
||||||
|
let rss = app.rss_kib().expect("read VmRSS");
|
||||||
|
eprintln!(
|
||||||
|
"NFR-3 idle RSS (debug binary): {} KiB ({:.1} MB)",
|
||||||
|
rss,
|
||||||
|
rss as f64 / 1024.0,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rss < 150_000,
|
||||||
|
"idle RSS {rss} KiB exceeds the generous 150 MB debug bound — likely a real regression",
|
||||||
|
);
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! Full-stack test for **ADR-0005 Amendment 2** (drop `blob`): a saved
|
||||||
|
//! v1 project that declares a `blob` column is migrated to v2 (`blob` →
|
||||||
|
//! `text`) on open, and a rebuild from the migrated text reconstructs the
|
||||||
|
//! column as `text` with the row data preserved — the base64 cell survives
|
||||||
|
//! as a recoverable string. This exercises the real migrator + the
|
||||||
|
//! `rebuild_from_text` path the runtime forces when a blob column was
|
||||||
|
//! converted (the runtime loop itself isn't booted in integration tests).
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use rdbms_playground::db::Database;
|
||||||
|
use rdbms_playground::dsl::Type;
|
||||||
|
use rdbms_playground::persistence::Persistence;
|
||||||
|
use rdbms_playground::persistence::migrations::{
|
||||||
|
MigratorRegistry, body_declares_blob_column, ensure_project_yaml_migrated,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio rt")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v1_blob_project_migrates_to_text_and_rebuilds() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let root = dir.path();
|
||||||
|
|
||||||
|
// A hand-written v1 project: a serial PK + a `blob` column carrying a
|
||||||
|
// (hand-edited, base64) value — the only way bytes ever reached a blob
|
||||||
|
// column, since no command can insert one.
|
||||||
|
let yaml = concat!(
|
||||||
|
"version: 1\n",
|
||||||
|
"project:\n created_at: 2026-01-01T00:00:00Z\n mode: simple\n",
|
||||||
|
"tables:\n",
|
||||||
|
" - name: Files\n",
|
||||||
|
" primary_key: [id]\n",
|
||||||
|
" columns:\n",
|
||||||
|
" - { name: id, type: serial }\n",
|
||||||
|
" - { name: data, type: blob }\n",
|
||||||
|
"relationships: []\n",
|
||||||
|
"indexes: []\n",
|
||||||
|
);
|
||||||
|
fs::write(root.join("project.yaml"), yaml).expect("write yaml");
|
||||||
|
fs::create_dir(root.join("data")).expect("data dir");
|
||||||
|
fs::write(root.join("data").join("Files.csv"), "id,data\n1,aGVsbG8=\n").expect("write csv");
|
||||||
|
|
||||||
|
// The runtime's blob-detection signal sees the column…
|
||||||
|
assert!(body_declares_blob_column(yaml), "blob column detected");
|
||||||
|
|
||||||
|
// 1. Migration runs (as the runtime does on open).
|
||||||
|
let outcome = ensure_project_yaml_migrated(root, &MigratorRegistry::production())
|
||||||
|
.expect("migration succeeds");
|
||||||
|
assert_eq!(outcome.migrated_from, Some(1), "v1 project was migrated");
|
||||||
|
|
||||||
|
let migrated = fs::read_to_string(root.join("project.yaml")).expect("read migrated");
|
||||||
|
assert!(
|
||||||
|
migrated.contains("version: 2"),
|
||||||
|
"version bumped: {migrated}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
migrated.contains("{ name: data, type: text }"),
|
||||||
|
"blob column rewritten to text: {migrated}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!migrated.contains("type: blob"),
|
||||||
|
"no blob type remains: {migrated}"
|
||||||
|
);
|
||||||
|
// The pre-migration original is preserved as a .bak.
|
||||||
|
let bak = fs::read_to_string(root.join("project.yaml.v1.bak")).expect("read bak");
|
||||||
|
assert!(
|
||||||
|
bak.contains("type: blob"),
|
||||||
|
"bak keeps the original blob column"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Rebuild from the migrated text (as the runtime forces when a blob
|
||||||
|
// column was converted): the column is now `text` and the row data
|
||||||
|
// survives as a recoverable string.
|
||||||
|
let db = Database::open_with_persistence(
|
||||||
|
root.join("playground.db"),
|
||||||
|
Persistence::new(root.to_path_buf()),
|
||||||
|
)
|
||||||
|
.expect("open db");
|
||||||
|
rt().block_on(async {
|
||||||
|
db.rebuild_from_text(root.to_path_buf(), None)
|
||||||
|
.await
|
||||||
|
.expect("rebuild");
|
||||||
|
|
||||||
|
let desc = db
|
||||||
|
.describe_table("Files".to_string())
|
||||||
|
.await
|
||||||
|
.expect("describe");
|
||||||
|
let data_col = desc
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.name == "data")
|
||||||
|
.expect("data column present");
|
||||||
|
assert_eq!(
|
||||||
|
data_col.user_type,
|
||||||
|
Some(Type::Text),
|
||||||
|
"the former blob column is now text",
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = db
|
||||||
|
.query_data("Files".to_string(), None, None)
|
||||||
|
.await
|
||||||
|
.expect("query");
|
||||||
|
assert_eq!(rows.rows.len(), 1, "row preserved through the migration");
|
||||||
|
assert_eq!(
|
||||||
|
rows.rows[0][1].as_deref(),
|
||||||
|
Some("aGVsbG8="),
|
||||||
|
"the base64 cell survives as recoverable text",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -115,6 +115,80 @@ fn help_create_covers_every_form_sharing_the_entry_word() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- issue #36: advanced-mode SQL forms get distinct help content -----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_select_renders_the_sql_select_block() {
|
||||||
|
// `select` is advanced-only (no simple sibling) and used to have
|
||||||
|
// `help_id: None`, so `help select` produced the unknown-topic note.
|
||||||
|
// It now carries its own help page.
|
||||||
|
let out = output_for("help select");
|
||||||
|
let joined = out.join("\n").to_lowercase();
|
||||||
|
assert!(
|
||||||
|
joined.contains("select") && joined.contains("from"),
|
||||||
|
"help select shows the SQL select form: {out:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!out.iter().any(|l| l.contains("No help for")),
|
||||||
|
"help select resolves to content, not the unknown-topic note: {out:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_with_renders_the_cte_block() {
|
||||||
|
let out = output_for("help with");
|
||||||
|
let joined = out.join("\n").to_lowercase();
|
||||||
|
assert!(
|
||||||
|
joined.contains("with") && joined.contains("as ("),
|
||||||
|
"help with shows the CTE form: {out:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!out.iter().any(|l| l.contains("No help for")),
|
||||||
|
"help with resolves to content: {out:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_insert_shows_both_simple_and_sql_forms() {
|
||||||
|
// `help insert` now covers the simple form AND the advanced SQL form
|
||||||
|
// (two clearly-labelled blocks, like `help create` already does).
|
||||||
|
let out = output_for("help insert");
|
||||||
|
let joined = out.join("\n").to_lowercase();
|
||||||
|
assert!(
|
||||||
|
joined.contains("insert into"),
|
||||||
|
"simple insert form shown: {out:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
joined.contains("advanced"),
|
||||||
|
"advanced SQL insert form shown alongside the simple one: {out:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_list_splits_simple_and_advanced_sections() {
|
||||||
|
let out = output_for("help");
|
||||||
|
let joined = out.join("\n");
|
||||||
|
assert!(
|
||||||
|
out.iter().any(|l| l.contains("Simple-mode commands")),
|
||||||
|
"simple-mode section header present: {out:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.iter()
|
||||||
|
.any(|l| l.contains("Advanced-mode") && l.contains("SQL")),
|
||||||
|
"advanced-mode (SQL) section header present: {out:?}",
|
||||||
|
);
|
||||||
|
// Copy rule: never say "DSL" in user-facing text (the old header did).
|
||||||
|
assert!(
|
||||||
|
!joined.contains("DSL"),
|
||||||
|
"help output must not contain 'DSL': {out:?}",
|
||||||
|
);
|
||||||
|
// The advanced query commands are now discoverable in the list.
|
||||||
|
assert!(
|
||||||
|
joined.to_lowercase().contains("select"),
|
||||||
|
"select is listed in help: {out:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_types_renders_the_type_reference() {
|
fn help_types_renders_the_type_reference() {
|
||||||
let out = output_for("help types");
|
let out = output_for("help types");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! `tests/typing_surface_matrix.rs` stays a separate binary (it is
|
//! `tests/typing_surface_matrix.rs` stays a separate binary (it is
|
||||||
//! already a consolidated `mod`-based target).
|
//! already a consolidated `mod`-based target).
|
||||||
|
|
||||||
|
mod blob_removal_migration;
|
||||||
mod case_insensitive_names;
|
mod case_insensitive_names;
|
||||||
mod column_op_guards;
|
mod column_op_guards;
|
||||||
mod compound_fk;
|
mod compound_fk;
|
||||||
|
|||||||
@@ -617,69 +617,6 @@ fn seed_refuses_when_a_parent_table_is_empty() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn seed_refuses_a_not_null_blob_column() {
|
|
||||||
let (_project, db, _dir) = open_project_db();
|
|
||||||
let rt = rt();
|
|
||||||
let mut payload = ColumnSpec::new("payload", Type::Blob);
|
|
||||||
payload.not_null = true;
|
|
||||||
rt.block_on(db.create_table(
|
|
||||||
"Files".to_string(),
|
|
||||||
vec![ColumnSpec::new("id", Type::Serial), payload],
|
|
||||||
vec!["id".to_string()],
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
.expect("create Files");
|
|
||||||
|
|
||||||
let err = rt
|
|
||||||
.block_on(db.seed(
|
|
||||||
"Files".into(),
|
|
||||||
None,
|
|
||||||
Some(2),
|
|
||||||
Vec::new(),
|
|
||||||
Some(1),
|
|
||||||
Some("seed Files 2".into()),
|
|
||||||
))
|
|
||||||
.expect_err("seed must refuse a NOT NULL blob");
|
|
||||||
let msg = err.to_string();
|
|
||||||
assert!(
|
|
||||||
msg.contains("payload") && msg.to_lowercase().contains("blob"),
|
|
||||||
"error should name the un-generatable blob column: {msg}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn seed_omits_a_nullable_blob_column() {
|
|
||||||
let (project, db, _dir) = open_project_db();
|
|
||||||
let rt = rt();
|
|
||||||
rt.block_on(db.create_table(
|
|
||||||
"Files".to_string(),
|
|
||||||
vec![
|
|
||||||
ColumnSpec::new("id", Type::Serial),
|
|
||||||
ColumnSpec::new("name", Type::Text),
|
|
||||||
// nullable blob → omitted (→ NULL), seed still succeeds.
|
|
||||||
ColumnSpec::new("payload", Type::Blob),
|
|
||||||
],
|
|
||||||
vec!["id".to_string()],
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
.expect("create Files");
|
|
||||||
|
|
||||||
let res = rt
|
|
||||||
.block_on(db.seed(
|
|
||||||
"Files".into(),
|
|
||||||
None,
|
|
||||||
Some(3),
|
|
||||||
Vec::new(),
|
|
||||||
Some(1),
|
|
||||||
Some("seed Files 3".into()),
|
|
||||||
))
|
|
||||||
.expect("seed succeeds despite the nullable blob");
|
|
||||||
assert_eq!(res.produced, 3);
|
|
||||||
let csv = read_csv(&project, "Files").expect("Files CSV");
|
|
||||||
assert_eq!(data_row_count(&csv), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// — uniqueness, junction distinct-combos, IN-CHECK (D10 / D14 / D17) —
|
// — uniqueness, junction distinct-combos, IN-CHECK (D10 / D14 / D17) —
|
||||||
|
|
||||||
/// The `n`th comma-separated field of each data row (the generated
|
/// The `n`th comma-separated field of each data row (the generated
|
||||||
|
|||||||
@@ -356,14 +356,6 @@ fn e2e_alter_column_type_static_refusals() {
|
|||||||
),
|
),
|
||||||
"text→serial is refused (only int→serial is allowed)"
|
"text→serial is refused (only int→serial is allowed)"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
replay_is_refused(
|
|
||||||
"create table T with pk id(int)\n\
|
|
||||||
add column T: v (text)\n\
|
|
||||||
alter table T alter column v type blob\n",
|
|
||||||
),
|
|
||||||
"↔ blob is statically refused"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+11
-14
@@ -208,13 +208,12 @@ fn e2e_insert_select_cross_table_copies_rows_and_persists_both() {
|
|||||||
// ===============================================================
|
// ===============================================================
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type() {
|
fn e2e_multirow_insert_all_nine_types_roundtrips_and_returning_recovers_each_type() {
|
||||||
let (project, db, _dir) = open_project_db();
|
let (project, db, _dir) = open_project_db();
|
||||||
let rt = rt();
|
let rt = rt();
|
||||||
// serial PK + shortid auto-fill; the other eight columns are
|
// serial PK + shortid auto-fill; the other seven columns are
|
||||||
// user-supplied. `blob` has no value-literal grammar yet
|
// user-supplied. Their *types* round-trip through the RETURNING
|
||||||
// (see src/dsl/value.rs), so it is inserted NULL — its *type*
|
// column-origin path.
|
||||||
// still round-trips through the RETURNING column-origin path.
|
|
||||||
create_cols(
|
create_cols(
|
||||||
&db,
|
&db,
|
||||||
&rt,
|
&rt,
|
||||||
@@ -228,7 +227,6 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
|
|||||||
("flag", Type::Bool),
|
("flag", Type::Bool),
|
||||||
("d", Type::Date),
|
("d", Type::Date),
|
||||||
("ts", Type::DateTime),
|
("ts", Type::DateTime),
|
||||||
("bl", Type::Blob),
|
|
||||||
("sid", Type::ShortId),
|
("sid", Type::ShortId),
|
||||||
],
|
],
|
||||||
&["ser"],
|
&["ser"],
|
||||||
@@ -237,17 +235,17 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
|
|||||||
let result = run_insert(
|
let result = run_insert(
|
||||||
&db,
|
&db,
|
||||||
&rt,
|
&rt,
|
||||||
"insert into allten (txt, i, r, dec, flag, d, ts, bl) values \
|
"insert into allten (txt, i, r, dec, flag, d, ts) values \
|
||||||
('hi', 42, 1.5, 9.50, true, '2026-05-23', '2026-05-23 10:00:00', null), \
|
('hi', 42, 1.5, 9.50, true, '2026-05-23', '2026-05-23 10:00:00'), \
|
||||||
('yo', 7, 2.5, 3.25, false, '2025-01-01', '2025-01-01 00:00:00', null) \
|
('yo', 7, 2.5, 3.25, false, '2025-01-01', '2025-01-01 00:00:00') \
|
||||||
returning ser, txt, i, r, dec, flag, d, ts, bl, sid",
|
returning ser, txt, i, r, dec, flag, d, ts, sid",
|
||||||
)
|
)
|
||||||
.expect("multi-row INSERT … RETURNING runs");
|
.expect("multi-row INSERT … RETURNING runs");
|
||||||
|
|
||||||
assert_eq!(result.rows_affected, 2, "two rows inserted");
|
assert_eq!(result.rows_affected, 2, "two rows inserted");
|
||||||
assert_eq!(result.data.rows.len(), 2, "RETURNING yields both rows");
|
assert_eq!(result.data.rows.len(), 2, "RETURNING yields both rows");
|
||||||
|
|
||||||
// Every one of the ten playground types is recovered via the
|
// Every one of the nine playground types is recovered via the
|
||||||
// RETURNING column-origin path (matrix R5).
|
// RETURNING column-origin path (matrix R5).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.data.column_types,
|
result.data.column_types,
|
||||||
@@ -260,10 +258,9 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
|
|||||||
Some(Type::Bool),
|
Some(Type::Bool),
|
||||||
Some(Type::Date),
|
Some(Type::Date),
|
||||||
Some(Type::DateTime),
|
Some(Type::DateTime),
|
||||||
Some(Type::Blob),
|
|
||||||
Some(Type::ShortId),
|
Some(Type::ShortId),
|
||||||
],
|
],
|
||||||
"RETURNING recovers each of the ten playground types; got {:?}",
|
"RETURNING recovers each of the nine playground types; got {:?}",
|
||||||
result.data.column_types,
|
result.data.column_types,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -281,7 +278,7 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
|
|||||||
"dates round-trip: {csv:?}"
|
"dates round-trip: {csv:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let sids: Vec<&str> = rows.iter().filter_map(|r| r[9].as_deref()).collect();
|
let sids: Vec<&str> = rows.iter().filter_map(|r| r[8].as_deref()).collect();
|
||||||
assert_eq!(sids.len(), 2, "both shortids present");
|
assert_eq!(sids.len(), 2, "both shortids present");
|
||||||
assert!(
|
assert!(
|
||||||
sids.iter().all(|s| !s.is_empty()),
|
sids.iter().all(|s| !s.is_empty()),
|
||||||
|
|||||||
+4
-31
@@ -575,11 +575,7 @@ fn database_run_select_type_recovery_works_on_empty_table() {
|
|||||||
// when no row matches.
|
// when no row matches.
|
||||||
//
|
//
|
||||||
// This test pins that invariant: a fresh table with no
|
// This test pins that invariant: a fresh table with no
|
||||||
// rows still yields the right `column_types` entry. It
|
// rows still yields the right `column_types` entry.
|
||||||
// also justifies the all-types test below using NULL for
|
|
||||||
// col_blob (the DSL Value enum has no Blob variant, but
|
|
||||||
// since metadata doesn't read row values, a NULL cell
|
|
||||||
// doesn't compromise the recovery).
|
|
||||||
let (_p, db, _dir) = open_project_db();
|
let (_p, db, _dir) = open_project_db();
|
||||||
let rt = rt();
|
let rt = rt();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
@@ -588,7 +584,6 @@ fn database_run_select_type_recovery_works_on_empty_table() {
|
|||||||
vec![
|
vec![
|
||||||
ColumnSpec::new("id", Type::Serial),
|
ColumnSpec::new("id", Type::Serial),
|
||||||
ColumnSpec::new("col_text", Type::Text),
|
ColumnSpec::new("col_text", Type::Text),
|
||||||
ColumnSpec::new("col_blob", Type::Blob),
|
|
||||||
],
|
],
|
||||||
vec!["id".to_string()],
|
vec!["id".to_string()],
|
||||||
None,
|
None,
|
||||||
@@ -602,32 +597,16 @@ fn database_run_select_type_recovery_works_on_empty_table() {
|
|||||||
.expect("SELECT runs even on empty table");
|
.expect("SELECT runs even on empty table");
|
||||||
assert!(data_text.rows.is_empty());
|
assert!(data_text.rows.is_empty());
|
||||||
assert_eq!(data_text.column_types, vec![Some(Type::Text)]);
|
assert_eq!(data_text.column_types, vec![Some(Type::Text)]);
|
||||||
|
|
||||||
let data_blob = rt
|
|
||||||
.block_on(db.run_select("select col_blob from Empty".to_string()))
|
|
||||||
.expect("SELECT runs even on empty table");
|
|
||||||
assert!(data_blob.rows.is_empty());
|
|
||||||
assert_eq!(
|
|
||||||
data_blob.column_types,
|
|
||||||
vec![Some(Type::Blob)],
|
|
||||||
"Blob metadata must be recoverable even with no row data",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn database_run_select_recovers_all_ten_playground_types() {
|
fn database_run_select_recovers_all_nine_playground_types() {
|
||||||
// ADR-0032 §12 + Amendment 1 — every playground type
|
// ADR-0032 §12 + Amendment 1 — every playground type
|
||||||
// round-trips through column-origin metadata on a bare
|
// round-trips through column-origin metadata on a bare
|
||||||
// projection ref. One table holds one column of each
|
// projection ref. One table holds one column of each
|
||||||
// type; a SELECT of that column produces the right
|
// type; a SELECT of that column produces the right
|
||||||
// `column_types[0]` entry.
|
// `column_types[0]` entry. `serial` and `shortid` are
|
||||||
//
|
// auto-generated.
|
||||||
// `serial` and `shortid` are auto-generated. `col_blob`
|
|
||||||
// is left NULL in the inserted row because the DSL Value
|
|
||||||
// enum has no Blob variant — but per
|
|
||||||
// `database_run_select_type_recovery_works_on_empty_table`
|
|
||||||
// above, column-origin metadata is row-independent, so
|
|
||||||
// the NULL cell doesn't compromise this test's correctness.
|
|
||||||
let (_p, db, _dir) = open_project_db();
|
let (_p, db, _dir) = open_project_db();
|
||||||
let rt = rt();
|
let rt = rt();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
@@ -642,7 +621,6 @@ fn database_run_select_recovers_all_ten_playground_types() {
|
|||||||
ColumnSpec::new("col_bool", Type::Bool),
|
ColumnSpec::new("col_bool", Type::Bool),
|
||||||
ColumnSpec::new("col_date", Type::Date),
|
ColumnSpec::new("col_date", Type::Date),
|
||||||
ColumnSpec::new("col_datetime", Type::DateTime),
|
ColumnSpec::new("col_datetime", Type::DateTime),
|
||||||
ColumnSpec::new("col_blob", Type::Blob),
|
|
||||||
ColumnSpec::new("col_shortid", Type::ShortId),
|
ColumnSpec::new("col_shortid", Type::ShortId),
|
||||||
],
|
],
|
||||||
vec!["pk".to_string()],
|
vec!["pk".to_string()],
|
||||||
@@ -650,10 +628,6 @@ fn database_run_select_recovers_all_ten_playground_types() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("create table");
|
.expect("create table");
|
||||||
// Blob has no DSL literal form, so col_blob takes the
|
|
||||||
// default NULL on insert. Column-origin metadata is
|
|
||||||
// based on the column DEFINITION, not the row value
|
|
||||||
// (Amendment 1), so the type recovery still succeeds.
|
|
||||||
db.insert(
|
db.insert(
|
||||||
"AllTypes".to_string(),
|
"AllTypes".to_string(),
|
||||||
Some(vec![
|
Some(vec![
|
||||||
@@ -691,7 +665,6 @@ fn database_run_select_recovers_all_ten_playground_types() {
|
|||||||
("col_bool", Type::Bool),
|
("col_bool", Type::Bool),
|
||||||
("col_date", Type::Date),
|
("col_date", Type::Date),
|
||||||
("col_datetime", Type::DateTime),
|
("col_datetime", Type::DateTime),
|
||||||
("col_blob", Type::Blob),
|
|
||||||
("col_shortid", Type::ShortId),
|
("col_shortid", Type::ShortId),
|
||||||
];
|
];
|
||||||
for (col, expected_type) in cases {
|
for (col, expected_type) in cases {
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ fn colon_escape_in_simple_mode_is_one_shot() {
|
|||||||
echoed.text,
|
echoed.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Issue #39: dispatching `:select 1` arms the schema-refresh gate (a
|
||||||
|
// cache refresh is conceptually in flight). In the real runtime that
|
||||||
|
// refresh lands before the user types the next line; model it here so
|
||||||
|
// the follow-up submission is processed rather than held.
|
||||||
|
app.update(AppEvent::SchemaCacheRefreshed(app.schema_cache.clone()));
|
||||||
|
|
||||||
// Subsequent submission (unrecognised in simple mode) parse-errors,
|
// Subsequent submission (unrecognised in simple mode) parse-errors,
|
||||||
// not echoes — confirming the mode reverted.
|
// not echoes — confirming the mode reverted.
|
||||||
type_str(&mut app, "list things");
|
type_str(&mut app, "list things");
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ pub fn schema_every_type() -> SchemaCache {
|
|||||||
("b", Type::Bool),
|
("b", Type::Bool),
|
||||||
("dt", Type::Date),
|
("dt", Type::Date),
|
||||||
("ts", Type::DateTime),
|
("ts", Type::DateTime),
|
||||||
("data", Type::Blob),
|
|
||||||
("sid", Type::ShortId),
|
("sid", Type::ShortId),
|
||||||
("auto", Type::Serial),
|
("auto", Type::Serial),
|
||||||
("note", Type::Text),
|
("note", Type::Text),
|
||||||
|
|||||||
+1
-10
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tests/typing_surface/create_table.rs
|
source: tests/typing_surface/create_table.rs
|
||||||
|
assertion_line: 89
|
||||||
description: "input=\"create table Customers with pk Code(\" cursor=36"
|
description: "input=\"create table Customers with pk Code(\" cursor=36"
|
||||||
expression: "& a"
|
expression: "& a"
|
||||||
---
|
---
|
||||||
@@ -45,11 +46,6 @@ Assessment {
|
|||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
Candidate {
|
|
||||||
text: "blob",
|
|
||||||
kind: Keyword,
|
|
||||||
mode: Both,
|
|
||||||
},
|
|
||||||
Candidate {
|
Candidate {
|
||||||
text: "serial",
|
text: "serial",
|
||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
@@ -107,11 +103,6 @@ Assessment {
|
|||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
Candidate {
|
|
||||||
text: "blob",
|
|
||||||
kind: Keyword,
|
|
||||||
mode: Both,
|
|
||||||
},
|
|
||||||
Candidate {
|
Candidate {
|
||||||
text: "serial",
|
text: "serial",
|
||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
|
|||||||
+1
-5
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tests/typing_surface/delete_with_where.rs
|
source: tests/typing_surface/delete_with_where.rs
|
||||||
|
assertion_line: 76
|
||||||
description: "input=\"delete from Things where ts=\" cursor=28"
|
description: "input=\"delete from Things where ts=\" cursor=28"
|
||||||
expression: "& a"
|
expression: "& a"
|
||||||
---
|
---
|
||||||
@@ -35,11 +36,6 @@ Assessment {
|
|||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
Candidate {
|
|
||||||
text: "data",
|
|
||||||
kind: Identifier,
|
|
||||||
mode: Both,
|
|
||||||
},
|
|
||||||
Candidate {
|
Candidate {
|
||||||
text: "dt",
|
text: "dt",
|
||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
|
|||||||
+1
-10
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tests/typing_surface/rename_change_column.rs
|
source: tests/typing_surface/rename_change_column.rs
|
||||||
|
assertion_line: 69
|
||||||
description: "input=\"change column in Customers: Email (\" cursor=35"
|
description: "input=\"change column in Customers: Email (\" cursor=35"
|
||||||
expression: "& a"
|
expression: "& a"
|
||||||
---
|
---
|
||||||
@@ -45,11 +46,6 @@ Assessment {
|
|||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
Candidate {
|
|
||||||
text: "blob",
|
|
||||||
kind: Keyword,
|
|
||||||
mode: Both,
|
|
||||||
},
|
|
||||||
Candidate {
|
Candidate {
|
||||||
text: "serial",
|
text: "serial",
|
||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
@@ -107,11 +103,6 @@ Assessment {
|
|||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
Candidate {
|
|
||||||
text: "blob",
|
|
||||||
kind: Keyword,
|
|
||||||
mode: Both,
|
|
||||||
},
|
|
||||||
Candidate {
|
Candidate {
|
||||||
text: "serial",
|
text: "serial",
|
||||||
kind: Keyword,
|
kind: Keyword,
|
||||||
|
|||||||
+1
-5
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tests/typing_surface/where_expression.rs
|
source: tests/typing_surface/where_expression.rs
|
||||||
|
assertion_line: 50
|
||||||
description: "input=\"delete from Things where k between \" cursor=35"
|
description: "input=\"delete from Things where k between \" cursor=35"
|
||||||
expression: "& a"
|
expression: "& a"
|
||||||
---
|
---
|
||||||
@@ -35,11 +36,6 @@ Assessment {
|
|||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
Candidate {
|
|
||||||
text: "data",
|
|
||||||
kind: Identifier,
|
|
||||||
mode: Both,
|
|
||||||
},
|
|
||||||
Candidate {
|
Candidate {
|
||||||
text: "dt",
|
text: "dt",
|
||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
|
|||||||
+1
-5
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tests/typing_surface/where_expression.rs
|
source: tests/typing_surface/where_expression.rs
|
||||||
|
assertion_line: 58
|
||||||
description: "input=\"delete from Things where k in (\" cursor=31"
|
description: "input=\"delete from Things where k in (\" cursor=31"
|
||||||
expression: "& a"
|
expression: "& a"
|
||||||
---
|
---
|
||||||
@@ -35,11 +36,6 @@ Assessment {
|
|||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
Candidate {
|
|
||||||
text: "data",
|
|
||||||
kind: Identifier,
|
|
||||||
mode: Both,
|
|
||||||
},
|
|
||||||
Candidate {
|
Candidate {
|
||||||
text: "dt",
|
text: "dt",
|
||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export default defineConfig({
|
|||||||
// Website ID is public by design (it ships in every page). The script
|
// Website ID is public by design (it ships in every page). The script
|
||||||
// loads from the Umami host, which also becomes the data destination,
|
// loads from the Umami host, which also becomes the data destination,
|
||||||
// so no `data-host-url` is needed.
|
// so no `data-host-url` is needed.
|
||||||
|
// - Served first-party under `umami.relplay.org` (a proxied CNAME to
|
||||||
|
// the Umami server) rather than the server's own host: privacy
|
||||||
|
// blockers (e.g. Brave Shields) block Umami only as a *third-party*
|
||||||
|
// request, so a same-registrable-domain host is not flagged.
|
||||||
// - data-domains: only run on the production apex, so the
|
// - data-domains: only run on the production apex, so the
|
||||||
// `website.relplay.pages.dev` preview and `staging.relplay.org`
|
// `website.relplay.pages.dev` preview and `staging.relplay.org`
|
||||||
// don't pollute the stats.
|
// don't pollute the stats.
|
||||||
@@ -71,7 +75,7 @@ export default defineConfig({
|
|||||||
tag: 'script',
|
tag: 'script',
|
||||||
attrs: {
|
attrs: {
|
||||||
defer: true,
|
defer: true,
|
||||||
src: 'https://umami.oliversturm.com/script.js',
|
src: 'https://umami.relplay.org/script.js',
|
||||||
'data-website-id': 'fd77cfa5-cffe-4fc8-addb-1c6d7b6d9939',
|
'data-website-id': 'fd77cfa5-cffe-4fc8-addb-1c6d7b6d9939',
|
||||||
'data-domains': 'relplay.org',
|
'data-domains': 'relplay.org',
|
||||||
'data-do-not-track': true,
|
'data-do-not-track': true,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ visitors. We keep the analytics deliberately minimal.
|
|||||||
|
|
||||||
We measure aggregate usage with **[Umami](https://umami.is/)**, a
|
We measure aggregate usage with **[Umami](https://umami.is/)**, a
|
||||||
privacy-friendly analytics tool that we **host ourselves** (on
|
privacy-friendly analytics tool that we **host ourselves** (on
|
||||||
`umami.oliversturm.com`). Nothing is sent to a third-party advertising
|
`umami.relplay.org`). Nothing is sent to a third-party advertising
|
||||||
network.
|
network.
|
||||||
|
|
||||||
For each page view we record:
|
For each page view we record:
|
||||||
|
|||||||
@@ -306,9 +306,6 @@ automatically — no override needed.
|
|||||||
(a guard against a typo like `seed members 1000000`). Seed in smaller
|
(a guard against a typo like `seed members 1000000`). Seed in smaller
|
||||||
batches if you genuinely need more.
|
batches if you genuinely need more.
|
||||||
- `seed members 0` does nothing.
|
- `seed members 0` does nothing.
|
||||||
- A `not null` column seed cannot produce a value for — the only real case is
|
|
||||||
a `not null blob` — makes seed refuse the whole command and name the column,
|
|
||||||
rather than fail partway through.
|
|
||||||
|
|
||||||
A whole `seed` is a **single step** in the history: one [`undo`](/using-the-playground/undo-and-history/)
|
A whole `seed` is a **single step** in the history: one [`undo`](/using-the-playground/undo-and-history/)
|
||||||
removes every row it added, not one row at a time.
|
removes every row it added, not one row at a time.
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
title: Types
|
title: Types
|
||||||
description: The ten column types, what they store, and the auto-generating serial and shortid types.
|
description: The nine column types, what they store, and the auto-generating serial and shortid types.
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 1
|
order: 1
|
||||||
---
|
---
|
||||||
|
|
||||||
Every column has a type. The playground offers ten, chosen to cover what a
|
Every column has a type. The playground offers nine, chosen to cover what a
|
||||||
learner needs without the sprawl of a production database.
|
learner needs without the sprawl of a production database.
|
||||||
|
|
||||||
## The ten types
|
## The nine types
|
||||||
|
|
||||||
| Type | Stores |
|
| Type | Stores |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -19,7 +19,6 @@ learner needs without the sprawl of a production database.
|
|||||||
| `bool` | A truth value, shown as `true` / `false`. |
|
| `bool` | A truth value, shown as `true` / `false`. |
|
||||||
| `date` | A calendar date, `YYYY-MM-DD`. |
|
| `date` | A calendar date, `YYYY-MM-DD`. |
|
||||||
| `datetime` | A date and time, `YYYY-MM-DDTHH:MM:SS`. |
|
| `datetime` | A date and time, `YYYY-MM-DDTHH:MM:SS`. |
|
||||||
| `blob` | Arbitrary binary data. |
|
|
||||||
| `serial` | An auto-incrementing whole number — see below. |
|
| `serial` | An auto-incrementing whole number — see below. |
|
||||||
| `shortid` | A short random identifier — see below. |
|
| `shortid` | A short random identifier — see below. |
|
||||||
|
|
||||||
@@ -68,5 +67,5 @@ In advanced mode you may also use familiar standard-SQL spellings, which map
|
|||||||
onto the types above — for example `integer`, `bigint`, and `smallint` are
|
onto the types above — for example `integer`, `bigint`, and `smallint` are
|
||||||
all `int`; `varchar` and `char` are `text`; `boolean` is `bool`; `timestamp`
|
all `int`; `varchar` and `char` are `text`; `boolean` is `bool`; `timestamp`
|
||||||
is `datetime`; `numeric` is `decimal`; `float` and `double precision` are
|
is `datetime`; `numeric` is `decimal`; `float` and `double precision` are
|
||||||
`real`. Simple mode uses only the ten names in the table, so it teaches one
|
`real`. Simple mode uses only the nine names in the table, so it teaches one
|
||||||
clear vocabulary.
|
clear vocabulary.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const repository = {
|
|||||||
name: 'constant.language.rdbms',
|
name: 'constant.language.rdbms',
|
||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
match: '(?i)\\b(text|int|real|decimal|bool|date|datetime|blob|serial|shortid)\\b',
|
match: '(?i)\\b(text|int|real|decimal|bool|date|datetime|serial|shortid)\\b',
|
||||||
name: 'support.type.rdbms',
|
name: 'support.type.rdbms',
|
||||||
},
|
},
|
||||||
keyword: {
|
keyword: {
|
||||||
|
|||||||
Reference in New Issue
Block a user