Compare commits
31 Commits
ae73a4be85
...
47a08166a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a08166a4 | |||
| 6429b56443 | |||
| 4bdfce6250 | |||
| 138e766817 | |||
| aeb92f56a7 | |||
| 4a5fd1b5c1 | |||
| 050b36391e | |||
| 9868442889 | |||
| 309d2e0b3f | |||
| e16ad50aa7 | |||
| 60dbb903cc | |||
| 9a126782f1 | |||
| 4d004f5847 | |||
| d5fb47bcc8 | |||
| 0878c6df19 | |||
| 52815f1a76 | |||
| 2721bd8d04 | |||
| e8fa859ab9 | |||
| 5869eec4f4 | |||
| 298475b326 | |||
| 04ebd83f08 | |||
| 18d08642d7 | |||
| da8bfebc36 | |||
| 89b9392c25 | |||
| bba24120f1 | |||
| 88145225cc | |||
| 8e3208528e | |||
| 9d8161218a | |||
| dc63ed66f1 | |||
| c7ac0c9877 | |||
| 9189740028 |
@@ -0,0 +1,17 @@
|
|||||||
|
# Windows cross-link fix for the D1 release matrix (cargo-zigbuild).
|
||||||
|
#
|
||||||
|
# Rust's std links `-lsynchronization` on Windows (WaitOnAddress-based thread
|
||||||
|
# parking). Rust normally satisfies this from the `self-contained` mingw libs
|
||||||
|
# of its `rust-mingw` component — which rust-overlay does NOT ship — and Zig's
|
||||||
|
# bundled mingw (used by `cargo zigbuild`) doesn't provide `libsynchronization.a`
|
||||||
|
# either. The actual symbols are *forwarded by kernel32* (already linked), so an
|
||||||
|
# empty stub import lib is enough to satisfy the linker. See `ci/winstub/`.
|
||||||
|
#
|
||||||
|
# These sections apply ONLY when building for the Windows targets, so host
|
||||||
|
# builds (the gate's `cargo test`/`clippy`) and the Linux release targets are
|
||||||
|
# unaffected.
|
||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
rustflags = ["-L", "native=ci/winstub"]
|
||||||
|
|
||||||
|
[target.aarch64-pc-windows-gnullvm]
|
||||||
|
rustflags = ["-L", "native=ci/winstub"]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# CI toolchain image for rdbms-playground.
|
||||||
|
#
|
||||||
|
# Purpose: a SMALL job-container image that
|
||||||
|
# (a) satisfies the Gitea act_runner job-container contract — /bin/sleep (the
|
||||||
|
# keep-alive entrypoint), bash (run: steps), node (JS actions such as
|
||||||
|
# actions/checkout); a bare nixos/nix image has none of these and won't
|
||||||
|
# even start (verified by the ci-probe run: "/bin/sleep: no such file"); and
|
||||||
|
# (b) carries the project's pinned nix toolchain with the flake's devShell
|
||||||
|
# pre-warmed, so CI runs `nix develop -c cargo ...` against a warm store.
|
||||||
|
#
|
||||||
|
# Base: node:22-bookworm-slim. Debian slim already provides bash + coreutils
|
||||||
|
# (sleep); the node tag adds the actions runtime. Far smaller than the
|
||||||
|
# catthehacker runner images (which bundle a whole GitHub-runner emulation we
|
||||||
|
# don't need).
|
||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
# nix install + flake eval needs these. git because flakes prefer a VCS context
|
||||||
|
# and tools shell out to it. Drop apt lists to keep the layer small.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
curl xz-utils ca-certificates git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Single-user nix (--no-daemon): store at /nix owned by root, no daemon/systemd
|
||||||
|
# needed — the correct mode for a container. The official installer refuses root
|
||||||
|
# and shells out to `sudo` purely to create /nix; pre-creating it ourselves (we
|
||||||
|
# ARE root) sidesteps both. Enable flakes globally so every nix invocation (and
|
||||||
|
# the runner's steps) get nix-command + flakes without flags.
|
||||||
|
# nix.conf is written FIRST so the installer's own `nix-env` profile step reads
|
||||||
|
# it: `build-users-group =` (empty) makes single-user nix build as the calling
|
||||||
|
# user (root) instead of demanding the nixbld group/users a daemon install would
|
||||||
|
# create; flakes are enabled globally in the same file.
|
||||||
|
RUN mkdir -m 0755 /nix && chown root:root /nix \
|
||||||
|
&& mkdir -p /etc/nix \
|
||||||
|
&& printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf \
|
||||||
|
&& curl --proto '=https' --tlsv1.2 -sSf -L https://nixos.org/nix/install -o /tmp/nix-install.sh \
|
||||||
|
&& sh /tmp/nix-install.sh --no-daemon \
|
||||||
|
&& rm /tmp/nix-install.sh
|
||||||
|
ENV PATH=/root/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH
|
||||||
|
# We set PATH directly instead of sourcing the profile, so also point nix at the
|
||||||
|
# Debian CA bundle (already installed) for substituter HTTPS — otherwise the
|
||||||
|
# profile-provided NIX_SSL_CERT_FILE is missing and store downloads fail.
|
||||||
|
ENV NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||||
|
|
||||||
|
# Warm the flake's devShell into the store: realizes nixpkgs + the pinned Rust
|
||||||
|
# toolchain (rustc/cargo/clippy/rustfmt) + cargo-sweep. Only the inputs that
|
||||||
|
# determine the shell are copied, so this expensive layer is cached and only
|
||||||
|
# re-runs when the flake or the toolchain pin changes — not on every source edit.
|
||||||
|
# (devShell eval is lazy: packages.default — and thus Cargo.toml/Cargo.lock — is
|
||||||
|
# never forced here, so it needn't be present.)
|
||||||
|
WORKDIR /warm
|
||||||
|
COPY flake.nix flake.lock rust-toolchain.toml ./
|
||||||
|
RUN nix develop -c rustc --version \
|
||||||
|
&& nix develop -c cargo --version \
|
||||||
|
&& nix develop -c cargo clippy --version \
|
||||||
|
&& nix develop -c cargo fmt --version \
|
||||||
|
&& nix develop -c cargo sweep --version
|
||||||
|
WORKDIR /
|
||||||
|
RUN rm -rf /warm
|
||||||
|
|
||||||
|
# FOLLOW-UP optimisation (intentionally NOT done here, see CI notes): cargo
|
||||||
|
# dependency + target caching. Each CI run still compiles the ~296-crate graph
|
||||||
|
# from scratch and pulls crate sources from crates.io. A later pass can bake
|
||||||
|
# `cargo fetch` (offline crate sources) and/or a warmed target dir, or wire
|
||||||
|
# sccache, to cut run time. Correctness/first-green first; speed next.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Builds the nix CI toolchain image (.gitea/ci-image/Dockerfile) and pushes it
|
||||||
|
# to the Gitea registry. The gate (ci.yaml) runs *inside* this image, so this
|
||||||
|
# workflow is the gate's prerequisite. It only needs to run when the image's
|
||||||
|
# inputs change — the Dockerfile, the flake, or the toolchain pin — plus on
|
||||||
|
# manual dispatch.
|
||||||
|
#
|
||||||
|
# DinD pattern: plain docker:27-dind (one of the tested ci-test samples). No
|
||||||
|
# registry proxy here — the runner's containers have direct internet egress
|
||||||
|
# (the ci-probe run cloned github.com and pulled docker.io with no proxy), and
|
||||||
|
# this image's RUN steps fetch from apt + nixos.org, which the proxy isn't
|
||||||
|
# guaranteed to forward. The dind-cached:local + REGISTRY_PROXY_HOST variant is
|
||||||
|
# a later speed optimisation for base-image pull caching, not needed for green.
|
||||||
|
name: build-ci-image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# Branch pushes only. Tag pushes ignore `paths:` filters and would rebuild
|
||||||
|
# the (unchanged) image on every release tag — `branches: ['**']` excludes
|
||||||
|
# tags, so this runs only when a branch push actually changes an image input.
|
||||||
|
branches: ['**']
|
||||||
|
paths:
|
||||||
|
- '.gitea/ci-image/Dockerfile'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
|
- 'rust-toolchain.toml'
|
||||||
|
- '.gitea/workflows/build-ci-image.yaml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ci-public
|
||||||
|
services:
|
||||||
|
docker:
|
||||||
|
image: docker:27-dind
|
||||||
|
options: --privileged
|
||||||
|
env:
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
env:
|
||||||
|
DOCKER_HOST: tcp://docker:2375
|
||||||
|
IMAGE: git.lazyeval.net/oli/rdbms-playground-ci
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: wait for docker
|
||||||
|
run: until docker version >/dev/null 2>&1; do sleep 1; done
|
||||||
|
- name: registry login
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
| docker login git.lazyeval.net -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
|
- name: build
|
||||||
|
run: docker build -f .gitea/ci-image/Dockerfile -t "$IMAGE:latest" .
|
||||||
|
- name: push
|
||||||
|
run: docker push "$IMAGE:latest"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# The CI gate. Runs inside the prebuilt nix toolchain image (built + pushed by
|
||||||
|
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
|
||||||
|
# just enter the flake devShell and run cargo.
|
||||||
|
#
|
||||||
|
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree
|
||||||
|
# isn't clean under stock rustfmt; revisit on main). The release job (static
|
||||||
|
# binary for D2) and the platform matrix layer on later, step by step.
|
||||||
|
name: ci
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# Branch pushes only — a tag push hits the same commit the branch push
|
||||||
|
# already gated, so `branches: ['**']` drops the redundant tag-triggered
|
||||||
|
# run (the release workflow owns tags). Pushing commits + a tag together
|
||||||
|
# still gates the commits via the branch push.
|
||||||
|
branches: ['**']
|
||||||
|
# Skip the gate for docs-only changes — markdown can't affect clippy/test.
|
||||||
|
# A push touching code *and* docs still runs (not all files are ignored).
|
||||||
|
# Note: flake/toolchain changes are NOT ignored — they can shift the
|
||||||
|
# toolchain and thus lint/test outcomes.
|
||||||
|
paths-ignore:
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gate:
|
||||||
|
runs-on: ci-public
|
||||||
|
# Public package → anonymous pull, no credentials needed.
|
||||||
|
container:
|
||||||
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: clippy (warnings denied)
|
||||||
|
run: nix develop -c cargo clippy --all-targets -- -D warnings
|
||||||
|
- name: test
|
||||||
|
run: nix develop -c cargo test --no-fail-fast
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# macOS release leg — the two *-apple-darwin binaries, built natively on the
|
||||||
|
# Tart (Apple-Silicon) runner and attached to an existing Gitea release.
|
||||||
|
#
|
||||||
|
# Manual dispatch only: the Mac runner is intermittent, so this is triggered by
|
||||||
|
# hand (with the Mac up) for a given release tag. The 4-target Linux/Windows
|
||||||
|
# release (release.yaml) runs on the tag itself and never waits on the Mac, so a
|
||||||
|
# release always has those four; the macOS two are added by dispatching this.
|
||||||
|
#
|
||||||
|
# NOTE: Gitea exposes workflow_dispatch only for workflows on the DEFAULT branch,
|
||||||
|
# so this becomes triggerable once the CI work is merged to `main`.
|
||||||
|
name: release-macos
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to build the macOS binaries for and attach to (e.g. v0.1.0)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-macos:
|
||||||
|
runs-on: macos
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: "experimental-features = nix-command flakes"
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
# Auto-provided by Gitea Actions; has repo write (release) scope.
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: nix develop -c cargo test --no-fail-fast
|
||||||
|
|
||||||
|
- name: build, de-nix, sign, package + publish
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p dist
|
||||||
|
for t in aarch64-apple-darwin x86_64-apple-darwin; do
|
||||||
|
echo "==================== $t ===================="
|
||||||
|
nix develop -c cargo build --release --target "$t"
|
||||||
|
f="target/$t/release/rdbms-playground"
|
||||||
|
|
||||||
|
# Rewrite the nix-store libiconv load path to the system one, then
|
||||||
|
# re-sign ad-hoc (install_name_tool invalidates the signature; arm64
|
||||||
|
# requires a valid one). Guard against any remaining /nix/store dep.
|
||||||
|
for l in $(otool -L "$f" | awk '/\/nix\/store.*libiconv.*dylib/ {print $1}'); do
|
||||||
|
install_name_tool -change "$l" /usr/lib/libiconv.2.dylib "$f"
|
||||||
|
done
|
||||||
|
codesign --force --sign - "$f"
|
||||||
|
if otool -L "$f" | grep -q /nix/store; then
|
||||||
|
echo "ERROR: $t binary links a /nix/store dylib"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
out="rdbms-playground-$TAG-$t"
|
||||||
|
cp "$f" "dist/$out"
|
||||||
|
( cd dist && shasum -a 256 "$out" > "$out.sha256" ) # macOS: shasum, not sha256sum
|
||||||
|
done
|
||||||
|
ls -l dist
|
||||||
|
|
||||||
|
# Idempotent create-or-get the release (release.yaml likely created it
|
||||||
|
# already from the tag), then upload the two macOS binaries + checksums.
|
||||||
|
created=$(curl -sS -X POST "$API/repos/$REPO/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated release for $TAG.\"}")
|
||||||
|
id=$(printf '%s' "$created" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const o=JSON.parse(s);process.stdout.write(String(o.id||""))}catch(e){}})')
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
id=$(curl -sS "$API/repos/$REPO/releases/tags/$TAG" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})')
|
||||||
|
fi
|
||||||
|
echo "release id: $id"
|
||||||
|
for fa in dist/*; do
|
||||||
|
name=$(basename "$fa")
|
||||||
|
echo "uploading $name"
|
||||||
|
curl -sS -X POST "$API/repos/$REPO/releases/$id/assets?name=$name" \
|
||||||
|
-H "Authorization: token $TOKEN" -F "attachment=@$fa" > /dev/null
|
||||||
|
done
|
||||||
|
echo "published macOS assets for $TAG"
|
||||||
|
|
||||||
|
- name: prune nix store — keep the last 2 toolchain generations
|
||||||
|
# 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.
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "--- disk before ---"; df -h / | tail -1
|
||||||
|
P="$HOME/.cache/rdbms-ci/toolchain"
|
||||||
|
nix develop --profile "$P" -c true || true
|
||||||
|
nix-env -p "$P" --delete-generations +2 || true
|
||||||
|
nix-collect-garbage || true
|
||||||
|
echo "--- disk after ---"; df -h / | tail -1
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Release: on a version tag, build the cross-platform binaries and publish them
|
||||||
|
# to a Gitea release with checksums. Runs in the prebuilt CI image, so the
|
||||||
|
# pinned toolchain + the release targets + cargo-zigbuild/zig are already warm.
|
||||||
|
#
|
||||||
|
# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild):
|
||||||
|
# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2)
|
||||||
|
# x86_64-pc-windows-gnu aarch64-pc-windows-gnullvm (standalone .exe)
|
||||||
|
# macOS is deferred — its arboard/AppKit link needs Apple's SDK (see ADR-ci-001).
|
||||||
|
# D3 package-manager manifests layer on later.
|
||||||
|
#
|
||||||
|
# Tests run once (host) before the matrix, so a tag can never publish untested
|
||||||
|
# code, even one pointing at a commit that was never gated on a branch.
|
||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ci-public
|
||||||
|
container:
|
||||||
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: test
|
||||||
|
run: nix develop -c cargo test --no-fail-fast
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ci-public
|
||||||
|
container:
|
||||||
|
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target:
|
||||||
|
- x86_64-unknown-linux-musl
|
||||||
|
- aarch64-unknown-linux-musl
|
||||||
|
- x86_64-pc-windows-gnu
|
||||||
|
- aarch64-pc-windows-gnullvm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: nix develop -c cargo zigbuild --release --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: package + publish
|
||||||
|
# Pin bash: the runner defaults scripted steps to dash, which rejects
|
||||||
|
# `set -o pipefail`. bash is in the CI image.
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
|
# GITEA_TOKEN is auto-provided with repo write (release) scope.
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Windows targets produce a .exe; the rest a bare binary.
|
||||||
|
case "$TARGET" in *windows*) EXT=.exe ;; *) EXT= ;; esac
|
||||||
|
BIN="target/$TARGET/release/rdbms-playground$EXT"
|
||||||
|
OUT="rdbms-playground-$TAG-$TARGET$EXT"
|
||||||
|
mkdir -p dist
|
||||||
|
cp "$BIN" "dist/$OUT"
|
||||||
|
( cd dist && sha256sum "$OUT" > "$OUT.sha256" )
|
||||||
|
ls -l dist
|
||||||
|
|
||||||
|
# Create the release for this tag; if a sibling matrix job already
|
||||||
|
# created it, look it up instead (idempotent + race-tolerant).
|
||||||
|
created=$(curl -sS -X POST "$API/repos/$REPO/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated release for $TAG.\"}")
|
||||||
|
id=$(printf '%s' "$created" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const o=JSON.parse(s);process.stdout.write(String(o.id||""))}catch(e){}})')
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
id=$(curl -sS "$API/repos/$REPO/releases/tags/$TAG" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})')
|
||||||
|
fi
|
||||||
|
echo "release id: $id"
|
||||||
|
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "uploading $name"
|
||||||
|
curl -sS -X POST "$API/repos/$REPO/releases/$id/assets?name=$name" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$f" > /dev/null
|
||||||
|
done
|
||||||
|
echo "published $TARGET assets for $TAG"
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
/target
|
/target
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
# `nix build` output symlinks (`result`, `result-<name>`), direnv's cached env
|
||||||
|
/result
|
||||||
|
/result-*
|
||||||
|
.direnv/
|
||||||
|
|
||||||
# Snapshot test review files
|
# Snapshot test review files
|
||||||
*.snap.new
|
*.snap.new
|
||||||
*.pending-snap
|
*.pending-snap
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ tempfile = "3.27.0"
|
|||||||
incremental = false
|
incremental = false
|
||||||
debug = "line-tables-only"
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
# Release builds back the distributed binaries (D2: single static binary).
|
||||||
|
# strip = "symbols" drops the symbol table at link time so the shipped artifact
|
||||||
|
# is lean (≈13 MB → 10 MB for the musl build) without a separate strip step.
|
||||||
|
[profile.release]
|
||||||
|
strip = "symbols"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
unreachable_pub = "warn"
|
unreachable_pub = "warn"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# `ci/winstub/` — empty Windows import-lib stub
|
||||||
|
|
||||||
|
`libsynchronization.a` here is an **empty `ar` archive** (8 bytes: `!<arch>\n`),
|
||||||
|
referenced by `.cargo/config.toml` via `-L native=ci/winstub` for the Windows
|
||||||
|
release targets.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The D1 release matrix cross-compiles Windows binaries from Linux with
|
||||||
|
`cargo zigbuild` (see `docs/ci/adr/`). Rust's `std` links `-lsynchronization`
|
||||||
|
for its `WaitOnAddress`-based thread parking. That import library is normally
|
||||||
|
provided by Rust's `rust-mingw` "self-contained" component — which `rust-overlay`
|
||||||
|
does not ship — and Zig's bundled mingw doesn't carry it either, so the link
|
||||||
|
fails with:
|
||||||
|
|
||||||
|
```
|
||||||
|
error: unable to find dynamic system library 'synchronization'
|
||||||
|
```
|
||||||
|
|
||||||
|
The functions it would import (`WaitOnAddress`, `WakeByAddressSingle`,
|
||||||
|
`WakeByAddressAll`) are **forwarded by `kernel32.dll`**, which is already linked,
|
||||||
|
so they resolve at link and run time without a real `synchronization` import
|
||||||
|
library. An **empty** stub is therefore sufficient: it satisfies the `-l`
|
||||||
|
lookup and contributes no symbols.
|
||||||
|
|
||||||
|
## Regenerating
|
||||||
|
|
||||||
|
```
|
||||||
|
zig ar rcs ci/winstub/libsynchronization.a
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
!<arch>
|
||||||
@@ -189,9 +189,18 @@ over keeping journaling coupled in the worker (which would have needed the
|
|||||||
no-op-skip / read-only sites no longer journal; success is journalled at
|
no-op-skip / read-only sites no longer journal; success is journalled at
|
||||||
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
|
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
|
||||||
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
|
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
|
||||||
are untouched. The vestigial worker `source` plumbing (the `_source`
|
are untouched. The vestigial worker `source` plumbing has since been
|
||||||
param on `finalize_persistence` / `do_rebuild_from_text` and the thin
|
**fully unwound** (2026-06-14 follow-up): `_source` removed from
|
||||||
read-only `*_request` wrappers) is left in place — a clean follow-up.
|
`finalize_persistence` / `do_rebuild_from_text`; the three read-only
|
||||||
|
`*_request` wrappers inlined and deleted; and — because the cascade ran
|
||||||
|
deeper than first estimated — the now-dead `source` param dropped from
|
||||||
|
the ~30 worker handlers (leaf + composite) that only forwarded it, plus
|
||||||
|
the `source` field removed from the `DescribeTable` / `QueryData` /
|
||||||
|
`RunSelect` requests and the matching `DatabaseHandle` method parameters
|
||||||
|
(the ~164 call-site churn was mostly tests). The only `source` left in
|
||||||
|
the worker is the snapshot/undo label (`snapshot_then` /
|
||||||
|
`stage_pre_mutation` / `begin_batch`), passed at the match-arm level.
|
||||||
|
Purely mechanical, compiler-guided, no behaviour change.
|
||||||
- **App commands recall bare.** Because they are dispatched outside the
|
- **App commands recall bare.** Because they are dispatched outside the
|
||||||
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
|
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
|
||||||
false`) at their own sites, and `submit` excludes them from the ring's
|
false`) at their own sites, and `submit` excludes them from the ring's
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — implementation in progress. Revised after a `/runda` review
|
||||||
|
(2026-06-14): corrected the verbosity-default fact; re-keyed tier-3
|
||||||
|
content off `help_id`; split the pre-submit-diagnostic and runtime-error
|
||||||
|
paths; added a comprehensiveness coverage test. Revised again during
|
||||||
|
Phase B implementation (2026-06-15): the first exemplar showed per-*node*
|
||||||
|
keying is too coarse for multi-form commands (`add`/`drop`/`show`/
|
||||||
|
`create`), so D3 now keys tier-3 content **per form** via a
|
||||||
|
`hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept
|
||||||
|
hints** are recorded as a deferred extension (separate tracking issue).
|
||||||
|
The parallel question of whether the in-app `help` command should
|
||||||
|
likewise distinguish advanced-SQL forms is tracked **separately** as
|
||||||
|
Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour).
|
||||||
|
|
||||||
|
Decided in conversation 2026-06-14. Closes the last open piece of **A1**
|
||||||
|
(the canonical app-command set, ADR-0003): every app command is
|
||||||
|
implemented except `hint`, which ADR-0003's command table listed as
|
||||||
|
*"Request a hint for the current input (ADR pending)."* This ADR is that
|
||||||
|
pending decision. Tracked as **H2** in `docs/requirements.md`.
|
||||||
|
|
||||||
|
References ADR-0003 (app-command set + the `:` escape), ADR-0019 (the
|
||||||
|
friendly error layer / H1), ADR-0021 (per-command usage templates / H1a),
|
||||||
|
ADR-0022 (ambient typing assistance — colour + hint panel + completion),
|
||||||
|
ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation +
|
||||||
|
responsive input hint), ADR-0049 (input-field readline keymap), and
|
||||||
|
ADR-0051 (context/state-aware keybinding strip).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`hint` is the only unbuilt app command. The naive reading — "show a hint" —
|
||||||
|
hides a real subtlety, and a real cost.
|
||||||
|
|
||||||
|
**The subtlety: a submitted `hint` command cannot see live input.** App
|
||||||
|
commands are submitted with Enter, which empties the input buffer. By the
|
||||||
|
time `hint` dispatches, the partial command it was meant to help with is
|
||||||
|
gone. So "a hint for the current input" cannot be served by a submitted
|
||||||
|
command alone — it needs a *keybinding* that acts on the live buffer
|
||||||
|
without submitting. ADR-0003 said "current input"; `requirements.md`
|
||||||
|
broadened it to "current input **or the most recent error**." Both are
|
||||||
|
wanted; they map to two different trigger surfaces.
|
||||||
|
|
||||||
|
**The cost: the value of `hint` is content, not plumbing.** The app
|
||||||
|
already carries two tiers of contextual text:
|
||||||
|
|
||||||
|
- **Tier 1** — terse, always-on: syntax colour (ADR-0022); the error
|
||||||
|
*headline* alone (ADR-0019, when `messages_verbosity: Short`).
|
||||||
|
- **Tier 2** — short contextual lines: the ambient typing prose /
|
||||||
|
`expected` set, shown live while typing (ADR-0022, catalogue
|
||||||
|
`hint.ambient_*` / `hint.value_slot_*`); and the error `hint:` field —
|
||||||
|
which, because `Verbosity::Verbose` is the **default**
|
||||||
|
(`src/friendly/translate.rs:46`), is shown **by default** beneath every
|
||||||
|
error headline (`messages short` is the opt-*out*, not `messages
|
||||||
|
verbose` the opt-in).
|
||||||
|
|
||||||
|
So the verbose error hint is **already on screen by default**. If `hint`
|
||||||
|
merely re-showed it, it would duplicate what the user can already see (and
|
||||||
|
the ambient panel). To justify itself, `hint` must add a **tier 3**: a
|
||||||
|
genuinely deeper, *teaching*-grade explanation — what the command/error
|
||||||
|
means, a worked example, and the underlying relational concept. That
|
||||||
|
corpus does not exist yet, and
|
||||||
|
authoring it (to the standard of a teaching tool, where "pedagogy wins
|
||||||
|
ties") is the bulk of the work.
|
||||||
|
|
||||||
|
The mechanism is small and reuses everything already present: the command
|
||||||
|
REGISTRY (`src/dsl/grammar/mod.rs`), the `AppCommand` enum
|
||||||
|
(`src/dsl/command.rs`), key dispatch (`App::handle_key`,
|
||||||
|
`src/app.rs:1155`), the `note_help`/`note_help_topic` renderers
|
||||||
|
(`src/app.rs:2982`/`3021`), the parser/walker expected-set
|
||||||
|
(`ParseError.expected`, `WalkResult.tail_expected`), the friendly
|
||||||
|
catalogue + `t!` macro + `keys.rs` validation, and the output styling
|
||||||
|
vocabulary (`OutputStyleClass::Hint`).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### D1 — Two surfaces, no topic argument
|
||||||
|
|
||||||
|
`hint` is delivered through **two complementary surfaces**:
|
||||||
|
|
||||||
|
1. **F1 keybinding → live input.** Pressing **F1** while typing renders a
|
||||||
|
tier-3 hint for the command currently in the buffer, into the output
|
||||||
|
panel, **without submitting or altering the buffer**. This is the
|
||||||
|
primary, most-valuable path (it serves the literal "current input").
|
||||||
|
2. **`hint` command → most recent error.** Submitting `hint` renders the
|
||||||
|
tier-3 expansion of the most recent error. This is why the command
|
||||||
|
exists despite the empty-buffer problem: the thing it helps with is
|
||||||
|
the *last thing you tried*, not the now-empty buffer.
|
||||||
|
|
||||||
|
`hint` takes **no topic argument**. Explicit per-command reference is
|
||||||
|
already `help <topic>` (H3); `hint` is purely *contextual*, which keeps
|
||||||
|
the two cleanly distinct (`hint` = "help me with what I'm doing right
|
||||||
|
now"; `help insert` = "show me the insert reference").
|
||||||
|
|
||||||
|
F1 is a **read-only overlay**: it never alters the input buffer, the
|
||||||
|
cursor, or the live completion memo (ADR-0022) — it only emits a block
|
||||||
|
into the output journal. (It must therefore be handled in `handle_key`
|
||||||
|
*before* the "any other key clears the memo" fall-through.)
|
||||||
|
|
||||||
|
### D2 — Trigger matrix
|
||||||
|
|
||||||
|
| Trigger | Buffer / state | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| **F1** | non-empty input | tier-3 hint for the command being typed, plus the live "expected next" (from the walker's `tail_expected` / parser `expected`) |
|
||||||
|
| **F1** | empty input, a recent error exists | tier-3 expansion of that error |
|
||||||
|
| **F1** | empty input, no recent error | a short "getting started" pointer (press F1 while typing a command; `help` for the full list) |
|
||||||
|
| **`hint`** (submitted) | a recent error exists | tier-3 expansion of that error (primary use) |
|
||||||
|
| **`hint`** (submitted) | no recent error | the same "getting started" pointer |
|
||||||
|
|
||||||
|
F1 is inert behind a modal and while a sidebar panel holds navigation
|
||||||
|
focus (consistent with the existing `handle_key` gates, ADR-0046); it is
|
||||||
|
active in the input context in both Simple and Advanced mode.
|
||||||
|
|
||||||
|
**Two error sources, one namespace.** Errors come in two kinds and reach
|
||||||
|
`hint` by different routes:
|
||||||
|
|
||||||
|
- **Pre-submit diagnostics** (the ~33 `diagnostic.*` classes — arity,
|
||||||
|
type, unknown table/column) are computed *while typing* by the walker.
|
||||||
|
The **F1 live-input path** reads the current under-cursor diagnostic
|
||||||
|
directly from the walker (the same source the ambient panel uses) and
|
||||||
|
renders its `hint.err.<class>` block — no stored state needed.
|
||||||
|
- **Runtime errors** (the 9 `translate_error` classes) occur *after*
|
||||||
|
submit. The **`hint` command / empty-input F1** path reads them via the
|
||||||
|
stored `last_error_hint_key` (D5).
|
||||||
|
|
||||||
|
Both render from the same `hint.err.*` namespace. **`:`-prefix handling:**
|
||||||
|
on the simple-mode one-shot escape (`: SELECT …`), command
|
||||||
|
identification for the F1 path strips the leading `:` first, so the
|
||||||
|
advanced form is matched.
|
||||||
|
|
||||||
|
### D3 — The tier-3 content model
|
||||||
|
|
||||||
|
Tier-3 blocks live in the friendly catalogue under the existing `hint:`
|
||||||
|
top-level namespace (where tier-2 ambient strings already live), in two
|
||||||
|
new sub-namespaces:
|
||||||
|
|
||||||
|
- **`hint.cmd.<hint_id>`** — one per command **form**, keyed by a **new
|
||||||
|
`hint_ids: &'static [&'static str]`** field on `CommandNode`
|
||||||
|
(`src/dsl/grammar/mod.rs:512`), **mirroring the existing `usage_ids`**.
|
||||||
|
The F1 live-input path resolves the current input to its form's hint key
|
||||||
|
via `hint_key_for_input_in_mode`, which reuses the same form-word
|
||||||
|
disambiguation as `usage_key_for_input_in_mode`.
|
||||||
|
|
||||||
|
**Why an array mirroring `usage_ids`, not a per-node `hint_id`**
|
||||||
|
*(`/runda`/implementation revision, 2026-06-15)*: a single per-node key
|
||||||
|
is too coarse. Several entry words are **one node spanning many forms** —
|
||||||
|
`add` (column/relationship/index/constraint), `drop` (table/column/
|
||||||
|
relationship/index), `show` (data/table/tables/relationships/indexes),
|
||||||
|
`create` (table/index). A live-input hint for `add 1:n relationship` is
|
||||||
|
only useful if it is *specific to relationships*, so the content must be
|
||||||
|
**per form**, not per node. The project already solved exactly this for
|
||||||
|
usage templates (`usage_ids` is a per-form array, disambiguated by the
|
||||||
|
form word), so `hint_ids` mirrors it. Single-form nodes carry one entry;
|
||||||
|
multi-form nodes carry one per form. This also covers the advanced-SQL
|
||||||
|
forms whose `usage_ids` are empty (`SQL_INSERT/UPDATE/DELETE`,
|
||||||
|
`EXPLAIN_SQL`) — they get their own `hint_ids` directly, independent of
|
||||||
|
usage, with mode-correct SQL examples. (The `help`-list collapse of
|
||||||
|
advanced-SQL forms is a separate gap — issue #36.)
|
||||||
|
|
||||||
|
**Deferred extension — clause-concept hints** (issue #37): per-form is
|
||||||
|
the right granularity for tier-3 *teaching* (position-awareness within a
|
||||||
|
form is owned by tier-2 ambient + the live `Next:` line, D4). But some
|
||||||
|
**concepts live inside a clause**, not a form — `… on delete ⟨cascade|
|
||||||
|
set null|restrict⟩` (referential actions), the `create table` constraint
|
||||||
|
slots (`primary`/`unique`/`check`/`foreign`), `with pk`, `1:n`/`m:n`
|
||||||
|
cardinality. A learner parked in such a clause may want teaching deeper
|
||||||
|
than tier-2's candidate list but narrower than the whole-form block. v1
|
||||||
|
does **not** build this (it would multiply content for points whose value
|
||||||
|
we can't yet measure, and we don't expect to accumulate usage statistics
|
||||||
|
to drive it empirically — it will be tackled as a deliberate follow-up
|
||||||
|
job). The keying does not lock it out: a later `hint.concept.<topic>`
|
||||||
|
namespace can be surfaced when the cursor sits in a recognized clause,
|
||||||
|
layered on top of the per-form block.
|
||||||
|
- **`hint.err.<class>`** — one per error/diagnostic class, keyed by the
|
||||||
|
friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`,
|
||||||
|
`hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by
|
||||||
|
both error routes (D2).
|
||||||
|
|
||||||
|
Each tier-3 block is a **structured entry with three labelled parts**, so
|
||||||
|
the voice stays consistent and the renderer can style them uniformly:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hint.cmd.dsl.insert:
|
||||||
|
what: "Add one or more rows to a table."
|
||||||
|
example: "insert into Customers values ('Ann', 'ann@x.io')"
|
||||||
|
concept: "A row is one record; each value lines up with a column, in
|
||||||
|
order. Columns typed `serial`/`shortid` fill themselves — leave them out."
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`what`** — one or two plain sentences: what this command does / what
|
||||||
|
this error means.
|
||||||
|
- **`example`** — a single concrete, copyable line (rendered neutral, not
|
||||||
|
muted, so it stands out as runnable).
|
||||||
|
- **`concept`** — the underlying relational idea, in teaching voice; the
|
||||||
|
part that makes this tier-3 rather than tier-2.
|
||||||
|
|
||||||
|
`concept` is optional where there is genuinely no concept beyond the
|
||||||
|
mechanics (e.g. `quit`); `what` + `example` are always present.
|
||||||
|
|
||||||
|
### D4 — Rendering
|
||||||
|
|
||||||
|
Both surfaces render through one new renderer, `App::note_hint*` (sibling
|
||||||
|
of `note_help`/`note_help_topic`, `src/app.rs`), emitting a small framed
|
||||||
|
block into the `output` buffer as `OutputKind::System` with
|
||||||
|
`OutputStyleClass::Hint` on the `what`/`concept` prose and `Neutral` on
|
||||||
|
the `example` line. The block is **persistent** (scrolls in the journal),
|
||||||
|
unlike the transient ambient panel — pressing F1 is an explicit request
|
||||||
|
to *keep* the deeper guidance on screen. The bottom keybinding strip
|
||||||
|
(ADR-0051) advertises F1 in the editing/typing state.
|
||||||
|
|
||||||
|
### D5 — "Most recent (runtime) error" state
|
||||||
|
|
||||||
|
The **runtime-error route** (submitted `hint`, and empty-input F1) needs
|
||||||
|
to map the last runtime error back to its `hint.err.<class>` key. Runtime
|
||||||
|
errors today live only as rendered text in the `output` buffer. We add a
|
||||||
|
single small piece of `App` state — **`last_error_hint_key:
|
||||||
|
Option<String>`** — set at the `translate_error` call sites
|
||||||
|
(`runtime.rs:2615`, `app.rs:2424`) when a friendly error is rendered,
|
||||||
|
cleared when a later command succeeds. Absent → the "getting started"
|
||||||
|
pointer.
|
||||||
|
|
||||||
|
The **pre-submit-diagnostic route** (the F1 live-input path) needs no
|
||||||
|
stored state: it reads the current diagnostic from the walker at F1 time
|
||||||
|
(D2). This is the cleaner split the `/runda` pass surfaced — typing-time
|
||||||
|
diagnostics and post-submit runtime errors are genuinely different
|
||||||
|
sources and should not be funnelled through one stored key.
|
||||||
|
|
||||||
|
### D6 — Content scope: comprehensive for v1
|
||||||
|
|
||||||
|
v1 ships tier-3 content for the **whole inventory**, not a subset (the
|
||||||
|
graceful tier-2 fallback below is a safety net, not the plan):
|
||||||
|
|
||||||
|
- **~37 command forms** — every distinct node in `REGISTRY` gets its own
|
||||||
|
`hint.cmd.<hint_id>` block (app + DSL + DDL + advanced-mode SQL forms),
|
||||||
|
each with a **mode-correct example** (the advanced-SQL forms show SQL
|
||||||
|
syntax, their simple siblings show DSL — no sharing).
|
||||||
|
- **9 runtime error classes** — `unique`, `foreign_key` (×4 sides),
|
||||||
|
`not_null`, `check`, `type_mismatch`, `not_found`, `already_exists`,
|
||||||
|
`generic`, `invalid_value` — each gets a `hint.err.*` block.
|
||||||
|
- **~33 `diagnostic.*` pre-submit classes** — arity, type, unknown
|
||||||
|
table/column, etc. — each gets a `hint.err.*` block.
|
||||||
|
|
||||||
|
The full enumerated checklist is the implementation plan's tracking
|
||||||
|
artifact (see *Content inventory*, below).
|
||||||
|
|
||||||
|
**Fallback (safety net):** if a tier-3 key is ever missing at runtime,
|
||||||
|
the surface degrades to tier 2 — the ambient prose for the command path,
|
||||||
|
or the verbose error `hint:` for the error path — never to a blank or an
|
||||||
|
error. The `keys.rs` build-time validation keeps the corpus honest, so a
|
||||||
|
missing key is caught in tests, not in front of a student.
|
||||||
|
|
||||||
|
### D7 — Authoring process: exemplars-first
|
||||||
|
|
||||||
|
Because the corpus is large and its *voice* is a pedagogical decision the
|
||||||
|
maintainer owns, content is produced in two stages:
|
||||||
|
|
||||||
|
1. This ADR carries **2–3 worked exemplars** (below) as the canonical
|
||||||
|
style reference. The `/runda` review of this ADR is where the voice and
|
||||||
|
depth are approved.
|
||||||
|
2. Once approved, the remaining blocks are authored to that template in
|
||||||
|
**reviewable batches** (grouped by area: DDL, DML, app commands,
|
||||||
|
error classes), not one monolithic drop.
|
||||||
|
|
||||||
|
### Exemplars (the style reference to approve)
|
||||||
|
|
||||||
|
**Command (F1 live-input), `insert`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Hint — insert
|
||||||
|
What: Add one or more rows to a table.
|
||||||
|
Example: insert into Customers values ('Ann', 'ann@x.io')
|
||||||
|
Concept: A row is one record; each value lines up with a column, in
|
||||||
|
order. Columns typed serial/shortid fill themselves — leave
|
||||||
|
them out.
|
||||||
|
Next: a value list `(...)`, or `(col, ...) values (...)` to name columns
|
||||||
|
```
|
||||||
|
(The "Next:" line is the live expected-set from the walker, shown only on
|
||||||
|
the non-empty-input F1 path.)
|
||||||
|
|
||||||
|
**Error (`hint` command), foreign-key child-side violation:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Hint — no parent row to point at
|
||||||
|
What: The value you inserted into Orders.customer_id doesn't match
|
||||||
|
any Customers row, so the foreign key has nothing to point at.
|
||||||
|
Example: First insert into Customers values ('Ann', ...)
|
||||||
|
Then insert into Orders values (..., 'Ann')
|
||||||
|
Concept: A foreign key is a promise that every child points at a real
|
||||||
|
parent. The parent must exist first. To allow orphans on
|
||||||
|
delete instead, set the relationship's `on delete` to
|
||||||
|
`set null` or `cascade`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Command (F1 live-input), `add 1:n relationship`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Hint — add relationship
|
||||||
|
What: Link two tables so a parent row can own many child rows.
|
||||||
|
Example: add 1:n relationship from Customers.id to Orders.customer_id
|
||||||
|
Concept: The "1:n" means one parent, many children. The child column
|
||||||
|
holds the foreign key; `--create-fk` adds it for you if it
|
||||||
|
doesn't exist yet.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forks (all user-chosen, 2026-06-14)
|
||||||
|
|
||||||
|
- **Trigger model:** both a keybinding (live input) and a submitted
|
||||||
|
command (last error), rather than command-only or keybinding-only — the
|
||||||
|
live-input path is the most useful, but the command completes the A1
|
||||||
|
slot and serves the error case.
|
||||||
|
- **Keybinding = F1:** the universal help convention; the key is
|
||||||
|
genuinely free (no `KeyCode::F(1)` binding exists today — the `"F1"`
|
||||||
|
strings in `input_render.rs`/tests are scenario labels, not the key, and
|
||||||
|
ADR-0022 uses no `F1` requirement label). No collision with the ADR-0049
|
||||||
|
readline keys, `Ctrl-O` (ADR-0046), `Esc`-clear, or the reserved
|
||||||
|
`Ctrl-C` cancel (I5). Rejected: `?` (a typeable character — fiddly
|
||||||
|
position-dependent handling) and a Ctrl/Alt chord (less discoverable, no
|
||||||
|
advantage).
|
||||||
|
- **No topic argument:** contextual only; `help <topic>` already owns
|
||||||
|
explicit reference lookup.
|
||||||
|
- **Comprehensive content for v1:** the full inventory, not a starter
|
||||||
|
subset.
|
||||||
|
- **Exemplars-first authoring:** lock the voice on a few blocks, then
|
||||||
|
mass-author to template.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **A1 closes.** With `hint` registered and built, all 15 canonical
|
||||||
|
app-level commands exist in both modes.
|
||||||
|
- **A third contextual tier exists.** Students get on-demand, teaching-
|
||||||
|
grade guidance that is deeper than the always-on colour, the headline,
|
||||||
|
the ambient one-liner, and the verbose error hint — without cluttering
|
||||||
|
those terse defaults.
|
||||||
|
- **One new keybinding (F1)** joins the keymap and the ADR-0051 strip.
|
||||||
|
- **A new `hint_ids: &[&str]` field on `CommandNode`** (mirroring
|
||||||
|
`usage_ids`) + a `hint_key_for_input_in_mode` lookup (reusing the
|
||||||
|
`usage_key_for_input_in_mode` form-disambiguation), one new field of
|
||||||
|
`App` state (`last_error_hint_key`), and one new renderer family
|
||||||
|
(`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT`
|
||||||
|
node, the REGISTRY one entry.
|
||||||
|
- **A large, durable content corpus** (~37 command blocks + ~42 error/
|
||||||
|
diagnostic blocks ≈ 80) enters the catalogue under `hint.cmd.*` /
|
||||||
|
`hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new
|
||||||
|
commands/error classes should ship with their tier-3 hint (a checklist
|
||||||
|
item for future feature ADRs).
|
||||||
|
- **Testing:** Tier-1 unit tests for the trigger matrix (F1 with
|
||||||
|
empty/non-empty input; `hint` with/without a recent error;
|
||||||
|
`last_error_hint_key` set on the `translate_error` sites and cleared on
|
||||||
|
success; the pre-submit-diagnostic vs runtime-error routing; the `:`
|
||||||
|
strip), the command-identification logic, and the tier-2 fallback;
|
||||||
|
Tier-2 `insta` snapshots for a representative rendered hint block;
|
||||||
|
Tier-3 integration tests for the end-to-end flows (type a partial
|
||||||
|
command → F1 → block appears, **buffer and completion memo untouched**;
|
||||||
|
run a failing command → `hint` → error expansion). **A
|
||||||
|
comprehensiveness coverage test** (enforces D6): iterate the REGISTRY
|
||||||
|
and assert every node has a `hint_id` resolving to a `hint.cmd.*` block,
|
||||||
|
and every runtime-error/diagnostic class has a `hint.err.*` block —
|
||||||
|
`keys.rs` only checks that *referenced* keys resolve, not that every
|
||||||
|
command/error *has* one, so this test is what makes "comprehensive"
|
||||||
|
enforceable rather than aspirational.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Per-topic `hint <topic>`** — OOS (rejected): `help <topic>` already
|
||||||
|
serves explicit lookup; a topic arg would overlap it and double the
|
||||||
|
content-authoring surface.
|
||||||
|
- **Re-showing tier-3 inline as the always-on ambient hint** — OOS
|
||||||
|
(rejected): the ambient panel stays terse by design (ADR-0022); tier-3
|
||||||
|
is on-demand. Promoting it would defeat the tiering.
|
||||||
|
- **Localised tier-3 content beyond `en-US`** — OOS (deferred): the
|
||||||
|
catalogue is structured for i18n (ADR-0019), but additional locales
|
||||||
|
follow the project's English-only-for-v1 stance (requirements X2).
|
||||||
|
- **`hint` for a *successful* command's deeper teaching** (e.g. "you just
|
||||||
|
created a table — here's what an index would add") — OOS (deferred): a
|
||||||
|
plausible future tier-3 use, but v1 scopes the command path to errors
|
||||||
|
and the F1 path to in-progress input.
|
||||||
|
- **Clause-concept hints** (`… on delete ⟨action⟩`, constraint slots,
|
||||||
|
`with pk`, cardinality) — OOS (deferred, issue #37): a
|
||||||
|
`hint.concept.<topic>` layer surfaced when the cursor sits in a
|
||||||
|
recognized clause, deeper than tier-2's candidate list but narrower than
|
||||||
|
the per-form block. Per-form keying (D3) does not lock it out. To be
|
||||||
|
tackled as a deliberate follow-up job, not gated on usage statistics.
|
||||||
|
|
||||||
|
## Content inventory (implementation tracking)
|
||||||
|
|
||||||
|
The implementation plan enumerates and checks off every block:
|
||||||
|
|
||||||
|
- **`hint.cmd.<hint_id>`** — one per distinct `REGISTRY` node (~37), each
|
||||||
|
with its own `hint_id` and a mode-correct example: app (`save`, `save
|
||||||
|
as`, `load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`,
|
||||||
|
`redo`, `mode`, `messages`, `copy`, `help`, `hint`, `quit`); DDL
|
||||||
|
(`create table`, `create m:n`, `add column`/`relationship`/`index`,
|
||||||
|
`drop`, `rename`, `change column`); DML (`insert`, `update`, `delete`,
|
||||||
|
`show`, `seed`, `explain`, `select`/`with`). The **7 advanced-mode SQL
|
||||||
|
forms** (`SQL CREATE TABLE`, `ALTER TABLE`, `CREATE/DROP INDEX`, `DROP
|
||||||
|
TABLE`, `SQL INSERT/UPDATE/DELETE`, `EXPLAIN SQL`, raw `SELECT`/`WITH`)
|
||||||
|
each get their **own** block with SQL syntax — they do **not** reuse
|
||||||
|
their simple sibling's (this is the `/runda` correction; the parallel
|
||||||
|
`help`-side gap is issue #36).
|
||||||
|
- **`hint.err.*`** — one per runtime error class (`unique`,
|
||||||
|
`foreign_key.{child,parent}_side`, `not_null`, `check`,
|
||||||
|
`type_mismatch`, `not_found`, `already_exists`, `generic`,
|
||||||
|
`invalid_value`) and per `diagnostic.*` pre-submit class.
|
||||||
+2
-1
@@ -57,4 +57,5 @@ This directory contains the project's ADRs, recorded per
|
|||||||
- [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank
|
- [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank
|
||||||
- [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table <T>` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself)
|
- [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table <T>` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself)
|
||||||
- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget)
|
- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget)
|
||||||
- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec<String>`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: unwinding the now-vestigial worker `source` plumbing (`_source` params + thin `*_request` wrappers — a clean follow-up); replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression)
|
- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec<String>`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*)
|
||||||
|
- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation in progress (2026-06-14; Phase A done, Phase B underway)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help <topic>` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.<hint_id>` (per command form) and `hint.err.<class>` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.<topic>`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint <topic>` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36)
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# ADR-ci-001: CI + release pipeline on Gitea Actions
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Accepted (2026-06-12); implemented the same day on the `ci` branch.** Every
|
||||||
|
fork below was settled with the user as the pipeline was built, and each stage
|
||||||
|
was verified live before acceptance:
|
||||||
|
|
||||||
|
- a throwaway probe workflow established how the runner executes jobs;
|
||||||
|
- the CI image was built and checked locally (runner contract, warm devShell);
|
||||||
|
- the gate ran green (**clippy clean; 2424 tests pass / 0 fail / 1 intentional
|
||||||
|
ignored doctest**);
|
||||||
|
- the release was exercised end-to-end — tag `v0.0.0-citest2` published a Gitea
|
||||||
|
release carrying the static binary (~10 MB) and its `.sha256`.
|
||||||
|
|
||||||
|
This ADR records the **CI/release pipeline**. The **dev/build environment it
|
||||||
|
runs on** — the nix flake (devShell + reproducible build, pinned Rust 1.95.0)
|
||||||
|
— is **ADR-ci-002** (relocated here from main's ADR-0049); this ADR builds on
|
||||||
|
it rather than restating it.
|
||||||
|
|
||||||
|
> **Namespacing.** Kept in `docs/ci/adr/` (id `ADR-ci-001`), disjoint from
|
||||||
|
> `main`'s integer ADR sequence, mirroring the website subproject's
|
||||||
|
> `docs/website/adr/`. This avoids the cross-branch number collisions that
|
||||||
|
> previously forced website ADRs to be renumbered (see that namespace's
|
||||||
|
> history note and ADR-0000 "Numbering discipline").
|
||||||
|
|
||||||
|
## Amendment — 2026-06-13: D1 matrix (non-macOS)
|
||||||
|
|
||||||
|
§3 (Release) below describes the original **single-target** (x86_64 Linux) job.
|
||||||
|
The release is now a **`test` → `build` matrix** over the four non-macOS D1
|
||||||
|
targets (Linux + Windows × x86_64/aarch64), cross-built with `cargo-zigbuild`.
|
||||||
|
The full decision — tooling, targets, the Windows `synchronization` stub, the
|
||||||
|
matrix shape, and the macOS deferral with its licensing rationale — is recorded
|
||||||
|
in its own record: **[ADR-ci-003](20260613-adr-ci-003.md)**.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The project is near feature-complete and needs CI (`requirements.md` **TT5**;
|
||||||
|
the **CI** item in the deferred list) and a release path for its distributed
|
||||||
|
binaries (**D1**/**D2**/**D3**). The self-hosted Gitea instance
|
||||||
|
(`git.lazyeval.net`) has its Actions runner freshly set up — a first-time
|
||||||
|
in-anger use — with a DinD-capable setup and a reusable `docker-build`
|
||||||
|
template, exercised by a handful of sample workflows.
|
||||||
|
|
||||||
|
The starting constraints, and what the probe found:
|
||||||
|
|
||||||
|
- The runner label is **`ci-public`**. A throwaway probe
|
||||||
|
(`ci-probe.yaml`, since removed) established that **jobs run *inside* a
|
||||||
|
container** — `ghcr.io/catthehacker/ubuntu:act-22.04` by default, as **root**
|
||||||
|
— and therefore the runner *host's* nix is **not** on the steps' PATH
|
||||||
|
(`nix NOT on PATH`, `no /nix`). A custom job `container:` *can* be pulled
|
||||||
|
(it pulled `nixos/nix:latest`), but the runner keeps job containers alive
|
||||||
|
with `entrypoint: /bin/sleep` and runs JS actions (e.g. `actions/checkout`)
|
||||||
|
with `node`, so the container must provide **`sleep` + `bash` + `node`** —
|
||||||
|
a bare `nixos/nix` image has none and fails to start.
|
||||||
|
- The reusable template only does `docker build`; it neither runs a Rust gate
|
||||||
|
nor pushes images nor uploads release assets — so a Rust pipeline can't just
|
||||||
|
call it.
|
||||||
|
- The whole motivation (per the user) is for CI to use the project's **nix
|
||||||
|
flake** for its tools rather than relying on whatever the build machine has
|
||||||
|
— i.e. **one toolchain definition shared by dev and CI**.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Toolchain delivery — a baked nix CI image
|
||||||
|
|
||||||
|
CI gets its toolchain from a **purpose-built job-container image**, not from
|
||||||
|
host nix and not by installing nix per-job:
|
||||||
|
|
||||||
|
- **Base `node:22-bookworm-slim`.** Debian slim already provides `bash` +
|
||||||
|
coreutils (`sleep`); the `node` tag adds the actions runtime. This satisfies
|
||||||
|
the act_runner job-container contract at a fraction of the size of the
|
||||||
|
catthehacker runner images (chosen on the user's prompt to avoid those
|
||||||
|
multi-GB images), and far more reliably than a bare `nixos/nix` (which can't
|
||||||
|
start). `.gitea/ci-image/Dockerfile`.
|
||||||
|
- **Single-user nix on top**, flakes enabled, with the **flake's devShell
|
||||||
|
pre-warmed** (`nix develop` realizes nixpkgs + the pinned Rust toolchain +
|
||||||
|
`cargo-sweep` + the musl cc into the store). CI then runs `nix develop -c …`
|
||||||
|
against a warm store — the *same* pinned toolchain as dev (ADR-ci-002),
|
||||||
|
reaching a ready toolchain in ~1.4 s.
|
||||||
|
- **Built + pushed by `build-ci-image.yaml`** via the DinD service to the
|
||||||
|
Gitea container registry as `git.lazyeval.net/<owner>/rdbms-playground-ci`,
|
||||||
|
a **public** package (anonymous pull, no gate-side credentials). It runs only
|
||||||
|
when an image input changes (Dockerfile / `flake.nix` / `flake.lock` /
|
||||||
|
`rust-toolchain.toml`) or on manual dispatch.
|
||||||
|
|
||||||
|
### 2. Gate — `ci.yaml`
|
||||||
|
|
||||||
|
On branch pushes and PRs, a single job runs **inside the CI image**:
|
||||||
|
`nix develop -c cargo clippy --all-targets -- -D warnings` then
|
||||||
|
`nix develop -c cargo test --no-fail-fast`.
|
||||||
|
|
||||||
|
**`fmt` is deliberately not gated.** The tree isn't clean under stock
|
||||||
|
`rustfmt` (~100 files would change; no `rustfmt.toml` is committed) and
|
||||||
|
reformatting would churn blame across the in-flight website branch and ongoing
|
||||||
|
`main` work — so, by user decision, the gate is **clippy + test** and fmt is
|
||||||
|
revisited on `main` (also recorded in ADR-ci-002).
|
||||||
|
|
||||||
|
### 3. Release — `release.yaml`
|
||||||
|
|
||||||
|
On a `v*` tag, one job in the CI image:
|
||||||
|
|
||||||
|
1. **tests** (`cargo test`) — so a tag can never publish untested code, even
|
||||||
|
one pointing at a never-gated commit (user choice over relying solely on the
|
||||||
|
branch gate);
|
||||||
|
2. **builds the static binary** for **`x86_64-unknown-linux-musl`** (D2:
|
||||||
|
single static binary, no runtime deps). The glibc/nix-store build is
|
||||||
|
non-portable; the musl target with `crt-static` is fully static. rusqlite's
|
||||||
|
`bundled` SQLite C is compiled by a **musl `cc`** (`pkgsCross.musl64`) wired
|
||||||
|
into the flake devShell via `CC_<target>` + `CARGO_TARGET_<TARGET>_LINKER`;
|
||||||
|
`[profile.release] strip = "symbols"` trims it (~13 MB → ~10 MB);
|
||||||
|
3. **publishes** the binary + a `.sha256` to a Gitea release via the API and
|
||||||
|
the auto-provided **`GITEA_TOKEN`** — no third-party action (just `curl` +
|
||||||
|
`node`, both in the image).
|
||||||
|
|
||||||
|
### 4. Triggers — branch vs tag hygiene
|
||||||
|
|
||||||
|
- Gate and image-build are scoped to **branch** pushes (`branches: ['**']`).
|
||||||
|
Tag pushes ignore `paths:` filters and would otherwise spuriously rebuild the
|
||||||
|
unchanged image and re-gate an already-gated commit; the branch filter
|
||||||
|
excludes tags. **`release.yaml` owns tags** (`tags: ['v*']`).
|
||||||
|
- Pushing commits + a tag together still gates the commits (via the branch
|
||||||
|
ref) and releases (via the tag ref) — no lost coverage, no duplicate runs.
|
||||||
|
|
||||||
|
### 5. Auth
|
||||||
|
|
||||||
|
- **Image push:** a dedicated PAT with `write:package`, supplied as the
|
||||||
|
`REGISTRY_USERNAME` / `REGISTRY_TOKEN` Actions secrets (the package owner
|
||||||
|
must match the token's user — an `oli`-namespace push with a different user
|
||||||
|
is refused with `reqPackageAccess`).
|
||||||
|
- **Release publish:** the auto `GITEA_TOKEN` (repo/release scope).
|
||||||
|
|
||||||
|
### 6. Scope this iteration — Linux x86_64, step by step
|
||||||
|
|
||||||
|
The user's target is the full **D1** matrix, approached incrementally. This
|
||||||
|
iteration ships **Linux x86_64 only**; the rest is deferred (below).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **One toolchain, dev and CI.** They build through the same flake and cannot
|
||||||
|
drift. New image rebuilds only when the flake/toolchain/Dockerfile change.
|
||||||
|
- **D2 is met on Linux.** The release artifact is a genuinely static,
|
||||||
|
stripped musl binary that runs with no runtime dependencies.
|
||||||
|
- **DinD is per-job (no layer cache across runs),** so every `build-ci-image`
|
||||||
|
run rebuilds from scratch (~6 min). Acceptable at its trigger frequency;
|
||||||
|
base-pull caching via the `dind-cached` proxy variant is a possible later
|
||||||
|
optimisation.
|
||||||
|
- **The CI image is ~5.5 GB+** (the Rust toolchain closure, now also musl).
|
||||||
|
Pulled once per runner and cached; slimming (multi-stage, prune) is optional.
|
||||||
|
- **Every gate run recompiles the full dependency graph** (warm *toolchain*,
|
||||||
|
cold *deps*; clippy and test don't share artifacts), ~2 min total. Fine for
|
||||||
|
now; dependency/`target` caching is a deferred speed item.
|
||||||
|
- **`GITEA_TOKEN` must retain release scope;** if an instance policy narrows
|
||||||
|
it, the release publish falls back to a repo-scoped PAT secret.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Run on the runner host's nix.** Rejected — the probe showed steps run in a
|
||||||
|
container where host nix is unreachable.
|
||||||
|
- **Install nix per-job in the default image.** Works but cold every run
|
||||||
|
(slow) and throwaway once the image exists; rejected in favour of the baked
|
||||||
|
image.
|
||||||
|
- **`catthehacker` or bare `nixos/nix` as the base.** catthehacker is a
|
||||||
|
multi-GB runner emulation we don't need; bare `nixos/nix` lacks
|
||||||
|
`sleep`/`bash`/`node` and won't start. `node:22-bookworm-slim` is the small,
|
||||||
|
contract-satisfying middle (user's suggestion).
|
||||||
|
- **A standard `rust:1.95` CI image instead of the flake.** Simpler in CI but a
|
||||||
|
*second* toolchain definition (drift) — counter to the unify-with-dev goal.
|
||||||
|
- **A third-party Gitea release action.** Avoided; the API + auto token keep
|
||||||
|
the release self-contained and debuggable.
|
||||||
|
|
||||||
|
## Deferred / out of scope (tracked, step by step)
|
||||||
|
|
||||||
|
- **D1 matrix:** **macOS only** now (x86_64 + aarch64). The four non-macOS
|
||||||
|
targets shipped via cargo-zigbuild (see the 2026-06-13 amendment); macOS needs
|
||||||
|
Apple's SDK (osxcross + private SDK, or a Mac runner).
|
||||||
|
- **D3 packaging:** Homebrew / Scoop / winget / `cargo-binstall` manifests
|
||||||
|
(and binstall-friendly asset naming/archives).
|
||||||
|
- **Tier 4 (PTY E2E):** still unwired (`requirements.md` **TT4**); the gate runs
|
||||||
|
tiers 1–3 only, so **TT5** ("CI runs all tiers on Linux/macOS/Windows") is
|
||||||
|
partially met — Linux, tiers 1–3.
|
||||||
|
- **CI speed:** dependency/`target` caching (cargo-chef into the image, or
|
||||||
|
`actions/cache`), and image slimming / `dind-cached` base-pull caching.
|
||||||
|
- **Website deploy:** the static site → Cloudflare via Gitea Actions (a
|
||||||
|
separate, simpler workflow on the website branch).
|
||||||
|
- **fmt gate:** revisit on `main` once a `rustfmt` style is chosen.
|
||||||
|
|
||||||
|
## Relationship to other decisions
|
||||||
|
|
||||||
|
- **Builds on ADR-ci-002** (nix flake dev + build env). This ADR adds the
|
||||||
|
musl-target/cc to that flake and consumes it from CI.
|
||||||
|
- **Advances `requirements.md`:** **TT5** (CI runs the tiers — Linux, 1–3),
|
||||||
|
**D2** (static binary — Linux, done), **D1**/**D3** (partial/deferred).
|
||||||
|
- **Mirrors the website subproject's** separate ADR namespace and its
|
||||||
|
static→Cloudflare-via-Gitea-Actions deployment posture (ADR-website-001).
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# ADR-ci-002: Nix flake for a reproducible dev + build environment
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Accepted (2026-06-12).** Implemented the same day on the `ci` branch:
|
||||||
|
`flake.nix`, `flake.lock`, `rust-toolchain.toml`, `.envrc`. Verified
|
||||||
|
end-to-end before acceptance — `nix develop` provides the pinned
|
||||||
|
toolchain; `nix build .#default` produces a working binary; `cargo
|
||||||
|
clippy --all-targets -- -D warnings` is clean and `cargo test` is
|
||||||
|
**2424 passed / 0 failed / 1 ignored** (the ignored item is the
|
||||||
|
intentional ```` ```ignore ```` doctest at `src/friendly/mod.rs:21`),
|
||||||
|
all run *through the flake*. This ADR is the dev/build-environment
|
||||||
|
foundation; the CI **pipeline** that consumes it (runner model, image,
|
||||||
|
gate, release) is **ADR-ci-001**.
|
||||||
|
|
||||||
|
> **History.** Created as **ADR-0049** in `main`'s integer ADR namespace
|
||||||
|
> (`docs/adr/`); moved here to **ADR-ci-002** on 2026-06-12 to keep the
|
||||||
|
> CI/dev-env decisions out of `main`'s sequence and end the cross-branch
|
||||||
|
> number collision (`main` independently reaches for the next integer too —
|
||||||
|
> the same problem the website subproject hit). Content is otherwise
|
||||||
|
> unchanged. See ADR-0000 "Numbering discipline".
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The project is near feature-complete and CI is finally being set up
|
||||||
|
(`requirements.md` **TT5**, **CI** in the deferred list). CI must not
|
||||||
|
depend on whatever Rust/toolchain happens to be installed on the build
|
||||||
|
machine — that is neither reproducible nor honest about what the build
|
||||||
|
needs.
|
||||||
|
|
||||||
|
The sibling project **datamage** already solved this with a Nix flake
|
||||||
|
(its ADR 0046): the flake is the single, version-pinned declaration of
|
||||||
|
the toolchain, and both the dev shell and CI go through it so they
|
||||||
|
cannot drift. We adopt the same pattern here. Ours is dramatically
|
||||||
|
simpler than datamage's — this is a pure-Rust TUI with no Tauri /
|
||||||
|
WebKitGTK / Node / WASM surface — so the flake carries almost no system
|
||||||
|
dependencies.
|
||||||
|
|
||||||
|
Two build facts drove the (tiny) dependency set, confirmed from
|
||||||
|
`Cargo.lock`:
|
||||||
|
|
||||||
|
- **`libsqlite3-sys` is built with `bundled`** → SQLite is compiled
|
||||||
|
from vendored C, which needs a C compiler. `nixpkgs`' `stdenv`
|
||||||
|
provides one automatically; nothing is declared for it.
|
||||||
|
- **`arboard`'s clipboard backend is `x11rb`** — a pure-Rust socket
|
||||||
|
XCB client that links *no* C X11 libraries. So no X11/`pkg-config`
|
||||||
|
system inputs are needed to build or test. A live X server is only
|
||||||
|
required at *runtime* to actually copy; headless sessions fall back
|
||||||
|
to OSC 52.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt a **Nix flake** at the repository root as the canonical
|
||||||
|
declaration of the dev *and* build environment.
|
||||||
|
|
||||||
|
- **`flake.nix`** exposes two outputs (user-chosen 2026-06-12 over a
|
||||||
|
dev-shell-only variant):
|
||||||
|
- **`devShells.default`** — the pinned Rust toolchain (from
|
||||||
|
`rust-toolchain.toml` via `rust-overlay`) plus `cargo-sweep` for
|
||||||
|
the `target/` build-hygiene discipline (CLAUDE.md / the datamage
|
||||||
|
ADR 0050 equivalent).
|
||||||
|
- **`packages.default`** (= `packages.rdbms-playground`) — a
|
||||||
|
`rustPlatform.buildRustPackage` that produces the binary
|
||||||
|
reproducibly from the pinned toolchain and the committed
|
||||||
|
`Cargo.lock` (`cargoLock.lockFile` → `importCargoLock`, which
|
||||||
|
fetches each dependency by its lockfile checksum: offline,
|
||||||
|
deterministic, no `cargoHash` to churn). `nix build` yields the
|
||||||
|
artifact CI's gate/release can consume.
|
||||||
|
- **`rust-toolchain.toml`** pins an **exact stable release**
|
||||||
|
(`1.95.0`), not the floating `stable` channel, so `nix flake update`
|
||||||
|
cannot surprise-bump Rust into new clippy lints that would fail the
|
||||||
|
`-D warnings` gate (same reasoning as datamage ADR 0046). Components:
|
||||||
|
`rustfmt` + `clippy`. No coverage/WASM tooling and no
|
||||||
|
cross-compilation targets yet — those are added when the release
|
||||||
|
matrix needs them, not before.
|
||||||
|
- **`flake.lock`** pins every input (`nixpkgs` `nixos-26.05`,
|
||||||
|
`rust-overlay`, `flake-utils`) to a commit, making the env
|
||||||
|
bit-reproducible.
|
||||||
|
- **`.envrc`** contains `use flake` for direnv auto-activation, kept
|
||||||
|
for parity with datamage even though direnv is not installed on the
|
||||||
|
current dev VM (entry is via `nix develop`).
|
||||||
|
- **`packages.default` sets `doCheck = false`.** The test suite is
|
||||||
|
*not* run during `nix build` — the Nix build sandbox has no `HOME`
|
||||||
|
and no X server, which fights the project-directory / clipboard
|
||||||
|
paths the tests touch. Tests run as their own CI stage via
|
||||||
|
`nix develop -c cargo test`, keeping "build the artifact" and "run
|
||||||
|
the suite" cleanly separate.
|
||||||
|
- **The package version is read from `Cargo.toml`** via
|
||||||
|
`builtins.fromTOML`, so it never drifts from the crate metadata.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **One toolchain definition.** Dev and CI share the exact pinned
|
||||||
|
toolchain; they cannot drift. New contributors run `nix develop`
|
||||||
|
(or get auto-activation via direnv) and have the same Rust as CI.
|
||||||
|
- **D2 (static binary) is unaffected and still pending.** The
|
||||||
|
`nix build` artifact links the Nix-store glibc *dynamically* — it is
|
||||||
|
a reproducible build/test artifact, **not** the single static
|
||||||
|
release binary D2 calls for. Release binaries will target a static
|
||||||
|
toolchain (e.g. `x86_64-unknown-linux-musl`) in the forthcoming CI
|
||||||
|
release work; that is a release-step concern, not a dev-shell one.
|
||||||
|
- **`fmt` is deliberately *not* gated yet.** The tree is not clean
|
||||||
|
under stock `rustfmt` (~100 files would change; no `rustfmt.toml` is
|
||||||
|
committed and the code was shaped by something other than default
|
||||||
|
`rustfmt`). Reformatting churns blame across every file and would
|
||||||
|
conflict with the in-flight website branch and ongoing `main` work,
|
||||||
|
so — user decision 2026-06-12 — the `fmt` gate is left out for now
|
||||||
|
and revisited on `main`. The CI gate is `clippy` + `test`.
|
||||||
|
- **Engine-name posture (CLAUDE.md) is respected.** The flake's
|
||||||
|
comments may name SQLite/`rusqlite` where technically necessary
|
||||||
|
(build-input rationale); no user-facing string is affected.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Dev-shell only (no build package).** Matches datamage exactly; CI
|
||||||
|
would `cargo build` inside `nix develop -c`. Rejected (user choice):
|
||||||
|
a `nix build` package gives a reproducible release artifact straight
|
||||||
|
from the pinned toolchain, which the release job wants.
|
||||||
|
- **A standard `rust:1.95` image in CI, flake for dev only.** Simpler
|
||||||
|
in CI (no nix-in-CI caching to solve), but it is a *second* place
|
||||||
|
that defines the toolchain — exactly the drift this ADR exists to
|
||||||
|
prevent. Rejected for the unified-env goal; the nix-in-CI caching
|
||||||
|
cost is solved in the CI pipeline work instead.
|
||||||
|
- **`rustup` on the build machine.** The status quo CI would replace —
|
||||||
|
non-reproducible, machine-dependent, the thing we are eliminating.
|
||||||
|
|
||||||
|
## Relationship to other decisions
|
||||||
|
|
||||||
|
- Mirrors **datamage ADR 0046** (nix flake dev env) and its build
|
||||||
|
hygiene companion. This is the rdbms-playground analogue, scoped to
|
||||||
|
a pure-Rust project.
|
||||||
|
- Feeds **ADR-ci-001** (the CI + release pipeline), which consumes this
|
||||||
|
flake for `requirements.md` **TT5** (CI runs the tiers) and the
|
||||||
|
**D1/D2/D3** distribution items (the release uses a static musl target
|
||||||
|
built through this flake).
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# ADR-ci-003: Cross-platform release builds (the D1 matrix)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Accepted (2026-06-13); implemented the same day on the `ci` branch.** Every
|
||||||
|
fork was settled with the user. Verified end-to-end:
|
||||||
|
|
||||||
|
- all four targets cross-build locally from Linux x86_64;
|
||||||
|
- the Linux binaries are statically linked (D2); the Windows artifacts are
|
||||||
|
valid PE32+ (x86-64 / Aarch64);
|
||||||
|
- a real release-matrix run (tag `v.0.0.0-citest3`) published **8 assets** — the
|
||||||
|
four binaries + a `.sha256` each.
|
||||||
|
|
||||||
|
**Runtime-verified (2026-06-13, by the user):** the **Linux x86_64** and
|
||||||
|
**Windows aarch64** binaries launch and run correctly — one of each OS family
|
||||||
|
and both architectures. The remaining two (**Linux aarch64**, **Windows
|
||||||
|
x86_64**) are link-clean and valid format but not yet runtime smoke-tested.
|
||||||
|
|
||||||
|
This ADR records the **cross-platform build strategy**; it sits on top of
|
||||||
|
**ADR-ci-002** (the nix flake, which now carries the cross toolchain) and
|
||||||
|
**ADR-ci-001** (the pipeline, whose release job this fills in).
|
||||||
|
|
||||||
|
## Amendment — 2026-06-14: macOS implemented (closes D1)
|
||||||
|
|
||||||
|
macOS is no longer deferred. The two `*-apple-darwin` targets now build on a
|
||||||
|
**Tart (Apple-Silicon) macOS runner** registered to Gitea — building on **real
|
||||||
|
Apple hardware** makes the SDK fully licensed, so the whole osxcross / SDK
|
||||||
|
grey-area + public-image-redistribution problem (§5 below) simply **does not
|
||||||
|
arise**. With all six D1 targets producing artifacts, **D1 is complete.**
|
||||||
|
|
||||||
|
Details, all verified on the runner via a throwaway smoke-test before wiring the
|
||||||
|
release leg:
|
||||||
|
|
||||||
|
- **`release-macos.yaml`** — `workflow_dispatch` with a `tag` input,
|
||||||
|
`runs-on: macos`. The runner registered as `macos:host`, but `:host` is
|
||||||
|
act_runner's execution-backend schema (run on host, no container), **not** part
|
||||||
|
of the label, so the label is `macos`. Steps: `cargo test` (macOS gets the only
|
||||||
|
automated test coverage outside the Linux gate — user choice) → build both
|
||||||
|
darwin targets natively through the flake (`apple-sdk` added to the devShell so
|
||||||
|
the toolchain links AppKit) → **upload to the same release** via the idempotent
|
||||||
|
create-or-get.
|
||||||
|
- **De-nix + re-sign.** The darwin stdenv bakes a `/nix/store` `libiconv` load
|
||||||
|
path into the binary (the *only* non-system dependency; everything else is
|
||||||
|
AppKit/Foundation/CoreGraphics/IOKit + `libSystem`/`libobjc`). The release step
|
||||||
|
rewrites it to `/usr/lib/libiconv.2.dylib` with `install_name_tool` and
|
||||||
|
**re-signs ad-hoc** (`codesign -f -s -`) — `install_name_tool` invalidates the
|
||||||
|
signature and Apple Silicon refuses an unsigned binary. A guard fails the build
|
||||||
|
if any `/nix/store` path remains. Result: portable, signed binaries (the native
|
||||||
|
one was confirmed to launch).
|
||||||
|
- **Dispatch-only, intermittent runner.** The Mac isn't always on, so macOS is a
|
||||||
|
separate dispatched workflow (not a job in `release.yaml`) — a release always
|
||||||
|
carries the four Linux/Windows assets regardless of the Mac, and the two macOS
|
||||||
|
assets are added by dispatching `release-macos` for that tag. **Caveat:** Gitea
|
||||||
|
exposes `workflow_dispatch` only for workflows on the **default branch**, so
|
||||||
|
`release-macos` becomes triggerable once the CI work is merged to `main`.
|
||||||
|
- **Cache hygiene (host-execution runner).** The runner wipes the workspace each
|
||||||
|
run, so cargo `target/` never accumulates; the persistent cache is the nix
|
||||||
|
store, bounded by **generation** — record the current devShell in a persistent
|
||||||
|
profile, keep the 2 newest generations (`nix-env --delete-generations +2`),
|
||||||
|
reclaim the rest. (The first sweep reclaimed a ~3.8 GB one-time backlog of
|
||||||
|
build scaffolding — source + build-only deps, not re-installed toolchains.)
|
||||||
|
- **D2 on macOS.** macOS binaries cannot be fully static (`libSystem` is always
|
||||||
|
dynamic); "no runtime deps" there means *system libraries only*, which the
|
||||||
|
de-nix step guarantees.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`requirements.md` **D1** asks for binaries on **Linux, macOS, Windows × x86_64
|
||||||
|
and aarch64** (six targets); **D2** asks for a **single static binary, no
|
||||||
|
runtime deps**. The CI runner executes jobs in a **Linux x86_64** container
|
||||||
|
(ADR-ci-001), so every target is **cross-compiled from Linux**.
|
||||||
|
|
||||||
|
What's feasible is decided almost entirely by one dependency — **`arboard`**
|
||||||
|
(the clipboard backend for the `copy` command). Its per-platform backends in
|
||||||
|
`Cargo.lock`:
|
||||||
|
|
||||||
|
| Target family | arboard backend | Needs a platform SDK to cross-link? |
|
||||||
|
|---|---|---|
|
||||||
|
| Linux x86_64 / aarch64 | `x11rb` (pure Rust) | No |
|
||||||
|
| Windows x86_64 / aarch64 | `clipboard-win` + `windows-sys` (import libs bundled) | No |
|
||||||
|
| **macOS x86_64 / aarch64** | **`objc2-app-kit` → links AppKit** | **Yes — Apple's SDK** |
|
||||||
|
|
||||||
|
So **four targets cross-compile with no SDK**; **macOS is the hard wall** —
|
||||||
|
AppKit can only be linked against Apple's SDK.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Tooling — `cargo-zigbuild`
|
||||||
|
|
||||||
|
Cross-compile with **`cargo-zigbuild`** (Zig's bundled clang + libc as a single
|
||||||
|
universal cross `cc`/linker), added to the flake devShell alongside `zig`. One
|
||||||
|
tool serves every non-macOS target, **including the `cc`-crate compile of
|
||||||
|
rusqlite's bundled SQLite C**, with no per-target toolchain. It replaced the
|
||||||
|
earlier single-target musl `cc` (ADR-ci-002's first cut).
|
||||||
|
|
||||||
|
### 2. Targets this iteration — the four non-macOS
|
||||||
|
|
||||||
|
Added to `rust-toolchain.toml` and the release matrix:
|
||||||
|
|
||||||
|
- **`x86_64-unknown-linux-musl`**, **`aarch64-unknown-linux-musl`** — musl +
|
||||||
|
`crt-static`, so **fully static** portable binaries (D2);
|
||||||
|
- **`x86_64-pc-windows-gnu`**, **`aarch64-pc-windows-gnullvm`** — Zig statically
|
||||||
|
links its libc, so the `.exe` is **standalone** (no mingw runtime DLLs).
|
||||||
|
|
||||||
|
### 3. The Windows `synchronization` stub
|
||||||
|
|
||||||
|
Rust's `std` links **`-lsynchronization`** (its `WaitOnAddress`-based thread
|
||||||
|
parking). That import library is normally supplied by Rust's `rust-mingw`
|
||||||
|
"self-contained" component — which **rust-overlay does not ship** — and Zig's
|
||||||
|
mingw doesn't carry it either, so the link fails with *"unable to find dynamic
|
||||||
|
system library 'synchronization'"*. The functions (`WaitOnAddress`,
|
||||||
|
`WakeByAddress*`) are **forwarded by `kernel32`** (already linked), so an
|
||||||
|
**empty stub** `libsynchronization.a` (committed at **`ci/winstub/`**, 8 bytes,
|
||||||
|
wired via **`.cargo/config.toml`** for the Windows targets *only*) satisfies the
|
||||||
|
linker without contributing symbols. Host and Linux builds are untouched by it.
|
||||||
|
|
||||||
|
### 4. Workflow shape — test once, then a build matrix
|
||||||
|
|
||||||
|
`release.yaml` is **`test` → `build`**:
|
||||||
|
|
||||||
|
- **`test`** runs once on the host (`cargo test`) — a tag never publishes
|
||||||
|
untested code;
|
||||||
|
- **`build`** is a **matrix over the four targets** (`needs: test`,
|
||||||
|
`fail-fast: false`), each `cargo zigbuild --release --target <triple>`, then
|
||||||
|
packages the binary (`.exe` for Windows) + a `.sha256` and uploads both to the
|
||||||
|
**shared release** via an **idempotent create-or-get** (the first matrix job
|
||||||
|
creates the release; the rest fetch it).
|
||||||
|
|
||||||
|
### 5. macOS — deferred, with rationale
|
||||||
|
|
||||||
|
macOS is **not** in this iteration. `arboard`→AppKit needs the macOS SDK, and:
|
||||||
|
|
||||||
|
- the SDK ships **only inside Xcode**; Apple's license ties its use to
|
||||||
|
**Apple-branded hardware**, so using it on a Linux runner is a **grey area**
|
||||||
|
(widely done, low enforcement, but technically against the terms);
|
||||||
|
- **redistributing** the SDK is a clearer violation — and our **CI image is
|
||||||
|
public**, so the SDK **cannot be baked into it** even if the grey area were
|
||||||
|
accepted; it would have to live in a private store;
|
||||||
|
- the **clean** path is building on **real Apple hardware** (a Mac registered as
|
||||||
|
a Gitea runner, or hosted Mac CI), where the SDK is fully licensed.
|
||||||
|
|
||||||
|
macOS therefore becomes its **own step**, choosing between **(a)** osxcross + a
|
||||||
|
**private** SDK kept out of the public image, or **(b)** a **Mac runner**. The
|
||||||
|
user decides when we get there.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **D1: four of six targets met** from a single Linux runner; **D2 met on
|
||||||
|
Linux** (static musl). Windows `.exe`s are standalone.
|
||||||
|
- **Runtime coverage:** Linux x86_64 + Windows aarch64 confirmed running
|
||||||
|
(user, 2026-06-13); Linux aarch64 + Windows x86_64 are the outstanding
|
||||||
|
runtime checks.
|
||||||
|
- **Each matrix target recompiles from scratch** (~2–4 min; ~10 min total on the
|
||||||
|
single runner), and Zig's per-target libc cache is cold each run. Fine at
|
||||||
|
release frequency; cacheable later if it matters.
|
||||||
|
- **The empty stub depends on `kernel32` forwarding `WaitOnAddress`** (true on
|
||||||
|
Windows 8+), which covers every supported target.
|
||||||
|
- **Asset naming** `rdbms-playground-<tag>-<target>[.exe]` is close to what
|
||||||
|
`cargo-binstall` / the D3 package managers will want.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **`cross` (cross-rs).** Docker-image-per-target; covers Linux + Windows but
|
||||||
|
**not macOS** (no legally redistributable Apple images), and needs DinD
|
||||||
|
orchestration inside our job. Rejected — no macOS, more moving parts than
|
||||||
|
zigbuild.
|
||||||
|
- **Per-target nix cross (`pkgsCross`).** Clean for Linux-musl and
|
||||||
|
Windows-x86_64 (mingw-w64, which *does* ship `libsynchronization.a`), but
|
||||||
|
Windows-aarch64 isn't readily packaged and **macOS-from-Linux is unsupported**
|
||||||
|
in nixpkgs. Rejected — incomplete.
|
||||||
|
- **Native runners per OS.** Cleanest for macOS/Windows, but needs mac/windows
|
||||||
|
runners we don't have. Kept on the table specifically for the deferred macOS
|
||||||
|
step.
|
||||||
|
- **A real `libsynchronization.a`** (from nixpkgs mingw or a `rust-mingw`
|
||||||
|
component) instead of the empty stub. More principled, but more flake
|
||||||
|
machinery, doesn't cover Windows-aarch64, and unnecessary — the stub links
|
||||||
|
clean because the symbols resolve via `kernel32`.
|
||||||
|
|
||||||
|
## Deferred / out of scope
|
||||||
|
|
||||||
|
- ~~**macOS** (x86_64 + aarch64)~~ — **done** via the Tart runner (see the
|
||||||
|
2026-06-14 amendment); §5 below is the as-deferred rationale, kept for history.
|
||||||
|
- **D3 packaging** — Homebrew / Scoop / winget / `cargo-binstall` manifests
|
||||||
|
(and binstall-friendly archive naming).
|
||||||
|
- **CI speed** — caching per-target builds / Zig's libc cache.
|
||||||
|
- **Runtime smoke test** of the two not-yet-checked targets (Linux aarch64,
|
||||||
|
Windows x86_64).
|
||||||
|
|
||||||
|
## Relationship to other decisions
|
||||||
|
|
||||||
|
- **Extends ADR-ci-002** — the flake devShell now carries `cargo-zigbuild` +
|
||||||
|
`zig` and the four release targets.
|
||||||
|
- **Fills in ADR-ci-001 §3 (Release)** — that single-target job is now this
|
||||||
|
matrix.
|
||||||
|
- **Advances `requirements.md`** **D1** (4/6) and **D2** (Linux, done).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# CI / Build Architecture Decision Records
|
||||||
|
|
||||||
|
Decision records for the **continuous-integration + release pipeline**
|
||||||
|
subproject — the Gitea Actions workflows under `.gitea/`, the nix CI image,
|
||||||
|
and the release tooling. These are kept in their own namespace, separate
|
||||||
|
from the project-wide ADRs in [`docs/adr/`](../../adr/README.md), so CI
|
||||||
|
decisions never compete with the main global ADR sequence for numbers — the
|
||||||
|
same split the website subproject uses (`docs/website/adr/`, on the `website`
|
||||||
|
branch), and for the same reason (see
|
||||||
|
[ADR-0000 "Numbering discipline"](../../adr/0000-record-architecture-decisions.md)).
|
||||||
|
|
||||||
|
**Numbering.** Files are named `<date>-adr-ci-<NNN>.md` and referenced in
|
||||||
|
prose as `ADR-ci-NNN`. The `<date>` (the ADR's accepted/created day,
|
||||||
|
`YYYYMMDD`) plus the `ci` segment keeps the namespace disjoint from `main`'s
|
||||||
|
integers. Assign the next free `NNN` from this index. Every ADR change
|
||||||
|
updates this index in the same edit (the ADR-0000 index-upkeep rule applies
|
||||||
|
here too).
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision).
|
||||||
|
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible).
|
||||||
|
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner; becomes triggerable once CI is on `main`), de-nixes the binary's libiconv load path (`install_name_tool` → `/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# Plan — ADR-0053: contextual `hint` command + F1 keybinding (H2)
|
||||||
|
|
||||||
|
Implements ADR-0053. Closes the last open piece of **A1** (the canonical
|
||||||
|
app-command set) and requirements **H2**. No Gitea issue — this is
|
||||||
|
requirements-driven work; any genuine "later" item found en route gets
|
||||||
|
its own issue (cf. #36, already filed for the parallel `help`-side gap).
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Give learners on-demand, **teaching-grade** contextual help — a *third*
|
||||||
|
tier beneath the existing terse always-on text (tier 1) and the
|
||||||
|
short contextual lines that are already shown (tier 2: the live ambient
|
||||||
|
prose, and the error `hint:` which is on by default since
|
||||||
|
`Verbosity::Verbose` is the default). Two surfaces:
|
||||||
|
|
||||||
|
- **F1** (read-only overlay) → a tier-3 block for the **live partial
|
||||||
|
input**, or — on empty input — for the **most recent runtime error**.
|
||||||
|
- **`hint`** (submitted app command) → the tier-3 block for the **most
|
||||||
|
recent runtime error** (the buffer is empty post-submit, so it can only
|
||||||
|
act on recent context).
|
||||||
|
|
||||||
|
The mechanism is small; the **content corpus is the feature** (~80
|
||||||
|
blocks, comprehensive for v1, authored exemplars-first per ADR-0053 D7).
|
||||||
|
|
||||||
|
## 2. The shape of the work (why this order)
|
||||||
|
|
||||||
|
The mechanism and the content are separable, and the mechanism should
|
||||||
|
land first with **graceful tier-2 fallback** so every surface works
|
||||||
|
before any tier-3 text exists. That lets us:
|
||||||
|
|
||||||
|
- build + test the trigger matrix / routing / `:`-strip / read-only-
|
||||||
|
overlay behaviour against a skeleton (TDD), then
|
||||||
|
- pour in content in reviewable batches without re-touching the wiring,
|
||||||
|
- and turn on the **comprehensiveness coverage test** only once the
|
||||||
|
corpus is complete (it is red until then — by design).
|
||||||
|
|
||||||
|
Build order: **Phase A** (mechanism skeleton, falls back to tier-2) →
|
||||||
|
**Phase B** (catalogue structure + the three approved exemplars) →
|
||||||
|
**Phase C** (comprehensive content, batched) → **Phase D** (polish:
|
||||||
|
strip advertisement, snapshots, full green).
|
||||||
|
|
||||||
|
## 3. Grammar: the `hint_ids` field + the `HINT` node
|
||||||
|
|
||||||
|
### 3a. New `CommandNode.hint_ids` (per-form — revised in Phase B)
|
||||||
|
- Add `pub hint_ids: &'static [&'static str]` to `CommandNode`
|
||||||
|
(`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`),
|
||||||
|
**mirroring `usage_ids`** — *not* a per-node `Option<&str>`. The Phase-B
|
||||||
|
exemplar (`add 1:n relationship`) showed per-*node* keying is too coarse:
|
||||||
|
`add`/`drop`/`show`/`create` are each one node spanning many forms, and
|
||||||
|
a live-input hint must be specific to the typed form. Compiler forces
|
||||||
|
every node literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to
|
||||||
|
set it — Phase A/B leave most `&[]` (tier-2 fallback); Phase C fills them.
|
||||||
|
**Multi-form nodes list ALL their form keys** (e.g. `add` →
|
||||||
|
`["add_column", "add_relationship", "add_index", "add_constraint"]`) so
|
||||||
|
the form-word disambiguation resolves correctly and unauthored forms fall
|
||||||
|
back at render rather than mis-resolving to a sibling.
|
||||||
|
- **Lookup:** `hint_key_for_input_in_mode(source, mode)` returns the single
|
||||||
|
typed form's hint stem, reusing `pick_form_key` (factored out of
|
||||||
|
`usage_key_for_input_in_mode` — shared digit/`m:n`/suffix disambiguation).
|
||||||
|
- **Why a new field, not `help_id`** (ADR-0053 D3): `help_id` is `None` on
|
||||||
|
the 7 advanced-SQL forms purely to dedup the `help` *list*; those forms
|
||||||
|
have distinct SQL syntax and need their own block. `hint_ids` is per
|
||||||
|
form. (The parallel `help`-side gap is issue #36; clause-concept hints
|
||||||
|
are deferred — issue #37.)
|
||||||
|
|
||||||
|
### 3b. `AppCommand::Hint` + the `HINT` node
|
||||||
|
- `AppCommand::Hint` variant (no fields — no topic arg) in
|
||||||
|
`src/dsl/command.rs:544`.
|
||||||
|
- `pub static HINT: CommandNode` in `grammar/app.rs` mirroring `HELP` but
|
||||||
|
with **no topic shape** (bare keyword, like `UNDO`): `entry:
|
||||||
|
Word::keyword("hint")`, `shape: EMPTY_SEQ` (as `UNDO`,
|
||||||
|
`grammar/app.rs:333`), `ast_builder:
|
||||||
|
build_hint` (returns `Command::App(AppCommand::Hint)`), `help_id:
|
||||||
|
Some("app.hint")`, `hint_id: Some("app.hint")`, `usage_ids:
|
||||||
|
&["parse.usage.hint"]`.
|
||||||
|
- Register `(&app::HINT, CommandCategory::Simple)` in `REGISTRY`
|
||||||
|
(`grammar/mod.rs`), beside `HELP`. (App commands are available in both
|
||||||
|
modes via the existing mechanism.)
|
||||||
|
|
||||||
|
## 4. Command identification (live-input → node)
|
||||||
|
|
||||||
|
The F1 live-input path needs "which command form is being typed." **The
|
||||||
|
lookup machinery already exists** — do not rebuild entry matching:
|
||||||
|
- `command_for_entry_word(word) -> Option<(usize, &'static CommandNode)>`
|
||||||
|
(`grammar/mod.rs:811`) returns the matched node for an entry word
|
||||||
|
(Simple-first; the caller extracts the first word of the input).
|
||||||
|
- `usage_keys_for_input_in_mode(source, mode)` (`grammar/mod.rs:564`)
|
||||||
|
already performs the **mode-aware** Simple/Advanced selection the hint
|
||||||
|
path needs (advanced `create` → the SQL nodes, simple → the DSL node) —
|
||||||
|
it just returns `usage_ids` rather than the node.
|
||||||
|
- **The only new bit:** a thin `hint_id_for_input_in_mode(source, mode)`
|
||||||
|
(or a node-returning sibling of `usage_keys_for_input_in_mode`) that
|
||||||
|
applies the same mode selection and returns the chosen node's
|
||||||
|
`hint_id`. Mirror the existing function; don't duplicate its matching.
|
||||||
|
- **`:`-strip:** in Simple mode, strip a leading `:` (one-shot escape,
|
||||||
|
ADR-0003) before identification so `: SELECT …` resolves to the
|
||||||
|
advanced `SELECT` node.
|
||||||
|
- No match (empty / unrecognised entry word) → the "getting started"
|
||||||
|
pointer (D2).
|
||||||
|
|
||||||
|
## 5. F1 keybinding (read-only overlay)
|
||||||
|
|
||||||
|
In `App::handle_key` (`src/app.rs:1155`):
|
||||||
|
- Add an F1 arm (`KeyCode::F(1)`) **after** the modal gate and the
|
||||||
|
sidebar-nav gate (inert there, per D2), and **before** the
|
||||||
|
"any other key clears the completion memo" fall-through (`_ =>
|
||||||
|
self.last_completion = None`, ~line 1228) — F1 must **not** clear the
|
||||||
|
memo or touch the buffer/cursor (D1).
|
||||||
|
- Behaviour (the trigger matrix, D2):
|
||||||
|
- non-empty input → `note_hint_for_input()` (the command's `hint.cmd`
|
||||||
|
block + the live "Next:" expected-set from the walker).
|
||||||
|
- empty input + `last_error_hint_key` set → `note_hint_for_error()`.
|
||||||
|
- empty input + no recent error → `note_getting_started()`.
|
||||||
|
- Returns `Vec::new()` (pure output emission, like `help`).
|
||||||
|
- `demo_badge_label` (`app.rs:520`) gains an `F1 → "[F1]"` entry so demo
|
||||||
|
mode surfaces it (ADR-0047).
|
||||||
|
|
||||||
|
## 6. The two error routes (D2 / D5)
|
||||||
|
|
||||||
|
- **Runtime errors:** add `last_error_hint_key: Option<String>` to `App`.
|
||||||
|
Set it where friendly errors are rendered (`runtime.rs:2615`,
|
||||||
|
`app.rs:2424`) from the error's class key; clear on the next successful
|
||||||
|
command. The `hint` command and empty-input F1 read it.
|
||||||
|
- **Pre-submit diagnostics:** the F1 live-input path, when the input
|
||||||
|
carries an under-cursor diagnostic, reads it straight from the walker
|
||||||
|
(`input_diagnostics_in_mode`, the same source the ambient panel uses)
|
||||||
|
and renders that diagnostic's `hint.err.<class>` block instead of (or
|
||||||
|
alongside) the command block. No stored state.
|
||||||
|
- Both render from `hint.err.*`.
|
||||||
|
|
||||||
|
## 7. Rendering: the `note_hint*` family (D4)
|
||||||
|
|
||||||
|
- New `App::note_hint_for_input`, `note_hint_for_error`,
|
||||||
|
`note_getting_started` (siblings of `note_help`/`note_help_topic`,
|
||||||
|
`app.rs:2982`/`3021`).
|
||||||
|
- A tier-3 block is **structured** (`what` / `example` / `concept`, plus
|
||||||
|
the live `Next:` line on the input path). The catalogue stores each part
|
||||||
|
under sub-keys (`hint.cmd.<id>.what`, `.example`, `.concept`); the
|
||||||
|
renderer fetches each via `t!` and lays them out as a small framed
|
||||||
|
block.
|
||||||
|
- Styling: `OutputKind::System`; `OutputStyleClass::Hint` (muted) on
|
||||||
|
`what`/`concept`/`Next`, `Neutral` on `example` so the runnable line
|
||||||
|
stands out. Reuse `OutputLine::styled` + `push_category_three_prose`
|
||||||
|
patterns (`app.rs:3121`).
|
||||||
|
- **Fallback:** if a node's `hint_id` is `None` or a key is missing,
|
||||||
|
degrade to tier-2 (ambient prose for the input path; the verbose error
|
||||||
|
`hint:` for the error path) — never blank.
|
||||||
|
|
||||||
|
## 8. Catalogue + `keys.rs`
|
||||||
|
|
||||||
|
- New sub-namespaces under the existing top-level `hint:` in
|
||||||
|
`src/friendly/strings/en-US.yaml`: `hint.cmd.<hint_id>.{what,example,
|
||||||
|
concept}` and `hint.err.<class>.{what,example,concept}`.
|
||||||
|
- Register every key + its placeholders in `src/friendly/keys.rs`
|
||||||
|
(`KEYS_AND_PLACEHOLDERS`) so the build-time validation covers them.
|
||||||
|
- `parse.usage.hint` + `help.app.hint` strings for the command itself.
|
||||||
|
|
||||||
|
## 9. Content (Phase C — the bulk, batched per D7)
|
||||||
|
|
||||||
|
Exemplars approved in the ADR (`insert` live-input, FK child-side error,
|
||||||
|
`add relationship`) are the template. Author in reviewable batches:
|
||||||
|
1. **App commands** (~16): save/save as/load/new/rebuild/export/import/
|
||||||
|
replay/undo/redo/mode/messages/copy/help/hint/quit.
|
||||||
|
2. **DDL** (simple): create table, create m:n, add column/relationship/
|
||||||
|
index, drop, rename, change column.
|
||||||
|
3. **DML** (simple): insert, update, delete, show, seed, explain,
|
||||||
|
select/with.
|
||||||
|
4. **Advanced-mode SQL forms** (7): SQL CREATE TABLE, ALTER TABLE,
|
||||||
|
CREATE/DROP INDEX, DROP TABLE, SQL INSERT/UPDATE/DELETE, EXPLAIN SQL —
|
||||||
|
**own blocks, SQL-syntax examples**.
|
||||||
|
5. **Runtime error classes** (9): unique, foreign_key ×{child,parent},
|
||||||
|
not_null, check, type_mismatch, not_found, already_exists, generic,
|
||||||
|
invalid_value.
|
||||||
|
6. **`diagnostic.*` classes** (~33): arity/type/unknown-table-column/etc.
|
||||||
|
|
||||||
|
Each block: `what` (1–2 sentences), `example` (one runnable line,
|
||||||
|
mode-correct), `concept` (the relational idea — the teaching part;
|
||||||
|
optional only where genuinely none, e.g. `quit`).
|
||||||
|
|
||||||
|
## 10. Tests
|
||||||
|
|
||||||
|
Written test-first against the Phase-A skeleton where possible.
|
||||||
|
|
||||||
|
- **Tier 1 (unit, `app.rs`):**
|
||||||
|
- trigger matrix: F1 non-empty → command block; F1 empty + recent error
|
||||||
|
→ error block; F1 empty + none → getting-started; `hint` command +
|
||||||
|
error → error block; `hint` + none → getting-started.
|
||||||
|
- `last_error_hint_key` set on a failing command, cleared on the next
|
||||||
|
success.
|
||||||
|
- routing: a pre-submit diagnostic on the input drives the diagnostic
|
||||||
|
`hint.err`; a runtime error drives the stored-key route.
|
||||||
|
- `:`-strip: `: SELECT …` in Simple mode resolves to the advanced node.
|
||||||
|
- **read-only overlay:** F1 leaves `input`, `input_cursor`, and
|
||||||
|
`last_completion` unchanged.
|
||||||
|
- tier-2 fallback when `hint_id`/key absent.
|
||||||
|
- **Tier 2 (`insta`):** snapshot a representative rendered tier-3 block
|
||||||
|
(the `insert` exemplar) so the framed layout + styling spans are locked.
|
||||||
|
- **Tier 3 (integration, `tests/it/`):** type a partial command → F1 →
|
||||||
|
block appears, buffer untouched; run a failing insert → `hint` → FK
|
||||||
|
error expansion.
|
||||||
|
- **Comprehensiveness coverage test** (enforces D6, the key one): iterate
|
||||||
|
`REGISTRY` and assert every node has a `hint_id` resolving to a
|
||||||
|
`hint.cmd.*` block; assert every runtime-error + `diagnostic.*` class
|
||||||
|
has a `hint.err.*` block. **Red until Phase C completes** — enable
|
||||||
|
(un-`ignore`) as the final gate.
|
||||||
|
- `keys.rs` validation continues to guarantee every *referenced* key
|
||||||
|
resolves.
|
||||||
|
|
||||||
|
## 11. Keybinding strip + discoverability (Phase D)
|
||||||
|
|
||||||
|
- The ADR-0051 bottom strip advertises **F1 = hint** in the editing/
|
||||||
|
typing state (and on the empty-input state, since F1 still does
|
||||||
|
something there). Re-accept the affected full-panel snapshots.
|
||||||
|
|
||||||
|
## 12. ADR / docs
|
||||||
|
|
||||||
|
- ADR-0053 is committed (`e16ad50`). On completion, flip its Status from
|
||||||
|
"implementation pending" to implemented (with date), and update the
|
||||||
|
README index entry + `requirements.md` **H2 → [x]** and **A1 → [x]**
|
||||||
|
(A1 closes when `hint` lands).
|
||||||
|
|
||||||
|
## 13. Risks / watch-list
|
||||||
|
|
||||||
|
- **Command-identification reuse.** The lookup exists
|
||||||
|
(`command_for_entry_word` + the mode-aware `usage_keys_for_input_in_mode`,
|
||||||
|
`grammar/mod.rs:811`/`564`); the only new code is a thin node/`hint_id`
|
||||||
|
variant that reuses their selection. Do **not** re-implement entry-word
|
||||||
|
matching — mirror the existing functions.
|
||||||
|
- **Structured-key ergonomics.** Three sub-keys per block × ~80 blocks is
|
||||||
|
~240 catalogue keys; keep the `keys.rs` registration generation tidy
|
||||||
|
(consider a helper that registers the `{what,example,concept}` triple
|
||||||
|
for an id).
|
||||||
|
- **Content voice drift across batches.** Re-check each batch against the
|
||||||
|
approved exemplars; the `concept` line is where drift (too terse / too
|
||||||
|
advanced) creeps in. Pedagogy wins ties.
|
||||||
|
- **F1 terminal capture.** A few terminals intercept F1; acceptable
|
||||||
|
(it's the convention) but note it if testing surfaces it.
|
||||||
|
- **Snapshot churn.** The strip change re-accepts ADR-0051 snapshots;
|
||||||
|
keep that diff isolated.
|
||||||
|
- **Coverage-test timing.** It is red through Phases A–C; gate it so CI
|
||||||
|
isn't broken mid-stream (e.g. `#[ignore]` until the final batch), then
|
||||||
|
make passing it the completion criterion.
|
||||||
|
```
|
||||||
Generated
+82
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1780902259,
|
||||||
|
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1781234414,
|
||||||
|
"narHash": "sha256-HdA+P4fKRGOomkewnI/Tww5Wz4xK1O7+hDO90YAsPB4=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "1d18bfe3de6244c641ca4e8011186d0981b81d76",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
description = "RDBMS Playground — Rust TUI dev environment + reproducible build";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Single source of the Rust toolchain: the rustup toolchain file.
|
||||||
|
# rust-overlay provisions the exact channel + components declared there,
|
||||||
|
# so the dev shell and the build package share one pinned toolchain.
|
||||||
|
rust = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||||
|
|
||||||
|
# Read the package version straight from Cargo.toml so it never drifts
|
||||||
|
# from the crate metadata (no hand-maintained duplicate here).
|
||||||
|
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
||||||
|
|
||||||
|
# System build inputs are deliberately tiny — this is a pure-Rust TUI:
|
||||||
|
# * libsqlite3-sys is built with the `bundled` feature, so SQLite is
|
||||||
|
# compiled from vendored C. That needs a C compiler, which the
|
||||||
|
# stdenv provides automatically (no entry required here).
|
||||||
|
# * arboard's clipboard backend is `x11rb` — a pure-Rust socket XCB
|
||||||
|
# client. It links no C X11 libraries, so none appear below. A live
|
||||||
|
# X server is only needed at *runtime* to copy; headless sessions
|
||||||
|
# fall back to OSC 52.
|
||||||
|
# If a future dependency introduces a pkg-config / native-lib link, add
|
||||||
|
# it here (and document why) rather than leaking it into the host env.
|
||||||
|
nativeBuildInputs = [ ];
|
||||||
|
buildInputs = [ ];
|
||||||
|
|
||||||
|
# `nix build` → the release binary, built reproducibly from the pinned
|
||||||
|
# toolchain and the committed Cargo.lock (importCargoLock fetches each
|
||||||
|
# dependency by its lockfile checksum — offline, no cargoHash to churn).
|
||||||
|
# CI's release job consumes this artifact; the gate's tests run
|
||||||
|
# separately via `nix develop -c cargo test` (see below), so the package
|
||||||
|
# build skips the suite — the nix sandbox has no HOME/X server and would
|
||||||
|
# fight the project-dirs / clipboard paths the tests touch.
|
||||||
|
rdbms-playground = pkgs.rustPlatform.buildRustPackage {
|
||||||
|
pname = cargoToml.package.name;
|
||||||
|
version = cargoToml.package.version;
|
||||||
|
src = ./.;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
inherit nativeBuildInputs buildInputs;
|
||||||
|
doCheck = false;
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
packages.default = rdbms-playground;
|
||||||
|
packages.rdbms-playground = rdbms-playground;
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = buildInputs ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [
|
||||||
|
# macOS release builds (aarch64/x86_64-apple-darwin) link AppKit
|
||||||
|
# (arboard) + libSystem; the Apple SDK provides those framework/
|
||||||
|
# system-lib stubs as *system* paths (/usr/lib, /System/Library).
|
||||||
|
# NOTE: the darwin stdenv still propagates a *nix-store* libiconv and
|
||||||
|
# links it regardless of inputs, so the release workflow rewrites that
|
||||||
|
# one load path to /usr/lib/libiconv.2.dylib (install_name_tool) and
|
||||||
|
# re-signs — see release-macos / the macOS smoke-test. Adding
|
||||||
|
# `pkgs.libiconv` here would only reinforce the wrong path, so don't.
|
||||||
|
pkgs.apple-sdk
|
||||||
|
];
|
||||||
|
nativeBuildInputs = nativeBuildInputs ++ [
|
||||||
|
rust
|
||||||
|
# Dev-disk maintenance: cargo never garbage-collects stale per-hash
|
||||||
|
# build artifacts, so target/ creeps into the tens of GB (see
|
||||||
|
# CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it
|
||||||
|
# periodically between milestones.
|
||||||
|
pkgs.cargo-sweep
|
||||||
|
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||||
|
# Cross-compilation for the non-macOS D1 targets: `cargo zigbuild`
|
||||||
|
# uses Zig's bundled clang + libc as one universal cross cc/linker
|
||||||
|
# (incl. the `cc`-crate compile of rusqlite's bundled SQLite C) for
|
||||||
|
# Linux musl + Windows gnu/gnullvm. macOS builds natively with the
|
||||||
|
# Apple toolchain on the Mac runner, so these are Linux-only.
|
||||||
|
pkgs.cargo-zigbuild
|
||||||
|
pkgs.zig
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "RDBMS Playground dev shell ($(uname -s))"
|
||||||
|
echo " rust: $(rustc --version | cut -d' ' -f1-2)"
|
||||||
|
echo " cargo: $(cargo --version | cut -d' ' -f1-2)"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[toolchain]
|
||||||
|
# Pinned to an exact stable release (not the floating "stable" channel) so
|
||||||
|
# `nix flake update` cannot surprise-bump Rust into new clippy lints that would
|
||||||
|
# fail the `-D warnings` CI gate. Matches the host toolchain and the datamage
|
||||||
|
# flake's convention (its ADR 0046). Bump deliberately, in its own commit.
|
||||||
|
channel = "1.95.0"
|
||||||
|
# rustfmt + clippy back the `fmt`/`clippy` CI stages; no coverage or WASM
|
||||||
|
# tooling is needed here (pure-Rust TUI).
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
|
# The non-macOS D1 release matrix, all cross-built from Linux x86_64 via
|
||||||
|
# `cargo zigbuild` (D1: cross-platform binaries; D2: single static binary).
|
||||||
|
# Linux uses musl + crt-static for fully static, portable binaries; Windows
|
||||||
|
# uses the gnu/gnullvm ABIs (Zig statically links libc, so the .exe is
|
||||||
|
# standalone). macOS is deferred — its arboard/AppKit link needs Apple's SDK,
|
||||||
|
# which a Linux runner can't supply cleanly (see docs/ci/adr ADR-ci-001).
|
||||||
|
targets = [
|
||||||
|
"x86_64-unknown-linux-musl",
|
||||||
|
"aarch64-unknown-linux-musl",
|
||||||
|
"x86_64-pc-windows-gnu",
|
||||||
|
"aarch64-pc-windows-gnullvm",
|
||||||
|
# macOS — built natively on the Apple-Silicon Mac runner (aarch64 native,
|
||||||
|
# x86_64 cross). These need Apple's SDK to link, which a Linux runner can't
|
||||||
|
# supply, so they are produced only on the Mac (see docs/ci/adr ADR-ci-003).
|
||||||
|
"aarch64-apple-darwin",
|
||||||
|
"x86_64-apple-darwin",
|
||||||
|
]
|
||||||
+343
@@ -271,6 +271,13 @@ pub struct App {
|
|||||||
pub nav_focus: NavFocus,
|
pub nav_focus: NavFocus,
|
||||||
pub output: VecDeque<OutputLine>,
|
pub output: VecDeque<OutputLine>,
|
||||||
pub hint: Option<String>,
|
pub hint: Option<String>,
|
||||||
|
/// Catalog class key of the most recent runtime error (H2 /
|
||||||
|
/// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a
|
||||||
|
/// friendly error is rendered, cleared on the next successful
|
||||||
|
/// command. The submitted `hint` command and empty-input F1 use
|
||||||
|
/// it to render that error's tier-3 `hint.err.<class>` block.
|
||||||
|
/// `None` → no recent error → the "getting started" pointer.
|
||||||
|
pub last_error_hint_key: Option<String>,
|
||||||
/// The validity indicator's currently-visible verdict
|
/// The validity indicator's currently-visible verdict
|
||||||
/// (ADR-0027). `None` means the indicator shows nothing —
|
/// (ADR-0027). `None` means the indicator shows nothing —
|
||||||
/// the input is clean, or it is hidden mid-typing while the
|
/// the input is clean, or it is hidden mid-typing while the
|
||||||
@@ -521,6 +528,7 @@ pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
|
|||||||
match (key.code, key.modifiers) {
|
match (key.code, key.modifiers) {
|
||||||
(KeyCode::Tab, _) => Some("[TAB]"),
|
(KeyCode::Tab, _) => Some("[TAB]"),
|
||||||
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
|
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
|
||||||
|
(KeyCode::F(1), _) => Some("[F1]"),
|
||||||
(KeyCode::Enter, _) => Some("[ENTER]"),
|
(KeyCode::Enter, _) => Some("[ENTER]"),
|
||||||
(KeyCode::Esc, _) => Some("[ESC]"),
|
(KeyCode::Esc, _) => Some("[ESC]"),
|
||||||
(KeyCode::Up, _) => Some("[UP]"),
|
(KeyCode::Up, _) => Some("[UP]"),
|
||||||
@@ -557,6 +565,7 @@ impl App {
|
|||||||
nav_focus: NavFocus::Input,
|
nav_focus: NavFocus::Input,
|
||||||
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
||||||
hint: None,
|
hint: None,
|
||||||
|
last_error_hint_key: None,
|
||||||
input_indicator: None,
|
input_indicator: None,
|
||||||
tables: Vec::new(),
|
tables: Vec::new(),
|
||||||
relationships: Vec::new(),
|
relationships: Vec::new(),
|
||||||
@@ -1208,6 +1217,21 @@ impl App {
|
|||||||
return self.handle_nav_key(key);
|
return self.handle_nav_key(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// H2 / ADR-0053: F1 is a read-only contextual-hint overlay —
|
||||||
|
// it emits into the output journal and must NOT touch the input
|
||||||
|
// buffer, cursor, or the completion memo, so it sits ahead of
|
||||||
|
// the memo-clearing completion match below. Non-empty input →
|
||||||
|
// a hint for the command being typed; empty input → expand on
|
||||||
|
// the most recent error (or a getting-started pointer).
|
||||||
|
if key.code == KeyCode::F(1) {
|
||||||
|
if self.input.trim().is_empty() {
|
||||||
|
self.note_hint_for_recent_error();
|
||||||
|
} else {
|
||||||
|
self.note_hint_for_input();
|
||||||
|
}
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
// ADR-0022 stage 8 — non-modal completion. Tab /
|
// ADR-0022 stage 8 — non-modal completion. Tab /
|
||||||
// Shift-Tab cycle; Esc / Backspace undo the whole
|
// Shift-Tab cycle; Esc / Backspace undo the whole
|
||||||
// last-Tab insertion in one keystroke while the memo
|
// last-Tab insertion in one keystroke while the memo
|
||||||
@@ -1774,6 +1798,13 @@ impl App {
|
|||||||
// recallable. The canonical (un-prefixed) text is what reaches
|
// recallable. The canonical (un-prefixed) text is what reaches
|
||||||
// the journal via `ExecuteDsl.source`.
|
// the journal via `ExecuteDsl.source`.
|
||||||
let is_app = matches!(&parsed, Ok(Command::App(_)));
|
let is_app = matches!(&parsed, Ok(Command::App(_)));
|
||||||
|
// H2 / ADR-0053 D5: a new *DSL* command supersedes the previous
|
||||||
|
// runtime error for `hint`. App commands (incl. `hint` itself)
|
||||||
|
// and parse errors leave it intact, so `hint` still expands the
|
||||||
|
// last real error after, say, a `help` in between.
|
||||||
|
if matches!(&parsed, Ok(cmd) if !matches!(cmd, Command::App(_))) {
|
||||||
|
self.last_error_hint_key = None;
|
||||||
|
}
|
||||||
let advanced = submission_mode.is_advanced() && !is_app;
|
let advanced = submission_mode.is_advanced() && !is_app;
|
||||||
let ring_line = if advanced {
|
let ring_line = if advanced {
|
||||||
format!(": {effective_input}")
|
format!(": {effective_input}")
|
||||||
@@ -1814,6 +1845,13 @@ impl App {
|
|||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
// H2 / ADR-0053: a submitted `hint` acts on the most recent
|
||||||
|
// runtime error (the buffer is empty post-submit). The
|
||||||
|
// live-input surface is the F1 keybinding (handle_key).
|
||||||
|
AppCommand::Hint => {
|
||||||
|
self.note_hint_for_recent_error();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
AppCommand::Rebuild => vec![Action::PrepareRebuild],
|
AppCommand::Rebuild => vec![Action::PrepareRebuild],
|
||||||
AppCommand::Save => self.handle_save_command(false),
|
AppCommand::Save => self.handle_save_command(false),
|
||||||
AppCommand::SaveAs => self.handle_save_command(true),
|
AppCommand::SaveAs => self.handle_save_command(true),
|
||||||
@@ -2422,6 +2460,10 @@ impl App {
|
|||||||
// runtime built before posting the event.
|
// runtime built before posting the event.
|
||||||
let ctx = self.build_translate_context(command, facts);
|
let ctx = self.build_translate_context(command, facts);
|
||||||
let rendered = crate::friendly::translate_error(&error, &ctx).render();
|
let rendered = crate::friendly::translate_error(&error, &ctx).render();
|
||||||
|
// H2 / ADR-0053 D5: remember this error's tier-3 class so a
|
||||||
|
// following `hint` (or empty-input F1) can expand on it.
|
||||||
|
self.last_error_hint_key =
|
||||||
|
crate::friendly::error_hint_class(&error, &ctx).map(String::from);
|
||||||
warn!(
|
warn!(
|
||||||
verb = command.verb(),
|
verb = command.verb(),
|
||||||
error = %rendered,
|
error = %rendered,
|
||||||
@@ -3091,6 +3133,94 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── H2 / ADR-0053: contextual `hint` ────────────────────────
|
||||||
|
// Phase A wires the two surfaces (F1 → live input; the `hint`
|
||||||
|
// command → most recent error) plus the tier-2 fallback. The
|
||||||
|
// tier-3 corpus (`hint.cmd.*` / `hint.err.*`) is authored in later
|
||||||
|
// phases; until a block exists, `emit_tier3_block` returns false
|
||||||
|
// and the surface degrades to the ambient prose / getting-started
|
||||||
|
// pointer — never blank.
|
||||||
|
|
||||||
|
/// F1 with a non-empty buffer: a tier-3 hint for the command form
|
||||||
|
/// being typed, else the tier-2 ambient prose (ADR-0053 D2).
|
||||||
|
/// Read-only — callers guarantee the buffer/cursor/memo are left
|
||||||
|
/// untouched.
|
||||||
|
fn note_hint_for_input(&mut self) {
|
||||||
|
// `feedback_view` strips the `:` one-shot sigil and
|
||||||
|
// `effective_mode` reflects the one-shot advanced surface, so
|
||||||
|
// the hint matches the command the user is actually typing.
|
||||||
|
let (view, cursor, _off) = self.feedback_view();
|
||||||
|
let probe = view.to_string();
|
||||||
|
let mode = self.effective_mode().as_mode();
|
||||||
|
if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode)
|
||||||
|
&& self.emit_tier3_block(&format!("hint.cmd.{id}"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tier-2 fallback: surface the ambient prose as a persistent
|
||||||
|
// line (computed exactly as the live panel does).
|
||||||
|
let ambient = crate::input_render::ambient_hint_in_mode(
|
||||||
|
&probe,
|
||||||
|
cursor,
|
||||||
|
self.last_completion.as_ref(),
|
||||||
|
&self.schema_cache,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
match ambient {
|
||||||
|
Some(crate::input_render::AmbientHint::Prose(text)) => {
|
||||||
|
self.push_category_three_prose(text);
|
||||||
|
}
|
||||||
|
Some(crate::input_render::AmbientHint::Candidates { items, .. }) => {
|
||||||
|
let names = items
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.text.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
|
||||||
|
}
|
||||||
|
None => self.note_getting_started(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `hint` command (and empty-input F1): expand on the most
|
||||||
|
/// recent runtime error, else point the user at how to start
|
||||||
|
/// (ADR-0053 D2/D5).
|
||||||
|
fn note_hint_for_recent_error(&mut self) {
|
||||||
|
if let Some(class) = self.last_error_hint_key.clone()
|
||||||
|
&& self.emit_tier3_block(&format!("hint.err.{class}"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.note_getting_started();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_getting_started(&mut self) {
|
||||||
|
self.note_system(crate::t!("hint.getting_started"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
|
||||||
|
/// when it has been authored; returns `false` if the `what` part is
|
||||||
|
/// absent so the caller can fall back to tier 2. `what` is
|
||||||
|
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
|
||||||
|
/// polish (the framed block) lands with the corpus.
|
||||||
|
fn emit_tier3_block(&mut self, stem: &str) -> bool {
|
||||||
|
let cat = crate::friendly::catalog();
|
||||||
|
if cat.get(&format!("{stem}.what")).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.note_system(crate::friendly::translate(&format!("{stem}.what"), &[]));
|
||||||
|
if cat.get(&format!("{stem}.example")).is_some() {
|
||||||
|
self.note_system(crate::friendly::translate(&format!("{stem}.example"), &[]));
|
||||||
|
}
|
||||||
|
if cat.get(&format!("{stem}.concept")).is_some() {
|
||||||
|
self.push_category_three_prose(crate::friendly::translate(
|
||||||
|
&format!("{stem}.concept"),
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn note_system(&mut self, text: impl Into<String>) {
|
fn note_system(&mut self, text: impl Into<String>) {
|
||||||
self.push_multiline(text.into(), OutputKind::System);
|
self.push_multiline(text.into(), OutputKind::System);
|
||||||
}
|
}
|
||||||
@@ -5539,6 +5669,219 @@ mod tests {
|
|||||||
assert!(last.text.contains("Ghost"), "{}", last.text);
|
assert!(last.text.contains("Ghost"), "{}", last.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ──────
|
||||||
|
|
||||||
|
fn f1(app: &mut App) -> Vec<Action> {
|
||||||
|
app.update(key(KeyCode::F(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_such_table_failure() -> AppEvent {
|
||||||
|
AppEvent::DslFailed {
|
||||||
|
command: Command::DropTable {
|
||||||
|
name: "Ghost".to_string(),
|
||||||
|
},
|
||||||
|
error: crate::db::DbError::Sqlite {
|
||||||
|
message: "no such table: Ghost".to_string(),
|
||||||
|
kind: crate::db::SqliteErrorKind::NoSuchTable,
|
||||||
|
},
|
||||||
|
facts: crate::friendly::FailureContext::default(),
|
||||||
|
source: String::new(),
|
||||||
|
advanced: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_command_parses_to_app_hint() {
|
||||||
|
use crate::dsl::{parse_command, AppCommand, Command};
|
||||||
|
assert!(matches!(
|
||||||
|
parse_command("hint"),
|
||||||
|
Ok(Command::App(AppCommand::Hint))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_command_with_no_recent_error_shows_getting_started() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hint");
|
||||||
|
submit(&mut app);
|
||||||
|
assert!(output_contains(&app, "press F1"), "{}", error_lines(&app));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_on_empty_input_with_no_error_shows_getting_started() {
|
||||||
|
let mut app = App::new();
|
||||||
|
let before = app.output.len();
|
||||||
|
f1(&mut app);
|
||||||
|
assert!(app.output.len() > before, "F1 must emit something");
|
||||||
|
assert!(output_contains(&app, "press F1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_is_a_read_only_overlay() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "insert into T");
|
||||||
|
let input = app.input.clone();
|
||||||
|
let cursor = app.input_cursor;
|
||||||
|
let before = app.output.len();
|
||||||
|
f1(&mut app);
|
||||||
|
assert_eq!(app.input, input, "F1 must not change the buffer");
|
||||||
|
assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor");
|
||||||
|
assert!(app.output.len() > before, "F1 emits a hint line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_preserves_the_completion_memo() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "show ");
|
||||||
|
app.update(key(KeyCode::Tab));
|
||||||
|
assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
|
||||||
|
let input = app.input.clone();
|
||||||
|
f1(&mut app);
|
||||||
|
assert!(app.last_completion.is_some(), "F1 must not clear the memo");
|
||||||
|
assert_eq!(app.input, input, "F1 must not change the buffer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(no_such_table_failure());
|
||||||
|
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
|
||||||
|
// A new DSL command supersedes the previous error.
|
||||||
|
type_str(&mut app, "drop table Ghost");
|
||||||
|
submit(&mut app);
|
||||||
|
assert_eq!(app.last_error_hint_key, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_command_does_not_clear_the_hint_class() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(no_such_table_failure());
|
||||||
|
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
|
||||||
|
// `help` (an app command) leaves the last error intact, so a
|
||||||
|
// following `hint` still expands on it.
|
||||||
|
type_str(&mut app, "help");
|
||||||
|
submit(&mut app);
|
||||||
|
assert_eq!(
|
||||||
|
app.last_error_hint_key.as_deref(),
|
||||||
|
Some("not_found"),
|
||||||
|
"an app command must not clear the last error's hint class"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_after_error_emits_a_hint_without_panicking() {
|
||||||
|
// Phase A: no tier-3 `hint.err.*` content exists yet, so the
|
||||||
|
// error path falls back to the getting-started pointer. (Phase C
|
||||||
|
// replaces this with the real error block.)
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(no_such_table_failure());
|
||||||
|
let before = app.output.len();
|
||||||
|
type_str(&mut app, "hint");
|
||||||
|
submit(&mut app);
|
||||||
|
assert!(app.output.len() > before, "hint must emit something");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_list_includes_hint() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "help");
|
||||||
|
submit(&mut app);
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "explain the most recent error"),
|
||||||
|
"help list must advertise the hint command"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_hint_describes_the_hint_command() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "help hint");
|
||||||
|
submit(&mut app);
|
||||||
|
assert!(output_contains(&app, "explain the most recent error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase B: tier-3 exemplar content renders ────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_on_insert_input_renders_the_insert_hint_block() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "insert into Customers ");
|
||||||
|
f1(&mut app);
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "Add one or more rows to a table"),
|
||||||
|
"expected the insert tier-3 block"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_on_add_relationship_renders_the_relationship_block() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust ");
|
||||||
|
f1(&mut app);
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "one parent, many children"),
|
||||||
|
"expected the add-relationship tier-3 block"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_on_add_column_does_not_render_the_relationship_block() {
|
||||||
|
// Per-form disambiguation (ADR-0053 D3): `add column` resolves
|
||||||
|
// to `add_column` (no tier-3 block yet → tier-2 fallback), NOT
|
||||||
|
// the relationship block — proving the multi-form node keys
|
||||||
|
// per form, not per node.
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "add column Note text to Customers");
|
||||||
|
f1(&mut app);
|
||||||
|
assert!(!output_contains(&app, "one parent, many children"));
|
||||||
|
assert!(!output_contains(&app, "1:n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_renders_the_foreign_key_error_block() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.last_error_hint_key = Some("foreign_key.child_side".to_string());
|
||||||
|
type_str(&mut app, "hint");
|
||||||
|
submit(&mut app);
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "doesn't match any parent row"),
|
||||||
|
"expected the FK child-side tier-3 block"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase C batch 1: app-command hints render ───────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_on_an_app_command_renders_its_hint_block() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "mode advanced");
|
||||||
|
f1(&mut app);
|
||||||
|
assert!(
|
||||||
|
output_contains(&app, "Switch between simple mode"),
|
||||||
|
"expected the `mode` tier-3 block"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase C batch 2: DDL hints render (incl. multi-form DROP) ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_on_create_table_renders_its_hint_block() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "create table Customers with pk id(serial)");
|
||||||
|
f1(&mut app);
|
||||||
|
assert!(output_contains(&app, "Create a new table"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn f1_disambiguates_drop_forms() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop index idx_email");
|
||||||
|
f1(&mut app);
|
||||||
|
// Resolves drop_index, not drop_table/column/etc.
|
||||||
|
assert!(output_contains(&app, "Remove an index by name"));
|
||||||
|
assert!(!output_contains(&app, "Remove a table"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn messages_command_toggles_verbosity_and_reports() {
|
fn messages_command_toggles_verbosity_and_reports() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -552,6 +552,11 @@ pub enum AppCommand {
|
|||||||
Help {
|
Help {
|
||||||
topic: Option<String>,
|
topic: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
|
||||||
|
/// when submitted, it expands on the most recent runtime error
|
||||||
|
/// (the buffer is empty post-submit). The live-input surface is
|
||||||
|
/// the F1 keybinding, handled in `App::handle_key`, not here.
|
||||||
|
Hint,
|
||||||
/// Rebuild `playground.db` from `project.yaml` + data/, with
|
/// Rebuild `playground.db` from `project.yaml` + data/, with
|
||||||
/// confirmation modal.
|
/// confirmation modal.
|
||||||
Rebuild,
|
Rebuild,
|
||||||
@@ -1013,6 +1018,7 @@ impl Command {
|
|||||||
Self::App(app) => match app {
|
Self::App(app) => match app {
|
||||||
AppCommand::Quit => "quit",
|
AppCommand::Quit => "quit",
|
||||||
AppCommand::Help { .. } => "help",
|
AppCommand::Help { .. } => "help",
|
||||||
|
AppCommand::Hint => "hint",
|
||||||
AppCommand::Rebuild => "rebuild",
|
AppCommand::Rebuild => "rebuild",
|
||||||
AppCommand::Save => "save",
|
AppCommand::Save => "save",
|
||||||
AppCommand::SaveAs => "save as",
|
AppCommand::SaveAs => "save as",
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
|
|||||||
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
Ok(Command::App(AppCommand::Undo))
|
Ok(Command::App(AppCommand::Undo))
|
||||||
}
|
}
|
||||||
|
const fn build_hint(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
|
Ok(Command::App(AppCommand::Hint))
|
||||||
|
}
|
||||||
|
|
||||||
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
Ok(Command::App(AppCommand::Redo))
|
Ok(Command::App(AppCommand::Redo))
|
||||||
@@ -263,6 +266,7 @@ pub static QUIT: CommandNode = CommandNode {
|
|||||||
shape: EMPTY_SEQ,
|
shape: EMPTY_SEQ,
|
||||||
ast_builder: build_quit,
|
ast_builder: build_quit,
|
||||||
help_id: Some("app.quit"),
|
help_id: Some("app.quit"),
|
||||||
|
hint_ids: &["quit"],
|
||||||
usage_ids: &["parse.usage.quit"],};
|
usage_ids: &["parse.usage.quit"],};
|
||||||
|
|
||||||
pub static HELP: CommandNode = CommandNode {
|
pub static HELP: CommandNode = CommandNode {
|
||||||
@@ -270,13 +274,24 @@ pub static HELP: CommandNode = CommandNode {
|
|||||||
shape: HELP_TOPIC_OPT,
|
shape: HELP_TOPIC_OPT,
|
||||||
ast_builder: build_help,
|
ast_builder: build_help,
|
||||||
help_id: Some("app.help"),
|
help_id: Some("app.help"),
|
||||||
|
hint_ids: &["help"],
|
||||||
usage_ids: &["parse.usage.help"],};
|
usage_ids: &["parse.usage.help"],};
|
||||||
|
|
||||||
|
pub static HINT: CommandNode = CommandNode {
|
||||||
|
entry: Word::keyword("hint"),
|
||||||
|
shape: EMPTY_SEQ,
|
||||||
|
ast_builder: build_hint,
|
||||||
|
help_id: Some("app.hint"),
|
||||||
|
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
|
||||||
|
hint_ids: &["hint"],
|
||||||
|
usage_ids: &["parse.usage.hint"],};
|
||||||
|
|
||||||
pub static REBUILD: CommandNode = CommandNode {
|
pub static REBUILD: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("rebuild"),
|
entry: Word::keyword("rebuild"),
|
||||||
shape: EMPTY_SEQ,
|
shape: EMPTY_SEQ,
|
||||||
ast_builder: build_rebuild,
|
ast_builder: build_rebuild,
|
||||||
help_id: Some("app.rebuild"),
|
help_id: Some("app.rebuild"),
|
||||||
|
hint_ids: &["rebuild"],
|
||||||
usage_ids: &["parse.usage.rebuild"],};
|
usage_ids: &["parse.usage.rebuild"],};
|
||||||
|
|
||||||
pub static SAVE: CommandNode = CommandNode {
|
pub static SAVE: CommandNode = CommandNode {
|
||||||
@@ -284,6 +299,7 @@ pub static SAVE: CommandNode = CommandNode {
|
|||||||
shape: SAVE_AS_OPT,
|
shape: SAVE_AS_OPT,
|
||||||
ast_builder: build_save,
|
ast_builder: build_save,
|
||||||
help_id: Some("app.save"),
|
help_id: Some("app.save"),
|
||||||
|
hint_ids: &["save"],
|
||||||
usage_ids: &["parse.usage.save"],};
|
usage_ids: &["parse.usage.save"],};
|
||||||
|
|
||||||
pub static NEW: CommandNode = CommandNode {
|
pub static NEW: CommandNode = CommandNode {
|
||||||
@@ -291,6 +307,7 @@ pub static NEW: CommandNode = CommandNode {
|
|||||||
shape: EMPTY_SEQ,
|
shape: EMPTY_SEQ,
|
||||||
ast_builder: build_new,
|
ast_builder: build_new,
|
||||||
help_id: Some("app.new"),
|
help_id: Some("app.new"),
|
||||||
|
hint_ids: &["new"],
|
||||||
usage_ids: &["parse.usage.new"],};
|
usage_ids: &["parse.usage.new"],};
|
||||||
|
|
||||||
pub static LOAD: CommandNode = CommandNode {
|
pub static LOAD: CommandNode = CommandNode {
|
||||||
@@ -298,6 +315,7 @@ pub static LOAD: CommandNode = CommandNode {
|
|||||||
shape: EMPTY_SEQ,
|
shape: EMPTY_SEQ,
|
||||||
ast_builder: build_load,
|
ast_builder: build_load,
|
||||||
help_id: Some("app.load"),
|
help_id: Some("app.load"),
|
||||||
|
hint_ids: &["load"],
|
||||||
usage_ids: &["parse.usage.load"],};
|
usage_ids: &["parse.usage.load"],};
|
||||||
|
|
||||||
pub static EXPORT: CommandNode = CommandNode {
|
pub static EXPORT: CommandNode = CommandNode {
|
||||||
@@ -305,6 +323,7 @@ pub static EXPORT: CommandNode = CommandNode {
|
|||||||
shape: EXPORT_PATH_OPT,
|
shape: EXPORT_PATH_OPT,
|
||||||
ast_builder: build_export,
|
ast_builder: build_export,
|
||||||
help_id: Some("app.export"),
|
help_id: Some("app.export"),
|
||||||
|
hint_ids: &["export"],
|
||||||
usage_ids: &["parse.usage.export"],};
|
usage_ids: &["parse.usage.export"],};
|
||||||
|
|
||||||
pub static IMPORT: CommandNode = CommandNode {
|
pub static IMPORT: CommandNode = CommandNode {
|
||||||
@@ -312,6 +331,7 @@ pub static IMPORT: CommandNode = CommandNode {
|
|||||||
shape: IMPORT_BODY_OPT,
|
shape: IMPORT_BODY_OPT,
|
||||||
ast_builder: build_import,
|
ast_builder: build_import,
|
||||||
help_id: Some("app.import"),
|
help_id: Some("app.import"),
|
||||||
|
hint_ids: &["import"],
|
||||||
usage_ids: &["parse.usage.import"],};
|
usage_ids: &["parse.usage.import"],};
|
||||||
|
|
||||||
pub static MODE: CommandNode = CommandNode {
|
pub static MODE: CommandNode = CommandNode {
|
||||||
@@ -319,6 +339,7 @@ pub static MODE: CommandNode = CommandNode {
|
|||||||
shape: MODE_VALUE,
|
shape: MODE_VALUE,
|
||||||
ast_builder: build_mode,
|
ast_builder: build_mode,
|
||||||
help_id: Some("app.mode"),
|
help_id: Some("app.mode"),
|
||||||
|
hint_ids: &["mode"],
|
||||||
usage_ids: &["parse.usage.mode"],};
|
usage_ids: &["parse.usage.mode"],};
|
||||||
|
|
||||||
pub static MESSAGES: CommandNode = CommandNode {
|
pub static MESSAGES: CommandNode = CommandNode {
|
||||||
@@ -326,6 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode {
|
|||||||
shape: MESSAGES_VALUE_OPT,
|
shape: MESSAGES_VALUE_OPT,
|
||||||
ast_builder: build_messages,
|
ast_builder: build_messages,
|
||||||
help_id: Some("app.messages"),
|
help_id: Some("app.messages"),
|
||||||
|
hint_ids: &["messages"],
|
||||||
usage_ids: &["parse.usage.messages"],};
|
usage_ids: &["parse.usage.messages"],};
|
||||||
|
|
||||||
pub static UNDO: CommandNode = CommandNode {
|
pub static UNDO: CommandNode = CommandNode {
|
||||||
@@ -333,6 +355,7 @@ pub static UNDO: CommandNode = CommandNode {
|
|||||||
shape: EMPTY_SEQ,
|
shape: EMPTY_SEQ,
|
||||||
ast_builder: build_undo,
|
ast_builder: build_undo,
|
||||||
help_id: Some("app.undo"),
|
help_id: Some("app.undo"),
|
||||||
|
hint_ids: &["undo"],
|
||||||
usage_ids: &["parse.usage.undo"],};
|
usage_ids: &["parse.usage.undo"],};
|
||||||
|
|
||||||
pub static REDO: CommandNode = CommandNode {
|
pub static REDO: CommandNode = CommandNode {
|
||||||
@@ -340,6 +363,7 @@ pub static REDO: CommandNode = CommandNode {
|
|||||||
shape: EMPTY_SEQ,
|
shape: EMPTY_SEQ,
|
||||||
ast_builder: build_redo,
|
ast_builder: build_redo,
|
||||||
help_id: Some("app.redo"),
|
help_id: Some("app.redo"),
|
||||||
|
hint_ids: &["redo"],
|
||||||
usage_ids: &["parse.usage.redo"],};
|
usage_ids: &["parse.usage.redo"],};
|
||||||
|
|
||||||
pub static COPY: CommandNode = CommandNode {
|
pub static COPY: CommandNode = CommandNode {
|
||||||
@@ -347,4 +371,5 @@ pub static COPY: CommandNode = CommandNode {
|
|||||||
shape: COPY_VALUE_OPT,
|
shape: COPY_VALUE_OPT,
|
||||||
ast_builder: build_copy,
|
ast_builder: build_copy,
|
||||||
help_id: Some("app.copy"),
|
help_id: Some("app.copy"),
|
||||||
|
hint_ids: &["copy"],
|
||||||
usage_ids: &["parse.usage.copy"],};
|
usage_ids: &["parse.usage.copy"],};
|
||||||
|
|||||||
@@ -1790,6 +1790,7 @@ pub static SHOW: CommandNode = CommandNode {
|
|||||||
shape: SHOW_SHAPE,
|
shape: SHOW_SHAPE,
|
||||||
ast_builder: build_show,
|
ast_builder: build_show,
|
||||||
help_id: Some("data.show"),
|
help_id: Some("data.show"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &[
|
usage_ids: &[
|
||||||
"parse.usage.show_data",
|
"parse.usage.show_data",
|
||||||
"parse.usage.show_table",
|
"parse.usage.show_table",
|
||||||
@@ -1805,6 +1806,7 @@ pub static SEED: CommandNode = CommandNode {
|
|||||||
shape: SEED_SHAPE,
|
shape: SEED_SHAPE,
|
||||||
ast_builder: build_seed,
|
ast_builder: build_seed,
|
||||||
help_id: Some("data.seed"),
|
help_id: Some("data.seed"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.seed"],
|
usage_ids: &["parse.usage.seed"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1813,6 +1815,8 @@ pub static INSERT: CommandNode = CommandNode {
|
|||||||
shape: INSERT_SHAPE,
|
shape: INSERT_SHAPE,
|
||||||
ast_builder: build_insert,
|
ast_builder: build_insert,
|
||||||
help_id: Some("data.insert"),
|
help_id: Some("data.insert"),
|
||||||
|
// ADR-0053 Phase-B exemplar.
|
||||||
|
hint_ids: &["insert"],
|
||||||
usage_ids: &["parse.usage.insert"],};
|
usage_ids: &["parse.usage.insert"],};
|
||||||
|
|
||||||
pub static UPDATE: CommandNode = CommandNode {
|
pub static UPDATE: CommandNode = CommandNode {
|
||||||
@@ -1820,6 +1824,7 @@ pub static UPDATE: CommandNode = CommandNode {
|
|||||||
shape: UPDATE_SHAPE,
|
shape: UPDATE_SHAPE,
|
||||||
ast_builder: build_update,
|
ast_builder: build_update,
|
||||||
help_id: Some("data.update"),
|
help_id: Some("data.update"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.update"],};
|
usage_ids: &["parse.usage.update"],};
|
||||||
|
|
||||||
pub static DELETE: CommandNode = CommandNode {
|
pub static DELETE: CommandNode = CommandNode {
|
||||||
@@ -1827,6 +1832,7 @@ pub static DELETE: CommandNode = CommandNode {
|
|||||||
shape: DELETE_SHAPE,
|
shape: DELETE_SHAPE,
|
||||||
ast_builder: build_delete,
|
ast_builder: build_delete,
|
||||||
help_id: Some("data.delete"),
|
help_id: Some("data.delete"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.delete"],};
|
usage_ids: &["parse.usage.delete"],};
|
||||||
|
|
||||||
pub static REPLAY: CommandNode = CommandNode {
|
pub static REPLAY: CommandNode = CommandNode {
|
||||||
@@ -1834,6 +1840,7 @@ pub static REPLAY: CommandNode = CommandNode {
|
|||||||
shape: REPLAY_PATH,
|
shape: REPLAY_PATH,
|
||||||
ast_builder: build_replay,
|
ast_builder: build_replay,
|
||||||
help_id: Some("data.replay"),
|
help_id: Some("data.replay"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.replay"],};
|
usage_ids: &["parse.usage.replay"],};
|
||||||
|
|
||||||
pub static EXPLAIN: CommandNode = CommandNode {
|
pub static EXPLAIN: CommandNode = CommandNode {
|
||||||
@@ -1841,6 +1848,7 @@ pub static EXPLAIN: CommandNode = CommandNode {
|
|||||||
shape: EXPLAIN_SHAPE,
|
shape: EXPLAIN_SHAPE,
|
||||||
ast_builder: build_explain,
|
ast_builder: build_explain,
|
||||||
help_id: Some("data.explain"),
|
help_id: Some("data.explain"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.explain"],};
|
usage_ids: &["parse.usage.explain"],};
|
||||||
|
|
||||||
/// `explain` over advanced-mode SQL (ADR-0039).
|
/// `explain` over advanced-mode SQL (ADR-0039).
|
||||||
@@ -1860,6 +1868,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
|||||||
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
|
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
|
||||||
// precedent; otherwise `note_help` would print `explain` twice.
|
// precedent; otherwise `note_help` would print `explain` twice.
|
||||||
help_id: None,
|
help_id: None,
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &[],};
|
usage_ids: &[],};
|
||||||
|
|
||||||
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
|
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
|
||||||
@@ -1875,6 +1884,7 @@ pub static SELECT: CommandNode = CommandNode {
|
|||||||
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
||||||
ast_builder: build_select,
|
ast_builder: build_select,
|
||||||
help_id: None,
|
help_id: None,
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.select"],};
|
usage_ids: &["parse.usage.select"],};
|
||||||
|
|
||||||
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
|
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
|
||||||
@@ -1889,6 +1899,7 @@ pub static WITH: CommandNode = CommandNode {
|
|||||||
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
||||||
ast_builder: build_select,
|
ast_builder: build_select,
|
||||||
help_id: None,
|
help_id: None,
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.with"],};
|
usage_ids: &["parse.usage.with"],};
|
||||||
|
|
||||||
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
||||||
@@ -1906,6 +1917,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
|
|||||||
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
||||||
ast_builder: build_sql_insert,
|
ast_builder: build_sql_insert,
|
||||||
help_id: None,
|
help_id: None,
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1919,6 +1931,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
|
|||||||
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||||
ast_builder: build_sql_update,
|
ast_builder: build_sql_update,
|
||||||
help_id: None,
|
help_id: None,
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1934,6 +1947,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
|
|||||||
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||||
ast_builder: build_sql_delete,
|
ast_builder: build_sql_delete,
|
||||||
help_id: None,
|
help_id: None,
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -968,6 +968,13 @@ pub static DROP: CommandNode = CommandNode {
|
|||||||
shape: DROP_SHAPE,
|
shape: DROP_SHAPE,
|
||||||
ast_builder: build_drop,
|
ast_builder: build_drop,
|
||||||
help_id: Some("ddl.drop"),
|
help_id: Some("ddl.drop"),
|
||||||
|
hint_ids: &[
|
||||||
|
"drop_table",
|
||||||
|
"drop_column",
|
||||||
|
"drop_relationship",
|
||||||
|
"drop_index",
|
||||||
|
"drop_constraint",
|
||||||
|
],
|
||||||
usage_ids: &[
|
usage_ids: &[
|
||||||
"parse.usage.drop_table",
|
"parse.usage.drop_table",
|
||||||
"parse.usage.drop_column",
|
"parse.usage.drop_column",
|
||||||
@@ -981,6 +988,16 @@ pub static ADD: CommandNode = CommandNode {
|
|||||||
shape: ADD_SHAPE,
|
shape: ADD_SHAPE,
|
||||||
ast_builder: build_add,
|
ast_builder: build_add,
|
||||||
help_id: Some("ddl.add"),
|
help_id: Some("ddl.add"),
|
||||||
|
// Per-form (ADR-0053 D3): every form is listed so the form-word
|
||||||
|
// disambiguation resolves correctly; forms without an authored
|
||||||
|
// block yet fall back to tier-2 at render. `add_relationship` is
|
||||||
|
// authored as a Phase-B exemplar.
|
||||||
|
hint_ids: &[
|
||||||
|
"add_column",
|
||||||
|
"add_relationship",
|
||||||
|
"add_index",
|
||||||
|
"add_constraint",
|
||||||
|
],
|
||||||
usage_ids: &[
|
usage_ids: &[
|
||||||
"parse.usage.add_column",
|
"parse.usage.add_column",
|
||||||
"parse.usage.add_relationship",
|
"parse.usage.add_relationship",
|
||||||
@@ -993,6 +1010,7 @@ pub static RENAME: CommandNode = CommandNode {
|
|||||||
shape: RENAME_COLUMN,
|
shape: RENAME_COLUMN,
|
||||||
ast_builder: build_rename_column,
|
ast_builder: build_rename_column,
|
||||||
help_id: Some("ddl.rename"),
|
help_id: Some("ddl.rename"),
|
||||||
|
hint_ids: &["rename_column"],
|
||||||
usage_ids: &["parse.usage.rename_column"],};
|
usage_ids: &["parse.usage.rename_column"],};
|
||||||
|
|
||||||
pub static CHANGE: CommandNode = CommandNode {
|
pub static CHANGE: CommandNode = CommandNode {
|
||||||
@@ -1000,6 +1018,7 @@ pub static CHANGE: CommandNode = CommandNode {
|
|||||||
shape: CHANGE_COLUMN,
|
shape: CHANGE_COLUMN,
|
||||||
ast_builder: build_change_column,
|
ast_builder: build_change_column,
|
||||||
help_id: Some("ddl.change"),
|
help_id: Some("ddl.change"),
|
||||||
|
hint_ids: &["change_column"],
|
||||||
usage_ids: &["parse.usage.change_column"],};
|
usage_ids: &["parse.usage.change_column"],};
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1360,6 +1379,7 @@ pub static CREATE: CommandNode = CommandNode {
|
|||||||
shape: CREATE_TABLE,
|
shape: CREATE_TABLE,
|
||||||
ast_builder: build_create_table,
|
ast_builder: build_create_table,
|
||||||
help_id: Some("ddl.create"),
|
help_id: Some("ddl.create"),
|
||||||
|
hint_ids: &["create_table"],
|
||||||
usage_ids: &["parse.usage.create_table"],};
|
usage_ids: &["parse.usage.create_table"],};
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1428,6 +1448,7 @@ pub static CREATE_M2N: CommandNode = CommandNode {
|
|||||||
shape: CREATE_M2N_SHAPE,
|
shape: CREATE_M2N_SHAPE,
|
||||||
ast_builder: build_create_m2n,
|
ast_builder: build_create_m2n,
|
||||||
help_id: Some("ddl.create_m2n"),
|
help_id: Some("ddl.create_m2n"),
|
||||||
|
hint_ids: &["create_m2n"],
|
||||||
usage_ids: &["parse.usage.create_m2n"],
|
usage_ids: &["parse.usage.create_m2n"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1858,6 +1879,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
|
|||||||
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
|
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
|
||||||
ast_builder: build_sql_create_table,
|
ast_builder: build_sql_create_table,
|
||||||
help_id: Some("ddl.sql_create_table"),
|
help_id: Some("ddl.sql_create_table"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.sql_create_table"],
|
usage_ids: &["parse.usage.sql_create_table"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1877,6 +1899,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
|
|||||||
shape: SQL_DROP_TABLE_SHAPE,
|
shape: SQL_DROP_TABLE_SHAPE,
|
||||||
ast_builder: build_sql_drop_table,
|
ast_builder: build_sql_drop_table,
|
||||||
help_id: Some("ddl.sql_drop_table"),
|
help_id: Some("ddl.sql_drop_table"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.sql_drop_table"],
|
usage_ids: &["parse.usage.sql_drop_table"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1896,6 +1919,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
|
|||||||
shape: SQL_DROP_INDEX_SHAPE,
|
shape: SQL_DROP_INDEX_SHAPE,
|
||||||
ast_builder: build_sql_drop_index,
|
ast_builder: build_sql_drop_index,
|
||||||
help_id: Some("ddl.sql_drop_index"),
|
help_id: Some("ddl.sql_drop_index"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.sql_drop_index"],
|
usage_ids: &["parse.usage.sql_drop_index"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1977,6 +2001,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
|
|||||||
shape: SQL_CREATE_INDEX_SHAPE,
|
shape: SQL_CREATE_INDEX_SHAPE,
|
||||||
ast_builder: build_sql_create_index,
|
ast_builder: build_sql_create_index,
|
||||||
help_id: Some("ddl.sql_create_index"),
|
help_id: Some("ddl.sql_create_index"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.sql_create_index"],
|
usage_ids: &["parse.usage.sql_create_index"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2535,6 +2560,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
|||||||
shape: SQL_ALTER_TABLE_SHAPE,
|
shape: SQL_ALTER_TABLE_SHAPE,
|
||||||
ast_builder: build_sql_alter_table,
|
ast_builder: build_sql_alter_table,
|
||||||
help_id: Some("ddl.sql_alter_table"),
|
help_id: Some("ddl.sql_alter_table"),
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &["parse.usage.sql_alter_table"],
|
usage_ids: &["parse.usage.sql_alter_table"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+126
-35
@@ -530,6 +530,18 @@ pub struct CommandNode {
|
|||||||
/// so a newly-registered command appears in `help`
|
/// so a newly-registered command appears in `help`
|
||||||
/// automatically (ADR-0024 §help_id).
|
/// automatically (ADR-0024 §help_id).
|
||||||
pub help_id: Option<&'static str>,
|
pub help_id: Option<&'static str>,
|
||||||
|
/// Catalog key stems (`hint.cmd.<id>`) for this command's
|
||||||
|
/// **tier-3** contextual hints (ADR-0053 / H2), **one per form**,
|
||||||
|
/// mirroring `usage_ids`. A single-form command carries one; a
|
||||||
|
/// multi-form command (`add`, `drop`, `show`, `create`) carries
|
||||||
|
/// one per form so a live-input hint can be specific to the form
|
||||||
|
/// being typed (`hint.cmd.add_relationship`, not a shared `add`
|
||||||
|
/// block). `hint_key_for_input_in_mode` disambiguates by the form
|
||||||
|
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
|
||||||
|
/// until a form's tier-3 block is authored (the surface falls back
|
||||||
|
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
|
||||||
|
/// `None` on advanced-SQL forms purely to dedup the `help` list).
|
||||||
|
pub hint_ids: &'static [&'static str],
|
||||||
/// Catalog keys under `parse.usage.*` to render in the
|
/// Catalog keys under `parse.usage.*` to render in the
|
||||||
/// "usage:" block when a parse error fires for this command
|
/// "usage:" block when a parse error fires for this command
|
||||||
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
|
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
|
||||||
@@ -574,32 +586,79 @@ pub fn usage_keys_for_input_in_mode(
|
|||||||
source: &str,
|
source: &str,
|
||||||
mode: crate::mode::Mode,
|
mode: crate::mode::Mode,
|
||||||
) -> Option<(&'static str, Vec<&'static str>)> {
|
) -> Option<(&'static str, Vec<&'static str>)> {
|
||||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
let pick = selected_nodes_for_input_in_mode(source, mode);
|
||||||
let start = skip_whitespace(source, 0);
|
if pick.is_empty() {
|
||||||
let (kw_start, kw_end) = consume_ident(source, start)?;
|
|
||||||
let word = &source[kw_start..kw_end];
|
|
||||||
let candidates = commands_for_entry_word(word);
|
|
||||||
if candidates.is_empty() {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let union = |nodes: &[(usize, &'static CommandNode, CommandCategory)]| -> Vec<&'static str> {
|
|
||||||
let mut keys: Vec<&'static str> = Vec::new();
|
let mut keys: Vec<&'static str> = Vec::new();
|
||||||
for (_, node, _) in nodes {
|
for (_, node, _) in &pick {
|
||||||
for k in node.usage_ids {
|
for k in node.usage_ids {
|
||||||
if !keys.contains(k) {
|
if !keys.contains(k) {
|
||||||
keys.push(*k);
|
keys.push(*k);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
keys
|
if keys.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let entry = pick[0].1.entry.primary;
|
||||||
|
Some((entry, keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single tier-3 hint key (`hint.cmd.<id>` stem) for the command
|
||||||
|
/// **form** `source` is currently typing, in `mode` (H2 / ADR-0053).
|
||||||
|
///
|
||||||
|
/// Mirrors [`usage_key_for_input_in_mode`]: the union of the
|
||||||
|
/// mode-selected nodes' `hint_ids`, disambiguated to the typed form by
|
||||||
|
/// [`pick_form_key`] — so `add 1:n relationship` resolves to the
|
||||||
|
/// relationship hint, and an advanced-SQL form resolves to its own
|
||||||
|
/// (not its simple sibling's). `None` if no entry word matches or the
|
||||||
|
/// form has no tier-3 block yet (the caller falls back to tier-2).
|
||||||
|
#[must_use]
|
||||||
|
pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
|
||||||
|
let nodes = selected_nodes_for_input_in_mode(source, mode);
|
||||||
|
if nodes.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut keys: Vec<&'static str> = Vec::new();
|
||||||
|
for (_, node, _) in &nodes {
|
||||||
|
for k in node.hint_ids {
|
||||||
|
if !keys.contains(k) {
|
||||||
|
keys.push(*k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pick_form_key(source, &keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared mode-aware command-form selection for the entry word at the
|
||||||
|
/// start of `source`.
|
||||||
|
///
|
||||||
|
/// Extracted so the usage-key and hint-id lookups agree on which form
|
||||||
|
/// the user is typing.
|
||||||
|
///
|
||||||
|
/// Advanced mode: every candidate form is reachable — the SQL nodes
|
||||||
|
/// are primary, and the DSL nodes remain valid via fallback (verified:
|
||||||
|
/// `create table … with pk` and `drop column …` both run in advanced
|
||||||
|
/// mode). Mode-primary (Advanced) first, so a hint never hides input
|
||||||
|
/// that works. Simple mode: only the DSL forms — the SQL-only forms
|
||||||
|
/// hit the "this is SQL" rail and are not reachable. (ADR-0042 G3.)
|
||||||
|
/// Degenerate guard: an advanced-only word in simple mode leaves the
|
||||||
|
/// selection empty; fall back to all candidates.
|
||||||
|
fn selected_nodes_for_input_in_mode(
|
||||||
|
source: &str,
|
||||||
|
mode: crate::mode::Mode,
|
||||||
|
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
|
||||||
|
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||||
|
let start = skip_whitespace(source, 0);
|
||||||
|
let Some((kw_start, kw_end)) = consume_ident(source, start) else {
|
||||||
|
return Vec::new();
|
||||||
};
|
};
|
||||||
// Advanced mode: every candidate form is reachable — the SQL
|
let word = &source[kw_start..kw_end];
|
||||||
// nodes are primary, and the DSL nodes remain valid via fallback
|
let candidates = commands_for_entry_word(word);
|
||||||
// (verified: `create table … with pk` and `drop column …` both
|
if candidates.is_empty() {
|
||||||
// run in advanced mode). Show them all, mode-primary (Advanced)
|
return Vec::new();
|
||||||
// first, so the usage hint never hides input that works. Simple
|
}
|
||||||
// mode: only the DSL forms — the SQL-only forms hit the "this is
|
|
||||||
// SQL" rail and are not reachable. (ADR-0042 G3.)
|
|
||||||
let selected: Vec<(usize, &'static CommandNode, CommandCategory)> =
|
let selected: Vec<(usize, &'static CommandNode, CommandCategory)> =
|
||||||
if mode == crate::mode::Mode::Advanced {
|
if mode == crate::mode::Mode::Advanced {
|
||||||
let mut v: Vec<_> = candidates
|
let mut v: Vec<_> = candidates
|
||||||
@@ -621,17 +680,7 @@ pub fn usage_keys_for_input_in_mode(
|
|||||||
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
// Degenerate guard: an advanced-only word in simple mode (not
|
if selected.is_empty() { candidates } else { selected }
|
||||||
// normally reachable — it hits the SQL rail first) leaves
|
|
||||||
// `selected` empty; fall back to all candidates so a usage block
|
|
||||||
// still renders rather than the available-commands fallback.
|
|
||||||
let pick = if selected.is_empty() { candidates } else { selected };
|
|
||||||
let keys = union(&pick);
|
|
||||||
if keys.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let entry = pick[0].1.entry.primary;
|
|
||||||
Some((entry, keys))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single usage template most relevant to `source`, when
|
/// The single usage template most relevant to `source`, when
|
||||||
@@ -658,14 +707,24 @@ pub fn usage_key_for_input_in_mode(
|
|||||||
source: &str,
|
source: &str,
|
||||||
mode: crate::mode::Mode,
|
mode: crate::mode::Mode,
|
||||||
) -> Option<&'static str> {
|
) -> Option<&'static str> {
|
||||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
|
||||||
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
|
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
|
||||||
|
pick_form_key(source, &keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// From the form word after the entry keyword, pick the single `keys`
|
||||||
|
/// entry for the form `source` names.
|
||||||
|
///
|
||||||
|
/// A single-entry list resolves to its one key; a multi-form list
|
||||||
|
/// disambiguates by the form word (`add 1:n relationship` → the
|
||||||
|
/// `…relationship` key, `create m:n …` → the `…m2n` key, else the
|
||||||
|
/// identifier form word matched against each key's suffix). Shared by
|
||||||
|
/// the usage-template and tier-3-hint single-key lookups so they agree.
|
||||||
|
fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
|
||||||
|
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||||
let first = *keys.first()?;
|
let first = *keys.first()?;
|
||||||
if keys.len() == 1 {
|
if keys.len() == 1 {
|
||||||
return Some(first);
|
return Some(first);
|
||||||
}
|
}
|
||||||
// Multi-form: the form is named by the token right after
|
|
||||||
// the entry keyword.
|
|
||||||
let start = skip_whitespace(source, 0);
|
let start = skip_whitespace(source, 0);
|
||||||
let (_, entry_end) = consume_ident(source, start)?;
|
let (_, entry_end) = consume_ident(source, start)?;
|
||||||
let after = skip_whitespace(source, entry_end);
|
let after = skip_whitespace(source, entry_end);
|
||||||
@@ -674,14 +733,12 @@ pub fn usage_key_for_input_in_mode(
|
|||||||
return keys.iter().copied().find(|k| k.ends_with("relationship"));
|
return keys.iter().copied().find(|k| k.ends_with("relationship"));
|
||||||
}
|
}
|
||||||
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
|
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
|
||||||
// — a letter, so the digit branch misses it, and its usage key ends
|
// — a letter, so the digit branch misses it; its key ends `…m2n`.
|
||||||
// `…create_m2n` (not `relationship`).
|
|
||||||
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
|
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
|
||||||
return keys.iter().copied().find(|k| k.ends_with("m2n"));
|
return keys.iter().copied().find(|k| k.ends_with("m2n"));
|
||||||
}
|
}
|
||||||
// Otherwise the form word is an identifier — `column`,
|
// Otherwise the form word is an identifier — `column`, `index`,
|
||||||
// `index`, `table`, `relationship` — matched against the
|
// `table`, `relationship` — matched against each key's suffix.
|
||||||
// usage key's suffix.
|
|
||||||
let (s, e) = consume_ident(source, after)?;
|
let (s, e) = consume_ident(source, after)?;
|
||||||
let form = source[s..e].to_ascii_lowercase();
|
let form = source[s..e].to_ascii_lowercase();
|
||||||
keys.iter().copied().find(|k| k.ends_with(form.as_str()))
|
keys.iter().copied().find(|k| k.ends_with(form.as_str()))
|
||||||
@@ -712,6 +769,7 @@ pub fn entry_words_alphabetised() -> Vec<&'static str> {
|
|||||||
pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||||
(&app::QUIT, CommandCategory::Simple),
|
(&app::QUIT, CommandCategory::Simple),
|
||||||
(&app::HELP, CommandCategory::Simple),
|
(&app::HELP, CommandCategory::Simple),
|
||||||
|
(&app::HINT, CommandCategory::Simple),
|
||||||
(&app::REBUILD, CommandCategory::Simple),
|
(&app::REBUILD, CommandCategory::Simple),
|
||||||
(&app::SAVE, CommandCategory::Simple),
|
(&app::SAVE, CommandCategory::Simple),
|
||||||
(&app::NEW, CommandCategory::Simple),
|
(&app::NEW, CommandCategory::Simple),
|
||||||
@@ -836,6 +894,39 @@ pub fn commands_for_entry_word(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod hint_key_tests {
|
||||||
|
use super::hint_key_for_input_in_mode;
|
||||||
|
use crate::mode::Mode;
|
||||||
|
|
||||||
|
/// Per-form hint keying (ADR-0053 D3): a multi-form command
|
||||||
|
/// resolves the *typed* form, not the node — `add 1:n
|
||||||
|
/// relationship` → the relationship hint, `add column` → the
|
||||||
|
/// (as-yet-unauthored) column hint, never the wrong form.
|
||||||
|
#[test]
|
||||||
|
fn hint_key_resolves_the_typed_form() {
|
||||||
|
assert_eq!(
|
||||||
|
hint_key_for_input_in_mode("add 1:n relationship from A.x to B.y", Mode::Simple),
|
||||||
|
Some("add_relationship")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hint_key_for_input_in_mode("add column Note text to T", Mode::Simple),
|
||||||
|
Some("add_column")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple),
|
||||||
|
Some("insert")
|
||||||
|
);
|
||||||
|
// Multi-form DROP disambiguates to the typed form too.
|
||||||
|
assert_eq!(
|
||||||
|
hint_key_for_input_in_mode("drop table T", Mode::Simple),
|
||||||
|
Some("drop_table")
|
||||||
|
);
|
||||||
|
// Unknown entry word → None (tier-2 fallback).
|
||||||
|
assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod usage_key_tests {
|
mod usage_key_tests {
|
||||||
use super::usage_key_for_input;
|
use super::usage_key_for_input;
|
||||||
|
|||||||
@@ -6910,6 +6910,7 @@ mod dispatch_3a_tests {
|
|||||||
shape: Node::Word(Word::keyword("dsltail")),
|
shape: Node::Word(Word::keyword("dsltail")),
|
||||||
ast_builder: dsl_builder,
|
ast_builder: dsl_builder,
|
||||||
help_id: None,
|
help_id: None,
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
static SMOKE_SQL: CommandNode = CommandNode {
|
static SMOKE_SQL: CommandNode = CommandNode {
|
||||||
@@ -6917,6 +6918,7 @@ mod dispatch_3a_tests {
|
|||||||
shape: Node::Word(Word::keyword("sqltail")),
|
shape: Node::Word(Word::keyword("sqltail")),
|
||||||
ast_builder: sql_builder,
|
ast_builder: sql_builder,
|
||||||
help_id: None,
|
help_id: None,
|
||||||
|
hint_ids: &[],
|
||||||
usage_ids: &[],
|
usage_ids: &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("help.unknown_topic", &["topic"]),
|
("help.unknown_topic", &["topic"]),
|
||||||
("help.app.quit", &[]),
|
("help.app.quit", &[]),
|
||||||
("help.app.help", &[]),
|
("help.app.help", &[]),
|
||||||
|
("help.app.hint", &[]),
|
||||||
("help.app.rebuild", &[]),
|
("help.app.rebuild", &[]),
|
||||||
("help.app.save", &[]),
|
("help.app.save", &[]),
|
||||||
("help.app.new", &[]),
|
("help.app.new", &[]),
|
||||||
@@ -222,6 +223,89 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
&["message", "usage"],
|
&["message", "usage"],
|
||||||
),
|
),
|
||||||
("hint.ambient_expected", &["expected"]),
|
("hint.ambient_expected", &["expected"]),
|
||||||
|
("hint.getting_started", &[]),
|
||||||
|
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
|
||||||
|
("hint.cmd.insert.what", &[]),
|
||||||
|
("hint.cmd.insert.example", &[]),
|
||||||
|
("hint.cmd.insert.concept", &[]),
|
||||||
|
("hint.cmd.add_relationship.what", &[]),
|
||||||
|
("hint.cmd.add_relationship.example", &[]),
|
||||||
|
("hint.cmd.add_relationship.concept", &[]),
|
||||||
|
("hint.err.foreign_key.child_side.what", &[]),
|
||||||
|
("hint.err.foreign_key.child_side.example", &[]),
|
||||||
|
("hint.err.foreign_key.child_side.concept", &[]),
|
||||||
|
// Phase C batch 1 — app-lifecycle command hints.
|
||||||
|
("hint.cmd.quit.what", &[]),
|
||||||
|
("hint.cmd.quit.example", &[]),
|
||||||
|
("hint.cmd.help.what", &[]),
|
||||||
|
("hint.cmd.help.example", &[]),
|
||||||
|
("hint.cmd.help.concept", &[]),
|
||||||
|
("hint.cmd.hint.what", &[]),
|
||||||
|
("hint.cmd.hint.example", &[]),
|
||||||
|
("hint.cmd.rebuild.what", &[]),
|
||||||
|
("hint.cmd.rebuild.example", &[]),
|
||||||
|
("hint.cmd.rebuild.concept", &[]),
|
||||||
|
("hint.cmd.save.what", &[]),
|
||||||
|
("hint.cmd.save.example", &[]),
|
||||||
|
("hint.cmd.new.what", &[]),
|
||||||
|
("hint.cmd.new.example", &[]),
|
||||||
|
("hint.cmd.load.what", &[]),
|
||||||
|
("hint.cmd.load.example", &[]),
|
||||||
|
("hint.cmd.export.what", &[]),
|
||||||
|
("hint.cmd.export.example", &[]),
|
||||||
|
("hint.cmd.export.concept", &[]),
|
||||||
|
("hint.cmd.import.what", &[]),
|
||||||
|
("hint.cmd.import.example", &[]),
|
||||||
|
("hint.cmd.mode.what", &[]),
|
||||||
|
("hint.cmd.mode.example", &[]),
|
||||||
|
("hint.cmd.mode.concept", &[]),
|
||||||
|
("hint.cmd.messages.what", &[]),
|
||||||
|
("hint.cmd.messages.example", &[]),
|
||||||
|
("hint.cmd.messages.concept", &[]),
|
||||||
|
("hint.cmd.undo.what", &[]),
|
||||||
|
("hint.cmd.undo.example", &[]),
|
||||||
|
("hint.cmd.undo.concept", &[]),
|
||||||
|
("hint.cmd.redo.what", &[]),
|
||||||
|
("hint.cmd.redo.example", &[]),
|
||||||
|
("hint.cmd.copy.what", &[]),
|
||||||
|
("hint.cmd.copy.example", &[]),
|
||||||
|
// Phase C batch 2 — DDL command hints.
|
||||||
|
("hint.cmd.create_table.what", &[]),
|
||||||
|
("hint.cmd.create_table.example", &[]),
|
||||||
|
("hint.cmd.create_table.concept", &[]),
|
||||||
|
("hint.cmd.create_m2n.what", &[]),
|
||||||
|
("hint.cmd.create_m2n.example", &[]),
|
||||||
|
("hint.cmd.create_m2n.concept", &[]),
|
||||||
|
("hint.cmd.add_column.what", &[]),
|
||||||
|
("hint.cmd.add_column.example", &[]),
|
||||||
|
("hint.cmd.add_column.concept", &[]),
|
||||||
|
("hint.cmd.add_index.what", &[]),
|
||||||
|
("hint.cmd.add_index.example", &[]),
|
||||||
|
("hint.cmd.add_index.concept", &[]),
|
||||||
|
("hint.cmd.add_constraint.what", &[]),
|
||||||
|
("hint.cmd.add_constraint.example", &[]),
|
||||||
|
("hint.cmd.add_constraint.concept", &[]),
|
||||||
|
("hint.cmd.drop_table.what", &[]),
|
||||||
|
("hint.cmd.drop_table.example", &[]),
|
||||||
|
("hint.cmd.drop_table.concept", &[]),
|
||||||
|
("hint.cmd.drop_column.what", &[]),
|
||||||
|
("hint.cmd.drop_column.example", &[]),
|
||||||
|
("hint.cmd.drop_column.concept", &[]),
|
||||||
|
("hint.cmd.drop_relationship.what", &[]),
|
||||||
|
("hint.cmd.drop_relationship.example", &[]),
|
||||||
|
("hint.cmd.drop_relationship.concept", &[]),
|
||||||
|
("hint.cmd.drop_index.what", &[]),
|
||||||
|
("hint.cmd.drop_index.example", &[]),
|
||||||
|
("hint.cmd.drop_index.concept", &[]),
|
||||||
|
("hint.cmd.drop_constraint.what", &[]),
|
||||||
|
("hint.cmd.drop_constraint.example", &[]),
|
||||||
|
("hint.cmd.drop_constraint.concept", &[]),
|
||||||
|
("hint.cmd.rename_column.what", &[]),
|
||||||
|
("hint.cmd.rename_column.example", &[]),
|
||||||
|
("hint.cmd.rename_column.concept", &[]),
|
||||||
|
("hint.cmd.change_column.what", &[]),
|
||||||
|
("hint.cmd.change_column.example", &[]),
|
||||||
|
("hint.cmd.change_column.concept", &[]),
|
||||||
(
|
(
|
||||||
"hint.ambient_invalid_ident",
|
"hint.ambient_invalid_ident",
|
||||||
&["kind", "found"],
|
&["kind", "found"],
|
||||||
@@ -299,6 +383,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.usage.rename_column", &[]),
|
("parse.usage.rename_column", &[]),
|
||||||
("parse.usage.export", &[]),
|
("parse.usage.export", &[]),
|
||||||
("parse.usage.help", &[]),
|
("parse.usage.help", &[]),
|
||||||
|
("parse.usage.hint", &[]),
|
||||||
("parse.usage.import", &[]),
|
("parse.usage.import", &[]),
|
||||||
("parse.usage.copy", &[]),
|
("parse.usage.copy", &[]),
|
||||||
("parse.usage.load", &[]),
|
("parse.usage.load", &[]),
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ pub mod translate;
|
|||||||
|
|
||||||
pub use error::{DiagnosticTable, FriendlyError};
|
pub use error::{DiagnosticTable, FriendlyError};
|
||||||
pub use format::{catalog, Catalog};
|
pub use format::{catalog, Catalog};
|
||||||
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity};
|
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
|
||||||
|
|
||||||
// `translate::translate` and `format::translate` are different
|
// `translate::translate` and `format::translate` are different
|
||||||
// callables — the former is the structured DbError → FriendlyError
|
// callables — the former is the structured DbError → FriendlyError
|
||||||
|
|||||||
@@ -256,6 +256,8 @@ help:
|
|||||||
help: |-
|
help: |-
|
||||||
help — show this command list
|
help — show this command list
|
||||||
help <command> — detailed help for one command (e.g. `help insert`)
|
help <command> — detailed help for one command (e.g. `help insert`)
|
||||||
|
hint: |-
|
||||||
|
hint — explain the most recent error (press F1 for a hint on what you're typing)
|
||||||
rebuild: |-
|
rebuild: |-
|
||||||
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
|
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
|
||||||
save: |-
|
save: |-
|
||||||
@@ -386,6 +388,129 @@ hint:
|
|||||||
ambient_complete: "Submit with Enter"
|
ambient_complete: "Submit with Enter"
|
||||||
ambient_expected: "Next: {expected}"
|
ambient_expected: "Next: {expected}"
|
||||||
ambient_error_with_usage: "{message} — usage: {usage}"
|
ambient_error_with_usage: "{message} — usage: {usage}"
|
||||||
|
# H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific
|
||||||
|
# to expand on (no recent error, empty input).
|
||||||
|
getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list."
|
||||||
|
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
|
||||||
|
# Per-form command hints (`hint.cmd.<form>`) and per-class error
|
||||||
|
# hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example`
|
||||||
|
# (one runnable, mode-correct line) / `concept` (the relational idea —
|
||||||
|
# the teaching part). Phase B seeds the three approved exemplars; the
|
||||||
|
# rest are authored in Phase C.
|
||||||
|
cmd:
|
||||||
|
insert:
|
||||||
|
what: "Add one or more rows to a table."
|
||||||
|
example: "insert into Customers values ('Ann', 'ann@example.io')"
|
||||||
|
concept: "A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out."
|
||||||
|
add_relationship:
|
||||||
|
what: "Link two tables so a parent row can own many child rows."
|
||||||
|
example: "add 1:n relationship from Customers.id to Orders.customer_id"
|
||||||
|
concept: "The \"1:n\" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet."
|
||||||
|
# App-lifecycle commands (Phase C batch 1). Reference-leaning, so
|
||||||
|
# `concept` appears only where there's a real idea to teach.
|
||||||
|
quit:
|
||||||
|
what: "Leave the playground. Your project is already saved to disk."
|
||||||
|
example: "quit"
|
||||||
|
help:
|
||||||
|
what: "List every command, or show the detail for one."
|
||||||
|
example: "help insert"
|
||||||
|
concept: "`help` is the reference; press F1 while typing for a hint about the command you're building right now."
|
||||||
|
hint:
|
||||||
|
what: "Explain the most recent error — or, pressing F1 while typing, the command you're building."
|
||||||
|
example: "hint"
|
||||||
|
rebuild:
|
||||||
|
what: "Rebuild the project database from its saved text files."
|
||||||
|
example: "rebuild"
|
||||||
|
concept: "The text files (project.yaml + the data folder) are the source of truth; the database is derived and can always be rebuilt from them."
|
||||||
|
save:
|
||||||
|
what: "Save the current project under a name; `save as` copies it to a new one."
|
||||||
|
example: "save as my-shop"
|
||||||
|
new:
|
||||||
|
what: "Close the current project and start a fresh temporary one."
|
||||||
|
example: "new"
|
||||||
|
load:
|
||||||
|
what: "Open the project picker to switch to a saved project."
|
||||||
|
example: "load"
|
||||||
|
export:
|
||||||
|
what: "Write a shareable zip of the project — its text files only, never the database."
|
||||||
|
example: "export my-shop.zip"
|
||||||
|
concept: "The zip carries the schema and data as text, so anyone can rebuild the very same database from it."
|
||||||
|
import:
|
||||||
|
what: "Unpack a project zip into a new project and switch to it."
|
||||||
|
example: "import my-shop.zip as shop-copy"
|
||||||
|
mode:
|
||||||
|
what: "Switch between simple mode (the guided teaching commands) and advanced mode (raw SQL)."
|
||||||
|
example: "mode advanced"
|
||||||
|
concept: "Simple mode uses keyword commands; advanced mode lets you write SQL directly. A leading `:` runs a single advanced command without switching modes."
|
||||||
|
messages:
|
||||||
|
what: "Show or set how much detail error messages give."
|
||||||
|
example: "messages short"
|
||||||
|
concept: "Verbose (the default) adds a fix-it hint under each error headline; short shows just the headline."
|
||||||
|
undo:
|
||||||
|
what: "Undo the most recent change, after a confirmation."
|
||||||
|
example: "undo"
|
||||||
|
concept: "Every data or schema change is snapshotted first, so you can step back; `redo` re-applies what you undid."
|
||||||
|
redo:
|
||||||
|
what: "Re-apply the most recently undone change."
|
||||||
|
example: "redo"
|
||||||
|
copy:
|
||||||
|
what: "Copy the output panel to the clipboard — all of it, or just the last command's output."
|
||||||
|
example: "copy last"
|
||||||
|
# DDL — schema-shaping commands (Phase C batch 2).
|
||||||
|
create_table:
|
||||||
|
what: "Create a new table — its columns, their types, and a primary key."
|
||||||
|
example: "create table Customers with pk id(serial), name(text), email(text)"
|
||||||
|
concept: "A table is a set of rows that share the same columns. The primary key uniquely identifies each row; a `serial` key numbers the rows for you."
|
||||||
|
create_m2n:
|
||||||
|
what: "Create a junction table linking two tables many-to-many."
|
||||||
|
example: "create m:n relationship from Students to Courses"
|
||||||
|
concept: "A many-to-many link (a student takes many courses; a course has many students) can't live in either table, so it gets its own junction table holding a foreign key to each side."
|
||||||
|
add_column:
|
||||||
|
what: "Add a new column to an existing table."
|
||||||
|
example: "add column Customers: phone (text)"
|
||||||
|
concept: "Existing rows take the column's default, or null. A `not null` column with no default can't be added to a table that already has rows — there'd be nothing to put in them."
|
||||||
|
add_index:
|
||||||
|
what: "Create an index on one or more columns to speed up lookups."
|
||||||
|
example: "add index as idx_email on Customers (email)"
|
||||||
|
concept: "An index is a sorted side-structure that makes a lookup like `where email = …` fast, at the cost of a little space and slightly slower writes."
|
||||||
|
add_constraint:
|
||||||
|
what: "Add a constraint — not null, unique, default, or check — to an existing column."
|
||||||
|
example: "add constraint not null to Customers.email"
|
||||||
|
concept: "A constraint is a rule the database enforces on every row. Adding one fails if existing rows already break it, so you fix the data first."
|
||||||
|
drop_table:
|
||||||
|
what: "Remove a table and all of its rows."
|
||||||
|
example: "drop table Customers"
|
||||||
|
concept: "If other tables reference this one through a relationship, drop those relationships (or their child rows) first — the database won't orphan them."
|
||||||
|
drop_column:
|
||||||
|
what: "Remove a column from a table."
|
||||||
|
example: "drop column Customers: phone"
|
||||||
|
concept: "The column's values are lost. You can't drop a primary-key column, or one a relationship depends on."
|
||||||
|
drop_relationship:
|
||||||
|
what: "Remove a relationship between two tables."
|
||||||
|
example: "drop relationship customer_orders"
|
||||||
|
concept: "This drops the foreign-key link and stops the database enforcing it; the tables and their rows stay. The foreign-key column itself remains unless you also drop it."
|
||||||
|
drop_index:
|
||||||
|
what: "Remove an index by name."
|
||||||
|
example: "drop index idx_email"
|
||||||
|
concept: "Only the lookup shortcut goes — the data is untouched. Queries still work, just without that speed-up."
|
||||||
|
drop_constraint:
|
||||||
|
what: "Remove a constraint from a column."
|
||||||
|
example: "drop constraint not null from Customers.email"
|
||||||
|
concept: "The rule stops being enforced from now on; rows already stored are left as they are."
|
||||||
|
rename_column:
|
||||||
|
what: "Rename a column, keeping its values and type."
|
||||||
|
example: "rename column Customers: email to contact_email"
|
||||||
|
concept: "Only the name changes — the stored data is the same. References to the column are reconciled so nothing breaks."
|
||||||
|
change_column:
|
||||||
|
what: "Change a column's type, converting the existing values."
|
||||||
|
example: "change column Customers: status (int)"
|
||||||
|
concept: "The database converts each stored value to the new type; if a value can't convert it refuses the change, so you don't silently lose data. Flags let you force or skip the conversion."
|
||||||
|
err:
|
||||||
|
foreign_key:
|
||||||
|
child_side:
|
||||||
|
what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at."
|
||||||
|
example: "First insert the parent (insert into Customers …), then the child that references it."
|
||||||
|
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`."
|
||||||
# Invalid identifier in a schema slot (ADR-0022 stage 8e
|
# Invalid identifier in a schema slot (ADR-0022 stage 8e
|
||||||
# + the user's #5). Voice mirrors ADR-0019's "no such
|
# + the user's #5). Voice mirrors ADR-0019's "no such
|
||||||
# {kind}" wording for consistency with engine errors.
|
# {kind}" wording for consistency with engine errors.
|
||||||
@@ -617,6 +742,7 @@ parse:
|
|||||||
# description.
|
# description.
|
||||||
quit: "quit"
|
quit: "quit"
|
||||||
help: "help [<command>]"
|
help: "help [<command>]"
|
||||||
|
hint: "hint"
|
||||||
rebuild: "rebuild"
|
rebuild: "rebuild"
|
||||||
save: "save | save as"
|
save: "save | save as"
|
||||||
new: "new"
|
new: "new"
|
||||||
|
|||||||
@@ -253,6 +253,73 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
|
|||||||
fe
|
fe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tier-3 hint class (`hint.err.<class>`) for an error.
|
||||||
|
///
|
||||||
|
/// The same classification [`translate`] performs, surfaced as a
|
||||||
|
/// stable key for the contextual `hint` (H2 / ADR-0053 D5). Returns
|
||||||
|
/// `None` for internal / fatal errors that carry no learner-facing
|
||||||
|
/// hint (persistence, IO, worker-gone).
|
||||||
|
///
|
||||||
|
/// **Keep in sync with [`translate`] / `translate_sqlite` /
|
||||||
|
/// `translate_constraint` / `translate_foreign_key`** — the unit tests
|
||||||
|
/// below pin each class.
|
||||||
|
#[must_use]
|
||||||
|
pub fn error_hint_class(error: &DbError, ctx: &TranslateContext) -> Option<&'static str> {
|
||||||
|
match error {
|
||||||
|
DbError::Sqlite { message, kind } => sqlite_hint_class(message, *kind, ctx),
|
||||||
|
DbError::Unsupported(_) | DbError::InvalidValue(_) => Some("invalid_value"),
|
||||||
|
DbError::PersistenceFatal { .. }
|
||||||
|
| DbError::RebuildRowFailed { .. }
|
||||||
|
| DbError::Io(_)
|
||||||
|
| DbError::WorkerGone => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_hint_class(
|
||||||
|
message: &str,
|
||||||
|
kind: SqliteErrorKind,
|
||||||
|
ctx: &TranslateContext,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
if matches!(ctx.operation, Some(Operation::ChangeColumnType)) {
|
||||||
|
return Some("type_mismatch");
|
||||||
|
}
|
||||||
|
Some(match kind {
|
||||||
|
SqliteErrorKind::NoSuchTable | SqliteErrorKind::NoSuchColumn => "not_found",
|
||||||
|
SqliteErrorKind::AlreadyExists => "already_exists",
|
||||||
|
SqliteErrorKind::UniqueViolation => constraint_hint_class(message, ctx),
|
||||||
|
SqliteErrorKind::Other => "generic",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constraint_hint_class(message: &str, ctx: &TranslateContext) -> &'static str {
|
||||||
|
let lower = message.to_ascii_lowercase();
|
||||||
|
if lower.contains("unique constraint failed") {
|
||||||
|
"unique"
|
||||||
|
} else if lower.contains("foreign key constraint failed") {
|
||||||
|
fk_hint_class(ctx)
|
||||||
|
} else if lower.contains("not null constraint failed") {
|
||||||
|
"not_null"
|
||||||
|
} else if lower.contains("check constraint failed") {
|
||||||
|
"check"
|
||||||
|
} else {
|
||||||
|
"generic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
|
||||||
|
// Mirrors `translate_foreign_key`'s side disambiguation.
|
||||||
|
if ctx.parent_table.is_some() {
|
||||||
|
return "foreign_key.child_side";
|
||||||
|
}
|
||||||
|
if ctx.child_table.is_some() {
|
||||||
|
return "foreign_key.parent_side";
|
||||||
|
}
|
||||||
|
match ctx.operation {
|
||||||
|
Some(Operation::Delete) => "foreign_key.parent_side",
|
||||||
|
_ => "foreign_key.child_side",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn translate_sqlite(
|
fn translate_sqlite(
|
||||||
message: &str,
|
message: &str,
|
||||||
kind: SqliteErrorKind,
|
kind: SqliteErrorKind,
|
||||||
@@ -798,6 +865,92 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── H2 / ADR-0053: error → tier-3 hint class ────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_class_maps_runtime_error_kinds() {
|
||||||
|
use crate::db::{DbError, SqliteErrorKind};
|
||||||
|
let sqlite = |kind, msg: &str| DbError::Sqlite {
|
||||||
|
message: msg.to_string(),
|
||||||
|
kind,
|
||||||
|
};
|
||||||
|
let d = TranslateContext::default;
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()),
|
||||||
|
Some("not_found")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()),
|
||||||
|
Some("not_found")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()),
|
||||||
|
Some("already_exists")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&sqlite(SqliteErrorKind::Other, "boom"), &d()),
|
||||||
|
Some("generic")
|
||||||
|
);
|
||||||
|
// Constraint-violation message splitting.
|
||||||
|
let cv = |msg: &str| sqlite(SqliteErrorKind::UniqueViolation, msg);
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&cv("UNIQUE constraint failed: T.c"), &d()),
|
||||||
|
Some("unique")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&cv("NOT NULL constraint failed: T.c"), &d()),
|
||||||
|
Some("not_null")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&cv("CHECK constraint failed: T"), &d()),
|
||||||
|
Some("check")
|
||||||
|
);
|
||||||
|
// change-column op routes any engine error to type_mismatch.
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(
|
||||||
|
&sqlite(SqliteErrorKind::Other, "x"),
|
||||||
|
&ctx_with(Operation::ChangeColumnType)
|
||||||
|
),
|
||||||
|
Some("type_mismatch")
|
||||||
|
);
|
||||||
|
// App-level refusals and internal/fatal errors.
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&DbError::InvalidValue("bad".to_string()), &d()),
|
||||||
|
Some("invalid_value")
|
||||||
|
);
|
||||||
|
assert_eq!(error_hint_class(&DbError::WorkerGone, &d()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_class_resolves_foreign_key_sides() {
|
||||||
|
use crate::db::{DbError, SqliteErrorKind};
|
||||||
|
let fk = || DbError::Sqlite {
|
||||||
|
message: "FOREIGN KEY constraint failed".to_string(),
|
||||||
|
kind: SqliteErrorKind::UniqueViolation,
|
||||||
|
};
|
||||||
|
// Enrichment: parent_table populated → child-side.
|
||||||
|
let ctx = TranslateContext {
|
||||||
|
parent_table: Some("Parent".to_string()),
|
||||||
|
..TranslateContext::default()
|
||||||
|
};
|
||||||
|
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side"));
|
||||||
|
// child_table populated → parent-side.
|
||||||
|
let ctx = TranslateContext {
|
||||||
|
child_table: Some("Child".to_string()),
|
||||||
|
..TranslateContext::default()
|
||||||
|
};
|
||||||
|
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side"));
|
||||||
|
// No enrichment: operation is the tiebreaker.
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&fk(), &ctx_with(Operation::Delete)),
|
||||||
|
Some("foreign_key.parent_side")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error_hint_class(&fk(), &ctx_with(Operation::Insert)),
|
||||||
|
Some("foreign_key.child_side")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
|
fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
|
||||||
DbError::Sqlite {
|
DbError::Sqlite {
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
|
|||||||
+13
-13
@@ -1190,7 +1190,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
|
|||||||
// miss leaves that table's columns unpopulated and the
|
// miss leaves that table's columns unpopulated and the
|
||||||
// walker falls back to the schemaless value-literal list.
|
// walker falls back to the schemaless value-literal list.
|
||||||
for name in cache.tables.clone() {
|
for name in cache.tables.clone() {
|
||||||
if let Ok(desc) = database.describe_table(name.clone(), None).await {
|
if let Ok(desc) = database.describe_table(name.clone()).await {
|
||||||
// Per-table indexes for the items panel (S2, ADR-0025).
|
// Per-table indexes for the items panel (S2, ADR-0025).
|
||||||
// Carry uniqueness so the panel can mark a UNIQUE index
|
// Carry uniqueness so the panel can mark a UNIQUE index
|
||||||
// (ADR-0035 §4d). Captured before `desc.columns` is
|
// (ADR-0035 §4d). Captured before `desc.columns` is
|
||||||
@@ -1650,7 +1650,7 @@ async fn build_show_data_echo(
|
|||||||
limit: Some(_),
|
limit: Some(_),
|
||||||
..
|
..
|
||||||
} => database
|
} => database
|
||||||
.describe_table(name.clone(), None)
|
.describe_table(name.clone())
|
||||||
.await
|
.await
|
||||||
.map(|desc| {
|
.map(|desc| {
|
||||||
desc.columns
|
desc.columns
|
||||||
@@ -1732,7 +1732,7 @@ async fn collect_echo_lookups(
|
|||||||
Command::DropIndex {
|
Command::DropIndex {
|
||||||
selector: IndexSelector::Columns { table, columns },
|
selector: IndexSelector::Columns { table, columns },
|
||||||
} => {
|
} => {
|
||||||
if let Ok(desc) = database.describe_table(table.clone(), None).await
|
if let Ok(desc) = database.describe_table(table.clone()).await
|
||||||
&& let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns)
|
&& let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns)
|
||||||
{
|
{
|
||||||
out.drop_index_name = Some(idx.name.clone());
|
out.drop_index_name = Some(idx.name.clone());
|
||||||
@@ -1747,7 +1747,7 @@ async fn collect_echo_lookups(
|
|||||||
child_column,
|
child_column,
|
||||||
},
|
},
|
||||||
} => {
|
} => {
|
||||||
if let Ok(desc) = database.describe_table(child_table.clone(), None).await
|
if let Ok(desc) = database.describe_table(child_table.clone()).await
|
||||||
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
|
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
|
||||||
// The Endpoints drop selector is single-column
|
// The Endpoints drop selector is single-column
|
||||||
// (ADR-0043 keeps DROP by-endpoints single-column;
|
// (ADR-0043 keeps DROP by-endpoints single-column;
|
||||||
@@ -1771,7 +1771,7 @@ async fn collect_echo_lookups(
|
|||||||
// resolver API would be the next step if schemas grow.
|
// resolver API would be the next step if schemas grow.
|
||||||
if let Ok(tables) = database.list_tables().await {
|
if let Ok(tables) = database.list_tables().await {
|
||||||
for table in tables {
|
for table in tables {
|
||||||
if let Ok(desc) = database.describe_table(table.clone(), None).await
|
if let Ok(desc) = database.describe_table(table.clone()).await
|
||||||
&& desc.outbound_relationships.iter().any(|r| r.name == *name)
|
&& desc.outbound_relationships.iter().any(|r| r.name == *name)
|
||||||
{
|
{
|
||||||
out.drop_relationship = Some((name.clone(), table.clone()));
|
out.drop_relationship = Some((name.clone(), table.clone()));
|
||||||
@@ -1795,8 +1795,8 @@ async fn collect_echo_lookups(
|
|||||||
// *before* execution to know which `ADD COLUMN` lines to
|
// *before* execution to know which `ADD COLUMN` lines to
|
||||||
// emit. The parent columns here are the explicit DSL list,
|
// emit. The parent columns here are the explicit DSL list,
|
||||||
// paired positionally with the child list.
|
// paired positionally with the child list.
|
||||||
let parent_desc = database.describe_table(parent_table.clone(), None).await;
|
let parent_desc = database.describe_table(parent_table.clone()).await;
|
||||||
let child_desc = database.describe_table(child_table.clone(), None).await;
|
let child_desc = database.describe_table(child_table.clone()).await;
|
||||||
if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
|
if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
|
||||||
let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
|
let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
|
||||||
for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
|
for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
|
||||||
@@ -2064,7 +2064,7 @@ async fn enrich_check_violation(
|
|||||||
.await
|
.await
|
||||||
.map(|v| v.to_string());
|
.map(|v| v.to_string());
|
||||||
// The rule itself — the column's compiled CHECK expression.
|
// The rule itself — the column's compiled CHECK expression.
|
||||||
if let Ok(desc) = database.describe_table(table.to_string(), None).await
|
if let Ok(desc) = database.describe_table(table.to_string()).await
|
||||||
&& let Some(col) = desc.columns.iter().find(|c| c.name == column)
|
&& let Some(col) = desc.columns.iter().find(|c| c.name == column)
|
||||||
{
|
{
|
||||||
facts.check_rule.clone_from(&col.check);
|
facts.check_rule.clone_from(&col.check);
|
||||||
@@ -2272,7 +2272,7 @@ async fn user_value_for_column_with_schema(
|
|||||||
} = command
|
} = command
|
||||||
{
|
{
|
||||||
let desc = database
|
let desc = database
|
||||||
.describe_table(table.to_string(), None)
|
.describe_table(table.to_string())
|
||||||
.await
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
// Build the natural-order column list the same way
|
// Build the natural-order column list the same way
|
||||||
@@ -2311,7 +2311,7 @@ async fn user_value_for_column_with_schema(
|
|||||||
&& literal_rows.len() == 1
|
&& literal_rows.len() == 1
|
||||||
{
|
{
|
||||||
let desc = database
|
let desc = database
|
||||||
.describe_table(table.to_string(), None)
|
.describe_table(table.to_string())
|
||||||
.await
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
let idx = desc.columns.iter().position(|c| c.name == column)?;
|
let idx = desc.columns.iter().position(|c| c.name == column)?;
|
||||||
@@ -2930,7 +2930,7 @@ async fn execute_command_typed(
|
|||||||
.await
|
.await
|
||||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||||
Command::ShowTable { name } => database
|
Command::ShowTable { name } => database
|
||||||
.describe_table(name, src)
|
.describe_table(name)
|
||||||
.await
|
.await
|
||||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||||
// ADR-0044: a named relationship renders as a diagram (App-side),
|
// ADR-0044: a named relationship renders as a diagram (App-side),
|
||||||
@@ -2983,14 +2983,14 @@ async fn execute_command_typed(
|
|||||||
filter,
|
filter,
|
||||||
limit,
|
limit,
|
||||||
} => database
|
} => database
|
||||||
.query_data(name, filter, limit, src)
|
.query_data(name, filter, limit)
|
||||||
.await
|
.await
|
||||||
.map(CommandOutcome::Query),
|
.map(CommandOutcome::Query),
|
||||||
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
|
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
|
||||||
// The grammar walker has already validated `sql` is in
|
// The grammar walker has already validated `sql` is in
|
||||||
// the supported subset; the worker runs it as text.
|
// the supported subset; the worker runs it as text.
|
||||||
Command::Select { sql } => database
|
Command::Select { sql } => database
|
||||||
.run_select(sql, src)
|
.run_select(sql)
|
||||||
.await
|
.await
|
||||||
.map(CommandOutcome::Query),
|
.map(CommandOutcome::Query),
|
||||||
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
|
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() {
|
|||||||
.expect("rename column via a case-variant table name");
|
.expect("rename column via a case-variant table name");
|
||||||
|
|
||||||
let desc = r
|
let desc = r
|
||||||
.block_on(db.describe_table("Items".to_string(), None))
|
.block_on(db.describe_table("Items".to_string()))
|
||||||
.expect("describe Items");
|
.expect("describe Items");
|
||||||
let amount = desc
|
let amount = desc
|
||||||
.columns
|
.columns
|
||||||
@@ -126,7 +126,7 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() {
|
|||||||
|
|
||||||
let db = fresh_rebuild(db, &project, &r);
|
let db = fresh_rebuild(db, &project, &r);
|
||||||
let rows = r
|
let rows = r
|
||||||
.block_on(db.query_data("Items".to_string(), None, None, None))
|
.block_on(db.query_data("Items".to_string(), None, None))
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)");
|
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)");
|
||||||
@@ -146,7 +146,7 @@ fn add_column_with_case_variant_table_survives_rebuild() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let db = fresh_rebuild(db, &project, &r);
|
let db = fresh_rebuild(db, &project, &r);
|
||||||
let desc = r.block_on(db.describe_table("Items".to_string(), None)).expect("describe");
|
let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe");
|
||||||
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added");
|
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added");
|
||||||
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild");
|
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild");
|
||||||
// The CHECK is intact too (a negative qty is refused under the real table).
|
// The CHECK is intact too (a negative qty is refused under the real table).
|
||||||
@@ -224,12 +224,12 @@ fn add_relationship_with_case_variant_tables_survives_rebuild() {
|
|||||||
add 1:n relationship from parent.id to child.parent_id\n",
|
add 1:n relationship from parent.id to child.parent_id\n",
|
||||||
);
|
);
|
||||||
// The parent's inbound relationship is visible under the stored case.
|
// The parent's inbound relationship is visible under the stored case.
|
||||||
let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent");
|
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
|
||||||
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case");
|
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case");
|
||||||
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
||||||
|
|
||||||
let db = fresh_rebuild(db, &project, &r);
|
let db = fresh_rebuild(db, &project, &r);
|
||||||
let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent");
|
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
|
||||||
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild");
|
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild");
|
||||||
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ fn compound_fk_declares_enforces_and_round_trips() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// describe shows the compound endpoints symmetrically.
|
// describe shows the compound endpoints symmetrically.
|
||||||
let city = db.describe_table("City".to_string(), None).await.unwrap();
|
let city = db.describe_table("City".to_string()).await.unwrap();
|
||||||
let outbound = &city.outbound_relationships[0];
|
let outbound = &city.outbound_relationships[0];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
outbound.local_columns,
|
outbound.local_columns,
|
||||||
@@ -329,7 +329,7 @@ fn compound_fk_create_fk_makes_both_child_columns() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("add compound relationship with --create-fk");
|
.expect("add compound relationship with --create-fk");
|
||||||
let city = db.describe_table("City".to_string(), None).await.unwrap();
|
let city = db.describe_table("City".to_string()).await.unwrap();
|
||||||
for col in ["c_country", "c_code"] {
|
for col in ["c_country", "c_code"] {
|
||||||
assert!(
|
assert!(
|
||||||
city.columns.iter().any(|c| c.name == col),
|
city.columns.iter().any(|c| c.name == col),
|
||||||
@@ -527,7 +527,7 @@ fn compound_fk_survives_rebuild_from_text() {
|
|||||||
.await;
|
.await;
|
||||||
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
|
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
|
||||||
// Endpoints survived the round-trip intact.
|
// Endpoints survived the round-trip intact.
|
||||||
let city = db.describe_table("City".to_string(), None).await.unwrap();
|
let city = db.describe_table("City".to_string()).await.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
city.outbound_relationships[0].other_columns,
|
city.outbound_relationships[0].other_columns,
|
||||||
vec!["country".to_string(), "code".to_string()],
|
vec!["country".to_string(), "code".to_string()],
|
||||||
@@ -563,7 +563,7 @@ fn compound_fk_undo_removes_the_relationship() {
|
|||||||
.await
|
.await
|
||||||
.expect("add compound relationship");
|
.expect("add compound relationship");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
db.describe_table("City".to_string(), None)
|
db.describe_table("City".to_string())
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.outbound_relationships
|
.outbound_relationships
|
||||||
@@ -573,7 +573,7 @@ fn compound_fk_undo_removes_the_relationship() {
|
|||||||
// One undo step removes the whole relationship (ADR-0013/0006).
|
// One undo step removes the whole relationship (ADR-0013/0006).
|
||||||
db.undo().await.unwrap().expect("undo applied");
|
db.undo().await.unwrap().expect("undo applied");
|
||||||
assert!(
|
assert!(
|
||||||
db.describe_table("City".to_string(), None)
|
db.describe_table("City".to_string())
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.outbound_relationships
|
.outbound_relationships
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ fn rebuild_restores_schema_only_project() {
|
|||||||
|
|
||||||
// Phase 4: confirm Customers exists with the right shape.
|
// Phase 4: confirm Customers exists with the right shape.
|
||||||
let desc = rt()
|
let desc = rt()
|
||||||
.block_on(async { db.describe_table("Customers".to_string(), None).await })
|
.block_on(async { db.describe_table("Customers".to_string()).await })
|
||||||
.expect("describe_table");
|
.expect("describe_table");
|
||||||
assert_eq!(desc.name, "Customers");
|
assert_eq!(desc.name, "Customers");
|
||||||
let cols: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
let cols: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
||||||
@@ -143,7 +143,7 @@ fn rebuild_restores_rows_from_csv() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let rows = rt()
|
let rows = rt()
|
||||||
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
|
.block_on(async { db.query_data("Customers".to_string(), None, None).await })
|
||||||
.expect("query_data");
|
.expect("query_data");
|
||||||
assert_eq!(rows.rows.len(), 2);
|
assert_eq!(rows.rows.len(), 2);
|
||||||
let names: Vec<Option<String>> = rows.rows.iter().map(|r| r[1].clone()).collect();
|
let names: Vec<Option<String>> = rows.rows.iter().map(|r| r[1].clone()).collect();
|
||||||
@@ -371,7 +371,7 @@ fn rebuild_preserves_created_at_from_yaml() {
|
|||||||
// Trigger any successful command so project.yaml is
|
// Trigger any successful command so project.yaml is
|
||||||
// rewritten from the now-rebuilt db state.
|
// rewritten from the now-rebuilt db state.
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
db.describe_table("T".to_string(), Some("show table T".to_string()))
|
db.describe_table("T".to_string())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// describe is read-only; force a rewrite by adding a column.
|
// describe is read-only; force a rewrite by adding a column.
|
||||||
@@ -451,7 +451,7 @@ fn rebuild_restores_indexes() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let desc = rt()
|
let desc = rt()
|
||||||
.block_on(async { db.describe_table("Customers".to_string(), None).await })
|
.block_on(async { db.describe_table("Customers".to_string()).await })
|
||||||
.expect("describe_table");
|
.expect("describe_table");
|
||||||
assert_eq!(desc.indexes.len(), 1, "index should survive rebuild");
|
assert_eq!(desc.indexes.len(), 1, "index should survive rebuild");
|
||||||
assert_eq!(desc.indexes[0].name, "idx_email");
|
assert_eq!(desc.indexes[0].name, "idx_email");
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
|
|||||||
.expect("rebuild");
|
.expect("rebuild");
|
||||||
});
|
});
|
||||||
let rows = rt()
|
let rows = rt()
|
||||||
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
|
.block_on(async { db.query_data("Customers".to_string(), None, None).await })
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rows.rows.len(), 1);
|
assert_eq!(rows.rows.len(), 1);
|
||||||
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
|
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ fn end_to_end_export_then_import_real_project() {
|
|||||||
|
|
||||||
// Round-trip: the inserted row is back.
|
// Round-trip: the inserted row is back.
|
||||||
let data_view = rt()
|
let data_view = rt()
|
||||||
.block_on(async { imported_db.query_data("Customers".to_string(), None, None, None).await })
|
.block_on(async { imported_db.query_data("Customers".to_string(), None, None).await })
|
||||||
.expect("query data");
|
.expect("query data");
|
||||||
assert_eq!(data_view.rows.len(), 1);
|
assert_eq!(data_view.rows.len(), 1);
|
||||||
// Serial id auto-filled to 1; Name was the inserted value.
|
// Serial id auto-filled to 1; Name was the inserted value.
|
||||||
|
|||||||
+6
-6
@@ -107,7 +107,7 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
|||||||
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
|
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
|
||||||
|
|
||||||
// Two FK columns, both part of the compound PK.
|
// Two FK columns, both part of the compound PK.
|
||||||
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
|
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
|
||||||
let cols: Vec<(&str, bool)> =
|
let cols: Vec<(&str, bool)> =
|
||||||
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
|
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -191,7 +191,7 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
|
|||||||
.await
|
.await
|
||||||
.expect("create m:n");
|
.expect("create m:n");
|
||||||
|
|
||||||
let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap();
|
let desc = db.describe_table("Students_Sections".to_string()).await.unwrap();
|
||||||
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
||||||
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
|
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
|
||||||
// All three form the compound PK.
|
// All three form the compound PK.
|
||||||
@@ -221,7 +221,7 @@ fn deleting_a_parent_cascades_to_the_junction() {
|
|||||||
|
|
||||||
// Deleting the student cascades to the junction (ON DELETE CASCADE).
|
// Deleting the student cascades to the junction (ON DELETE CASCADE).
|
||||||
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
|
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
|
||||||
let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap();
|
let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap();
|
||||||
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
|
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ fn create_m2n_is_one_undo_step() {
|
|||||||
let tables = db.list_tables().await.unwrap();
|
let tables = db.list_tables().await.unwrap();
|
||||||
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
|
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
|
||||||
// The parents' relationships are gone too (the junction held them).
|
// The parents' relationships are gone too (the junction held them).
|
||||||
let students = db.describe_table("Students".to_string(), None).await.unwrap();
|
let students = db.describe_table("Students".to_string()).await.unwrap();
|
||||||
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
|
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -321,7 +321,7 @@ fn the_junction_can_be_renamed() {
|
|||||||
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
||||||
assert!(!tables.contains(&"Students_Courses".to_string()));
|
assert!(!tables.contains(&"Students_Courses".to_string()));
|
||||||
// Both relationships survive the rename (rebuild-preserving).
|
// Both relationships survive the rename (rebuild-preserving).
|
||||||
let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap();
|
let desc = db.describe_table("Enrollments".to_string()).await.unwrap();
|
||||||
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
|
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -362,7 +362,7 @@ fn junction_survives_save_and_rebuild() {
|
|||||||
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
|
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
|
||||||
let tables = db.list_tables().await.unwrap();
|
let tables = db.list_tables().await.unwrap();
|
||||||
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
|
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
|
||||||
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
|
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
|
||||||
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
|
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
|
||||||
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
|
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,13 +108,13 @@ fn replay_runs_advanced_sql_create_table_as_a_write() {
|
|||||||
|
|
||||||
// The SQL DDL line actually created the structural table…
|
// The SQL DDL line actually created the structural table…
|
||||||
let desc = rt()
|
let desc = rt()
|
||||||
.block_on(async { db.describe_table("Widget".to_string(), None).await })
|
.block_on(async { db.describe_table("Widget".to_string()).await })
|
||||||
.expect("describe");
|
.expect("describe");
|
||||||
let names: Vec<String> = desc.columns.iter().map(|c| c.name.clone()).collect();
|
let names: Vec<String> = desc.columns.iter().map(|c| c.name.clone()).collect();
|
||||||
assert_eq!(names, vec!["id".to_string(), "name".to_string()]);
|
assert_eq!(names, vec!["id".to_string(), "name".to_string()]);
|
||||||
// …and the following insert (serial id auto-filled) ran against it.
|
// …and the following insert (serial id auto-filled) ran against it.
|
||||||
let rows = rt()
|
let rows = rt()
|
||||||
.block_on(async { db.query_data("Widget".to_string(), None, None, None).await })
|
.block_on(async { db.query_data("Widget".to_string(), None, None).await })
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
@@ -139,7 +139,7 @@ fn replay_three_lines_dispatches_three_commands() {
|
|||||||
|
|
||||||
// The dispatched commands actually mutated state.
|
// The dispatched commands actually mutated state.
|
||||||
let data_result = rt()
|
let data_result = rt()
|
||||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
.block_on(async { db.query_data("T".to_string(), None, None).await })
|
||||||
.expect("query_data");
|
.expect("query_data");
|
||||||
assert_eq!(data_result.rows.len(), 1, "row inserted");
|
assert_eq!(data_result.rows.len(), 1, "row inserted");
|
||||||
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice"));
|
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice"));
|
||||||
@@ -174,7 +174,7 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
|
|||||||
assert_completed(&events, 3);
|
assert_completed(&events, 3);
|
||||||
|
|
||||||
let data_result = rt()
|
let data_result = rt()
|
||||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
.block_on(async { db.query_data("T".to_string(), None, None).await })
|
||||||
.expect("query_data");
|
.expect("query_data");
|
||||||
assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied");
|
assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied");
|
||||||
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
|
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
|
||||||
@@ -227,7 +227,7 @@ fn replay_skips_app_lifecycle_commands_silently() {
|
|||||||
other => panic!("expected ReplayCompleted, got {other:?}"),
|
other => panic!("expected ReplayCompleted, got {other:?}"),
|
||||||
}
|
}
|
||||||
let data_result = rt()
|
let data_result = rt()
|
||||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
.block_on(async { db.query_data("T".to_string(), None, None).await })
|
||||||
.expect("query_data");
|
.expect("query_data");
|
||||||
assert!(
|
assert!(
|
||||||
data_result.columns.iter().any(|c| c == "v"),
|
data_result.columns.iter().any(|c| c == "v"),
|
||||||
@@ -401,14 +401,14 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
|
|||||||
// but earlier commands stayed applied (table T exists with
|
// but earlier commands stayed applied (table T exists with
|
||||||
// the `name` column).
|
// the `name` column).
|
||||||
let desc = rt()
|
let desc = rt()
|
||||||
.block_on(async { db.describe_table("T".to_string(), None).await })
|
.block_on(async { db.describe_table("T".to_string()).await })
|
||||||
.expect("describe_table");
|
.expect("describe_table");
|
||||||
assert!(
|
assert!(
|
||||||
desc.columns.iter().any(|c| c.name == "name"),
|
desc.columns.iter().any(|c| c.name == "name"),
|
||||||
"earlier add column should have stayed applied"
|
"earlier add column should have stayed applied"
|
||||||
);
|
);
|
||||||
let data_result = rt()
|
let data_result = rt()
|
||||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
.block_on(async { db.query_data("T".to_string(), None, None).await })
|
||||||
.expect("query_data");
|
.expect("query_data");
|
||||||
assert!(
|
assert!(
|
||||||
data_result.rows.is_empty(),
|
data_result.rows.is_empty(),
|
||||||
@@ -467,7 +467,7 @@ fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
|
|||||||
// The earlier two lines stayed applied; the failing insert
|
// The earlier two lines stayed applied; the failing insert
|
||||||
// did not run — state is intact.
|
// did not run — state is intact.
|
||||||
let data_result = rt()
|
let data_result = rt()
|
||||||
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
|
.block_on(async { db.query_data("T".to_string(), None, None).await })
|
||||||
.expect("query_data");
|
.expect("query_data");
|
||||||
assert!(
|
assert!(
|
||||||
data_result.rows.is_empty(),
|
data_result.rows.is_empty(),
|
||||||
@@ -527,7 +527,7 @@ fn replay_skips_nested_replay_with_a_warning() {
|
|||||||
other => panic!("expected ReplayCompleted (nested replay skipped), got {other:?}"),
|
other => panic!("expected ReplayCompleted (nested replay skipped), got {other:?}"),
|
||||||
}
|
}
|
||||||
// The nested file's table was NOT created (the replay was skipped).
|
// The nested file's table was NOT created (the replay was skipped).
|
||||||
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None, None).await });
|
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None).await });
|
||||||
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
|
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ fn app_show_table_renders_relationships_as_compact_diagrams() {
|
|||||||
rt.block_on(seed_schema(&db));
|
rt.block_on(seed_schema(&db));
|
||||||
// Orders holds the FK to Customers — an outbound relationship.
|
// Orders holds the FK to Customers — an outbound relationship.
|
||||||
let desc = rt
|
let desc = rt
|
||||||
.block_on(db.describe_table("Orders".to_string(), None))
|
.block_on(db.describe_table("Orders".to_string()))
|
||||||
.expect("describe Orders");
|
.expect("describe Orders");
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
+17
-17
@@ -111,7 +111,7 @@ fn e2e_alter_drop_compound_primary_key_member_is_refused() {
|
|||||||
|
|
||||||
/// The current user-facing type of column `name` in table `T`.
|
/// The current user-facing type of column `name` in table `T`.
|
||||||
fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> {
|
fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> {
|
||||||
r.block_on(db.describe_table("T".to_string(), None))
|
r.block_on(db.describe_table("T".to_string()))
|
||||||
.expect("describe")
|
.expect("describe")
|
||||||
.columns
|
.columns
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -120,7 +120,7 @@ fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Ty
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn column_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
fn column_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
||||||
r.block_on(db.describe_table("T".to_string(), None))
|
r.block_on(db.describe_table("T".to_string()))
|
||||||
.expect("describe")
|
.expect("describe")
|
||||||
.columns
|
.columns
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -163,7 +163,7 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
|
|||||||
|
|
||||||
// The DEFAULT backfilled the pre-existing row to qty = 0.
|
// The DEFAULT backfilled the pre-existing row to qty = 0.
|
||||||
let rows = r
|
let rows = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
@@ -252,7 +252,7 @@ fn e2e_alter_column_type_clean_and_lossy_convert() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rows = r
|
let rows = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
@@ -292,7 +292,7 @@ fn e2e_alter_column_type_int_to_serial_is_allowed() {
|
|||||||
}
|
}
|
||||||
assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column");
|
assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column");
|
||||||
let rows = r
|
let rows = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved");
|
assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved");
|
||||||
@@ -635,7 +635,7 @@ fn e2e_drop_composite_unique_is_one_undo_step() {
|
|||||||
.expect("write");
|
.expect("write");
|
||||||
r.block_on(run_replay(&db, project.path(), "u.commands"));
|
r.block_on(run_replay(&db, project.path(), "u.commands"));
|
||||||
let has_unique = || {
|
let has_unique = || {
|
||||||
!r.block_on(db.describe_table("T".to_string(), None))
|
!r.block_on(db.describe_table("T".to_string()))
|
||||||
.expect("describe")
|
.expect("describe")
|
||||||
.unique_constraints
|
.unique_constraints
|
||||||
.is_empty()
|
.is_empty()
|
||||||
@@ -878,7 +878,7 @@ fn e2e_describe_shows_table_level_constraints() {
|
|||||||
"events: {events:?}"
|
"events: {events:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let desc = r.block_on(db.describe_table("T".to_string(), None)).expect("describe");
|
let desc = r.block_on(db.describe_table("T".to_string())).expect("describe");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
desc.unique_constraints,
|
desc.unique_constraints,
|
||||||
vec![vec!["a".to_string(), "b".to_string()]],
|
vec![vec!["a".to_string(), "b".to_string()]],
|
||||||
@@ -976,7 +976,7 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() {
|
|||||||
assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed");
|
assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed");
|
||||||
|
|
||||||
let rows = r
|
let rows = r
|
||||||
.block_on(db.query_data("Purchases".to_string(), None, None, None))
|
.block_on(db.query_data("Purchases".to_string(), None, None))
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(rows.len(), 2);
|
assert_eq!(rows.len(), 2);
|
||||||
@@ -991,7 +991,7 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() {
|
|||||||
"Purchases round-tripped through a fresh rebuild: {tables:?}"
|
"Purchases round-tripped through a fresh rebuild: {tables:?}"
|
||||||
);
|
);
|
||||||
let rows = r
|
let rows = r
|
||||||
.block_on(db.query_data("Purchases".to_string(), None, None, None))
|
.block_on(db.query_data("Purchases".to_string(), None, None))
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(rows.len(), 2);
|
assert_eq!(rows.len(), 2);
|
||||||
@@ -1077,7 +1077,7 @@ fn e2e_rename_fk_parent_updates_metadata_and_still_enforces() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The child's outbound relationship now points at the new parent name.
|
// The child's outbound relationship now points at the new parent name.
|
||||||
let c = r.block_on(db.describe_table("C".to_string(), None)).expect("describe C");
|
let c = r.block_on(db.describe_table("C".to_string())).expect("describe C");
|
||||||
assert_eq!(c.outbound_relationships.len(), 1);
|
assert_eq!(c.outbound_relationships.len(), 1);
|
||||||
assert_eq!(c.outbound_relationships[0].other_table, "Parent");
|
assert_eq!(c.outbound_relationships[0].other_table, "Parent");
|
||||||
|
|
||||||
@@ -1129,7 +1129,7 @@ fn e2e_rename_fk_child_updates_metadata_and_still_enforces() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The parent's inbound relationship now names the renamed child.
|
// The parent's inbound relationship now names the renamed child.
|
||||||
let p = r.block_on(db.describe_table("P".to_string(), None)).expect("describe P");
|
let p = r.block_on(db.describe_table("P".to_string())).expect("describe P");
|
||||||
assert_eq!(p.inbound_relationships.len(), 1);
|
assert_eq!(p.inbound_relationships.len(), 1);
|
||||||
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
assert_eq!(p.inbound_relationships[0].other_table, "Child");
|
||||||
|
|
||||||
@@ -1168,7 +1168,7 @@ fn e2e_rename_self_referential_table_updates_both_ends() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Both ends of the self-reference now name `Tree`.
|
// Both ends of the self-reference now name `Tree`.
|
||||||
let t = r.block_on(db.describe_table("Tree".to_string(), None)).expect("describe Tree");
|
let t = r.block_on(db.describe_table("Tree".to_string())).expect("describe Tree");
|
||||||
assert_eq!(t.outbound_relationships[0].other_table, "Tree");
|
assert_eq!(t.outbound_relationships[0].other_table, "Tree");
|
||||||
assert_eq!(t.inbound_relationships[0].other_table, "Tree");
|
assert_eq!(t.inbound_relationships[0].other_table, "Tree");
|
||||||
|
|
||||||
@@ -1216,7 +1216,7 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
|
|||||||
"events: {events:?}"
|
"events: {events:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users");
|
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users");
|
||||||
assert_eq!(u.indexes.len(), 1, "the index followed the rename");
|
assert_eq!(u.indexes.len(), 1, "the index followed the rename");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
u.indexes[0].name, "T_email_idx",
|
u.indexes[0].name, "T_email_idx",
|
||||||
@@ -1226,7 +1226,7 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
|
|||||||
|
|
||||||
// Survives a fresh rebuild (recreated from IndexSchema on table Users).
|
// Survives a fresh rebuild (recreated from IndexSchema on table Users).
|
||||||
let db = fresh_rebuild(db, &project, &r);
|
let db = fresh_rebuild(db, &project, &r);
|
||||||
let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users");
|
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users");
|
||||||
assert_eq!(u.indexes.len(), 1);
|
assert_eq!(u.indexes.len(), 1);
|
||||||
assert_eq!(u.indexes[0].name, "T_email_idx");
|
assert_eq!(u.indexes[0].name, "T_email_idx");
|
||||||
}
|
}
|
||||||
@@ -1255,7 +1255,7 @@ fn e2e_rename_table_is_one_undo_step() {
|
|||||||
"undo restored the old table name: {tables:?}"
|
"undo restored the old table name: {tables:?}"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
r.block_on(db.query_data("Orders".to_string(), None, None, None)).expect("query").rows.len(),
|
r.block_on(db.query_data("Orders".to_string(), None, None)).expect("query").rows.len(),
|
||||||
1,
|
1,
|
||||||
"the row is back under the old name"
|
"the row is back under the old name"
|
||||||
);
|
);
|
||||||
@@ -1427,7 +1427,7 @@ fn e2e_alter_column_set_default_applies() {
|
|||||||
))
|
))
|
||||||
.expect("insert omitting qty");
|
.expect("insert omitting qty");
|
||||||
let rows = r
|
let rows = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1473,7 +1473,7 @@ fn e2e_alter_column_drop_default_removes_it() {
|
|||||||
))
|
))
|
||||||
.expect("insert omitting qty");
|
.expect("insert omitting qty");
|
||||||
let rows = r
|
let rows = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn index(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<(Vec<String>, bool)> {
|
fn index(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<(Vec<String>, bool)> {
|
||||||
r.block_on(db.describe_table("T".to_string(), None))
|
r.block_on(db.describe_table("T".to_string()))
|
||||||
.expect("describe")
|
.expect("describe")
|
||||||
.indexes
|
.indexes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ fn created_table_appears_with_playground_types() {
|
|||||||
assert!(tables.contains(&"Widget".to_string()));
|
assert!(tables.contains(&"Widget".to_string()));
|
||||||
|
|
||||||
let desc = r
|
let desc = r
|
||||||
.block_on(db.describe_table("Widget".to_string(), None))
|
.block_on(db.describe_table("Widget".to_string()))
|
||||||
.expect("describe");
|
.expect("describe");
|
||||||
let types: Vec<(String, Option<Type>)> = desc
|
let types: Vec<(String, Option<Type>)> = desc
|
||||||
.columns
|
.columns
|
||||||
@@ -98,7 +98,7 @@ fn integer_primary_key_is_plain_int() {
|
|||||||
))
|
))
|
||||||
.expect("create");
|
.expect("create");
|
||||||
let desc = r
|
let desc = r
|
||||||
.block_on(db.describe_table("T".to_string(), None))
|
.block_on(db.describe_table("T".to_string()))
|
||||||
.expect("describe");
|
.expect("describe");
|
||||||
assert_eq!(desc.columns[0].user_type, Some(Type::Int));
|
assert_eq!(desc.columns[0].user_type, Some(Type::Int));
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ fn serial_pk_autoincrements_in_multi_column_table() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let data = r
|
let data = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query");
|
.expect("query");
|
||||||
let id_idx = data
|
let id_idx = data
|
||||||
.columns
|
.columns
|
||||||
@@ -220,7 +220,7 @@ fn table_without_primary_key_is_allowed() {
|
|||||||
))
|
))
|
||||||
.expect("insert into PK-less table");
|
.expect("insert into PK-less table");
|
||||||
let data = r
|
let data = r
|
||||||
.block_on(db.query_data("Notes".to_string(), None, None, None))
|
.block_on(db.query_data("Notes".to_string(), None, None))
|
||||||
.expect("query");
|
.expect("query");
|
||||||
assert_eq!(data.rows.len(), 1);
|
assert_eq!(data.rows.len(), 1);
|
||||||
}
|
}
|
||||||
@@ -299,7 +299,7 @@ fn default_is_applied_when_column_omitted() {
|
|||||||
))
|
))
|
||||||
.expect("insert");
|
.expect("insert");
|
||||||
let data = r
|
let data = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query");
|
.expect("query");
|
||||||
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
|
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
|
||||||
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied");
|
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied");
|
||||||
@@ -381,7 +381,7 @@ fn check_default_and_composite_unique_survive_rebuild() {
|
|||||||
// A valid row inserts; DEFAULT n=7 survived.
|
// A valid row inserts; DEFAULT n=7 survived.
|
||||||
r.block_on(ins("1", "1", "5")).expect("valid row");
|
r.block_on(ins("1", "1", "5")).expect("valid row");
|
||||||
let data = r
|
let data = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query");
|
.expect("query");
|
||||||
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
|
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
|
||||||
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild");
|
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild");
|
||||||
@@ -679,7 +679,7 @@ fn sql_create_table_is_one_undo_step() {
|
|||||||
/// Sorted `id` column values of table `T`.
|
/// Sorted `id` column values of table `T`.
|
||||||
fn ids(db: &Database, r: &tokio::runtime::Runtime) -> Vec<Option<String>> {
|
fn ids(db: &Database, r: &tokio::runtime::Runtime) -> Vec<Option<String>> {
|
||||||
let d = r
|
let d = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query");
|
.expect("query");
|
||||||
let idx = d.columns.iter().position(|c| c == "id").expect("id column");
|
let idx = d.columns.iter().position(|c| c == "id").expect("id column");
|
||||||
let mut v: Vec<Option<String>> = d.rows.iter().map(|row| row[idx].clone()).collect();
|
let mut v: Vec<Option<String>> = d.rows.iter().map(|row| row[idx].clone()).collect();
|
||||||
@@ -801,7 +801,7 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() {
|
|||||||
|
|
||||||
// The table is intact: both columns survive (rollback) ...
|
// The table is intact: both columns survive (rollback) ...
|
||||||
let desc = r
|
let desc = r
|
||||||
.block_on(db.describe_table("T".to_string(), None))
|
.block_on(db.describe_table("T".to_string()))
|
||||||
.expect("describe still works");
|
.expect("describe still works");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
desc.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
|
desc.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
|
||||||
@@ -925,14 +925,14 @@ fn foreign_key_creates_named_relationship_visible_in_describe() {
|
|||||||
.expect("create child with FK");
|
.expect("create child with FK");
|
||||||
|
|
||||||
// The child has an outbound relationship; the parent an inbound one.
|
// The child has an outbound relationship; the parent an inbound one.
|
||||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe child");
|
let child = r.block_on(db.describe_table("child".to_string())).expect("describe child");
|
||||||
assert_eq!(child.outbound_relationships.len(), 1, "child references parent");
|
assert_eq!(child.outbound_relationships.len(), 1, "child references parent");
|
||||||
let rel = &child.outbound_relationships[0];
|
let rel = &child.outbound_relationships[0];
|
||||||
assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013");
|
assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013");
|
||||||
assert_eq!(rel.other_table, "parent");
|
assert_eq!(rel.other_table, "parent");
|
||||||
assert_eq!(rel.local_columns, vec!["pid".to_string()]);
|
assert_eq!(rel.local_columns, vec!["pid".to_string()]);
|
||||||
|
|
||||||
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
|
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
|
||||||
assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child");
|
assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -954,7 +954,7 @@ fn explicit_constraint_name_is_used() {
|
|||||||
Some("create table child (id serial primary key, pid int, constraint child_to_parent foreign key (pid) references parent(id))".to_string()),
|
Some("create table child (id serial primary key, pid int, constraint child_to_parent foreign key (pid) references parent(id))".to_string()),
|
||||||
))
|
))
|
||||||
.expect("create child with named FK");
|
.expect("create child with named FK");
|
||||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
|
||||||
assert_eq!(child.outbound_relationships[0].name, "child_to_parent");
|
assert_eq!(child.outbound_relationships[0].name, "child_to_parent");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,7 +974,7 @@ fn bare_references_resolves_to_parent_single_column_pk() {
|
|||||||
Some("create table child (id serial primary key, pid int references parent)".to_string()),
|
Some("create table child (id serial primary key, pid int references parent)".to_string()),
|
||||||
))
|
))
|
||||||
.expect("create child with bare REFERENCES");
|
.expect("create child with bare REFERENCES");
|
||||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
|
||||||
assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK");
|
assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,7 +1108,7 @@ fn create_table_with_fk_is_one_undo_step() {
|
|||||||
// parent (now un-referenced) can be described without a dangling rel.
|
// parent (now un-referenced) can be described without a dangling rel.
|
||||||
r.block_on(db.undo()).expect("undo").expect("a step was undone");
|
r.block_on(db.undo()).expect("undo").expect("a step was undone");
|
||||||
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string()));
|
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string()));
|
||||||
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
|
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
|
||||||
assert!(parent.inbound_relationships.is_empty(), "the relationship was undone with the table");
|
assert!(parent.inbound_relationships.is_empty(), "the relationship was undone with the table");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1152,7 +1152,7 @@ fn foreign_key_on_delete_cascade_takes_effect() {
|
|||||||
))
|
))
|
||||||
.expect("delete parent");
|
.expect("delete parent");
|
||||||
let child_rows = r
|
let child_rows = r
|
||||||
.block_on(db.query_data("child".to_string(), None, None, None))
|
.block_on(db.query_data("child".to_string(), None, None))
|
||||||
.expect("query child");
|
.expect("query child");
|
||||||
assert!(child_rows.rows.is_empty(), "ON DELETE CASCADE removed the child row");
|
assert!(child_rows.rows.is_empty(), "ON DELETE CASCADE removed the child row");
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1232,7 @@ fn fk_survives_a_rebuild_triggering_column_add() {
|
|||||||
.expect("add column via rebuild");
|
.expect("add column via rebuild");
|
||||||
|
|
||||||
// The relationship still exists after the rebuild.
|
// The relationship still exists after the rebuild.
|
||||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
|
||||||
assert_eq!(child.outbound_relationships.len(), 1, "FK survived the column-add rebuild");
|
assert_eq!(child.outbound_relationships.len(), 1, "FK survived the column-add rebuild");
|
||||||
// And the engine still enforces it (now and after a fresh rebuild).
|
// And the engine still enforces it (now and after a fresh rebuild).
|
||||||
insert_parent_row(&db, &r);
|
insert_parent_row(&db, &r);
|
||||||
@@ -1275,7 +1275,7 @@ fn fk_referential_actions_survive_rebuild() {
|
|||||||
))
|
))
|
||||||
.expect("create");
|
.expect("create");
|
||||||
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild");
|
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild");
|
||||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
|
||||||
let rel = &child.outbound_relationships[0];
|
let rel = &child.outbound_relationships[0];
|
||||||
assert_eq!(rel.on_delete, ReferentialAction::Cascade, "ON DELETE survived rebuild");
|
assert_eq!(rel.on_delete, ReferentialAction::Cascade, "ON DELETE survived rebuild");
|
||||||
assert_eq!(rel.on_update, ReferentialAction::SetNull, "ON UPDATE survived rebuild");
|
assert_eq!(rel.on_update, ReferentialAction::SetNull, "ON UPDATE survived rebuild");
|
||||||
@@ -1299,7 +1299,7 @@ fn dropping_the_child_clears_the_fk_relationship() {
|
|||||||
.expect("create");
|
.expect("create");
|
||||||
r.block_on(db.drop_table("child".to_string(), Some("drop table child".to_string())))
|
r.block_on(db.drop_table("child".to_string(), Some("drop table child".to_string())))
|
||||||
.expect("drop child");
|
.expect("drop child");
|
||||||
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
|
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
|
||||||
assert!(parent.inbound_relationships.is_empty(), "dropping the child cleared the relationship");
|
assert!(parent.inbound_relationships.is_empty(), "dropping the child cleared the relationship");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,7 +1341,7 @@ fn bare_self_reference_resolves_to_own_pk() {
|
|||||||
Some("create table emp (id int primary key, mgr int references emp)".to_string()),
|
Some("create table emp (id int primary key, mgr int references emp)".to_string()),
|
||||||
))
|
))
|
||||||
.expect("create self-referential emp with a bare reference");
|
.expect("create self-referential emp with a bare reference");
|
||||||
let emp = r.block_on(db.describe_table("emp".to_string(), None)).expect("describe");
|
let emp = r.block_on(db.describe_table("emp".to_string())).expect("describe");
|
||||||
assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK");
|
assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK");
|
||||||
// Enforced: a non-existent manager is rejected.
|
// Enforced: a non-existent manager is rejected.
|
||||||
r.block_on(db.insert(
|
r.block_on(db.insert(
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ fn delete_without_where_runs_across_all_rows() {
|
|||||||
let csv = read_csv(&project, "t").unwrap_or_default();
|
let csv = read_csv(&project, "t").unwrap_or_default();
|
||||||
assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}");
|
assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}");
|
||||||
let remaining = rt
|
let remaining = rt
|
||||||
.block_on(db.query_data("t".to_string(), None, None, None))
|
.block_on(db.query_data("t".to_string(), None, None))
|
||||||
.expect("query t");
|
.expect("query t");
|
||||||
assert!(remaining.rows.is_empty(), "table empty after unfiltered delete");
|
assert!(remaining.rows.is_empty(), "table empty after unfiltered delete");
|
||||||
}
|
}
|
||||||
@@ -302,8 +302,8 @@ fn cascade_to_two_children_reports_both() {
|
|||||||
assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded");
|
assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded");
|
||||||
assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded");
|
assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded");
|
||||||
// Both child CSVs re-persisted to the post-cascade (empty) state.
|
// Both child CSVs re-persisted to the post-cascade (empty) state.
|
||||||
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None, None)).unwrap();
|
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None)).unwrap();
|
||||||
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None, None)).unwrap();
|
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None)).unwrap();
|
||||||
assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied");
|
assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied");
|
||||||
let _ = &project;
|
let _ = &project;
|
||||||
}
|
}
|
||||||
@@ -361,7 +361,7 @@ fn delete_violating_fk_fails_and_persists_nothing() {
|
|||||||
let result = run_delete(&db, &rt, input);
|
let result = run_delete(&db, &rt, input);
|
||||||
assert!(result.is_err(), "delete of a referenced parent must be rejected");
|
assert!(result.is_err(), "delete of a referenced parent must be rejected");
|
||||||
// Rolled back: Alice survives.
|
// Rolled back: Alice survives.
|
||||||
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None, None)).unwrap();
|
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None)).unwrap();
|
||||||
assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete");
|
assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete");
|
||||||
// No history line for the failed statement (written only on success).
|
// No history line for the failed statement (written only on success).
|
||||||
let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default();
|
let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default();
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec<Vec<Option<String>>> {
|
fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec<Vec<Option<String>>> {
|
||||||
rt.block_on(db.query_data(table.to_string(), None, None, None))
|
rt.block_on(db.query_data(table.to_string(), None, None))
|
||||||
.unwrap_or_else(|e| panic!("query_data {table}: {e:?}"))
|
.unwrap_or_else(|e| panic!("query_data {table}: {e:?}"))
|
||||||
.rows
|
.rows
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn index_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
fn index_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
||||||
r.block_on(db.describe_table("T".to_string(), None))
|
r.block_on(db.describe_table("T".to_string()))
|
||||||
.expect("describe")
|
.expect("describe")
|
||||||
.indexes
|
.indexes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ fn drop_table_is_one_undo_step_and_restores_data() {
|
|||||||
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
|
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
|
||||||
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
|
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
|
||||||
let data = r
|
let data = r
|
||||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
.block_on(db.query_data("T".to_string(), None, None))
|
||||||
.expect("query");
|
.expect("query");
|
||||||
assert_eq!(data.rows.len(), 1, "the dropped row was restored by undo");
|
assert_eq!(data.rows.len(), 1, "the dropped row was restored by undo");
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-16
@@ -215,7 +215,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
|
|||||||
|
|
||||||
// The reported case: the aggregate no longer leaks float noise.
|
// The reported case: the aggregate no longer leaks float noise.
|
||||||
let agg = rt
|
let agg = rt
|
||||||
.block_on(db.run_select("select sum(price * qty) from Products".to_string(), None))
|
.block_on(db.run_select("select sum(price * qty) from Products".to_string()))
|
||||||
.expect("aggregate select");
|
.expect("aggregate select");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
agg.rows[0][0].as_deref(),
|
agg.rows[0][0].as_deref(),
|
||||||
@@ -226,7 +226,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
|
|||||||
// Raw decimal column is still exact — TEXT storage preserves
|
// Raw decimal column is still exact — TEXT storage preserves
|
||||||
// the input string verbatim, including the trailing zero.
|
// the input string verbatim, including the trailing zero.
|
||||||
let raw = rt
|
let raw = rt
|
||||||
.block_on(db.run_select("select price from Products".to_string(), None))
|
.block_on(db.run_select("select price from Products".to_string()))
|
||||||
.expect("raw decimal select");
|
.expect("raw decimal select");
|
||||||
let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect();
|
let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -240,10 +240,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
|
|||||||
fn database_run_select_constant_returns_a_single_row() {
|
fn database_run_select_constant_returns_a_single_row() {
|
||||||
let (_p, db, _dir) = open_project_db();
|
let (_p, db, _dir) = open_project_db();
|
||||||
let data = rt()
|
let data = rt()
|
||||||
.block_on(db.run_select(
|
.block_on(db.run_select("select 1".to_string()))
|
||||||
"select 1".to_string(),
|
|
||||||
Some("select 1".to_string()),
|
|
||||||
))
|
|
||||||
.expect("`select 1` runs clean");
|
.expect("`select 1` runs clean");
|
||||||
assert_eq!(data.rows.len(), 1, "one result row");
|
assert_eq!(data.rows.len(), 1, "one result row");
|
||||||
assert_eq!(data.rows[0].len(), 1, "one column");
|
assert_eq!(data.rows[0].len(), 1, "one column");
|
||||||
@@ -288,7 +285,7 @@ fn database_run_select_from_user_table_returns_inserted_rows() {
|
|||||||
.expect("insert row");
|
.expect("insert row");
|
||||||
});
|
});
|
||||||
let data = rt
|
let data = rt
|
||||||
.block_on(db.run_select("select Name from T".to_string(), None))
|
.block_on(db.run_select("select Name from T".to_string()))
|
||||||
.expect("SELECT runs");
|
.expect("SELECT runs");
|
||||||
assert_eq!(data.rows.len(), 1);
|
assert_eq!(data.rows.len(), 1);
|
||||||
assert_eq!(data.rows[0][0].as_deref(), Some("Ada"));
|
assert_eq!(data.rows[0][0].as_deref(), Some("Ada"));
|
||||||
@@ -336,7 +333,7 @@ fn database_run_select_recovers_bool_column_type() {
|
|||||||
.expect("insert row");
|
.expect("insert row");
|
||||||
});
|
});
|
||||||
let data = rt
|
let data = rt
|
||||||
.block_on(db.run_select("select Active from Products".to_string(), None))
|
.block_on(db.run_select("select Active from Products".to_string()))
|
||||||
.expect("SELECT runs");
|
.expect("SELECT runs");
|
||||||
assert_eq!(data.rows.len(), 2);
|
assert_eq!(data.rows.len(), 2);
|
||||||
assert_eq!(data.column_types, vec![Some(Type::Bool)]);
|
assert_eq!(data.column_types, vec![Some(Type::Bool)]);
|
||||||
@@ -374,7 +371,7 @@ fn database_run_select_recovers_text_type_through_alias() {
|
|||||||
// playground type is recovered.
|
// playground type is recovered.
|
||||||
let data = rt
|
let data = rt
|
||||||
.block_on(
|
.block_on(
|
||||||
db.run_select("select Name as n from Users".to_string(), None),
|
db.run_select("select Name as n from Users".to_string()),
|
||||||
)
|
)
|
||||||
.expect("SELECT runs");
|
.expect("SELECT runs");
|
||||||
assert_eq!(data.columns, vec!["n".to_string()]);
|
assert_eq!(data.columns, vec!["n".to_string()]);
|
||||||
@@ -402,7 +399,7 @@ fn database_run_select_computed_expression_stays_typeless() {
|
|||||||
.expect("insert");
|
.expect("insert");
|
||||||
});
|
});
|
||||||
let data = rt
|
let data = rt
|
||||||
.block_on(db.run_select("select Score + 1 from T".to_string(), None))
|
.block_on(db.run_select("select Score + 1 from T".to_string()))
|
||||||
.expect("SELECT runs");
|
.expect("SELECT runs");
|
||||||
assert_eq!(data.column_types, vec![None]);
|
assert_eq!(data.column_types, vec![None]);
|
||||||
}
|
}
|
||||||
@@ -439,7 +436,6 @@ fn engine_aggregate_in_where_routes_through_catalog() {
|
|||||||
let err = rt
|
let err = rt
|
||||||
.block_on(db.run_select(
|
.block_on(db.run_select(
|
||||||
"select id from T where count(score) > 0".to_string(),
|
"select id from T where count(score) > 0".to_string(),
|
||||||
None,
|
|
||||||
))
|
))
|
||||||
.expect_err("engine should reject aggregate in WHERE");
|
.expect_err("engine should reject aggregate in WHERE");
|
||||||
let DbError::Sqlite { .. } = &err else {
|
let DbError::Sqlite { .. } = &err else {
|
||||||
@@ -512,7 +508,6 @@ fn engine_group_by_missing_routes_through_catalog() {
|
|||||||
let _ = rt
|
let _ = rt
|
||||||
.block_on(db.run_select(
|
.block_on(db.run_select(
|
||||||
"select category, count(*) from T group by category".to_string(),
|
"select category, count(*) from T group by category".to_string(),
|
||||||
None,
|
|
||||||
))
|
))
|
||||||
.expect("benign GROUP BY query runs");
|
.expect("benign GROUP BY query runs");
|
||||||
// Direct unit test on the matcher: ensure a message that
|
// Direct unit test on the matcher: ensure a message that
|
||||||
@@ -574,7 +569,6 @@ fn engine_scalar_subquery_too_many_rows_routes_through_catalog() {
|
|||||||
let _ = rt
|
let _ = rt
|
||||||
.block_on(db.run_select(
|
.block_on(db.run_select(
|
||||||
"select (select v from T) from T".to_string(),
|
"select (select v from T) from T".to_string(),
|
||||||
None,
|
|
||||||
))
|
))
|
||||||
.expect("benign scalar subquery query runs");
|
.expect("benign scalar subquery query runs");
|
||||||
let synthetic = DbError::Sqlite {
|
let synthetic = DbError::Sqlite {
|
||||||
@@ -624,13 +618,13 @@ fn database_run_select_type_recovery_works_on_empty_table() {
|
|||||||
});
|
});
|
||||||
// No INSERT — the table is empty.
|
// No INSERT — the table is empty.
|
||||||
let data_text = rt
|
let data_text = rt
|
||||||
.block_on(db.run_select("select col_text from Empty".to_string(), None))
|
.block_on(db.run_select("select col_text from Empty".to_string()))
|
||||||
.expect("SELECT runs even on empty table");
|
.expect("SELECT runs even on empty table");
|
||||||
assert!(data_text.rows.is_empty());
|
assert!(data_text.rows.is_empty());
|
||||||
assert_eq!(data_text.column_types, vec![Some(Type::Text)]);
|
assert_eq!(data_text.column_types, vec![Some(Type::Text)]);
|
||||||
|
|
||||||
let data_blob = rt
|
let data_blob = rt
|
||||||
.block_on(db.run_select("select col_blob from Empty".to_string(), None))
|
.block_on(db.run_select("select col_blob from Empty".to_string()))
|
||||||
.expect("SELECT runs even on empty table");
|
.expect("SELECT runs even on empty table");
|
||||||
assert!(data_blob.rows.is_empty());
|
assert!(data_blob.rows.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -723,7 +717,7 @@ fn database_run_select_recovers_all_ten_playground_types() {
|
|||||||
for (col, expected_type) in cases {
|
for (col, expected_type) in cases {
|
||||||
let sql = format!("select {col} from AllTypes");
|
let sql = format!("select {col} from AllTypes");
|
||||||
let data = rt
|
let data = rt
|
||||||
.block_on(db.run_select(sql.clone(), None))
|
.block_on(db.run_select(sql.clone()))
|
||||||
.expect("SELECT runs");
|
.expect("SELECT runs");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data.column_types,
|
data.column_types,
|
||||||
|
|||||||
@@ -501,7 +501,7 @@ fn update_all_rows_flag_in_advanced_updates_every_row() {
|
|||||||
"the --all-rows update replays through the DSL fall-back; events: {events:?}"
|
"the --all-rows update replays through the DSL fall-back; events: {events:?}"
|
||||||
);
|
);
|
||||||
let rows = rt
|
let rows = rt
|
||||||
.block_on(db.query_data("t".to_string(), None, None, None))
|
.block_on(db.query_data("t".to_string(), None, None))
|
||||||
.expect("query")
|
.expect("query")
|
||||||
.rows;
|
.rows;
|
||||||
assert_eq!(rows.len(), 2, "both rows present");
|
assert_eq!(rows.len(), 2, "both rows present");
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ async fn insert_named(db: &Database, name: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn row_count(db: &Database) -> usize {
|
async fn row_count(db: &Database) -> usize {
|
||||||
db.query_data("Customers".to_string(), None, None, None)
|
db.query_data("Customers".to_string(), None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.rows
|
.rows
|
||||||
@@ -306,7 +306,7 @@ async fn sql_delete(db: &Database, input: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn count_t(db: &Database) -> usize {
|
async fn count_t(db: &Database) -> usize {
|
||||||
db.query_data("T".to_string(), None, None, None)
|
db.query_data("T".to_string(), None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.rows
|
.rows
|
||||||
@@ -378,7 +378,7 @@ fn undo_restores_db_and_csv_consistently() {
|
|||||||
// Both the database read model and the on-disk CSV are
|
// Both the database read model and the on-disk CSV are
|
||||||
// restored — the (db, csv) pair stays consistent.
|
// restored — the (db, csv) pair stays consistent.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
db.query_data("T".to_string(), None, None, None)
|
db.query_data("T".to_string(), None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.rows
|
.rows
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
|||||||
App(app) => match app {
|
App(app) => match app {
|
||||||
AppCommand::Quit => "App(Quit)".into(),
|
AppCommand::Quit => "App(Quit)".into(),
|
||||||
AppCommand::Help { .. } => "App(Help)".into(),
|
AppCommand::Help { .. } => "App(Help)".into(),
|
||||||
|
AppCommand::Hint => "App(Hint)".into(),
|
||||||
AppCommand::Rebuild => "App(Rebuild)".into(),
|
AppCommand::Rebuild => "App(Rebuild)".into(),
|
||||||
AppCommand::Save => "App(Save)".into(),
|
AppCommand::Save => "App(Save)".into(),
|
||||||
AppCommand::SaveAs => "App(SaveAs)".into(),
|
AppCommand::SaveAs => "App(SaveAs)".into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user