Compare commits
21
Commits
v0.2.0
...
c1f24599da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f24599da | ||
|
|
a8ebbfba26 | ||
|
|
f06cbbc788 | ||
|
|
f2b4ed00f4 | ||
|
|
88204f25c5 | ||
|
|
fd63de3441 | ||
|
|
65eab71439 | ||
|
|
c86df95a0f | ||
|
|
fb536b4245 | ||
|
|
1237aa59c1 | ||
|
|
ba0872dbb9 | ||
|
|
56e3456cfc | ||
|
|
0208c67e59 | ||
|
|
6bb2288470 | ||
|
|
6d54c1e96c | ||
|
|
c0531aa048 | ||
|
|
42b40bc099 | ||
|
|
cabc8131a9 | ||
|
|
8ebe213b5d | ||
|
|
d3af1c413a | ||
|
|
3c87dbb391 |
@@ -6,6 +6,11 @@
|
||||
# was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 /
|
||||
# issue #35). The release job (static binary for D2) and the platform matrix
|
||||
# layer on later, step by step.
|
||||
#
|
||||
# A separate, lightweight `manifests` job logic-tests the package-manifest
|
||||
# render scripts (Scoop/Homebrew) used by publish.yaml — bash + node only, no
|
||||
# toolchain — so a render regression surfaces on the breaking push rather than
|
||||
# weeks later at the next manual publish dispatch (ADR-0056 Amendment 2).
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
@@ -46,3 +51,21 @@ jobs:
|
||||
run: nix develop -c cargo clippy --all-targets -- -D warnings
|
||||
- name: test
|
||||
run: nix develop -c cargo test --no-fail-fast
|
||||
|
||||
# Logic test for the package-manifest render scripts. Renders with DUMMY
|
||||
# inputs and validates the output — it never publishes or touches the lazyeval
|
||||
# repos (that is publish.yaml's manual job). Runs on the same image but skips
|
||||
# nix: it needs only bash + node, both in the base image.
|
||||
#
|
||||
# NOTE: the CI image has no ruby, so the script's `ruby -c` formula syntax
|
||||
# check is skipped here (it degrades gracefully); the Scoop JSON is still
|
||||
# validated with node and both manifests' fields are asserted. Full formula
|
||||
# syntax is checked dev-side (ruby present) on every pre-commit local run.
|
||||
manifests:
|
||||
runs-on: ci-public
|
||||
container:
|
||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: render-script tests (Scoop + Homebrew)
|
||||
run: bash scripts/test-package-renders.sh
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# Manual publication workflow (workflow_dispatch) — the outward, irreversible
|
||||
# release steps a human triggers AFTER the automated `release.yaml` build has
|
||||
# produced downloadable assets (and they've been eyeballed as good).
|
||||
#
|
||||
# Why manual + separate from release.yaml:
|
||||
# * Publishing to a public registry is irreversible (crates.io versions can
|
||||
# only be *yanked*, never deleted) — a human pulls this lever, and the
|
||||
# registry token never sits on every tag push.
|
||||
# * Our release is split (Linux/Windows on the tag, macOS dispatched), so a
|
||||
# human is the natural "all assets are up — go" gate. crates.io publish
|
||||
# reads SOURCE so it doesn't strictly need the release, but binstall's
|
||||
# metadata points at the release assets — hence run this once builds exist.
|
||||
#
|
||||
# Structure: each registry is its OWN job with NO inter-job `needs`, so jobs run
|
||||
# independently and one failing (or a newly-added one) never breaks another.
|
||||
# Every job is IDEMPOTENT — re-dispatching when a target is already published is
|
||||
# a clean no-op. Add Scoop / Homebrew / winget as sibling jobs here later.
|
||||
name: publish
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish (e.g. v0.2.0)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
crates-io:
|
||||
runs-on: ci-public
|
||||
container:
|
||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: publish to crates.io (idempotent)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
# A crate-scoped, publish-update crates.io token, stored as a Gitea
|
||||
# Actions secret. `cargo publish` reads CARGO_REGISTRY_TOKEN from env.
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Source of truth = the [package] version at the checked-out tag
|
||||
# (toolchain-free read; same approach as release.yaml's guard, which
|
||||
# avoids the flake devShell's stdout banner corrupting a parse).
|
||||
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
|
||||
[ -n "$VER" ] || { echo "ERROR: could not read version from Cargo.toml" >&2; exit 1; }
|
||||
if [ "$TAG" != "v$VER" ]; then
|
||||
echo "ERROR: dispatch tag '$TAG' != 'v$VER' (Cargo.toml at that tag)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Idempotency: if this version is already on crates.io, no-op.
|
||||
# (crates.io requires a descriptive User-Agent per its data policy;
|
||||
# without one the API returns 403.) Only an explicit 200 means
|
||||
# "already there" — anything else proceeds, and `cargo publish` is the
|
||||
# final backstop (it refuses to overwrite an existing version).
|
||||
UA="rdbms-playground-release-ci (oliver@sturmnet.org)"
|
||||
code=$(curl -sS -o /dev/null -w '%{http_code}' -A "$UA" \
|
||||
"https://crates.io/api/v1/crates/rdbms-playground/$VER" || echo 000)
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "rdbms-playground $VER is already on crates.io — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
echo "crates.io returned HTTP $code for $VER (not 200) — proceeding to publish."
|
||||
|
||||
echo "publishing rdbms-playground $VER to crates.io ..."
|
||||
nix develop -c cargo publish --locked
|
||||
echo "published rdbms-playground $VER to crates.io."
|
||||
|
||||
# Update the lazyeval Scoop bucket (Windows). Renders the manifest from the
|
||||
# release's .sha256 sidecars and commits it to lazyeval/scoop-bucket. Pushes
|
||||
# with the lazyeval-ci bot token (LAZYEVAL_PKG_TOKEN), which is scoped — via
|
||||
# the bot's org-team membership — to the lazyeval package repos only, so a
|
||||
# leak cannot touch oli/rdbms-playground.
|
||||
scoop-bucket:
|
||||
runs-on: ci-public
|
||||
container:
|
||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # default ref (main) — current render script
|
||||
|
||||
- name: update the lazyeval Scoop bucket (idempotent)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
# Passed via env, never inlined into the script, so the value stays
|
||||
# masked in logs; it only materialises in the clone URL at runtime.
|
||||
PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${TAG#v}"
|
||||
echo "scoop: targeting rdbms-playground $VER ($TAG)"
|
||||
|
||||
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
|
||||
fetch_hash() {
|
||||
local asset="$1" line
|
||||
echo "scoop: fetching $asset.sha256" >&2
|
||||
line=$(curl -fsSL "$base/$asset.sha256") \
|
||||
|| { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; }
|
||||
# First whitespace-delimited field is the hash. `read` is a bash
|
||||
# builtin (no awk, which the slim CI image may lack).
|
||||
local hash _
|
||||
read -r hash _ <<<"$line"
|
||||
printf '%s' "$hash"
|
||||
}
|
||||
h_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe")
|
||||
h_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe")
|
||||
|
||||
echo "scoop: rendering manifest"
|
||||
bash scripts/render-scoop-manifest.sh "$VER" "$h_x64" "$h_arm" > /tmp/rdbms-playground.json
|
||||
node -e 'JSON.parse(require("fs").readFileSync("/tmp/rdbms-playground.json","utf8"))' \
|
||||
|| { echo "ERROR: rendered Scoop manifest is not valid JSON" >&2; exit 1; }
|
||||
|
||||
work=$(mktemp -d)
|
||||
echo "scoop: cloning lazyeval/scoop-bucket"
|
||||
git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/scoop-bucket.git" "$work"
|
||||
cp /tmp/rdbms-playground.json "$work/rdbms-playground.json"
|
||||
|
||||
cd "$work"
|
||||
git config user.name "lazyeval-ci"
|
||||
git config user.email "ci@lazyeval.net"
|
||||
git add rdbms-playground.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "scoop: manifest already at $VER — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "rdbms-playground $VER"
|
||||
# Push to main explicitly: a freshly-created (empty) repo clone may put
|
||||
# the first commit on a differently-named local branch. Assumes the
|
||||
# bucket/tap default branch is `main` (Gitea's default for new repos).
|
||||
git push origin HEAD:main
|
||||
echo "scoop: bucket updated to rdbms-playground $VER."
|
||||
|
||||
# Update the lazyeval Homebrew tap (macOS + Linux). Same shape as scoop-bucket;
|
||||
# writes Formula/rdbms-playground.rb into lazyeval/homebrew-tap.
|
||||
homebrew-tap:
|
||||
runs-on: ci-public
|
||||
container:
|
||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: update the lazyeval Homebrew tap (idempotent)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${TAG#v}"
|
||||
echo "homebrew: targeting rdbms-playground $VER ($TAG)"
|
||||
|
||||
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
|
||||
fetch_hash() {
|
||||
local asset="$1" line
|
||||
echo "homebrew: fetching $asset.sha256" >&2
|
||||
line=$(curl -fsSL "$base/$asset.sha256") \
|
||||
|| { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; }
|
||||
# First whitespace-delimited field is the hash. `read` is a bash
|
||||
# builtin (no awk, which the slim CI image may lack).
|
||||
local hash _
|
||||
read -r hash _ <<<"$line"
|
||||
printf '%s' "$hash"
|
||||
}
|
||||
mac_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-apple-darwin")
|
||||
mac_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-apple-darwin")
|
||||
lin_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-unknown-linux-musl")
|
||||
lin_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-unknown-linux-musl")
|
||||
|
||||
echo "homebrew: rendering formula"
|
||||
bash scripts/render-homebrew-formula.sh "$VER" "$mac_arm" "$mac_x64" "$lin_arm" "$lin_x64" \
|
||||
> /tmp/rdbms-playground.rb
|
||||
grep -q '^class RdbmsPlayground < Formula$' /tmp/rdbms-playground.rb \
|
||||
|| { echo "ERROR: rendered formula looks malformed" >&2; exit 1; }
|
||||
|
||||
work=$(mktemp -d)
|
||||
echo "homebrew: cloning lazyeval/homebrew-tap"
|
||||
git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/homebrew-tap.git" "$work"
|
||||
mkdir -p "$work/Formula"
|
||||
cp /tmp/rdbms-playground.rb "$work/Formula/rdbms-playground.rb"
|
||||
|
||||
cd "$work"
|
||||
git config user.name "lazyeval-ci"
|
||||
git config user.email "ci@lazyeval.net"
|
||||
git add Formula/rdbms-playground.rb
|
||||
if git diff --cached --quiet; then
|
||||
echo "homebrew: formula already at $VER — nothing to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "rdbms-playground $VER"
|
||||
# Push to main explicitly: a freshly-created (empty) repo clone may put
|
||||
# the first commit on a differently-named local branch. Assumes the
|
||||
# bucket/tap default branch is `main` (Gitea's default for new repos).
|
||||
git push origin HEAD:main
|
||||
echo "homebrew: tap updated to rdbms-playground $VER."
|
||||
|
||||
# Update the winget package (Windows) by opening a PR to microsoft/winget-pkgs
|
||||
# with komac. Unlike scoop-bucket/homebrew-tap (which push to OUR repos and are
|
||||
# live at once), winget is a PR into Microsoft's central, human-gated catalog —
|
||||
# asynchronous, and re-submitting the same version would open a DUPLICATE PR. So
|
||||
# this job guards on both already-merged versions AND already-open PRs before
|
||||
# submitting, which keeps a repeated `publish` dispatch safe.
|
||||
#
|
||||
# Auth: komac needs a CLASSIC GitHub PAT with `public_repo` (fine-grained tokens
|
||||
# cannot open the cross-fork PR — komac #310). It is held on a dedicated GitHub
|
||||
# bot account (a leak can't reach other repos — same reasoning as lazyeval-ci)
|
||||
# and referenced ONLY in this job's env (job-level secret scoping keeps it out
|
||||
# of the other publish jobs).
|
||||
#
|
||||
# PREREQUISITE: the package `LazyEvaluation.RdbmsPlayground` must already exist
|
||||
# in winget-pkgs via a one-time `komac new` (interactive — run manually once;
|
||||
# see ADR-0056 Amendment 4). This job only does the per-release `komac update`.
|
||||
winget:
|
||||
runs-on: ci-public
|
||||
container:
|
||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||
steps:
|
||||
- name: submit the winget update PR (idempotent)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
# Classic public_repo PAT for the winget bot account. komac reads it
|
||||
# from GITHUB_TOKEN; the API guards below read it via a curl config file
|
||||
# so the token never appears in a command line / process list.
|
||||
GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${TAG#v}"
|
||||
PKG="LazyEvaluation.RdbmsPlayground"
|
||||
echo "winget: targeting $PKG $VER ($TAG)"
|
||||
|
||||
# Auth header in a 0600 curl config (keeps the token out of argv/logs).
|
||||
umask 077
|
||||
printf 'header = "Authorization: Bearer %s"\nheader = "Accept: application/vnd.github+json"\n' \
|
||||
"$GITHUB_TOKEN" > /tmp/gh-curlrc
|
||||
api="https://api.github.com"
|
||||
|
||||
# Guard 1 — already merged into winget-pkgs?
|
||||
# (manifests/<first-letter>/<Publisher>/<Package>/<version>)
|
||||
merged=$(curl -sS -o /dev/null -w '%{http_code}' --config /tmp/gh-curlrc \
|
||||
"$api/repos/microsoft/winget-pkgs/contents/manifests/l/LazyEvaluation/RdbmsPlayground/$VER" || echo 000)
|
||||
if [ "$merged" = "200" ]; then
|
||||
echo "winget: $PKG $VER already in winget-pkgs — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Guard 2 — an open PR for this exact id+version already? (avoid a dup)
|
||||
# curl -G --data-urlencode does the URL-encoding (no jq in the image).
|
||||
curl -sS -G --config /tmp/gh-curlrc \
|
||||
--data-urlencode "q=repo:microsoft/winget-pkgs type:pr state:open in:title \"$PKG\" \"$VER\"" \
|
||||
"$api/search/issues" -o /tmp/winget-search.json
|
||||
open=$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync("/tmp/winget-search.json","utf8")).total_count||0))')
|
||||
if [ "$open" != "0" ]; then
|
||||
echo "winget: an open PR for $PKG $VER already exists — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Install pinned komac (prebuilt glibc binary; the CI image has no
|
||||
# cargo/komac). Pinned for reproducibility — bump deliberately.
|
||||
KOMAC_VER=2.16.0
|
||||
curl -fsSL -o /tmp/komac.tgz \
|
||||
"https://github.com/russellbanks/Komac/releases/download/v$KOMAC_VER/komac-$KOMAC_VER-x86_64-unknown-linux-gnu.tar.gz"
|
||||
tar -xzf /tmp/komac.tgz -C /tmp
|
||||
komac_bin=$(find /tmp -maxdepth 2 -type f -name komac | head -1)
|
||||
[ -n "$komac_bin" ] || { echo "ERROR: komac binary not found after extract" >&2; exit 1; }
|
||||
|
||||
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
|
||||
echo "winget: submitting update PR via komac $KOMAC_VER"
|
||||
# NB: confirm flags against `komac update --help` on first run — komac
|
||||
# evolves; --version/--urls/--submit are the stable core. komac infers
|
||||
# architecture + the `portable` installer type from the binaries.
|
||||
"$komac_bin" update "$PKG" \
|
||||
--version "$VER" \
|
||||
--urls "$base/rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe" \
|
||||
"$base/rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe" \
|
||||
--submit
|
||||
echo "winget: update PR submitted for $PKG $VER (Microsoft review is async)."
|
||||
|
||||
# No `needs:` between jobs — each is independent and idempotent, so one failing
|
||||
# or being added never breaks another.
|
||||
@@ -84,12 +84,21 @@ jobs:
|
||||
# The runner wipes the workspace each run, so cargo target/ never
|
||||
# accumulates. Bound the persistent nix store by generation: record the
|
||||
# current devShell as a generation of a persistent profile (in $HOME),
|
||||
# keep the 2 newest, reclaim what older ones referenced.
|
||||
# keep the 2 newest, reclaim what older ones referenced — so the
|
||||
# toolchain stays *warm* across runs and only stale generations are GC'd.
|
||||
#
|
||||
# The profile's parent dir MUST exist first, or `nix develop --profile`
|
||||
# errors ("cannot read directory …") and the profile/gc-root is never
|
||||
# created — which made `nix-collect-garbage` delete the whole toolchain
|
||||
# closure every run (re-downloaded ~3.8 GiB each time; the retention was
|
||||
# silently broken by the swallowed `|| true`). No `|| true` on the
|
||||
# profile realization now: a future breakage should fail loudly.
|
||||
if: always()
|
||||
run: |
|
||||
echo "--- disk before ---"; df -h / | tail -1
|
||||
P="$HOME/.cache/rdbms-ci/toolchain"
|
||||
nix develop --profile "$P" -c true || true
|
||||
mkdir -p "$(dirname "$P")"
|
||||
nix develop --profile "$P" -c true
|
||||
nix-env -p "$P" --delete-generations +2 || true
|
||||
nix-collect-garbage || true
|
||||
echo "--- disk after ---"; df -h / | tail -1
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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
|
||||
- Install via **Scoop**, **Homebrew**, and **winget** in addition to the
|
||||
existing channels.
|
||||
- Tier-4 end-to-end test suite that exercises the real application in a
|
||||
terminal (cold launch, save/reopen, export/import, undo) — guards against
|
||||
regressions the unit and render tests can't see.
|
||||
- Automated colour-accessibility checks: every theme now has its
|
||||
foreground/background contrast and its syntax-colour distinctness verified
|
||||
on each build.
|
||||
|
||||
### Fixed
|
||||
- **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
|
||||
Generated
+113
-3
@@ -64,6 +64,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
|
||||
|
||||
[[package]]
|
||||
name = "atomic"
|
||||
version = "0.6.1"
|
||||
@@ -164,6 +170,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
@@ -481,6 +493,12 @@ dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -976,7 +994,7 @@ version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"nix 0.29.0",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -1038,6 +1056,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.1.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -1046,7 +1076,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
@@ -1360,6 +1390,27 @@ version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"downcast-rs",
|
||||
"filedescriptor",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.28.0",
|
||||
"serial2",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
"winapi",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
@@ -1548,6 +1599,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gethostname",
|
||||
"insta",
|
||||
"portable-pty",
|
||||
"pretty_assertions",
|
||||
"rand 0.10.1",
|
||||
"ratatui",
|
||||
@@ -1559,6 +1611,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"vt100",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -1738,6 +1791,17 @@ dependencies = [
|
||||
"unsafe-libyaml-norway",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial2"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -1758,6 +1822,22 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -1968,7 +2048,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"memmem",
|
||||
"nix",
|
||||
"nix 0.29.0",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"ordered-float",
|
||||
@@ -2240,6 +2320,27 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vt100"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"unicode-width",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vtparse"
|
||||
version = "0.6.2"
|
||||
@@ -2639,6 +2740,15 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -59,8 +59,10 @@ zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1.47.2", features = ["yaml"] }
|
||||
portable-pty = "0.9"
|
||||
pretty_assertions = "1.4.1"
|
||||
tempfile = "3.27.0"
|
||||
vt100 = "0.16"
|
||||
|
||||
# Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test`
|
||||
# links ~25 separate integration-test binaries, each statically
|
||||
|
||||
@@ -146,4 +146,57 @@ and on a nightly schedule for any extended coverage.
|
||||
- CI cost is real but bounded: Tiers 1–3 are fast; Tier 4 is the
|
||||
only slow tier and is kept narrow.
|
||||
- Adding a feature implies adding tests at the appropriate tier
|
||||
(or tiers); coverage is not retrofitted later.
|
||||
(or tiers); coverage is not retrofitted later.
|
||||
|
||||
## Amendment 1 — 2026-06-22: Tier 4 realized (TT4)
|
||||
|
||||
Tier 4 was a specification only (no PTY deps, no tests) until this
|
||||
amendment. It is now implemented in `tests/e2e_pty.rs`, and the realized
|
||||
shape refines the original decision in three ways.
|
||||
|
||||
**Tooling — `expectrl` dropped.** The decision named
|
||||
`portable-pty` + `expectrl` + `vt100`. We kept **`portable-pty`** (0.9, to
|
||||
spawn the real binary in a PTY at a fixed size) and **`vt100`** (0.16, to
|
||||
parse the output stream into an inspectable cell grid), both current and
|
||||
maintained. We **dropped `expectrl`**: it bundles its own PTY abstraction
|
||||
(which conflicts with `portable-pty`) and matches line-by-line, a poor fit
|
||||
for a full-screen TUI. A small hand-rolled `wait_for(predicate, timeout)`
|
||||
that polls the vt100 screen replaces it — fewer dependencies, and assertions
|
||||
read the actual rendered grid.
|
||||
|
||||
**Harness shape.** Each test spawns the binary
|
||||
(`env!("CARGO_BIN_EXE_rdbms-playground")`) under a fresh PTY at **100×30**
|
||||
(the wide three-region layout, ADR-0046), with its **own temp `--data-dir`**
|
||||
so it never touches real projects or resume state, and `--theme dark` for
|
||||
deterministic styling. Tests run **serially** (one PTY + child at a time)
|
||||
via a poison-tolerant global lock, so timing stays predictable on the
|
||||
low-parallelism self-hosted runner. Waits are **tight and fail-fast** (3 s):
|
||||
a slow wait is a genuine hang, and a timeout panics with a full screen dump
|
||||
for debuggability.
|
||||
|
||||
Two non-obvious robustness rules the harness encodes:
|
||||
- **Read table presence from the Tables *sidebar* region, not the whole
|
||||
screen** — the Output panel echoes every command, so a table name typed
|
||||
in a command would pollute a whole-screen match.
|
||||
- **Pace each command to completion before the next** (real-user / cast
|
||||
cadence). A command submitted while the previous one's worker rebuild is
|
||||
still in flight can be misread against a stale schema cache (issue #39,
|
||||
discovered here — interactive use is unaffected).
|
||||
|
||||
**The four flows** mirror this ADR's Tier-4 scope: (1) cold launch → first
|
||||
DDL → graceful quit (Ctrl-C); (2) create → quit → reopen via `--resume`,
|
||||
asserting the *column* (not just the table name) round-trips; (3) export →
|
||||
import into a fresh project → schema **and** data rebuilt; (4) `undo` after
|
||||
`DROP TABLE`, through the Y/N confirm modal.
|
||||
|
||||
**CI.** The target runs by default in `cargo test`, so the existing Linux
|
||||
gate exercises it on every push — this **advances TT5** (Tier 4 now runs in
|
||||
CI on Linux). PTY-in-container was validated (openpty + child spawn work
|
||||
under the Docker runtime the Gitea runner uses); the first real CI run is
|
||||
the final confirmation. **Windows execution remains out of scope** (no
|
||||
Windows runner), and the original Tier-4 note about a *nightly broader
|
||||
coverage* schedule is **not** implemented — the focused four flows are the
|
||||
committed TT4 scope; broader coverage is deferred.
|
||||
|
||||
**Piggybacked NFR measurement.** The same harness measures NFR-1 (startup
|
||||
to first frame) and NFR-3 (idle RSS, Linux) — see ADR-0057.
|
||||
@@ -86,3 +86,234 @@ verified against cargo-binstall SUPPORT.md):
|
||||
- Remaining follow-up: run the real `cargo binstall` validation at the
|
||||
first publish + matching release (the license files, © holder, and
|
||||
CONTRIBUTING are now in place).
|
||||
|
||||
## Amendment 1 — 2026-06-18: published live + a manual `publish` workflow
|
||||
|
||||
**`rdbms-playground 0.2.0` is published to crates.io** (`cargo install` and
|
||||
`cargo binstall rdbms-playground` both verified working by the user). The
|
||||
"unverified binstall" caveat is resolved — the per-target overrides
|
||||
resolve correctly against the `v0.2.0` release assets.
|
||||
|
||||
**How publishing is wired:** a new **manual `workflow_dispatch` workflow**
|
||||
(`.gitea/workflows/publish.yaml`), mirroring `release-macos.yaml`, takes a
|
||||
`tag` input and runs `cargo publish` (token via the
|
||||
`CARGO_REGISTRY_TOKEN` Gitea Actions secret — a crate-scoped,
|
||||
publish-update token). **Not** automated on the tag, by decision: the
|
||||
publish is irreversible (yank-only), keeping the registry token off every
|
||||
tag push; the release is split (Linux/Windows on the tag, macOS
|
||||
dispatched), so a human is the natural "all assets are up — go" gate; and
|
||||
crates.io has no Gitea-Actions trusted-publishing path today, so a stored
|
||||
token on the self-hosted runner would be the only automated option.
|
||||
Each registry is its **own idempotent job** (no inter-job `needs`) — the
|
||||
crates.io job skips cleanly if the version is already published (crates.io
|
||||
API pre-check + `cargo publish` as the backstop) — so future
|
||||
Scoop/Homebrew/winget jobs can be added alongside without breaking one
|
||||
another or re-runs. The first such job's `tag`-vs-`Cargo.toml` guard
|
||||
mirrors `release.yaml`.
|
||||
|
||||
## Amendment 2 — 2026-06-19: Scoop bucket + Homebrew tap (D3 §3b/§3c)
|
||||
|
||||
Two more package managers wired as **sibling `publish.yaml` jobs**
|
||||
(`scoop-bucket`, `homebrew-tap`), following Amendment 1's independent +
|
||||
idempotent pattern. Each fetches the release's `.sha256` sidecars, renders
|
||||
a manifest, and commits it into a per-manager repo.
|
||||
|
||||
**Repos — org-level and multi-package.** Both live under a new **`lazyeval`
|
||||
Gitea organisation** (created with the `oli` account, which gives the
|
||||
`git.lazyeval.net/lazyeval/...` paths): `lazyeval/scoop-bucket` and
|
||||
`lazyeval/homebrew-tap`. A Scoop *bucket* and a Homebrew *tap* are by
|
||||
definition **collections of manifests**, so these are reusable for future
|
||||
tools, not single-package repos. Homebrew's `homebrew-` repo-name prefix is
|
||||
mandatory (→ referenced as `lazyeval/tap`); Scoop's bucket name is free.
|
||||
Users: `scoop bucket add lazyeval <url>` (the label is local/arbitrary;
|
||||
only the URL owner is real) then `scoop install rdbms-playground`; and
|
||||
`brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap`
|
||||
(the explicit-URL form — the `user/repo` shorthand assumes GitHub) then
|
||||
`brew install lazyeval/tap/rdbms-playground`.
|
||||
|
||||
**Credential — a scoped bot user, not an `oli` PAT.** Gitea PATs scope by
|
||||
**permission category, not per-repository** (`write:repository` grants
|
||||
write to *every* repo the account can reach — there is no repo picker like
|
||||
GitHub fine-grained PATs). So an `oli` token would also be able to push to
|
||||
`oli/rdbms-playground` itself. Instead a dedicated bot user **`lazyeval-ci`**
|
||||
is a member of a `lazyeval` org team with **Write** to the package repos
|
||||
only; its `write:repository` PAT is therefore effectively scoped to those
|
||||
repos and **cannot touch the main project repo**. Stored as the
|
||||
`LAZYEVAL_PKG_TOKEN` Actions secret on `oli/rdbms-playground` (where the
|
||||
workflow runs — *not* an org secret, which wouldn't reach a user-repo
|
||||
workflow; *not* on the target repos, which only receive pushes). Passed via
|
||||
`env:` (never inlined), so it stays masked and only materialises in the
|
||||
clone URL at runtime; pushes go to `HEAD:main` (assumes the repos default
|
||||
to `main`).
|
||||
|
||||
**Render scripts are dependency-free bash.** The CI job container is
|
||||
`node:22-bookworm-slim` — **no jq, no ruby** — so
|
||||
`scripts/render-{scoop-manifest,homebrew-formula}.sh` are pure bash
|
||||
(heredocs, no external deps) taking a version + the relevant hashes and
|
||||
emitting the manifest on stdout. `scripts/test-package-renders.sh` is their
|
||||
test (JSON validated with `node` — present in the image — plus `jq`/`ruby`
|
||||
when available; field-level assertions). The job validates the rendered
|
||||
Scoop JSON with `node -e JSON.parse` before committing.
|
||||
|
||||
**Manifest specifics.**
|
||||
- *Scoop* (`rdbms-playground.json` at bucket root): `64bit` =
|
||||
`x86_64-pc-windows-gnu.exe`, `arm64` =
|
||||
`aarch64-pc-windows-gnullvm.exe`; each URL carries a
|
||||
`#/rdbms-playground.exe` rename fragment so the `bin` shim resolves
|
||||
regardless of version. Carries `checkver` (lets `scoop status` / the
|
||||
community excavator see lag) but **no `autoupdate`** — our pipeline is the
|
||||
updater.
|
||||
- *Homebrew* (`Formula/rdbms-playground.rb`): `on_macos`/`on_linux` ×
|
||||
`on_arm`/`on_intel` selecting the four bare-binary assets (macOS direct;
|
||||
Linux = the static `-musl` build). **Windows absent** — Homebrew has no
|
||||
Windows port. `install` drops the single staged binary under a stable
|
||||
name; the `test` block runs `--version`.
|
||||
|
||||
**Unverified (validate on first real use):** an actual `scoop install` and
|
||||
`brew install`/`brew test`; the `HEAD:main` default-branch assumption; and
|
||||
whether macOS Gatekeeper accepts the **ad-hoc-signed** mac binary via
|
||||
`brew` (execution should be fine — ad-hoc satisfies arm64's signing
|
||||
requirement and `brew`'s curl download sets no quarantine xattr, unlike a
|
||||
browser download — but this rides on the still-parked Developer-ID signing
|
||||
decision). **Remaining D3:** winget (komac on Linux CI, or a manual PR).
|
||||
|
||||
## Amendment 3 — 2026-06-19: validated end-to-end; a presigned-URL/HEAD gotcha + the Caddy fix it needs
|
||||
|
||||
**Scoop and Homebrew now install and run end-to-end** (`v0.2.0`,
|
||||
user-verified on real Windows + macOS): `scoop install rdbms-playground`
|
||||
and `brew install lazyeval/tap/rdbms-playground` both fetch, checksum,
|
||||
install, and the installed binary launches. Amendment 2's "unverified"
|
||||
caveats are resolved (the `HEAD:main` push populated both repos cleanly).
|
||||
|
||||
**Distribution depends on a server-side Caddy rule — RECORD THIS: it lives
|
||||
in the Gitea edge config, NOT this repo, and if it is lost in a server
|
||||
rebuild Homebrew silently 403s again.** Symptom: `brew install` failed with
|
||||
`curl (22) … 403` on the asset URL. Root cause: Gitea (≥1.25; here 1.26.2)
|
||||
serves release-asset downloads by **303-redirecting to a method-bound
|
||||
AWS-SigV4 presigned S3 URL** (OVH), signed for the *incoming* request's HTTP
|
||||
verb. Homebrew **resolves the URL with a HEAD, captures the returned
|
||||
(HEAD-signed) presigned URL, then runs the download GET against that
|
||||
captured URL** → GET-on-a-HEAD-signed-URL → 403. Reproduced precisely:
|
||||
`GET on HEAD-resolved = 403`, `HEAD on HEAD-resolved = 200`, `GET on
|
||||
GET-resolved = 200`, `HEAD on GET-resolved = 403`. GET-only tools
|
||||
(`install.sh`, `install.ps1`, `cargo binstall`, curl) are unaffected — they
|
||||
GET the original URL and let it redirect fresh to a GET-signed URL. This is
|
||||
a **Homebrew defect** (it reuses an ephemeral, method-scoped credential as
|
||||
if it were a durable resource locator); Gitea's per-request method-bound
|
||||
signing is correct, and SigV4 *cannot* sign one URL for two verbs.
|
||||
`SERVE_DIRECT=false` (Gitea proxies, no presign) would also fix it but was
|
||||
declined — not reshaping storage for one client.
|
||||
|
||||
**Fix (deployed, "Tier A"): a Caddy rule that answers HEAD on
|
||||
release-download paths directly** — 200, no redirect, no body — so brew's
|
||||
resolve records the *original* URL (no presigned credential captured) and
|
||||
its download GET runs through the redirect fresh → GET-signed → 200. Scoped
|
||||
to `method HEAD` + the release-download path; **GET is untouched**, so every
|
||||
working channel is unaffected, and a HEAD carries no payload so no client
|
||||
can *break* (at most a HEAD-probing download manager loses a progress-bar
|
||||
size — cosmetic). Reference Caddyfile (place before the Gitea
|
||||
reverse_proxy):
|
||||
|
||||
```
|
||||
@release_head {
|
||||
method HEAD
|
||||
path_regexp ^/[^/]+/[^/]+/releases/download/.+
|
||||
}
|
||||
handle @release_head {
|
||||
header Accept-Ranges bytes
|
||||
header Content-Type application/octet-stream
|
||||
respond 200
|
||||
}
|
||||
```
|
||||
|
||||
A "Tier B" variant (a sidecar that fetches and returns the *real*
|
||||
Content-Length so even HEAD-probing clients stay fully faithful) was specced
|
||||
but proved unnecessary — brew is happy with the bare 200.
|
||||
|
||||
**macOS signing:** the brew-installed binary **runs** under the current
|
||||
**ad-hoc** signature (`codesign --sign -`) — confirmed on Apple Silicon.
|
||||
**Developer-ID signing + notarization remains parked** pending the user's
|
||||
Apple account conversion to an Organization (GA plan / ADR-ci-003); it is
|
||||
needed for *browser-download* trust, not for the package-manager paths,
|
||||
which are all working now.
|
||||
|
||||
**Remaining D3:** winget only.
|
||||
|
||||
## Amendment 4 — 2026-06-20: winget (D3 §3d) — the last package manager
|
||||
|
||||
winget wired as the fourth `publish.yaml` sibling job, **completing the D3
|
||||
package-manager set** (crates.io/binstall, Scoop, Homebrew, winget; plus the
|
||||
install scripts + direct binaries).
|
||||
|
||||
**Model — fundamentally unlike Scoop/Homebrew.** winget has no
|
||||
self-hosted-source equivalent: its default catalog is the central,
|
||||
**human-gated** GitHub repo `microsoft/winget-pkgs`, and you get in by
|
||||
**opening a PR** of manifests into it. So the job submits a PR (via
|
||||
**komac**, pinned `2.16.0`, prebuilt glibc binary — the CI image has no
|
||||
cargo) and **Microsoft's pipeline validates async** (schema, SHA256,
|
||||
AV/SmartScreen scan, sandbox install) + moderator review. It is *not* live
|
||||
on dispatch like the bucket/tap.
|
||||
|
||||
**PackageIdentifier `LazyEvaluation.RdbmsPlayground`** (publisher segment =
|
||||
the publishing entity / `lazyeval` org / license holder — consistent with
|
||||
everything else). Bare-exe → winget **`portable`** installer type; x64
|
||||
(`-pc-windows-gnu`) + arm64 (`-pc-windows-gnullvm`), komac infers arch +
|
||||
type from the binaries.
|
||||
|
||||
**Auth — a dedicated GitHub bot, classic token.** komac needs a **classic
|
||||
`public_repo` PAT**; **fine-grained tokens cannot open the cross-fork PR**
|
||||
(komac #310 — the PR is created on the *target* repo you don't administer,
|
||||
which the fine-grained model can't express). A classic `public_repo` token
|
||||
can't be scoped to one repo, so it lives on a **dedicated GitHub bot
|
||||
account** (a leak can't reach other repos — the lazyeval-ci reasoning), as
|
||||
the `WINGET_GITHUB_TOKEN` secret, referenced **only** in the `winget` job
|
||||
(job-level scoping keeps it away from the crates.io / lazyeval tokens) and
|
||||
passed to the API guards via a 0600 curl config file (never argv).
|
||||
|
||||
**Idempotency — stronger than the others need.** A re-submitted version
|
||||
would open a *duplicate* PR, so before submitting the job guards on **both**
|
||||
(1) already-merged versions (`contents` API on
|
||||
`manifests/l/LazyEvaluation/RdbmsPlayground/<ver>`) and (2) an already-open
|
||||
PR for the id+version (`search/issues`). Either → clean skip, so a repeated
|
||||
`publish` dispatch is safe even mid-review.
|
||||
|
||||
**Signing:** none required to *submit* (only MSIX needs it; ours is
|
||||
portable). The unsigned binary may earn an AV/SmartScreen **manual-review
|
||||
label** on the first PR — usually clears; a nudge toward Trusted Signing
|
||||
(the UK Ltd clears the 3-year-history bar) but not a blocker. Continues the
|
||||
parked Developer-ID/notarization posture.
|
||||
|
||||
**One-time bootstrap (manual — NOT in CI, because `komac new` is
|
||||
interactive).** Run once to create the package in winget-pkgs, then CI
|
||||
`komac update` handles every release after:
|
||||
|
||||
```
|
||||
komac token update # paste the bot's classic public_repo PAT at the prompt (not in argv/history)
|
||||
komac new LazyEvaluation.RdbmsPlayground \
|
||||
-v 0.2.0 \
|
||||
-u https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-x86_64-pc-windows-gnu.exe \
|
||||
https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-aarch64-pc-windows-gnullvm.exe \
|
||||
--publisher "Lazy Evaluation Ltd" --package-name "RDBMS Playground" \
|
||||
--moniker rdbms-playground --license "MIT OR Apache-2.0" \
|
||||
--package-url https://relplay.org -s
|
||||
```
|
||||
komac downloads the two exes, detects the bare binary as **`portable`**, and
|
||||
**prompts for the command alias — enter `rdbms-playground`** so users get
|
||||
that on PATH (not the long versioned filename). `-s` opens the PR; fill any
|
||||
remaining prompts. (Flags verified against komac 2.16.0: `-v/--version`,
|
||||
`-u/--urls`, `-s/--submit`; the alias has no flag and is prompted.) Once
|
||||
that first PR merges, the CI job's `komac update` takes over.
|
||||
|
||||
**Bootstrap learnings (2026-06-21, first real run).** GitHub bot account =
|
||||
`lazyeval-ci`. Its fork **`lazyeval-ci/winget-pkgs` must exist *before*
|
||||
`komac new`** — komac's auto-fork races on winget-pkgs (one of GitHub's
|
||||
largest repos) and fails with *"Could not resolve to a Repository
|
||||
'lazyeval-ci/winget-pkgs'"*; fork it manually (web **Fork**, or `gh repo
|
||||
fork microsoft/winget-pkgs --clone=false`), wait for it to populate, then
|
||||
re-run. **Alias:** for a **direct single-exe portable**, winget derives the
|
||||
on-PATH command from **`Commands[0]`** (the interactive "Commands" prompt →
|
||||
`rdbms-playground`) — **not** `PortableCommandAlias`, which is
|
||||
**archive/nested-portable-only** and must NOT be added to our manifest
|
||||
(verified against winget-cli's portable-install logic + the 1.6.0 installer
|
||||
schema; komac correctly omits it). So komac's generated manifest needs no
|
||||
hand-editing. First PR: **#391335**.
|
||||
|
||||
@@ -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).
|
||||
+3
-2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,137 @@
|
||||
# Session handoff — 2026-06-18 (74)
|
||||
|
||||
Large session. Continues from handoff-73 (Ctrl-G demo alias). This one ran
|
||||
the **road to public availability** end to end: a post-merge doc
|
||||
reconciliation, then versioning, installers, crates.io/binstall, the
|
||||
`cargo fmt` gate, and an actual **`v0.2.0` release that is now live on
|
||||
crates.io**. Three new main-sequence ADRs (0054/0055/0056), one CI-ADR
|
||||
amendment, issue **#35 closed**.
|
||||
|
||||
## §1. State
|
||||
|
||||
**Branch `main`.** **2509 pass / 0 fail / 1 ignored** (the long-standing
|
||||
`friendly` doctest); **clippy clean**; **`cargo fmt --check` clean** (the
|
||||
tree is now stock-rustfmt formatted, and CI gates it). `rdbms-playground
|
||||
--version` → `rdbms-playground 0.2.0`.
|
||||
|
||||
**Released:** **`v0.2.0`** — Gitea release with all six D1 targets
|
||||
(Linux/Windows via `release.yaml` on the tag; macOS via the dispatched
|
||||
`release-macos.yaml`, **ad-hoc-signed**). **Published to crates.io**
|
||||
(`cargo install rdbms-playground` and `cargo binstall rdbms-playground`
|
||||
both user-verified).
|
||||
|
||||
**Push state:** earlier commits were pushed (they triggered CI/releases).
|
||||
The **most recent commits are unpushed** and matter:
|
||||
- `3c87dbb` macOS nix-prune fix (takes effect next macOS dispatch).
|
||||
- `d3af1c4` + `8ebe213` the manual `publish.yaml` workflow — ADR-0056 in
|
||||
the first, the **workflow file itself in the second** (`git commit -am`
|
||||
had skipped the new untracked file).
|
||||
- this handoff.
|
||||
Push `main` to land them. (Push is the user's step.)
|
||||
|
||||
## §2. What shipped (commits `628b250`→`d3af1c4`)
|
||||
|
||||
- **`628b250` doc reconciliation** after the CI + website branch merges:
|
||||
rewrote CLAUDE.md's stale repo-layout tree; added a Website subproject
|
||||
note; fixed the CI note; **gitignored `.wrangler/` + `.vscode/`** and
|
||||
removed a tracked `website/.vscode/`; updated requirements (D1 macOS
|
||||
runtime-verified, DOC1 canonical-docs-on-website) + ADR-ci-003.
|
||||
- **`c30a611` ADR-0054 — version surfaces.** `--version`/`-V` + an in-app
|
||||
**`version`** command, both reading `CARGO_PKG_VERSION` via one
|
||||
`cli::version_text()`. A **release-CI guard** fails the release unless
|
||||
the `v*` tag equals `v<Cargo.toml version>`.
|
||||
- **`ef99e6c` ADR-0055 — `scripts/install.sh`** (curl|sh, POSIX,
|
||||
shellcheck-clean, checksum-verified, `~/.local/bin`). Verified
|
||||
end-to-end against the live release. **`install.ps1`** (Windows
|
||||
`irm|iex`) added later (`e9606b5`) — **written but untested here** (no
|
||||
PowerShell on this box; validate on Windows).
|
||||
- **`e9606b5` ADR-0056 — crates.io + binstall prep.** Publish-ready
|
||||
Cargo.toml (dropped `publish=false`; homepage/keywords/categories/
|
||||
`exclude`); `README.md`; `LICENSE-MIT`/`LICENSE-APACHE` (dual, © Lazy
|
||||
Evaluation Ltd) + `CONTRIBUTING.md` (inbound=outbound); the
|
||||
`[package.metadata.binstall]` block with per-target overrides
|
||||
(linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct).
|
||||
- **`41b7e9a` + `ec3c7c3` — #35 fmt gate.** One mechanical `cargo fmt`
|
||||
(stock defaults, 102 files, behaviour-preserving) recorded in
|
||||
`.git-blame-ignore-revs`; `ci.yaml` now gates `fmt --check` (ADR-ci-002
|
||||
Amendment 1). **Closes #35.**
|
||||
- **`88830ed`+`bd5be5e` — v0.2.0 bump + the guard bug.** The first
|
||||
`release.yaml` run **failed** at the version guard: it piped `nix
|
||||
develop -c cargo metadata` to node, but the **flake devShell prints a
|
||||
banner to stdout**, corrupting the JSON. Fixed to a toolchain-free
|
||||
`grep -m1 '^version = ' Cargo.toml`. The `v0.2.0` tag was re-pointed
|
||||
(Option A) to the fix commit; re-run went green.
|
||||
- **`3c87dbb` — macOS nix-prune fix.** The prune step's profile dir
|
||||
(`~/.cache/rdbms-ci`) didn't exist, so `nix develop --profile` errored
|
||||
(swallowed by `|| true`) → the gc-root was never created → the whole
|
||||
toolchain (~3.8 GiB) was deleted **and re-downloaded every run**. Added
|
||||
`mkdir -p` + dropped the `|| true`. Diagnosed from the run-74 log via
|
||||
`tea actions runs logs 74`.
|
||||
- **`d3af1c4` — manual `publish.yaml`.** `workflow_dispatch` + `tag`
|
||||
input (mirrors `release-macos.yaml`). Idempotent `crates-io` job
|
||||
(crates.io API pre-check + `cargo publish` backstop), independent jobs
|
||||
so Scoop/Homebrew/winget slot in later. ADR-0056 Amendment 1.
|
||||
|
||||
## §3. Live vs manual vs parked
|
||||
|
||||
- **Automated on a `v*` tag:** `release.yaml` builds + publishes the four
|
||||
Linux/Windows targets (+ fmt/clippy/test gate).
|
||||
- **Manual `workflow_dispatch`:** `release-macos.yaml` (mac binaries —
|
||||
intermittent runner) and `publish.yaml` (crates.io now; more registries
|
||||
later). Run them once the tag's build is up.
|
||||
- **Parked (user decisions):**
|
||||
- **macOS Developer-ID signing.** The pipeline **ad-hoc-signs**
|
||||
(`codesign --sign -`). The user's `Apple Development` cert is the
|
||||
**wrong type** — distribution needs **`Developer ID Application`** +
|
||||
**notarization** (App Store Connect API key recommended). Fine for
|
||||
`curl|sh` (no quarantine); matters for browser downloads. Details in
|
||||
`docs/plans/20260616-public-availability.md`.
|
||||
- **Remaining D3:** Scoop (`lazyeval` bucket), Homebrew (`lazyeval`
|
||||
tap), winget (komac on Linux CI, or manual PR) — each a sibling job
|
||||
in `publish.yaml` + a manifest repo.
|
||||
|
||||
## §4. Immediate next steps
|
||||
|
||||
1. **Push `main`** (lands `3c87dbb` + `d3af1c4`).
|
||||
2. **Add the `CARGO_REGISTRY_TOKEN` secret** (crate-scoped,
|
||||
`publish-update`) so `publish.yaml` works: `tea actions secrets create
|
||||
CARGO_REGISTRY_TOKEN` (paste at prompt) or the Gitea UI.
|
||||
3. **Smoke-test `publish.yaml`:** dispatch it for `v0.2.0` — it should
|
||||
**idempotently skip** ("already on crates.io"), exercising the path
|
||||
risk-free.
|
||||
4. The release ritual going forward (ADR-0054): bump `Cargo.toml` →
|
||||
commit → tag `v<x.y.z>` → push tag (Linux/Windows release builds) →
|
||||
dispatch `release-macos` → dispatch `publish`.
|
||||
|
||||
## §5. Gotchas learned (don't relearn the hard way)
|
||||
|
||||
- **The flake devShell prints a banner to stdout** — never pipe `nix
|
||||
develop -c <cmd>` into a parser. Read Cargo.toml directly, etc.
|
||||
- **Workflow-file source differs by trigger:** a **tag**-triggered run
|
||||
(`release.yaml`) uses the workflow **at the tagged commit**; a
|
||||
**`workflow_dispatch`** run (`release-macos`/`publish`) uses the
|
||||
**default branch** (`main`). So fixing a dispatched workflow only needs
|
||||
a `main` push; fixing a tag-triggered one needs the tag re-pointed.
|
||||
- **Version vs tag:** `Cargo.toml` is bare `0.2.0`; the git tag is
|
||||
`v0.2.0`; the guard checks `tag == "v" + version`; binstall `pkg-url`
|
||||
spells `v{ version }`.
|
||||
- **CI logs are reachable** via `tea actions runs logs <id>` (and `tea
|
||||
actions runs list --output tsv`). Use it instead of guessing from a
|
||||
step name.
|
||||
- **crates.io API needs a descriptive User-Agent** (403 without one).
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 72 → 73 → 74, `CLAUDE.md`, `docs/requirements.md`, and
|
||||
**`docs/plans/20260616-public-availability.md`** (the GA roadmap with
|
||||
all decisions + parked items).
|
||||
2. Confirm green: `cargo test` (**2509 / 1 ignored**), `cargo clippy
|
||||
--all-targets`, `cargo fmt --check`.
|
||||
3. ADRs for this arc: **0054** (versioning), **0055** (installer),
|
||||
**0056** (crates.io/binstall + the publish workflow); CI side
|
||||
**ADR-ci-002 Amendment 1** (fmt gate), **ADR-ci-003** (release matrix
|
||||
+ macOS).
|
||||
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
|
||||
ADR amendment + README index-upkeep for decided-area changes, confirm
|
||||
commit messages, never push.
|
||||
5. Consider a `cargo sweep` at this milestone (`target/` grows).
|
||||
@@ -0,0 +1,145 @@
|
||||
# Session handoff — 2026-06-21 (75)
|
||||
|
||||
Continues from handoff-74 (v0.2.0 live on crates.io). This session **finished
|
||||
the D3 package-manager rollout** — Scoop, Homebrew, and winget — plus the
|
||||
Windows `install.ps1` fixes, and included a deep **presigned-URL / Homebrew**
|
||||
debugging saga that's fixed by a server-side Caddy rule. Five commits; one
|
||||
new ADR amendment arc (ADR-0056 Amendments 2–4).
|
||||
|
||||
## §1. State
|
||||
|
||||
**Branch `main`.** **No crate (Rust) code changed this session** — only
|
||||
`.gitea/workflows/`, `scripts/`, and `docs/`. So the `cargo test` baseline
|
||||
from handoff-74 (**2509 pass / 0 fail / 1 ignored**, clippy + `fmt --check`
|
||||
clean) stands unchanged by construction; not re-run (nothing in the build
|
||||
graph moved). New coverage this session is **`scripts/test-package-renders.sh`**
|
||||
(shellcheck-clean, green), now gated on every push by a new **`ci.yaml`
|
||||
`manifests` job** (bash + node; ruby-absent in CI degrades gracefully).
|
||||
|
||||
**Commits this session** (`cabc813`→`0208c67`; the workflow-bearing ones were
|
||||
pushed, since the live `publish` dispatches + raw-URL installs the user tested
|
||||
needed them on `main`):
|
||||
- `42b40bc` install.ps1 → Windows PowerShell 5.1 compat
|
||||
- `c0531aa` install.ps1 → immediate-use PATH + honest messaging
|
||||
- `6d54c1e` Scoop + Homebrew jobs (D3 §3b/§3c)
|
||||
- `6bb2288` Scoop/Homebrew validated + Caddy fix recorded (Amendment 3)
|
||||
- `0208c67` winget job via komac (D3 §3d, Amendment 4)
|
||||
|
||||
**External / infra state created this session (NOT all in the repo):**
|
||||
- **Caddy Tier-A HEAD rule on the Gitea server** — *load-bearing for Homebrew*,
|
||||
lives in the Gitea edge config, **not** this repo (see §4). If lost on a
|
||||
server rebuild, brew 403s again.
|
||||
- **Gitea:** `lazyeval` org with repos **`scoop-bucket`** + **`homebrew-tap`**;
|
||||
**`lazyeval-ci`** Gitea bot user (org team, Write to those repos);
|
||||
**`LAZYEVAL_PKG_TOKEN`** secret on `oli/rdbms-playground`.
|
||||
- **GitHub:** **`lazyeval-ci`** bot account; classic `public_repo` PAT →
|
||||
**`WINGET_GITHUB_TOKEN`** secret on `oli/rdbms-playground`; fork
|
||||
**`lazyeval-ci/winget-pkgs`**.
|
||||
- **winget bootstrap PR [#391335](https://github.com/microsoft/winget-pkgs/pull/391335)**
|
||||
submitted, in Microsoft review.
|
||||
- **komac 2.16.0** installed locally at `~/.local/bin/komac`.
|
||||
|
||||
## §2. D3 — the package-manager set (the session's throughline)
|
||||
|
||||
| Channel | State |
|
||||
| --- | --- |
|
||||
| crates.io / `cargo binstall` | **live** (handoff-74) |
|
||||
| `install.sh` / `install.ps1` | **live** — PS1 validated on ARM Windows 11 (5.1 + 7.6) |
|
||||
| Scoop (`lazyeval/scoop-bucket`) | **live + validated** (install + run) |
|
||||
| Homebrew (`lazyeval/homebrew-tap`) | **live + validated** — needs the Caddy rule (§4) |
|
||||
| winget (`LazyEvaluation.RdbmsPlayground`) | **wired**; PR #391335 awaiting Microsoft merge |
|
||||
|
||||
How Scoop/Homebrew/winget are wired: sibling jobs in
|
||||
`.gitea/workflows/publish.yaml` (manual `workflow_dispatch`, `tag` input),
|
||||
each idempotent + independent. Scoop/Homebrew render **dependency-free bash**
|
||||
manifests (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) from the
|
||||
release `.sha256` sidecars and push to the org repos via `LAZYEVAL_PKG_TOKEN`.
|
||||
winget runs `komac update --submit` to PR `microsoft/winget-pkgs`, guarded
|
||||
against duplicate PRs. ADR-0056 **Amendments 2–4** carry the full design.
|
||||
|
||||
## §3. The Homebrew 403 saga + Caddy fix (don't lose this)
|
||||
|
||||
Homebrew `brew install` 403'd while every other tool worked. Root cause,
|
||||
reproduced: Gitea (≥1.25; here 1.26.2) **303-redirects release downloads to a
|
||||
method-bound AWS-SigV4 presigned S3 URL** (OVH), signed for the *incoming*
|
||||
request's HTTP verb. Homebrew **resolves with a HEAD, captures the returned
|
||||
HEAD-signed URL, then runs the download GET against it** → GET-on-a-HEAD-signed
|
||||
URL → 403. (`GET on HEAD-resolved = 403`, `HEAD on HEAD-resolved = 200`,
|
||||
`GET on GET-resolved = 200`.) GET-only tools (install.sh/ps1, binstall, curl)
|
||||
are unaffected. This is a **Homebrew defect** (reusing an ephemeral
|
||||
method-scoped credential as a durable locator); Gitea's per-request signing is
|
||||
correct, and SigV4 *cannot* sign one URL for two verbs. `SERVE_DIRECT=false`
|
||||
would fix it but was **declined** (don't reshape storage for one client).
|
||||
|
||||
**Fix (deployed, "Tier A"):** a Caddy rule answering **HEAD on
|
||||
`…/releases/download/…` directly** (200, no redirect, no body) so brew's
|
||||
resolve records the *original* URL and its download GET redirects fresh →
|
||||
GET-signed → 200. GET untouched; a HEAD carries no payload so nothing can
|
||||
break (a HEAD-probing download manager at most loses a progress-bar size).
|
||||
Reference Caddyfile is in **ADR-0056 Amendment 3**.
|
||||
|
||||
## §4. Immediate next steps
|
||||
|
||||
1. **winget PR #391335:** watch validation — the one likely speed bump is an
|
||||
**AV/SmartScreen manual-review label** (unsigned binary); usually clears.
|
||||
Once a moderator merges, winget is live.
|
||||
2. **Post-merge check (Windows):** `winget install LazyEvaluation.RdbmsPlayground`,
|
||||
confirm the command is **`rdbms-playground`** (it is — a direct single-exe
|
||||
portable takes its PATH alias from `Commands[0]`, which komac set; see §5).
|
||||
3. After merge, future releases are hands-off: the CI `winget` job's
|
||||
`komac update` carries each version (alias inherited from the merged
|
||||
manifest). The release ritual is unchanged from ADR-0054, with the
|
||||
`publish` dispatch now also doing Scoop/Homebrew/winget.
|
||||
|
||||
## §5. Gotchas learned (don't relearn the hard way)
|
||||
|
||||
- **The Caddy HEAD rule is server-side, not in this repo.** Homebrew (and any
|
||||
HEAD-then-GET client) depends on it. Record/back it up.
|
||||
- **GitHub tokens:** komac needs a **classic `public_repo`** PAT — **fine-grained
|
||||
tokens can't open the cross-fork PR** (komac #310: the PR is created on the
|
||||
*target* repo you don't administer, which the fine-grained model can't
|
||||
express). A classic token can't be repo-scoped, so it lives on a **dedicated
|
||||
bot** (`lazyeval-ci`) to bound the blast radius.
|
||||
- **komac fork must pre-exist:** `komac new` auto-fork *races* on winget-pkgs
|
||||
(huge repo) → *"Could not resolve to a Repository 'lazyeval-ci/winget-pkgs'"*.
|
||||
Fork manually first (`gh repo fork microsoft/winget-pkgs --clone=false`),
|
||||
wait, then re-run. Pre-fill `komac new` flags to skip the long interactive
|
||||
prompt parade.
|
||||
- **Direct portable alias = `Commands[0]`, NOT `PortableCommandAlias`.** The
|
||||
latter is **archive/nested-portable-only**; adding it to a bare-exe portable
|
||||
is wrong (komac correctly omits it). The "Commands" prompt → `rdbms-playground`
|
||||
is the on-PATH command. Verified vs winget-cli logic + the 1.6.0 schema.
|
||||
- **install.ps1 / Windows PowerShell 5.1** (the in-box shell — PS7 is opt-in):
|
||||
arch via `PROCESSOR_ARCHITECTURE` env (not `RuntimeInformation::OSArchitecture`,
|
||||
which is absent under 5.1's .NET-Framework facade + StrictMode); force TLS 1.2;
|
||||
`-UseBasicParsing`. Also update `$env:Path` in-session (the persisted User PATH
|
||||
only reaches *new* processes; "restart your shell" was wrong — sign-out/in or
|
||||
the in-session update).
|
||||
- **Gitea release downloads go through OVH S3 presigned URLs** — method-bound,
|
||||
300 s expiry. Never assume HEAD-resolve-then-GET works against them.
|
||||
|
||||
## §6. Parked / deferred (user decisions)
|
||||
|
||||
- **macOS Developer-ID signing + notarization** — pending the Apple account →
|
||||
Organization conversion. Ad-hoc signing covers all package-manager paths
|
||||
(brew-installed binary *runs* on Apple Silicon); Developer-ID only matters for
|
||||
browser-download Gatekeeper trust.
|
||||
- **Windows code signing (Trusted Signing)** — not required to *ship* winget
|
||||
(portable; only MSIX needs signing). Unsigned can earn an AV/SmartScreen
|
||||
manual-review label + a user-run warning. **Lazy Evaluation Ltd (since 2012)
|
||||
clears Trusted Signing's 3-year-org bar** when the user wants to remove the
|
||||
warning (individual onboarding is currently paused; org path is open).
|
||||
- **Release notes / CHANGELOG** — raised this session, not done. If wanted:
|
||||
a `CHANGELOG.md` and/or populated Gitea release bodies, then the CI `winget`
|
||||
job can pass komac `--release-notes-url …/releases/tag/$TAG`.
|
||||
|
||||
## §7. How to take over
|
||||
|
||||
1. Read handoffs 73 → 74 → 75, `CLAUDE.md`, `docs/requirements.md` (D1/D3),
|
||||
and **ADR-0056 (esp. Amendments 1–4)** + the GA plan
|
||||
`docs/plans/20260616-public-availability.md`.
|
||||
2. Workflow unchanged: phased, test-first, `/runda` + DA before commits, ADR
|
||||
amendment + README index-upkeep for decided-area changes, confirm commit
|
||||
messages, never push.
|
||||
3. If `cargo test` is needed, the baseline is **2509 / 1 ignored** (handoff-74).
|
||||
4. Consider a `cargo sweep` at this milestone (`target/` grows).
|
||||
@@ -0,0 +1,130 @@
|
||||
# Website-branch handoff — 2026-06-21 (website-3)
|
||||
|
||||
Third handoff for the **website** work (separate sequence from `main`'s
|
||||
`YYYYMMDD-handoff-NN.md`). Read **website-2** (2026-06-11) first for the
|
||||
scaffolding, cast pipeline, and the no-advertising constraint; this note covers
|
||||
everything since, and — importantly — the state has moved on (see §1).
|
||||
|
||||
## §1. State (read this first)
|
||||
|
||||
- **Branch:** `website`. **HEAD `dff7841`.** Working tree **clean**.
|
||||
- **Pushed:** `origin/website` == `dff7841` (this session's work is pushed).
|
||||
- **Already merged into `main`:** `dff7841` is an ancestor of `main` — every
|
||||
website change below is in `main`. **`main` has since advanced ~20 commits**
|
||||
(now `56e3456`, handoff 75) with **v0.2.0 + install/packaging** work. So
|
||||
**`website` is ~20 commits behind `main`.**
|
||||
- **Consequence for the next session:** start by **merging `main` into
|
||||
`website` again** (or rebranching `website` off `main`). There is real new
|
||||
user-facing surface to document — see §4.
|
||||
- **Agent limitation:** this machine's SSH key for `git.lazyeval.net` was **not
|
||||
available to the agent** — `git fetch`/`push` fail from inside the session.
|
||||
**Push and fetch are the user's steps.** Local merges work fine.
|
||||
|
||||
## §2. What shipped this session (all in `main` now)
|
||||
|
||||
The website lives in **`website/`**; living style guide **`website/STYLE.md`**;
|
||||
decisions **`docs/website/adr/20260604-adr-website-001.md`**.
|
||||
|
||||
**CI / deployment — the big one (ADR-website-001 §4):**
|
||||
- **`.gitea/workflows/website.yaml`** — on a push to `main` or `website` that
|
||||
touches `website/**`, build the Astro site with pnpm and `wrangler pages
|
||||
deploy website/dist` to the Cloudflare **Pages project `relplay`** (Direct
|
||||
Upload, no Git integration). `--branch` selects environment vs the project's
|
||||
production branch (`main`): **`main` → production (`relplay.org`)**,
|
||||
**`website` → preview (`website.relplay.pages.dev`)**.
|
||||
- Pure-Node build (the `.cast` files are committed, so no cargo). Runs on the
|
||||
bare `ci-public` runner (node present; pnpm via corepack, pinned by
|
||||
`package.json`'s `packageManager: pnpm@10.30.3`).
|
||||
- **Secrets (set in repo Actions settings):** `CLOUDFLARE_API_TOKEN` (Pages:
|
||||
Edit) + `CLOUDFLARE_ACCOUNT_ID`. Pages project created with production branch
|
||||
= `main`.
|
||||
- **`ci.yaml` gate** now skips website-only pushes (`paths-ignore` += `website/**`,
|
||||
`.gitea/workflows/website.yaml`).
|
||||
- **`staging.relplay.org`** (optional): attach the custom domain to the `website`
|
||||
branch alias — add it in the Pages dashboard, then point its **proxied**
|
||||
CNAME at `website.relplay.pages.dev` (external/unproxied DNS falls through to
|
||||
production). Cloudflare Pages maps branches to **subdomains, not sub-folders**.
|
||||
|
||||
**Content updates (from two `main` merges that landed seed/readline/hint/etc.):**
|
||||
- **Seed page** (`reference/generating-sample-data.mdx`) refreshed for **#33/#34**:
|
||||
year-as-int columns (`*_year`/`published` → plausible years), built-in value
|
||||
sets (`priority`/`severity`/`rating`), advisory now **status-only**. All output
|
||||
blocks re-captured; the seed cast reflects it.
|
||||
- **`hint` feature** (ADR-0053) documented: `getting-help.md` → **`.mdx`** with a
|
||||
"Hints" section (F1 = tier-3 live-input hint; `hint` command = last-error hint)
|
||||
+ the real rendered block; `the-assistive-editor.mdx` points at F1.
|
||||
- **Readline keys (#29)** + **cross-mode history recall (#30)** documented on
|
||||
`the-assistive-editor.mdx`; "Planned" narrowed to multi-line entry only.
|
||||
- **Landing** (`index.mdx`): the redundant plain **`<h1>` "RDBMS Playground"** is
|
||||
hidden (kept for SEO/a11y via a **title-only hero**, landing-scoped in
|
||||
`global.css`); the **Wordmark + tagline + buttons** render in the body so the
|
||||
lockup sits where the title was and content rises above the fold (it was nearly
|
||||
hidden on an iPad).
|
||||
- **All 10 casts re-recorded** + a **new `hint` cast** (11 total) against the
|
||||
current app.
|
||||
|
||||
**Casts — the F1 trick (important for future hint/keybinding casts):**
|
||||
- autocast **cannot send F1** (or arrows/function keys — escape sequences split
|
||||
by the per-key delay). Solution shipped on `main`: **ADR-0047 Amendment 1** —
|
||||
in `--demo` mode **Ctrl-G aliases F1** and **badges AS `[F1]`**, so a cast looks
|
||||
like a real F1 press. The cast generator gained `CtrlG: '^G'`.
|
||||
- The **hint cast** uses it realistically: type `add column `, pause, **F1**
|
||||
(Ctrl-G) mid-command to recall the syntax, finish the command from the example,
|
||||
then later a failed command + `hint` for the error.
|
||||
|
||||
**Hint-vs-docs audit (user-requested):** one genuine conflict found —
|
||||
`hint.cmd.create_table.example` declared a 3-column compound PK; **fixed upstream**
|
||||
(`5a37437`, now `create table Customers with pk id(serial)`). Everything else
|
||||
(export/import, indexes, constraints, DML, SQL forms, error hints, terminology)
|
||||
was consistent.
|
||||
|
||||
## §3. Verification status
|
||||
|
||||
- `cd website && pnpm build` → **clean, 27 pages**. Forbidden-terms grep clean
|
||||
(`DSL`/`SQLite`/`STRICT`/`rusqlite`/`PRAGMA`). Output-table box-drawing intact;
|
||||
anchors resolve; hint cast content verified (`[F1]` badge + both hint blocks).
|
||||
- **NOT done — needs a human visual pass:** the new **landing spacing/aesthetics**
|
||||
(couldn't be eyeballed by the agent) and the **11 re-recorded casts** (verified
|
||||
programmatically only). Do this in staging/production before relying on them.
|
||||
|
||||
## §4. NEXT WORK — priority order
|
||||
|
||||
1. **Re-merge `main` into `website`** (it's ~20 behind) — or rebranch off `main`.
|
||||
2. **Reconcile the installation / getting-started docs with what `main` actually
|
||||
shipped (v0.2.0):** the website branch's `installation.md` predates several
|
||||
real install methods now live —
|
||||
- **crates.io v0.2.0** (`cargo install rdbms-playground`, `cargo binstall`),
|
||||
- the **curl | sh installer** (ADR-0055),
|
||||
- **Windows `install.ps1`** (`fix(install)` commits),
|
||||
- **package managers**: Scoop / Homebrew / **winget** (ADR-0056; winget via
|
||||
komac). `installation.md` mentions brew/scoop but **not** winget, the
|
||||
curl|sh installer, or binstall — reconcile against the shipped reality
|
||||
(check ADR-0055/0056 + handoffs 74/75 + the `publish.yaml` workflow).
|
||||
- There's also `--version`/`-V` + an in-app `version` command (ADR-0054) — a
|
||||
line on the command-line-options page may be warranted.
|
||||
3. **Visual sweep** (§3): landing + casts; final caption review (website-2 §2).
|
||||
4. **Carryover from website-2:** light/dark player theme (deferred), open STYLE
|
||||
decisions (#7/#8 mostly resolved; recheck), output-pane-scroll cast still
|
||||
blocked (PgUp/PgDn unsendable — would need a typeable scroll key like #24).
|
||||
|
||||
## §5. Process pins
|
||||
|
||||
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
|
||||
**append-only** (no amend/rebase/force-push). **Push/fetch are the user's
|
||||
steps** (and the agent can't reach the remote anyway — §1).
|
||||
- **Ground every page in source** (`src/dsl/*`, `src/friendly/strings/en-US.yaml`,
|
||||
the ADRs) — not `requirements.md` markers. No engine name, no "DSL" in copy.
|
||||
- **Output blocks captured from the real app**, never hand-drawn (STYLE.md). The
|
||||
capture recipe (throwaway in-crate/`it` test → render → paste → delete) and the
|
||||
cast pipeline (`pnpm casts [name]`, needs `../target/debug`) are in website-2.
|
||||
- **Cast stance (revised 2026-06-12):** default to a cast; justify its *absence*.
|
||||
- **Issues** via `tea` (repo `oli/rdbms-playground` on `git.lazyeval.net`;
|
||||
append `< /dev/null` + `timeout 30`). This session filed **#33** (year ints)
|
||||
and **#34** (enum value sets) — both **implemented** on `main` now.
|
||||
|
||||
## §6. Footnote — a git oddity in this branch's history
|
||||
|
||||
`c84a640 "docs(website): document the hint feature with a cast"` is an **empty
|
||||
rename commit** (a `git add` tooling slip staged only the `.md`→`.mdx` rename);
|
||||
its actual content is in the **next** commit `028d324`. Harmless, left in place
|
||||
per the append-only rule (user-approved). No action needed.
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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`.** Three commits this session:
|
||||
- `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`).
|
||||
- **(pending)** a docs commit — ADR-0008 Amendment 1, ADR-0057, README index,
|
||||
`requirements.md`, `CHANGELOG.md`, this handoff, and the plan doc
|
||||
`docs/plans/20260622-tt4-nfr-changelog.md`. *(Propose + confirm before
|
||||
committing; not yet committed at time of writing.)*
|
||||
|
||||
**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.
|
||||
|
||||
## §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).
|
||||
- Untouched larger items from the "what's next" survey: hint/help issues
|
||||
**#36/#37/#38**; the big features (V4 session journal, TU1 tutorial). 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).
|
||||
@@ -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.
|
||||
+90
-32
@@ -85,11 +85,24 @@ since ADR-0027.)
|
||||
No target requires anything the user must install. ADR-ci-003.)*
|
||||
- [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop,
|
||||
`winget`, and `cargo binstall`.
|
||||
*(Prebuilt binaries + checksums now published to Gitea releases
|
||||
(D1); the package-manager manifests (Homebrew / Scoop / winget /
|
||||
`cargo binstall`) remain to do. The asset naming
|
||||
`rdbms-playground-<tag>-<target>` is already binstall-friendly.
|
||||
Tracked under ADR-ci-003 "Deferred".)*
|
||||
*(Prebuilt binaries + checksums on Gitea releases (D1); **`cargo
|
||||
binstall` + crates.io live** (ADR-0056); **Scoop + Homebrew wired and
|
||||
validated end-to-end** on real Windows + macOS (ADR-0056 Amendments
|
||||
2 & 3) — `publish.yaml` `scoop-bucket` / `homebrew-tap` jobs render
|
||||
dependency-free manifests from the release `.sha256` sidecars and push
|
||||
them, via the scoped `lazyeval-ci` bot token, to
|
||||
`lazyeval/scoop-bucket` and `lazyeval/homebrew-tap`; rendering covered
|
||||
by `scripts/test-package-renders.sh`. NB: the Homebrew path depends on
|
||||
a **server-side Caddy rule** answering HEAD on release-download paths
|
||||
directly (Gitea's presigned-S3 redirect is method-bound and brew
|
||||
reuses a HEAD-signed URL for its GET) — see ADR-0056 Amendment 3;
|
||||
that rule lives in the Gitea edge config, not this repo. **winget
|
||||
wired** (ADR-0056 Amendment 4) — a `publish.yaml` `winget` job submits
|
||||
a PR to `microsoft/winget-pkgs` via komac (`LazyEvaluation.RdbmsPlayground`,
|
||||
portable, x64+arm64), guarded against duplicate PRs; pending the
|
||||
**one-time manual `komac new` bootstrap** + Microsoft review before
|
||||
it goes live. Asset naming `rdbms-playground-<tag>-<target>` is
|
||||
binstall-friendly.)*
|
||||
|
||||
## TUI shell
|
||||
|
||||
@@ -905,28 +918,34 @@ since ADR-0027.)
|
||||
for representative views.
|
||||
- [x] **TT3** Tier 3: synthetic event-loop integration tests
|
||||
covering the user-facing flows in this checklist.
|
||||
- [~] **TT4** Tier 4: PTY-based end-to-end for the four critical
|
||||
- [x] **TT4** Tier 4: PTY-based end-to-end for the four critical
|
||||
flows named in ADR-0008 (cold launch → DDL → quit; save →
|
||||
reopen; export → import → rebuild; undo after DROP).
|
||||
*(Verified 2026-06-07: **nothing is wired** — no
|
||||
`portable-pty` / `expectrl` / `vt100` dependencies, no PTY test
|
||||
files; ADR-0008 §Tier-4 is a specification only. The Tier-3
|
||||
`tests/it/*_e2e.rs` files are synthetic event-loop tests, not
|
||||
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is
|
||||
wired only for the listed critical flows" — it was not wired at
|
||||
all. Genuinely deferred.)*
|
||||
*(Implemented 2026-06-22 — ADR-0008 **Amendment 1**;
|
||||
`tests/e2e_pty.rs` (commit `fd63de3`). Drives the actual built
|
||||
binary in a real pseudo-terminal: `portable-pty` + `vt100`
|
||||
(**`expectrl` dropped** — conflicting PTY layer, replaced by a
|
||||
hand-rolled `wait_for` on the vt100 grid). Fixed 100×30, per-test
|
||||
temp `--data-dir`, serial, tight fail-fast 3 s waits; table
|
||||
presence read from the Tables sidebar region (Output echoes
|
||||
commands); commands paced to completion (stale-schema-cache
|
||||
misparse on faster-than-human input → issue #39). All four flows
|
||||
green (flow 2 asserts a **column** round-trips, not just the
|
||||
table name). Was previously a spec-only deferral; the earlier
|
||||
2026-06-07 "nothing is wired" note is now resolved.)*
|
||||
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
|
||||
stable Rust.
|
||||
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea
|
||||
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` +
|
||||
`cargo test` (Tiers 1–3) on the **Linux** runner for every branch
|
||||
push / PR, and `release-macos` runs the suite natively on the
|
||||
**macOS** runner. **Windows is build-only** — cross-compiled, not
|
||||
executed (no Windows runner). **Tier 4** (PTY, TT4) is still
|
||||
unwired, so "all tiers" is not yet fully met. "Stable Rust" is
|
||||
satisfied by the flake's pinned `1.95.0` (a stable release, not
|
||||
nightly). Remaining for full TT5: a Windows execution runner and
|
||||
Tier-4 PTY in CI.)*
|
||||
*(Partial, updated 2026-06-22. **CI is live** on the self-hosted
|
||||
Gitea Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings`
|
||||
+ `cargo test` (now Tiers **1–4** — the `e2e_pty` target runs by
|
||||
default) on the **Linux** runner for every branch push / PR, and
|
||||
`release-macos` runs the suite natively on the **macOS** runner.
|
||||
**Tier 4 in CI on Linux is now met** (TT4); PTY-in-container was
|
||||
validated (openpty + spawn under Docker), first real CI run is the
|
||||
final confirmation. **Windows is build-only** — cross-compiled,
|
||||
not executed (no Windows runner). "Stable Rust" is satisfied by
|
||||
the flake's pinned `1.95.0`. **Remaining for full TT5: a Windows
|
||||
execution runner** (the macOS + Tier-4 gaps are now closed).)*
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
@@ -1002,41 +1021,80 @@ target is measurable, it is stated numerically; where it is
|
||||
necessarily qualitative, the criterion is named and the bar is
|
||||
"reviewer judgement against the criterion."
|
||||
|
||||
- [ ] **NFR-1 Performance — startup.** Cold launch to first
|
||||
All seven were formally verified 2026-06-22 (ADR-0057). Approach:
|
||||
contrast/distinctness **gated** by tests, startup/memory **measured**
|
||||
against the targets, the rest **verified by argument / reviewer note**.
|
||||
|
||||
- [x] **NFR-1 Performance — startup.** Cold launch to first
|
||||
rendered frame under 500ms on commodity hardware (developer
|
||||
laptop, mid-range desktop). Measured in CI on the Linux runner
|
||||
as a regression gate.
|
||||
- [ ] **NFR-2 Performance — input latency.** Keystroke-to-render
|
||||
laptop, mid-range desktop).
|
||||
*(Verified 2026-06-22, ADR-0057 — measured via the Tier-4 PTY
|
||||
harness; release-binary median **~29 ms** to first frame (5
|
||||
trials, this dev machine), ~17× under target. The harness test
|
||||
`startup_to_first_frame_is_reasonable` gates a generous
|
||||
debug-binary bound for gross-regression detection; a tight 500 ms
|
||||
CI gate was declined as flaky on a shared runner — user decision.)*
|
||||
- [x] **NFR-2 Performance — input latency.** Keystroke-to-render
|
||||
latency under 16ms during normal editing; long-running queries
|
||||
must execute off the UI thread so the interface remains
|
||||
responsive (typing, scrolling, mode switching) while a query is
|
||||
running.
|
||||
- [ ] **NFR-3 Performance — resource footprint.** Idle memory
|
||||
*(Verified 2026-06-22, ADR-0057 — by architecture: database work
|
||||
runs on the dedicated worker thread (ADR-0010) and terminal input
|
||||
on a separate Tokio task (`spawn_event_reader`, `runtime.rs`), so
|
||||
a running query cannot block input/render. No automated sub-16 ms
|
||||
latency test — it would be flaky and low-value.)*
|
||||
- [x] **NFR-3 Performance — resource footprint.** Idle memory
|
||||
under 50MB on the smallest target platform; no busy-loops; CPU
|
||||
near zero when waiting for input.
|
||||
- [ ] **NFR-4 Visual quality — distinctive design.** Colour
|
||||
*(Verified 2026-06-22, ADR-0057 — release-binary idle RSS
|
||||
**~10 MB** (5 trials, Linux), ~5× under target. The event loop
|
||||
blocks on `recv` (no busy-loop). The harness test
|
||||
`idle_memory_footprint_is_reasonable` gates a generous
|
||||
debug-binary RSS bound on Linux.)*
|
||||
- [/] **NFR-4 Visual quality — distinctive design.** Colour
|
||||
palette and typography are deliberate and consistent across
|
||||
views; layout uses Unicode box-drawing and symbols where they
|
||||
add clarity; rendering avoids the generic flat-default look
|
||||
that ships with most TUI frameworks. Criterion: a reviewer can
|
||||
identify the app from a screenshot of any view.
|
||||
- [ ] **NFR-5 Visual quality — colour use.** Colour conveys
|
||||
*(Reviewer note 2026-06-22, ADR-0057 — satisfied by the bespoke
|
||||
two-theme palette, the three-region framed layout, and the
|
||||
annotated plan/relationship rendering; qualitative, not
|
||||
automatable.)*
|
||||
- [x] **NFR-5 Visual quality — colour use.** Colour conveys
|
||||
information rather than decoration: mode indication, query
|
||||
result types (numeric vs text vs null), error severity,
|
||||
syntax highlighting categories. Foreground/background
|
||||
combinations meet WCAG-AA contrast (4.5:1 for normal text)
|
||||
even though we have not committed to broader accessibility.
|
||||
- [ ] **NFR-6 Cross-platform parity.** Behaviour and visual
|
||||
*(Verified 2026-06-22, ADR-0057 — **gated** in `src/theme.rs`:
|
||||
every text foreground clears 4.5:1 on both themes; the
|
||||
advanced-mode border clears 3:1 (plain border decorative-exempt);
|
||||
token pairs clear ΔE2000 ≥ 15. Writing the gate caught + fixed two
|
||||
shipped light-theme defects (`tok_string` 4.42:1, `tok_flag`
|
||||
3.15:1) and two near-duplicate dark pairs, `65eab71`.)*
|
||||
- [/] **NFR-6 Cross-platform parity.** Behaviour and visual
|
||||
quality are equivalent across Linux, macOS, and Windows on
|
||||
crossterm-supported terminals. Platform-specific divergence
|
||||
(e.g. font fallbacks) is documented, not silently tolerated.
|
||||
- [ ] **NFR-7 Light and dark background support.** The colour
|
||||
*(Reviewer note 2026-06-22, ADR-0057 — partly evidenced by the CI
|
||||
matrix (Linux + macOS execute the suite incl. Tier 4; Windows is
|
||||
build-only). Documented divergence: on terminals/multiplexers
|
||||
without true-colour passthrough the 24-bit palette is quantised to
|
||||
256 colours and near hues can collide (a terminal-capability
|
||||
issue). Full parity awaits a Windows execution runner — see TT5.)*
|
||||
- [x] **NFR-7 Light and dark background support.** The colour
|
||||
scheme remains legible and visually coherent on both light and
|
||||
dark terminal backgrounds. The mechanism (auto-detect via
|
||||
terminal query, explicit user setting, or both) is an
|
||||
implementation choice, but the outcome is non-negotiable: no
|
||||
dark-on-dark or light-on-light readability failures on either
|
||||
background.
|
||||
*(Verified 2026-06-22, ADR-0057 — the WCAG-AA contrast gate
|
||||
(NFR-5) runs over **both** themes, so the legibility outcome is
|
||||
enforced on light and dark. Selection mechanism is `--theme` +
|
||||
`COLORFGBG` auto-detect (OSC-11 querying deferred).)*
|
||||
|
||||
---
|
||||
|
||||
|
||||
+38
-10
@@ -24,9 +24,11 @@
|
||||
%LOCALAPPDATA%\Programs\rdbms-playground.
|
||||
|
||||
.NOTES
|
||||
Written but NOT tested on Windows from this environment (no PowerShell
|
||||
here) — validate on a real Windows host. The verified sibling is
|
||||
install.sh (Linux/macOS).
|
||||
Verified end-to-end on ARM64 Windows 11 under both Windows PowerShell 5.1
|
||||
and PowerShell 7.6, against the live v0.2.0 release. The x86_64 branch is
|
||||
symmetric (env-based arch detection + a confirmed matching release asset)
|
||||
but has not been run directly. The sibling installer is install.sh
|
||||
(Linux/macOS).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -37,15 +39,30 @@ param(
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Windows PowerShell 5.1 (the in-box shell) can negotiate only TLS 1.0/1.1 by
|
||||
# default, which modern hosts reject. Opt into TLS 1.2 without disturbing any
|
||||
# protocols already enabled. (No-op on PowerShell 7.)
|
||||
[Net.ServicePointManager]::SecurityProtocol =
|
||||
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$Repo = 'https://git.lazyeval.net/oli/rdbms-playground'
|
||||
$Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground'
|
||||
$Bin = 'rdbms-playground'
|
||||
|
||||
# Map the host CPU to the target triple we publish for Windows.
|
||||
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
|
||||
# Read the architecture from the environment rather than
|
||||
# RuntimeInformation::OSArchitecture: under Windows PowerShell 5.1 that type
|
||||
# resolves from a .NET Framework facade that lacks OSArchitecture, which (with
|
||||
# StrictMode) throws "property cannot be found". PROCESSOR_ARCHITECTURE is set
|
||||
# on every PowerShell version; PROCESSOR_ARCHITEW6432 reports the true OS
|
||||
# architecture when a 32-bit shell runs under WOW64.
|
||||
$osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITEW6432')
|
||||
if (-not $osArch) {
|
||||
$osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE')
|
||||
}
|
||||
switch ($osArch) {
|
||||
'X64' { $target = 'x86_64-pc-windows-gnu' }
|
||||
'Arm64' { $target = 'aarch64-pc-windows-gnullvm' }
|
||||
'AMD64' { $target = 'x86_64-pc-windows-gnu' }
|
||||
'ARM64' { $target = 'aarch64-pc-windows-gnullvm' }
|
||||
default { throw "install: unsupported CPU architecture: $osArch" }
|
||||
}
|
||||
|
||||
@@ -65,8 +82,11 @@ try {
|
||||
$shaFile = "$exe.sha256"
|
||||
|
||||
Write-Host "downloading $asset ..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
Invoke-WebRequest -Uri "$url.sha256" -OutFile $shaFile
|
||||
# -UseBasicParsing: Windows PowerShell 5.1's Invoke-WebRequest otherwise
|
||||
# tries to use the Internet Explorer engine and can fail when it is absent.
|
||||
# (No-op on PowerShell 7.)
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $exe
|
||||
Invoke-WebRequest -UseBasicParsing -Uri "$url.sha256" -OutFile $shaFile
|
||||
|
||||
# The sidecar is "<hash> <name>"; compare just the hash.
|
||||
$expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower()
|
||||
@@ -80,14 +100,22 @@ try {
|
||||
Move-Item -Path $exe -Destination $dest -Force
|
||||
Write-Host "installed $Bin $Version -> $dest"
|
||||
|
||||
# Add the install dir to the user PATH (persistent) if it's not there.
|
||||
# Persist the install dir on the user PATH (for future shells) if missing.
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (-not $userPath) { $userPath = '' }
|
||||
if (($userPath -split ';') -notcontains $InstallDir) {
|
||||
$newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir }
|
||||
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
||||
Write-Host "added $InstallDir to your user PATH — restart your shell to pick it up"
|
||||
}
|
||||
# Also update THIS session's PATH so the command works immediately. The
|
||||
# persisted change only reaches newly-started processes; an already-running
|
||||
# shell (and, depending on how the terminal inherited its environment, even
|
||||
# a freshly-opened one) won't see it until the next sign-out/in.
|
||||
if (($env:Path -split ';') -notcontains $InstallDir) {
|
||||
$env:Path = "$env:Path;$InstallDir"
|
||||
}
|
||||
Write-Host "added $InstallDir to your PATH — '$Bin' works in this window now."
|
||||
Write-Host "for shells already open elsewhere, sign out and back in (or open a fresh one)."
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
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
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Render the Homebrew formula for rdbms-playground to stdout.
|
||||
#
|
||||
# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in
|
||||
# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a
|
||||
# version and the four macOS/Linux asset SHA-256 hashes it prints a complete
|
||||
# formula. The publish.yaml `homebrew-tap` job fetches the hashes from the
|
||||
# release .sha256 sidecars and commits the result into lazyeval/homebrew-tap as
|
||||
# Formula/rdbms-playground.rb.
|
||||
#
|
||||
# The release assets are bare binaries (no archive), so Homebrew stages the
|
||||
# single downloaded file in the build dir and `install` drops it under a stable
|
||||
# name. Windows is intentionally absent — Homebrew has no Windows port (Scoop /
|
||||
# winget cover Windows).
|
||||
#
|
||||
# Usage: render-homebrew-formula.sh <version> <mac-arm> <mac-intel> <linux-arm> <linux-intel>
|
||||
# <version> version, with or without a leading 'v'
|
||||
# <mac-arm> sha256 of aarch64-apple-darwin
|
||||
# <mac-intel> sha256 of x86_64-apple-darwin
|
||||
# <linux-arm> sha256 of aarch64-unknown-linux-musl
|
||||
# <linux-intel> sha256 of x86_64-unknown-linux-musl
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 5 ]; then
|
||||
echo "usage: $0 <version> <mac-arm> <mac-intel> <linux-arm> <linux-intel>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
version=${1#v}
|
||||
mac_arm=$2
|
||||
mac_intel=$3
|
||||
linux_arm=$4
|
||||
linux_intel=$5
|
||||
|
||||
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/v$version"
|
||||
|
||||
# Ruby interpolations (#{version}, #{bin}) must survive verbatim into the
|
||||
# formula; they contain no '$', so this unquoted heredoc leaves them untouched
|
||||
# and only expands the shell variables below.
|
||||
cat <<EOF
|
||||
# typed: false
|
||||
# frozen_string_literal: true
|
||||
|
||||
# rdbms-playground — installs the prebuilt release binary for the host
|
||||
# platform. Regenerated for each release by scripts/render-homebrew-formula.sh;
|
||||
# do not edit by hand.
|
||||
class RdbmsPlayground < Formula
|
||||
desc "Cross-platform TUI playground for learning relational databases"
|
||||
homepage "https://relplay.org"
|
||||
version "$version"
|
||||
license any_of: ["MIT", "Apache-2.0"]
|
||||
|
||||
on_macos do
|
||||
on_arm do
|
||||
url "$base/rdbms-playground-v$version-aarch64-apple-darwin"
|
||||
sha256 "$mac_arm"
|
||||
end
|
||||
on_intel do
|
||||
url "$base/rdbms-playground-v$version-x86_64-apple-darwin"
|
||||
sha256 "$mac_intel"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "$base/rdbms-playground-v$version-aarch64-unknown-linux-musl"
|
||||
sha256 "$linux_arm"
|
||||
end
|
||||
on_intel do
|
||||
url "$base/rdbms-playground-v$version-x86_64-unknown-linux-musl"
|
||||
sha256 "$linux_intel"
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
# The release asset is a single bare binary; Homebrew stages it in the
|
||||
# build dir under its (versioned) basename. Install it as a stable name.
|
||||
bin.install Dir["*"].first => "rdbms-playground"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "rdbms-playground #{version}", shell_output("#{bin}/rdbms-playground --version")
|
||||
end
|
||||
end
|
||||
EOF
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Render the Scoop manifest for rdbms-playground to stdout.
|
||||
#
|
||||
# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in
|
||||
# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a
|
||||
# version and the two Windows asset SHA-256 hashes it prints a complete,
|
||||
# schema-valid Scoop manifest. The publish.yaml `scoop-bucket` job fetches the
|
||||
# hashes from the release's .sha256 sidecars and commits the result into the
|
||||
# lazyeval/scoop-bucket repository as rdbms-playground.json.
|
||||
#
|
||||
# Manifest updates are CI-driven (this script, per release), so the manifest
|
||||
# carries `checkver` (so `scoop status` / the community excavator can see when
|
||||
# the bucket lags upstream) but deliberately NO `autoupdate` — our pipeline is
|
||||
# the updater, not Scoop's maintainer tooling.
|
||||
#
|
||||
# Usage: render-scoop-manifest.sh <version> <hash-x64> <hash-arm64>
|
||||
# <version> version, with or without a leading 'v' (e.g. 0.2.0 or v0.2.0)
|
||||
# <hash-x64> sha256 of the x86_64-pc-windows-gnu.exe asset
|
||||
# <hash-arm64> sha256 of the aarch64-pc-windows-gnullvm.exe asset
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 3 ]; then
|
||||
echo "usage: $0 <version> <hash-x64> <hash-arm64>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Accept either 0.2.0 or v0.2.0; the manifest 'version' field is bare.
|
||||
version=${1#v}
|
||||
hash_x64=$2
|
||||
hash_arm64=$3
|
||||
|
||||
repo="https://git.lazyeval.net/oli/rdbms-playground"
|
||||
base="$repo/releases/download/v$version"
|
||||
|
||||
# The `#/rdbms-playground.exe` fragment tells Scoop to save the versioned asset
|
||||
# under a stable filename, so the `bin` shim resolves regardless of version.
|
||||
url_x64="$base/rdbms-playground-v$version-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe"
|
||||
url_arm64="$base/rdbms-playground-v$version-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe"
|
||||
|
||||
# Note: \$.tag_name emits a literal $ (Scoop's JSONPath); the regex uses [0-9.]
|
||||
# rather than \d so the manifest contains no backslashes to escape.
|
||||
cat <<EOF
|
||||
{
|
||||
"version": "$version",
|
||||
"description": "A cross-platform TUI playground for learning relational databases.",
|
||||
"homepage": "https://relplay.org",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "$url_x64",
|
||||
"hash": "$hash_x64"
|
||||
},
|
||||
"arm64": {
|
||||
"url": "$url_arm64",
|
||||
"hash": "$hash_arm64"
|
||||
}
|
||||
},
|
||||
"bin": "rdbms-playground.exe",
|
||||
"checkver": {
|
||||
"url": "https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground/releases/latest",
|
||||
"jsonpath": "\$.tag_name",
|
||||
"regex": "v([0-9.]+)"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Tests for the package-manifest render scripts (Scoop + Homebrew).
|
||||
#
|
||||
# Validates that each render script emits a well-formed manifest for given
|
||||
# inputs, and that the inputs land in the right places. Dependency-light:
|
||||
# requires node (always in the CI image) for JSON parsing; uses jq and ruby for
|
||||
# extra checks when present (both available on the dev box). No network.
|
||||
#
|
||||
# Run: scripts/test-package-renders.sh
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
pass() { echo "ok: $*"; }
|
||||
|
||||
# Distinct dummy hashes so we can assert each lands in the right slot.
|
||||
VER=9.9.9
|
||||
H_WIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01
|
||||
H_WIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa02
|
||||
H_MAC_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa03
|
||||
H_MAC_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa04
|
||||
H_LIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa05
|
||||
H_LIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa06
|
||||
|
||||
# ---- Scoop ----------------------------------------------------------------
|
||||
scoop=$tmp/rdbms-playground.json
|
||||
# Pass a leading 'v' to confirm it gets stripped.
|
||||
"$here/render-scoop-manifest.sh" "v$VER" "$H_WIN_X64" "$H_WIN_ARM" > "$scoop"
|
||||
|
||||
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$scoop" \
|
||||
|| fail "scoop manifest is not valid JSON"
|
||||
pass "scoop manifest parses as JSON"
|
||||
|
||||
# Field-level assertions via node (no jq dependency).
|
||||
check_json() { # <jsexpr> <expected>
|
||||
local got
|
||||
got=$(node -e '
|
||||
const m = JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
|
||||
process.stdout.write(String(eval(process.argv[2])));
|
||||
' "$scoop" "$1")
|
||||
[ "$got" = "$2" ] || fail "scoop: $1 = '$got', expected '$2'"
|
||||
}
|
||||
check_json 'm.version' "$VER"
|
||||
check_json 'm.bin' "rdbms-playground.exe"
|
||||
check_json 'm.architecture["64bit"].hash' "$H_WIN_X64"
|
||||
check_json 'm.architecture["arm64"].hash' "$H_WIN_ARM"
|
||||
check_json 'm.checkver.jsonpath' '$.tag_name'
|
||||
grep -q "rdbms-playground-v$VER-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe" "$scoop" \
|
||||
|| fail "scoop: x64 url/fragment missing"
|
||||
grep -q "rdbms-playground-v$VER-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe" "$scoop" \
|
||||
|| fail "scoop: arm64 url/fragment missing"
|
||||
pass "scoop manifest fields correct"
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -e . "$scoop" >/dev/null || fail "scoop: jq rejected the manifest"
|
||||
pass "scoop manifest valid per jq"
|
||||
fi
|
||||
|
||||
# ---- Homebrew -------------------------------------------------------------
|
||||
formula=$tmp/rdbms-playground.rb
|
||||
"$here/render-homebrew-formula.sh" "$VER" "$H_MAC_ARM" "$H_MAC_X64" "$H_LIN_ARM" "$H_LIN_X64" > "$formula"
|
||||
|
||||
if command -v ruby >/dev/null 2>&1; then
|
||||
ruby -c "$formula" >/dev/null || fail "homebrew formula is not valid Ruby"
|
||||
pass "homebrew formula parses as Ruby"
|
||||
else
|
||||
echo "warn: ruby not present — skipping formula syntax check" >&2
|
||||
fi
|
||||
|
||||
# Each hash must appear exactly once (right asset → right slot), the version
|
||||
# must be present, and #{version} must survive verbatim for brew's test block.
|
||||
for pair in \
|
||||
"aarch64-apple-darwin:$H_MAC_ARM" \
|
||||
"x86_64-apple-darwin:$H_MAC_X64" \
|
||||
"aarch64-unknown-linux-musl:$H_LIN_ARM" \
|
||||
"x86_64-unknown-linux-musl:$H_LIN_X64"; do
|
||||
target=${pair%%:*}; hash=${pair#*:}
|
||||
grep -q "rdbms-playground-v$VER-$target\"" "$formula" || fail "homebrew: url for $target missing"
|
||||
grep -q "sha256 \"$hash\"" "$formula" || fail "homebrew: sha256 for $target missing"
|
||||
done
|
||||
grep -q 'version "'"$VER"'"' "$formula" || fail "homebrew: version line missing"
|
||||
grep -q 'assert_match "rdbms-playground #{version}"' "$formula" \
|
||||
|| fail "homebrew: test block #{version} interpolation was mangled"
|
||||
grep -q 'rdbms-playground-v9.9.9-x86_64-pc-windows' "$formula" \
|
||||
&& fail "homebrew: formula unexpectedly references a Windows asset"
|
||||
pass "homebrew formula fields correct"
|
||||
|
||||
echo "all render tests passed"
|
||||
+276
-5
@@ -106,13 +106,13 @@ impl Theme {
|
||||
// distinct from the mode-banner blue.
|
||||
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
|
||||
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
|
||||
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
|
||||
tok_type: Color::Rgb(0xF5, 0x8A, 0xAE), // rose-pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier (ADR-0057: widened ΔE from keyword 14.5→18.4)
|
||||
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
|
||||
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
|
||||
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
|
||||
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
|
||||
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
|
||||
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
|
||||
tok_function: Color::Rgb(0x6C, 0xB2, 0xFF), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + rose type (ADR-0057: widened ΔE from identifier 14.3→19.5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@ impl Theme {
|
||||
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
|
||||
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
|
||||
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
|
||||
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
|
||||
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
|
||||
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
|
||||
tok_string: Color::Rgb(0x1B, 0x7A, 0x33), // forest green (ADR-0057: darkened for WCAG-AA, 4.42→5.18:1)
|
||||
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
|
||||
tok_flag: Color::Rgb(0x7A, 0x5C, 0x00), // dark mustard/olive (ADR-0057: darkened for WCAG-AA, 3.15→5.98:1)
|
||||
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
|
||||
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
|
||||
}
|
||||
@@ -264,4 +264,275 @@ mod tests {
|
||||
assert_ne!(t.tok_function, t.tok_type);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- NFR-5 / NFR-7 (ADR-0057): WCAG-AA contrast gate ----
|
||||
//
|
||||
// Every text-bearing foreground must clear WCAG-AA 4.5:1 against
|
||||
// the panel background, in BOTH themes, so the colour scheme stays
|
||||
// legible on light and dark terminals (no dark-on-dark / light-on-
|
||||
// light failures). Borders are non-text structural chrome and are
|
||||
// handled by `advanced_mode_border_meets_ui_contrast` below; the
|
||||
// plain `border` is intentionally exempt (decorative — see ADR-0057).
|
||||
//
|
||||
// These checks rely on every `Theme` colour being `Color::Rgb`
|
||||
// (true-colour). `rgb_channels` panics on any other variant, which
|
||||
// also guards against a future regression to a named palette colour
|
||||
// (whose real contrast can't be known).
|
||||
|
||||
fn rgb_channels(c: Color) -> (f64, f64, f64) {
|
||||
match c {
|
||||
Color::Rgb(r, g, b) => (f64::from(r), f64::from(g), f64::from(b)),
|
||||
other => panic!("theme colours must be Color::Rgb for contrast checks; got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// WCAG 2.x relative luminance of an sRGB colour.
|
||||
// The luminance/Lab/CIEDE2000 maths below are published standards; we
|
||||
// keep them in canonical `a*x + b*y` form (verified against reference
|
||||
// vectors) rather than refactoring into `mul_add`, which would obscure
|
||||
// the formula for a negligible test-only gain.
|
||||
#[allow(clippy::suboptimal_flops)]
|
||||
fn relative_luminance(c: Color) -> f64 {
|
||||
let chan = |v: f64| {
|
||||
let v = v / 255.0;
|
||||
if v <= 0.03928 {
|
||||
v / 12.92
|
||||
} else {
|
||||
((v + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
let (r, g, b) = rgb_channels(c);
|
||||
0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
|
||||
}
|
||||
|
||||
/// WCAG 2.x contrast ratio between two colours (1.0 ..= 21.0).
|
||||
fn contrast_ratio(a: Color, b: Color) -> f64 {
|
||||
let (la, lb) = (relative_luminance(a), relative_luminance(b));
|
||||
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||
(hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
fn theme_label(t: &Theme) -> &'static str {
|
||||
match t.background {
|
||||
Background::Dark => "dark",
|
||||
Background::Light => "light",
|
||||
}
|
||||
}
|
||||
|
||||
/// Every text foreground (incl. dimmed `muted`/`tok_punct` and the
|
||||
/// syntax-token classes) on `bg`, both themes.
|
||||
fn text_foregrounds(t: &Theme) -> [(&'static str, Color); 17] {
|
||||
[
|
||||
("fg", t.fg),
|
||||
("muted", t.muted),
|
||||
("mode_simple", t.mode_simple),
|
||||
("mode_advanced", t.mode_advanced),
|
||||
("system", t.system),
|
||||
("error", t.error),
|
||||
("warning", t.warning),
|
||||
("plan_efficient", t.plan_efficient),
|
||||
("tok_keyword", t.tok_keyword),
|
||||
("tok_identifier", t.tok_identifier),
|
||||
("tok_type", t.tok_type),
|
||||
("tok_number", t.tok_number),
|
||||
("tok_string", t.tok_string),
|
||||
("tok_punct", t.tok_punct),
|
||||
("tok_flag", t.tok_flag),
|
||||
("tok_error", t.tok_error),
|
||||
("tok_function", t.tok_function),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_text_colours_meet_wcag_aa_contrast() {
|
||||
for t in [Theme::dark(), Theme::light()] {
|
||||
let label = theme_label(&t);
|
||||
for (name, c) in text_foregrounds(&t) {
|
||||
let ratio = contrast_ratio(c, t.bg);
|
||||
assert!(
|
||||
ratio >= 4.5,
|
||||
"{label} theme: {name} contrast {ratio:.2}:1 on bg is below WCAG-AA 4.5:1",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_mode_border_meets_ui_contrast() {
|
||||
// The advanced-mode border carries a mode-warning signal, so it
|
||||
// meets WCAG's 3:1 non-text / UI-component threshold in both
|
||||
// themes. The plain `border` is decorative structural chrome and
|
||||
// is intentionally exempt (recorded in ADR-0057).
|
||||
for t in [Theme::dark(), Theme::light()] {
|
||||
let label = theme_label(&t);
|
||||
let ratio = contrast_ratio(t.border_advanced, t.bg);
|
||||
assert!(
|
||||
ratio >= 3.0,
|
||||
"{label} theme: border_advanced contrast {ratio:.2}:1 below the 3:1 UI bar",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- NFR-5 perceptual distinctness (ADR-0057) ----
|
||||
//
|
||||
// Contrast-against-background is necessary but not sufficient: two
|
||||
// token colours can each clear 4.5:1 yet be hard to tell APART from
|
||||
// each other (a real pain reported on high-quality screens). We lock
|
||||
// that in with a CIEDE2000 (ΔE2000) floor between every pair of
|
||||
// syntax-token classes that can share a line. The metric itself is
|
||||
// validated against the Sharma et al. reference vectors.
|
||||
|
||||
/// sRGB `Color` → CIELAB (D65), for perceptual-difference checks.
|
||||
#[allow(clippy::suboptimal_flops)]
|
||||
fn rgb_to_lab(c: Color) -> (f64, f64, f64) {
|
||||
let lin = |v: f64| {
|
||||
let v = v / 255.0;
|
||||
if v <= 0.03928 {
|
||||
v / 12.92
|
||||
} else {
|
||||
((v + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
let (r0, g0, b0) = rgb_channels(c);
|
||||
let (r, g, b) = (lin(r0), lin(g0), lin(b0));
|
||||
let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.950_47;
|
||||
let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||
let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.088_83;
|
||||
let f = |t: f64| {
|
||||
if t > 0.008_856 {
|
||||
t.cbrt()
|
||||
} else {
|
||||
7.787 * t + 16.0 / 116.0
|
||||
}
|
||||
};
|
||||
let (fx, fy, fz) = (f(x), f(y), f(z));
|
||||
(116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
|
||||
}
|
||||
|
||||
/// CIEDE2000 colour difference between two CIELAB values.
|
||||
#[allow(clippy::suboptimal_flops)]
|
||||
fn delta_e_2000(lab1: (f64, f64, f64), lab2: (f64, f64, f64)) -> f64 {
|
||||
let (l1, a1, b1) = lab1;
|
||||
let (l2, a2, b2) = lab2;
|
||||
let pow7 = |v: f64| v.powi(7);
|
||||
let k_l = pow7(25.0);
|
||||
|
||||
let avg_lp = (l1 + l2) / 2.0;
|
||||
let c1 = a1.hypot(b1);
|
||||
let c2 = a2.hypot(b2);
|
||||
let avg_c = (c1 + c2) / 2.0;
|
||||
let g = if avg_c > 0.0 {
|
||||
0.5 * (1.0 - (pow7(avg_c) / (pow7(avg_c) + k_l)).sqrt())
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let a1p = (1.0 + g) * a1;
|
||||
let a2p = (1.0 + g) * a2;
|
||||
let c1p = a1p.hypot(b1);
|
||||
let c2p = a2p.hypot(b2);
|
||||
let avg_cp = (c1p + c2p) / 2.0;
|
||||
|
||||
let hp = |ap: f64, b: f64| -> f64 {
|
||||
if ap == 0.0 && b == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let ang = b.atan2(ap).to_degrees();
|
||||
if ang < 0.0 { ang + 360.0 } else { ang }
|
||||
};
|
||||
let h1p = hp(a1p, b1);
|
||||
let h2p = hp(a2p, b2);
|
||||
|
||||
let dlp = l2 - l1;
|
||||
let dcp = c2p - c1p;
|
||||
let dhp = if c1p * c2p == 0.0 {
|
||||
0.0
|
||||
} else if (h2p - h1p).abs() <= 180.0 {
|
||||
h2p - h1p
|
||||
} else if h2p - h1p > 180.0 {
|
||||
h2p - h1p - 360.0
|
||||
} else {
|
||||
h2p - h1p + 360.0
|
||||
};
|
||||
let d_big_hp = 2.0 * (c1p * c2p).sqrt() * (dhp.to_radians() / 2.0).sin();
|
||||
|
||||
let avg_hp = if c1p * c2p == 0.0 {
|
||||
h1p + h2p
|
||||
} else if (h1p - h2p).abs() <= 180.0 {
|
||||
(h1p + h2p) / 2.0
|
||||
} else if h1p + h2p < 360.0 {
|
||||
(h1p + h2p + 360.0) / 2.0
|
||||
} else {
|
||||
(h1p + h2p - 360.0) / 2.0
|
||||
};
|
||||
let t = 1.0 - 0.17 * (avg_hp - 30.0).to_radians().cos()
|
||||
+ 0.24 * (2.0 * avg_hp).to_radians().cos()
|
||||
+ 0.32 * (3.0 * avg_hp + 6.0).to_radians().cos()
|
||||
- 0.20 * (4.0 * avg_hp - 63.0).to_radians().cos();
|
||||
let d_ro = 30.0 * (-((avg_hp - 275.0) / 25.0).powi(2)).exp();
|
||||
let rc = if avg_cp > 0.0 {
|
||||
2.0 * (pow7(avg_cp) / (pow7(avg_cp) + k_l)).sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let sl = 1.0 + (0.015 * (avg_lp - 50.0).powi(2)) / (20.0 + (avg_lp - 50.0).powi(2)).sqrt();
|
||||
let sc = 1.0 + 0.045 * avg_cp;
|
||||
let sh = 1.0 + 0.015 * avg_cp * t;
|
||||
let rt = -(2.0 * d_ro.to_radians()).sin() * rc;
|
||||
|
||||
((dlp / sl).powi(2)
|
||||
+ (dcp / sc).powi(2)
|
||||
+ (d_big_hp / sh).powi(2)
|
||||
+ rt * (dcp / sc) * (d_big_hp / sh))
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_e_2000_matches_reference_vectors() {
|
||||
// Sharma, Wu & Dalal (2005) CIEDE2000 test data — validates the
|
||||
// metric so the distinctness gate below rests on a correct base.
|
||||
let cases = [
|
||||
((50.0, 2.6772, -79.7751), (50.0, 0.0, -82.7485), 2.0425),
|
||||
((50.0, 3.1571, -77.2803), (50.0, 0.0, -82.7485), 2.8615),
|
||||
((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0009), 7.1792),
|
||||
((50.0, -1.0, 2.0), (50.0, 0.0, 0.0), 2.3669),
|
||||
];
|
||||
for (l1, l2, exp) in cases {
|
||||
let got = delta_e_2000(l1, l2);
|
||||
assert!(
|
||||
(got - exp).abs() < 0.01,
|
||||
"ΔE2000 {got:.4} != reference {exp:.4}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_token_colours_are_perceptually_distinct() {
|
||||
// Every pair of syntax-token classes that can appear together on
|
||||
// one line must be perceptually separable, not merely unequal in
|
||||
// hex. Floor at ΔE2000 >= 15 (post-ADR-0057 minimum is ~18).
|
||||
// Excludes tok_punct (== muted) and tok_error (== error), which
|
||||
// deliberately alias other roles.
|
||||
const MIN_DE: f64 = 15.0;
|
||||
for t in [Theme::dark(), Theme::light()] {
|
||||
let label = theme_label(&t);
|
||||
let toks = [
|
||||
("tok_keyword", t.tok_keyword),
|
||||
("tok_identifier", t.tok_identifier),
|
||||
("tok_type", t.tok_type),
|
||||
("tok_number", t.tok_number),
|
||||
("tok_string", t.tok_string),
|
||||
("tok_flag", t.tok_flag),
|
||||
("tok_function", t.tok_function),
|
||||
];
|
||||
for (i, (name_a, ca)) in toks.iter().enumerate() {
|
||||
for (name_b, cb) in toks.iter().skip(i + 1) {
|
||||
let de = delta_e_2000(rgb_to_lab(*ca), rgb_to_lab(*cb));
|
||||
assert!(
|
||||
de >= MIN_DE,
|
||||
"{label} theme: {name_a} vs {name_b} ΔE2000 {de:.1} below {MIN_DE} — too similar",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Tier-4 PTY-based end-to-end tests (ADR-0008 §Tier-4, requirements TT4).
|
||||
//!
|
||||
//! These drive the **actual built binary** in a real pseudo-terminal and
|
||||
//! assert on what a user would see on screen — catching what the lower
|
||||
//! tiers can't: TTY setup, raw-mode / alternate-screen transitions, real
|
||||
//! I/O timing, and graceful quit. The four flows mirror ADR-0008's
|
||||
//! initial Tier-4 scope exactly:
|
||||
//!
|
||||
//! 1. cold launch → first DDL command → graceful quit
|
||||
//! 2. project save → reopen → identical state
|
||||
//! 3. project export → import into a fresh project → rebuilt state
|
||||
//! 4. `undo` immediately after `DROP TABLE` (incl. the confirm modal)
|
||||
//!
|
||||
//! Tooling (ADR-0008, refined): `portable-pty` to spawn the binary in a
|
||||
//! PTY at a fixed window size, `vt100` to parse the output stream into an
|
||||
//! inspectable screen grid. ADR-0008 also named `expectrl`; we dropped it
|
||||
//! — it bundles its own PTY abstraction (conflicts with portable-pty) and
|
||||
//! is line-oriented, a poor fit for a full-screen TUI. A small hand-rolled
|
||||
//! `wait_for` polling the vt100 screen replaces it.
|
||||
//!
|
||||
//! Determinism: each test gets its own temp `--data-dir` (no contact with
|
||||
//! the user's real projects / resume state), a fixed 100×30 terminal (the
|
||||
//! wide three-region layout, ADR-0046), and `--theme dark`. Tests run
|
||||
//! **serially** (one PTY + child process at a time) so timing stays
|
||||
//! predictable on the low-parallelism self-hosted CI runner, with tight,
|
||||
//! fail-fast timeouts: a slow wait means a real hang, not contention.
|
||||
|
||||
// `PtyApp` deliberately holds a serial `MutexGuard` for its whole lifetime
|
||||
// (to run PTY tests one at a time), and the screen-reading helpers hold the
|
||||
// vt100 parser lock for the duration of a read. `significant_drop_tightening`
|
||||
// flags both as droppable-earlier, but tightening them is either impossible
|
||||
// (the guard *is* the serialization) or pointless here.
|
||||
#![allow(clippy::significant_drop_tightening)]
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||
use tempfile::TempDir;
|
||||
|
||||
const COLS: u16 = 100;
|
||||
const ROWS: u16 = 30;
|
||||
/// Tight, fail-fast wait. The self-hosted runner has little parallelism,
|
||||
/// so anything slower than this is a genuine hang worth failing on.
|
||||
const WAIT: Duration = Duration::from_secs(3);
|
||||
const POLL: Duration = Duration::from_millis(20);
|
||||
|
||||
/// Tier-4 tests share one global lock so only one PTY + child runs at a
|
||||
/// time (predictable timing; no resource races). Poison-tolerant so a
|
||||
/// panicking test doesn't cascade-fail the rest.
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn lock_serial() -> MutexGuard<'static, ()> {
|
||||
SERIAL
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A running instance of the app under a pseudo-terminal.
|
||||
struct PtyApp {
|
||||
_serial: MutexGuard<'static, ()>,
|
||||
/// Present when this instance owns its data dir (single-launch
|
||||
/// flows); `None` when the test owns it (multi-launch flows that
|
||||
/// reopen the same dir).
|
||||
_owned_dir: Option<TempDir>,
|
||||
_master: Box<dyn MasterPty + Send>,
|
||||
writer: Box<dyn Write + Send>,
|
||||
parser: Arc<Mutex<vt100::Parser>>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_reader: thread::JoinHandle<()>,
|
||||
/// Spawn → first rendered frame (the `SIMPLE` mode label). Measured
|
||||
/// for NFR-1; note this is the *debug* binary, so it's a generous
|
||||
/// gross-regression signal, not the release startup figure.
|
||||
startup: Duration,
|
||||
}
|
||||
|
||||
impl PtyApp {
|
||||
/// Launch against a fresh, owned temp data dir.
|
||||
fn launch(args: &[&str]) -> Self {
|
||||
let dir = TempDir::new().expect("create temp data dir");
|
||||
let mut app = Self::launch_in(dir.path(), args);
|
||||
app._owned_dir = Some(dir);
|
||||
app
|
||||
}
|
||||
|
||||
/// Launch against a caller-owned data dir (so a later launch can
|
||||
/// reopen the same projects — flow 2).
|
||||
fn launch_in(data_dir: &Path, args: &[&str]) -> Self {
|
||||
let serial = lock_serial();
|
||||
let pty = native_pty_system();
|
||||
let pair = pty
|
||||
.openpty(PtySize {
|
||||
rows: ROWS,
|
||||
cols: COLS,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.expect("open pty");
|
||||
|
||||
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_rdbms-playground"));
|
||||
cmd.arg("--data-dir");
|
||||
cmd.arg(data_dir);
|
||||
cmd.arg("--theme");
|
||||
cmd.arg("dark");
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("RDBMS_PLAYGROUND_DEMO", "0");
|
||||
cmd.cwd(data_dir);
|
||||
|
||||
let started = Instant::now();
|
||||
let child = pair.slave.spawn_command(cmd).expect("spawn binary");
|
||||
drop(pair.slave); // we never write to the slave directly
|
||||
|
||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(ROWS, COLS, 0)));
|
||||
let mut reader = pair.master.try_clone_reader().expect("clone pty reader");
|
||||
let writer = pair.master.take_writer().expect("take pty writer");
|
||||
|
||||
let parser_for_reader = Arc::clone(&parser);
|
||||
let reader_handle = thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => parser_for_reader
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.process(&buf[..n]),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut app = Self {
|
||||
_serial: serial,
|
||||
_owned_dir: None,
|
||||
_master: pair.master,
|
||||
writer,
|
||||
parser,
|
||||
child,
|
||||
_reader: reader_handle,
|
||||
startup: Duration::ZERO,
|
||||
};
|
||||
// Every flow needs a booted, idle app; block on the first frame.
|
||||
app.wait_for("SIMPLE");
|
||||
app.startup = started.elapsed();
|
||||
app
|
||||
}
|
||||
|
||||
/// Current visible screen as text (one line per row, trailing blanks
|
||||
/// trimmed) — what a human would read.
|
||||
fn screen_text(&self) -> String {
|
||||
self.parser
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.screen()
|
||||
.contents()
|
||||
}
|
||||
|
||||
/// Text of just the left **Tables sidebar** region (first
|
||||
/// [`SIDEBAR_W`] columns). The Output panel echoes every command, so
|
||||
/// a table name typed in a command pollutes a whole-screen match —
|
||||
/// table presence/absence must be read from the sidebar, where the
|
||||
/// items list actually lives.
|
||||
fn sidebar(&self) -> String {
|
||||
const SIDEBAR_W: u16 = 28;
|
||||
let parser = self
|
||||
.parser
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let screen = parser.screen();
|
||||
let mut out = String::new();
|
||||
for row in 0..ROWS {
|
||||
for col in 0..SIDEBAR_W {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
out.push_str(cell.contents());
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Poll a predicate to the tight timeout, panicking with a screen dump.
|
||||
fn wait_until(&self, desc: &str, pred: impl Fn(&Self) -> bool) {
|
||||
let deadline = Instant::now() + WAIT;
|
||||
loop {
|
||||
if pred(self) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out after {WAIT:?} waiting for {desc}\n\
|
||||
----- screen -----\n{}\n------------------",
|
||||
self.screen_text(),
|
||||
);
|
||||
thread::sleep(POLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for `needle` anywhere on screen (Output-panel messages, modals).
|
||||
fn wait_for(&self, needle: &str) {
|
||||
self.wait_until(&format!("{needle:?} on screen"), |a| {
|
||||
a.screen_text().contains(needle)
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for a table `name` to appear in the Tables sidebar.
|
||||
fn wait_for_table(&self, name: &str) {
|
||||
self.wait_until(&format!("table {name:?} in sidebar"), |a| {
|
||||
a.sidebar().contains(name)
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for the Tables sidebar to be empty (the `(none yet)` placeholder).
|
||||
fn wait_for_no_tables(&self) {
|
||||
self.wait_until("empty Tables sidebar", |a| {
|
||||
a.sidebar().contains("(none yet)")
|
||||
});
|
||||
}
|
||||
|
||||
fn send(&mut self, bytes: &[u8]) {
|
||||
self.writer.write_all(bytes).expect("write to pty");
|
||||
self.writer.flush().expect("flush pty");
|
||||
}
|
||||
|
||||
/// Type a command and submit it (Enter == carriage return in raw mode).
|
||||
fn submit(&mut self, line: &str) {
|
||||
self.send(line.as_bytes());
|
||||
self.send(b"\r");
|
||||
}
|
||||
|
||||
fn pid(&self) -> Option<u32> {
|
||||
self.child.process_id()
|
||||
}
|
||||
|
||||
/// Resident set size of the child in KiB (Linux only).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn rss_kib(&self) -> Option<u64> {
|
||||
let pid = self.pid()?;
|
||||
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();
|
||||
}
|
||||
|
||||
// ===================== 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();
|
||||
}
|
||||
@@ -58,6 +58,29 @@ export default defineConfig({
|
||||
},
|
||||
{ tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } },
|
||||
{ tag: 'meta', attrs: { name: 'twitter:image', content: 'https://relplay.org/og-card.png' } },
|
||||
// Privacy-friendly, cookieless analytics (self-hosted Umami). The
|
||||
// Website ID is public by design (it ships in every page). The script
|
||||
// loads from the Umami host, which also becomes the data destination,
|
||||
// so no `data-host-url` is needed.
|
||||
// - Served first-party under `umami.relplay.org` (a proxied CNAME to
|
||||
// the Umami server) rather than the server's own host: privacy
|
||||
// blockers (e.g. Brave Shields) block Umami only as a *third-party*
|
||||
// request, so a same-registrable-domain host is not flagged.
|
||||
// - data-domains: only run on the production apex, so the
|
||||
// `website.relplay.pages.dev` preview and `staging.relplay.org`
|
||||
// don't pollute the stats.
|
||||
// - data-do-not-track: honour the browser's Do-Not-Track signal
|
||||
// (those visits are not counted at all). See /privacy.
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: {
|
||||
defer: true,
|
||||
src: 'https://umami.relplay.org/script.js',
|
||||
'data-website-id': 'fd77cfa5-cffe-4fc8-addb-1c6d7b6d9939',
|
||||
'data-domains': 'relplay.org',
|
||||
'data-do-not-track': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
// Register the simple-mode command grammar with Expressive Code (Shiki).
|
||||
expressiveCode: { shiki: { langs: [rdbmsLang, rdbmsSyntax] } },
|
||||
|
||||
@@ -15,6 +15,8 @@ import Default from '@astrojs/starlight/components/Footer.astro';
|
||||
Made by <a href="https://www.lazyevaluation.biz/">Lazy Evaluation Ltd</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="https://git.lazyeval.net/oli/rdbms-playground">Source & issues</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="/privacy/">Privacy</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
// Pre-selects the install-instructions tab that matches the visitor's OS.
|
||||
//
|
||||
// This piggybacks on Starlight's synced-tabs mechanism rather than fighting it.
|
||||
// A `<Tabs syncKey="install-os">` restores its active tab from
|
||||
// `localStorage["starlight-synced-tabs__install-os"]` via a script Starlight
|
||||
// inlines *before* the tabs render (so there is no flash of the wrong tab). All
|
||||
// we do here is seed that same key with the detected OS — and only when the
|
||||
// visitor has not already chosen a tab themselves, so a manual choice always wins.
|
||||
//
|
||||
// Requirements (mirrors Starlight's own restore script):
|
||||
// - Must be placed *before* the `<Tabs>` it targets, so this runs first.
|
||||
// - The stored value must equal one of the TabItem labels exactly
|
||||
// ("Linux" / "macOS" / "Windows").
|
||||
// - Inlined, so it runs during parse before the restore script's
|
||||
// connectedCallback and paint.
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const KEY = 'starlight-synced-tabs__install-os';
|
||||
try {
|
||||
// A previously stored value is a deliberate user choice — never override it.
|
||||
if (localStorage.getItem(KEY)) return;
|
||||
const data = navigator.userAgentData;
|
||||
const platform = ((data && data.platform) || navigator.platform || '').toLowerCase();
|
||||
const ua = (navigator.userAgent || '').toLowerCase();
|
||||
let os = null;
|
||||
if (platform.includes('win') || ua.includes('windows')) os = 'Windows';
|
||||
else if (platform.includes('mac') || ua.includes('mac os x')) os = 'macOS';
|
||||
else if (platform.includes('linux') || platform.includes('x11') || ua.includes('linux'))
|
||||
os = 'Linux';
|
||||
if (os) localStorage.setItem(KEY, os);
|
||||
} catch (e) {
|
||||
// localStorage unavailable (private mode, blocked cookies, …) — silently
|
||||
// fall back to the default first tab. No detection, no harm.
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
title: Installation
|
||||
description: Install RDBMS Playground from a prebuilt binary or a package manager, and run it for the first time.
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
RDBMS Playground is a single self-contained program. There is nothing to
|
||||
configure and no separate database to install — everything it needs is
|
||||
built in.
|
||||
|
||||
## Prebuilt binaries
|
||||
|
||||
Download the binary for your platform, make it executable if needed, and
|
||||
put it somewhere on your `PATH`.
|
||||
|
||||
:::note
|
||||
Download links are published with each release. They are added here when the
|
||||
first public version ships.
|
||||
:::
|
||||
|
||||
## Package managers
|
||||
|
||||
Once published, the playground will be installable through common package
|
||||
managers:
|
||||
|
||||
```sh
|
||||
# macOS / Linux (Homebrew)
|
||||
brew install rdbms-playground
|
||||
|
||||
# Windows (Scoop)
|
||||
scoop install rdbms-playground
|
||||
```
|
||||
|
||||
:::note
|
||||
Package-manager availability lands with the first public release; the exact
|
||||
names are confirmed here at that time.
|
||||
:::
|
||||
|
||||
## Run it
|
||||
|
||||
Start the playground with no arguments and it opens a fresh, automatically
|
||||
named temporary project so you can start experimenting immediately:
|
||||
|
||||
```sh
|
||||
rdbms-playground
|
||||
```
|
||||
|
||||
To open an existing project, pass its path:
|
||||
|
||||
```sh
|
||||
rdbms-playground path/to/project
|
||||
```
|
||||
|
||||
Useful options (run `rdbms-playground --help` for the full list):
|
||||
|
||||
| Option | What it does |
|
||||
|---|---|
|
||||
| `--resume` | Reopen the most recently used project. |
|
||||
| `--data-dir <PATH>` | Use a different location for stored projects. |
|
||||
| `--theme <light\|dark>` | Force a theme instead of auto-detecting. |
|
||||
| `--mode <simple\|advanced>` | Start in a specific input mode. |
|
||||
|
||||
Next: [create your first project](/getting-started/first-project/).
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Installation
|
||||
description: Install RDBMS Playground with a one-line installer, a package manager, cargo, or a prebuilt binary.
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
import InstallOsDetect from '../../../components/InstallOsDetect.astro';
|
||||
|
||||
RDBMS Playground is a single self-contained program. There is nothing to
|
||||
configure and no separate database to install — everything it needs is
|
||||
built in.
|
||||
|
||||
Pick the tab for your platform — we try to select it automatically — then
|
||||
use whichever method you prefer. The one-line installers are the quickest if
|
||||
you just want to get going.
|
||||
|
||||
<InstallOsDetect />
|
||||
|
||||
<Tabs syncKey="install-os">
|
||||
<TabItem label="Linux">
|
||||
|
||||
**One-line installer**
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
It detects your CPU, installs to `~/.local/bin`, and prints a hint if that
|
||||
directory isn't on your `PATH`. Set `RDBMS_INSTALL_DIR` to install elsewhere,
|
||||
or `RDBMS_VERSION=vX.Y.Z` to pin a version. Prefer to read a script before
|
||||
running it? It lives at
|
||||
[`scripts/install.sh`](https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh).
|
||||
|
||||
**Homebrew**
|
||||
|
||||
```sh
|
||||
brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap
|
||||
brew install lazyeval/tap/rdbms-playground
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="macOS">
|
||||
|
||||
**One-line installer**
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
It detects your CPU (Apple Silicon or Intel), installs to `~/.local/bin`, and
|
||||
prints a hint if that directory isn't on your `PATH`. Set `RDBMS_INSTALL_DIR`
|
||||
to install elsewhere, or `RDBMS_VERSION=vX.Y.Z` to pin a version. Prefer to
|
||||
read a script before running it? It lives at
|
||||
[`scripts/install.sh`](https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh).
|
||||
|
||||
**Homebrew**
|
||||
|
||||
```sh
|
||||
brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap
|
||||
brew install lazyeval/tap/rdbms-playground
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Windows">
|
||||
|
||||
**One-line installer (PowerShell)**
|
||||
|
||||
```powershell
|
||||
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
It downloads the matching `.exe`, verifies its checksum, installs to
|
||||
`%LOCALAPPDATA%\Programs\rdbms-playground`, and adds that folder to your user
|
||||
`PATH`. Use `-InstallDir` to choose a different location or `-Version` to pin
|
||||
a release.
|
||||
|
||||
**Scoop**
|
||||
|
||||
```sh
|
||||
scoop bucket add lazyeval https://git.lazyeval.net/lazyeval/scoop-bucket
|
||||
scoop install rdbms-playground
|
||||
```
|
||||
|
||||
**winget**
|
||||
|
||||
A winget package (`winget install LazyEvaluation.RdbmsPlayground`) is on its
|
||||
way and awaiting review in the public winget catalogue. Until it lands, use
|
||||
Scoop or the PowerShell one-liner above.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## With cargo
|
||||
|
||||
These work on any platform with a Rust toolchain. Install from
|
||||
[crates.io](https://crates.io/crates/rdbms-playground):
|
||||
|
||||
```sh
|
||||
cargo install rdbms-playground
|
||||
```
|
||||
|
||||
For a faster install that fetches a prebuilt binary instead of compiling, use
|
||||
[`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall) (install it
|
||||
first — it is not part of `cargo` itself):
|
||||
|
||||
```sh
|
||||
cargo binstall rdbms-playground
|
||||
```
|
||||
|
||||
## Prebuilt binaries
|
||||
|
||||
Every release publishes static Linux, standalone Windows, and macOS binaries
|
||||
(x86_64 and aarch64), each with a `.sha256` checksum, on the
|
||||
[releases page](https://git.lazyeval.net/oli/rdbms-playground/releases).
|
||||
Download the one for your platform, make it executable if needed, and put it
|
||||
somewhere on your `PATH`.
|
||||
|
||||
## Run it
|
||||
|
||||
Start the playground with no arguments and it opens a fresh, automatically
|
||||
named temporary project so you can start experimenting immediately:
|
||||
|
||||
```sh
|
||||
rdbms-playground
|
||||
```
|
||||
|
||||
To open an existing project, pass its path:
|
||||
|
||||
```sh
|
||||
rdbms-playground path/to/project
|
||||
```
|
||||
|
||||
Useful options (run `rdbms-playground --help` for the full list):
|
||||
|
||||
| Option | What it does |
|
||||
|---|---|
|
||||
| `--resume` | Reopen the most recently used project. |
|
||||
| `--data-dir <PATH>` | Use a different location for stored projects. |
|
||||
| `--theme <light\|dark>` | Force a theme instead of auto-detecting. |
|
||||
| `--mode <simple\|advanced>` | Start in a specific input mode. |
|
||||
| `--version` | Print the version and exit. |
|
||||
|
||||
The full list, with examples, is on the
|
||||
[command-line options](/using-the-playground/command-line-options/) page.
|
||||
|
||||
Next: [create your first project](/getting-started/first-project/).
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: Privacy
|
||||
description: What we measure on this site, and what we don't. No cookies, no personal data.
|
||||
tableOfContents: false
|
||||
---
|
||||
|
||||
This site is run as a free teaching tool, not a business that profiles its
|
||||
visitors. We keep the analytics deliberately minimal.
|
||||
|
||||
## What we collect
|
||||
|
||||
We measure aggregate usage with **[Umami](https://umami.is/)**, a
|
||||
privacy-friendly analytics tool that we **host ourselves** (on
|
||||
`umami.relplay.org`). Nothing is sent to a third-party advertising
|
||||
network.
|
||||
|
||||
For each page view we record:
|
||||
|
||||
- the page you viewed and the site you arrived from (the referrer),
|
||||
- your browser, operating system, and device type,
|
||||
- an approximate **country**.
|
||||
|
||||
That's it. The numbers are aggregated into counts — how many people read a
|
||||
page, which pages are popular — and are never combined into a profile of an
|
||||
individual.
|
||||
|
||||
## What we don't do
|
||||
|
||||
- **No cookies, and nothing stored on your device.** The tracker sets no
|
||||
cookies and writes nothing to local storage. You can confirm this in your
|
||||
browser's developer tools — there are no entries to clear.
|
||||
- **No personal data.** Your IP address is used for a moment on our own
|
||||
server to derive the approximate country and to tell repeated page-loads
|
||||
apart within a single day; it is **not stored**.
|
||||
- **No cross-site tracking.** We cannot and do not follow you to other sites,
|
||||
and we never sell or share what we measure.
|
||||
|
||||
## Your choices
|
||||
|
||||
If your browser sends a **Do Not Track** signal, we honour it: those visits
|
||||
are not counted at all.
|
||||
|
||||
Because the analytics use no cookies and store nothing on your device, there
|
||||
is no cookie banner to dismiss — there is simply nothing to consent to.
|
||||
|
||||
## Questions
|
||||
|
||||
This site and its analytics are operated by
|
||||
[Lazy Evaluation Ltd](https://www.lazyevaluation.biz/). If you have any
|
||||
questions about what we measure, get in touch through the contact details
|
||||
there.
|
||||
@@ -29,8 +29,13 @@ rdbms-playground path/to/project
|
||||
| `--no-undo` | Disable the undo machinery for this run — no snapshot is taken before each change (see [Undo, redo & history](/using-the-playground/undo-and-history/)). |
|
||||
| `--demo` | Turn on demonstration mode — a teaching aid that briefly shows an on-screen badge for keys that otherwise leave no visible mark (Tab, Enter, the arrows, and the like). Useful for screencasts and for demonstrating the playground to a class. Off by default. |
|
||||
| `--log-file <PATH>` | Write diagnostic logging to `PATH`. |
|
||||
| `-V`, `--version` | Print the version and exit. |
|
||||
| `-h`, `--help` | Print the usage banner and exit. |
|
||||
|
||||
Once the playground is running, the `version` command prints the same
|
||||
version line into the output panel — handy when you want to note which
|
||||
version you're on without leaving the app.
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
|
||||
Reference in New Issue
Block a user