Compare commits

7 Commits
Author SHA1 Message Date
ahmed mohammedandClaude Opus 4.7 8b42a0931c docs: karpathy-pattern wiki built from the spec, plan, design contract and decisions ledger
kb/ is its own wiki root: 34 pages across 7 categories, index.md catalog, log.md, AGENTS.md schema layer, and raw/ holding copies of README, the spec, the 13-task plan, the design master and the SDD ledger.

Every page cross-references 3 to 6 others and all 107 wikilinks resolve except two [["list"]] artefacts inside JSON snippets in the copied plan.

Kept separate from the repository root on purpose: its generated graph lands in kb/.ua/ instead of overwriting the code graph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 12:14:05 +03:00
ahmed mohammedandClaude Opus 4.7 735a57bfd6 chore: untrack QA database, the local production plan, and a stray image
- .qa-data/qai.db{,-shm,-wal} was committed by an add -A during the cookie bug hunt; it holds throwaway accounts and sealed key blobs.
- plan-cxpaket.sqlplan (2.7 MB, real schema names) was committed one commit before the /plan-*.sqlplan ignore rule existed.
- an unrelated upscaled PNG from the working tree went in with the same add -A.
All four stay on disk and out of git; .ua/ is ignored too so generated graph data never sneaks in the same way.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 11:48:47 +03:00
ahmed mohammedandClaude Opus 4.7 6248540857 fix: candidates carry runnable T-SQL; placeholder sql is dropped, not rendered
Measured on a 2.7 MB production plan (plan-cxpaket.sqlplan, kept out of git):
- rewrite_sql becomes option_type + sql_to_run. The old name told the model 'query rewrite only', so an index option carried no CREATE INDEX anywhere in the pipeline and the report had zero SqlBlocks. It now requires paste-ready T-SQL per option, and the report prompt makes a SqlBlock per non-null sql_to_run a hard requirement.
- verify_semantics keys on option_type === 'rewrite' rather than 'has sql', so index and statistics options are not flagged just for carrying DDL.
- dropPlaceholderSql: 'SELECT ... FROM ... OPTION (MAXDOP 1)' is not runnable and was rendered as if it were. Ellipsis SQL is now nulled, and digestForModel reports sql_truncated so the model is told not to offer a rewrite for a statement it never received.
- max_tokens 2048 -> 4096 for both calls: the larger JSON outgrew the old ceiling and the user waited 38s for 'malformed JSON'. A truncated response now says how many characters it got, and extra params still override the ceiling.
- Real plan, live: 52s, 17 cards, 3 SqlBlocks with real table names and no placeholders, 3 ScoreBars, headline 'Query Plan Timeout on Parallel Join'. 43 unit, 23 e2e.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 11:30:27 +03:00
ahmed mohammedandClaude Opus 4.7 3fa436103a fix: digest real production plans (nesting depth, Object table names, character refs)
Measured against a 2.7 MB production plan that failed to import:
- maxNestedTags: real spaghettified plans nest 2729 tags deep and fast-xml-parser caps at 128, which surfaced as 'Maximum nested tags exceeded' and blamed the export. Raised to 20000; the 12 MB input cap bounds the worst case.
- table names live on <Object>, not on <IndexScan>/<TableScan>. The digest reported 0 tables for a plan with 51 objects; the analyst model never saw a table name. Now 37 distinct tables.
- SSMS writes StatementText with numeric character references (&#xD;&#xA; on every line, 90 in this plan). Attribute/tag processors decode them, so the model gets SQL instead of entity noise.
- 3 new digest tests, each from one of the three measurements above. 39 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 11:16:18 +03:00
ahmed mohammedandClaude Opus 4.7 4b5c19b5d2 fix: decode .sqlplan files the way SSMS writes them; name the parse failure
- lib/plan-file.ts: BOM-aware decode for UTF-8, UTF-16LE/BE (with and without BOM) and UTF-32, with a NUL-density fallback for BOM-less UTF-16. The browser had a two-byte FF FE sniff only, so a UTF-16BE or BOM-less plan arrived as NUL-filled junk and the digest blamed the export.
- digestPlan now quotes fast-xml-parser's first line instead of a fixed sentence, so a truncated file, a stray entity and a wrong file stay distinguishable.
- plan-file.ts holds the decode so the XML parser stays out of the client bundle.
- tests: 6 encodings round-trip through decodePlan into a digest; a truncated plan's message is asserted to carry a reason; e2e drops a real UTF-16LE file. 36 unit, 23 e2e.
- history.spec picked the rail's first row, which is now one of the new failing-input rows; it selects the newest finished run instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 11:08:55 +03:00
ahmed mohammedandClaude Opus 4.7 3d1866367b docs: ledger for the secure-cookie sign-out and the missing console gate
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 10:46:43 +03:00
ahmed mohammedandClaude Opus 4.7 2ebfcfb7a6 fix: session cookie over plain http, console onboarding gate, webkit qa coverage
- the session cookie was Secure whenever NODE_ENV=production, so a production build on http://localhost got no cookie back in Safari: every page after signup looked signed-out. It is now Secure only when X-Forwarded-Proto says https, or when COOKIE_SECURE=1.
- /app and /app/[id] now require an analyst and a Jev key, the same rule /api/analyze enforces with a 412. Closing the guide mid-way used to leave a console that could not run anything.
- Playwright runs the production server (pree2e builds first) and gains a webkit project for session survival; chromium-only QA could never see a Secure-cookie rejection.
- tests-e2e/session.spec.ts pins both; .env.example gains COOKIE_SECURE and stops shipping a blank APP_SECRET.
- e2e: 21 passed (setup, chromium, webkit, mobile).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 10:45:12 +03:00
61 changed files with 3902 additions and 41 deletions
+10 -2
View File
@@ -1,2 +1,10 @@
APP_SECRET= # At least 16 characters. Encrypts the model keys you paste in /settings (AES-256-GCM).
DATA_DIR=/data # Changing it makes already-stored keys unreadable — you will have to paste them again.
APP_SECRET=replace-with-32-random-characters
# Where qai.db lives. Default ./data; the Docker image sets /data.
DATA_DIR=./data
# Only for a direct https deploy with no proxy setting X-Forwarded-Proto.
# Leave unset on plain http: Safari will not store a Secure cookie there.
# COOKIE_SECURE=1
+7
View File
@@ -13,6 +13,7 @@ next-env.d.ts
# runtime data (sqlite) # runtime data (sqlite)
/data /data
/.playwright-data /.playwright-data
.qa-data/
# e2e artifacts # e2e artifacts
/.auth /.auth
@@ -22,3 +23,9 @@ next-env.d.ts
# e2e fixture input is committed; scratch output is not # e2e fixture input is committed; scratch output is not
.DS_Store .DS_Store
.playwright-data .playwright-data
# real production plans dropped in for debugging stay out of git
/plan-*.sqlplan
# generated knowledge graph (regenerate with /understand)
.ua/
+9 -3
View File
@@ -40,6 +40,7 @@ bash scripts/smoke.sh # healthz, login page, signed-out redirect, analy
| --- | --- | | --- | --- |
| `APP_SECRET` | ≥16 chars. Encrypts API keys at rest (AES-256-GCM). Changing it makes stored keys unreadable. | | `APP_SECRET` | ≥16 chars. Encrypts API keys at rest (AES-256-GCM). Changing it makes stored keys unreadable. |
| `DATA_DIR` | where `qai.db` (SQLite, `node:sqlite`) lives. Default `./data`. Docker uses `/data`. | | `DATA_DIR` | where `qai.db` (SQLite, `node:sqlite`) lives. Default `./data`. Docker uses `/data`. |
| `COOKIE_SECURE` | set to `1` only when the app is served over https without a proxy that sets `X-Forwarded-Proto`. The session cookie is `Secure` when that header says `https`; forcing it on plain http makes Safari refuse to store it. |
Model keys are **not** config. Each user types them in the app (first-run guide or `/settings`), so a second user Model keys are **not** config. Each user types them in the app (first-run guide or `/settings`), so a second user
brings their own endpoint and their own spend. Blank key field = keep the stored key. brings their own endpoint and their own spend. Blank key field = keep the stored key.
@@ -56,12 +57,17 @@ brings their own endpoint and their own spend. Blank key field = keep the stored
```bash ```bash
npm test # node --test on tests/*.test.ts, no test framework installed npm test # node --test on tests/*.test.ts, no test framework installed
npm run build # production build + type check npm run build # production build + type check
npm run e2e # Playwright: signup, settings, real analyst + real Jev run, copy, print, history, 4 widths npm run e2e # builds first, then Playwright against the production server
``` ```
The unit suites run offline: the analyst and Jev are exercised through injected `fetch`, so no key and no network. The unit suites run offline: the analyst and Jev are exercised through injected `fetch`, so no key and no network.
`npm run e2e` is the opposite — it drives the real app against the real models, and reads `JEV_API_KEY` from `npm run e2e` is the opposite — `pree2e` builds, then Playwright drives `npm run start` (the production server,
`.env.local`. Expect it to be slow; that is the models, not the harness. not `next dev`: cookie flags and hydration differ, and that difference once hid a sign-out bug) against the real
models. It reads `JEV_API_KEY` from `.env.local`. Expect it to be slow; that is the models, not the harness.
Projects: `chromium` (everything except the session spec, reusing one signed-in session), `webkit` (Safari —
session survival only, because it is the one engine that rejects a `Secure` cookie over `http://localhost`),
and `mobile` (Pixel 7, layout only, so no model run is paid for twice).
`tests/digest.test.ts` skips unless a genuine plan exists at `fixtures/real.sqlplan`. Drop one there from SSMS and the `tests/digest.test.ts` skips unless a genuine plan exists at `fixtures/real.sqlplan`. Drop one there from SSMS and the
skip becomes the real check on ShowPlan attribute spellings. skip becomes the real check on ShowPlan attribute spellings.
+6
View File
@@ -1,8 +1,14 @@
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { settingsView } from "@/lib/settings";
import { Rail } from "@/components/rail"; import { Rail } from "@/components/rail";
export default async function Console({ children }: { children: React.ReactNode }) { export default async function Console({ children }: { children: React.ReactNode }) {
const u = await requireUser(); const u = await requireUser();
const v = settingsView(u.id);
// Same requirement /api/analyze enforces (it answers 412). Without this, a user who closes the guide
// mid-way lands on a console that cannot run anything, and only finds out when Analyse turns red.
if (!v.has_analyst || !v.has_jev) redirect("/setup?step=2");
return <div className="shell"> return <div className="shell">
<aside className="rail no-print" aria-label="Previous analyses"><Rail userId={u.id} /></aside> <aside className="rail no-print" aria-label="Previous analyses"><Rail userId={u.id} /></aside>
<main className="main">{children}</main> <main className="main">{children}</main>
+3 -4
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { readSse } from "@/lib/stream"; import { readSse } from "@/lib/stream";
import { decodePlan } from "@/lib/plan-file.ts";
import { ReportView } from "./report-view"; import { ReportView } from "./report-view";
import { ExportButtons } from "./export-buttons"; import { ExportButtons } from "./export-buttons";
@@ -59,10 +60,8 @@ export function Runner() {
<input type="file" accept=".sqlplan,.xml" aria-label="Load a .sqlplan file" style={{ fontSize: "var(--qai-text-xs)" }} <input type="file" accept=".sqlplan,.xml" aria-label="Load a .sqlplan file" style={{ fontSize: "var(--qai-text-xs)" }}
onChange={async (e) => { onChange={async (e) => {
const f = e.target.files?.[0]; if (!f) return; const f = e.target.files?.[0]; if (!f) return;
const buf = new Uint8Array(await f.arrayBuffer()); // SSMS saves UTF-16LE; File.text() would decode that as UTF-8 and fill the plan with NULs.
// SSMS saves UTF-16LE; File.text() decodes that as UTF-8 and fills the plan with NUL bytes. setXml(decodePlan(new Uint8Array(await f.arrayBuffer())));
const utf16 = buf[0] === 0xff && buf[1] === 0xfe;
setXml(new TextDecoder(utf16 ? "utf-16le" : "utf-8").decode(buf).replace(/^/, ""));
}} /> }} />
{at >= 0 && <span className="pill" aria-live="polite">{at + 1} of 4 · {LABEL[STAGES[at]]}</span>} {at >= 0 && <span className="pill" aria-live="polite">{at + 1} of 4 · {LABEL[STAGES[at]]}</span>}
</div> </div>
+26
View File
@@ -0,0 +1,26 @@
# Wiki conventions (schema layer)
This directory is a Karpathy-pattern wiki: raw sources, wiki pages, and this schema file.
## Layout
- `raw/` — immutable sources copied from the repository. Never edited here.
- `index.md` — the catalog, grouped by category. Every page appears exactly once.
- `log.md` — chronological record of what was added, and why.
- `<Page Name>.md` — one page per concept. File name is the link target.
## Categories
- Pipeline
- Data and storage
- Security and credentials
- Interface
- Verification
- Operations
- Decisions
## Page rules
1. Start with `# <Page Name>`, then 120 to 250 words of prose.
2. Cross-reference with `[[Page Name]]`. Every page links 3 to 6 others, and every link must resolve.
3. State measured facts (numbers, timings, error strings) instead of adjectives. Note the measurement when it
came from a specific artifact.
4. A page about a decision says: what the old behaviour was, what it cost, what replaced it, what it broke.
5. No code blocks longer than 5 lines. Long code belongs in `raw/`.
+13
View File
@@ -0,0 +1,13 @@
# Analysis row lifecycle
A row is created before the plan is validated, so every attempt is visible in the rail. Status moves through
`running` → `done` | `failed` | `partial` | `abandoned`.
`partial` means some stages produced output before the run stopped. `abandoned` means the browser went away
mid-run. Both surface as pills in the history rail rather than being hidden, and `/app/[id]` renders whatever
exists with a "this run was interrupted before it finished" note.
`/app/[id]` never redirects a `running` row. An earlier version did, which turned a row orphaned by a crashed
process into an infinite self-redirect.
Related: [[SSE stream]], [[SQLite schema]], [[History rail]].
+20
View File
@@ -0,0 +1,20 @@
# Analyst model
`lib/analyst.ts` talks to any OpenAI-compatible endpoint the user pastes in settings. Two calls, two jobs.
`proposeCandidates` asks for 2 to 4 genuinely different remediation options and validates them against a zod
schema: `key`, `title`, `diagnosis`, 1-4 `actions`, `option_type`, `sql_to_run`, `expected`. Fewer than two options is
an error, because [[Jev scoring]] cannot rank a single option.
`streamReport` writes the final answer as OpenUI Lang, streamed token by token into [[OpenUI report language]].
Its system prompt carries hard requirements: every option with `sql_to_run` gets a SqlBlock containing exactly that
T-SQL, and every option gets a ScoreBar.
Both calls spread the user's extra JSON parameters *after* `max_tokens`, which is how the output ceiling is raised
from the settings screen without a rebuild. `extractJson` recovers an object from prose or fenced output and, when
the response was cut off, names the character count instead of blaming the model.
The local QA endpoint is `http://192.168.88.131:8888/v1` running `qwen3.8-flash-next`; it streams reasoning
instead of content unless extra params include `{"chat_template_kwargs":{"enable_thinking":false}}`.
Related: [[Jev scoring]], [[Option type and sql to run]], [[Placeholder SQL is dropped]], [[Output token ceiling]].
+13
View File
@@ -0,0 +1,13 @@
# Console runner
`components/runner.tsx` is the whole console: file drop with encoding-aware decode, the paste box, stage pills,
SSE reading, the live report, a cancel button, and the error line.
The Analyse button is disabled while a stage is active and re-enabled only on the `done` frame — which is what the
end-to-end test waits on before asserting report contents.
Design follows the [[Design tokens]] contract: dark workbench, JetBrains Mono for plan data, IBM Plex Sans for
prose, one phosphor accent. The layout holds at 320 px without horizontal scroll, and every settings field has a
real `<label for>`.
Related: [[SSE stream]], [[OpenUI report language]], [[Export and print]], [[History rail]].
+12
View File
@@ -0,0 +1,12 @@
# Container plan
`Dockerfile` is a multi-stage Node 25 build — Node 25 because `node:sqlite` is what the data layer uses — and
`docker-compose.yml` mounts a named volume at `/data` and reads `APP_SECRET` from the host environment.
The image is written but unbuilt on the development machine, which has no Docker. The build is therefore the
first thing to verify on a machine that has it: `bash scripts/smoke.sh --docker`.
Known rough edges carried forward deliberately: `.dockerignore` excludes tests but the image copies `node_modules`
including devDependencies, and a first real build may need a tweak.
Related: [[Running locally]], [[Smoke script]], [[Environment variables]].
+13
View File
@@ -0,0 +1,13 @@
# Credential vault
Model keys are pasted by the user into the settings screen. They are never stored as server environment
configuration and never appear in the DOM of a subsequent page.
`lib/vault.ts` seals with AES-256-GCM under a key derived from `APP_SECRET`, and renders secrets as bullets plus the
last four characters (`••••1234`). `open` returns null instead of throwing when `APP_SECRET` changed, so the app
asks for the key again instead of crashing.
`settingsView` is the only shape the forms ever receive, which is what makes "never echo a stored secret back"
structural rather than a per-field discipline.
Related: [[Session cookie rules]], [[Settings form]], [[SQLite schema]].
+9
View File
@@ -0,0 +1,9 @@
# Design contract
`design-system/sql-server-qai/MASTER.md` is the contract the UI is built against: dark workbench palette, type
scale, spacing, component rules, and the reason the accent is reserved for action and state rather than decoration.
The Hallmark audit log in `.hallmark/log.json` records the decisions taken against it. Two of them shaped the
code: no utility CSS framework, and no network font loader.
Related: [[Design tokens]], [[Console runner]].
+13
View File
@@ -0,0 +1,13 @@
# Design tokens
`app/tokens.css` defines the palette and scale: dark console background, phosphor-green accent reserved for
action and state, JetBrains Mono for anything numeric or machine-written, IBM Plex Sans for prose, and a spacing
scale used everywhere instead of literal margins.
`app/ui.css` turns the tokens into semantic classes — `card`, `field`, `btn`, `pill`, `bar`, `rail` — plus the print
stylesheet. Tailwind was installed by the scaffold and removed by decision: the design contract is small enough to
own directly, and utility classes were duplicating tokens rather than expressing them.
Fonts come from the token stack rather than a network font loader, keeping the build offline-light.
Related: [[Export and print]], [[Console runner]], [[Design contract]].
+13
View File
@@ -0,0 +1,13 @@
# Decision: email collision check
Changing your email in settings wrote straight to a UNIQUE column. A collision surfaced as a raw constraint error
inside a server action — the form looked like it did nothing.
`changeEmail` in `lib/auth.ts` now validates the address, compares it case-insensitively after trimming, and
returns "That email already has an account." when another user owns it. It returns ok when the address is already
yours.
A deferred neighbour: sessions are not revoked when the password changes, so a 30-day token survives a password
rotation.
Related: [[First-run gate]], [[Session cookie rules]], [[SQLite schema]].
+11
View File
@@ -0,0 +1,11 @@
# Environment variables
Deliberately short. `APP_SECRET` seals stored keys; `DATA_DIR` locates the SQLite file; `COOKIE_SECURE=1` forces the
Secure flag for a direct https deploy with no proxy setting `X-Forwarded-Proto`.
Nothing about models lives here. Base URL, key, model name, extra JSON parameters, Jev key and Jev model are
per-user settings encrypted at rest, because the deployment is expected to be self-hosted and shared.
The full file list, with the reasoning for each default, is in the repository README.
Related: [[Credential vault]], [[Session cookie rules]], [[Running locally]].
+9
View File
@@ -0,0 +1,9 @@
# Export and print
Two export actions, both client-side, no PDF library: a Copy button that pulls the SQL blocks out of the rendered
report, and Print, which opens the browser print dialog against a print stylesheet.
`app/ui.css` carries the print rules — the rail, the toolbar and the runner all hide, so the printed page is the
report. Saving as PDF from the dialog is the export path.
Related: [[Console runner]], [[OpenUI report language]], [[Design tokens]].
+14
View File
@@ -0,0 +1,14 @@
# First-run gate
The console requires two things before it renders: an analyst base URL with model and key, and a Jev key. The gate
lives in `app/app/layout.tsx`, so it covers `/app` and `/app/[id]` in one place, and it matches the rule
`POST /api/analyze` already enforces with a 412.
Before the gate existed, the guide was only reachable because the signup and sign-in actions redirect there. Close
the wizard halfway and the console rendered anyway, then failed only when Analyse turned red.
The three-step guide is reused as the destination after a save from settings, via a hidden `after` field on
`SettingsForm`. Without it, a save from the wizard bounced the user straight to the console and the Save button
looked dead.
Related: [[Session cookie rules]], [[Settings form]], [[Email collision check]].
+10
View File
@@ -0,0 +1,10 @@
# Gitea remote
The repository is pushed to a self-hosted Gitea at `http://192.168.88.39:3000/ahmed/sql-server-query-ai.git`, branch
`main`. Credentials were used inline for the push and are deliberately absent from `.git/config`, the README and
every tracked file; the README documents using a keychain entry or a token instead.
Tracked artifacts that must stay out: the generated `.ua/` graph directory, the QA databases under `.qa-data/`,
`.playwright-data`, and any production plan file matching `/plan-*.sqlplan` — real plans carry real schema names.
Related: [[Running locally]], [[Wiki conventions]].
+10
View File
@@ -0,0 +1,10 @@
# History rail
The rail lists the signed-in user's runs newest first, with the settings link pinned at the bottom. Each row is a
card holding the title link, an inline rename field, a delete button, and a status pill that appears only when
status is not `done`.
Delete never fires on a stray click: the form asks for confirmation and does nothing when it is dismissed. Rename
and delete are server actions scoped by owner id, so another user's id in a hidden field deletes nothing.
Related: [[Analysis row lifecycle]], [[Console runner]], [[SQLite schema]].
+13
View File
@@ -0,0 +1,13 @@
# Jev goal gate
`scripts/jev-gate.mjs` asks Jev whether a document actually builds the stated goal, before code is written from it.
It asks for a yes/no, a coverage score, coverage confidence, and the biggest gap as a typed choice rather than an
unfalsifiable "is something missing".
Both the plan and the spec pass: verdict YES, goal match 0.90, coverage 3.71 of 4, biggest gap `nothing`.
Two earlier failures were measurement artefacts, not model verdicts: sending a raw 60k truncation hid the tail of
the plan, and replacing the text with a heading-and-checkbox skeleton stripped too much prose. The gate now strips
fenced code blocks, keeps prose, and sends up to 90k.
Related: [[Jev scoring]], [[Smoke script]].
+18
View File
@@ -0,0 +1,18 @@
# Jev scoring
Jev is a separate typed judgment model, not a second opinion in prose. `lib/jev.ts` asks it four questions per
option, gets a value plus a confidence for each, and combines them through `WEIGHTS` into one composite score.
Three flags come out of the scores:
- `verify_semantics` when semantic safety falls below 0.45, or below 0.6 for an option whose `option_type` is
`rewrite`. Live Jev scores an index create around 0.57 ("probably equivalent, no stated proof"), so keying the
soft floor on the option type rather than on "has SQL" is what keeps the warning useful.
- `low_confidence` when any dimension's confidence drops under the floor.
- `change_control` when an action is marked as needing change control.
When Jev is unreachable or abstains, `jevFallback` returns the composite order and marks the source
`jev_unavailable`, so the report prints "composite order — Jev did not decide" rather than implying Jev chose.
A real run on the 2.7 MB production plan hit exactly this path and stayed honest.
Related: [[Analyst model]], [[Option type and sql to run]], [[Jev goal gate]].
+13
View File
@@ -0,0 +1,13 @@
# Lazy database open
Opening SQLite at module import time broke `npm run build` with "database is locked": Next collects page data in
parallel workers, each importing the module and running DDL at once.
The fix is a bound Proxy. Nothing opens until a property is first touched; then `DatabaseSync` is constructed,
`journal_mode = WAL` and `busy_timeout = 5000` are set, and the schema DDL runs once. The Proxy methods are bound to
the real instance — an unbound Proxy made every call fail with "TypeError: Illegal invocation".
A second trap: SQLite rows arrive as null-prototype objects, which Next refuses to serialize from a server
component to a client component. Rows are normalised on read.
Related: [[SQLite schema]], [[Running locally]].
+13
View File
@@ -0,0 +1,13 @@
# Decision: max_nested_tags for real plans
A 2.7 MB production plan refused to import with "Maximum nested tags exceeded", which read like a corrupt export.
The plan was fine: fast-xml-parser's default `maxNestedTags` is 128 and that plan nested **2729 tags deep**. Spaghettified
nested-loop plans do that.
The cap is now 20 000, bounded in practice by the 12 MB input limit. The same plan digests in 56 ms.
Two more real-plan defects were fixed in the same pass, both invisible to the synthetic fixture: table names are
read from the `<Object>` child rather than the `<IndexScan>` element itself, and numeric character references such
as `&#xD;&#xA;` (90 of them in that plan's statement text) are decoded before the text reaches a model.
Related: [[Table names live on Object]], [[ShowPlan digest]].
+16
View File
@@ -0,0 +1,16 @@
# OpenUI report language
The report is elements, not markdown. `lib/report-spec.ts` defines the component vocabulary and the library prompt
with `@openuidev/lang-core`; `lib/report-ui.ts` attaches the React renderers.
The split exists because `@openuidev/react-lang` calls `React.createContext` at module scope, which breaks the API
route's server-side collection. A route that needs the prompt must not import a React-backed module.
Components: Headline, MetricGrid, Finding, OptionTable, ActionSteps, SqlBlock, ScoreBar, Callout, VerdictBlock.
The report renders while `oul` is still arriving, so the headline appears before the option table.
Every numeric the model supplies passes through `num` and `conf` guards. Before that, a `null` confidence inside
ScoreBar threw and OpenUI dropped the whole block — visible live as "runtime/render-error: Component ScoreBar
render failed".
Related: [[SSE stream]], [[Analyst model]], [[Console runner]].
+15
View File
@@ -0,0 +1,15 @@
# Decision: option_type and sql_to_run
The candidate schema used to carry `rewrite_sql`. The name was load-bearing — it is sent to the model — and it
said "query rewrite only". Result: an index recommendation had no `CREATE INDEX` anywhere in the pipeline, and the
report rendered zero SQL blocks for a plan whose headline finding was missing indexes.
It became `option_type` (index | statistics | rewrite | schema | app | ops) plus `sql_to_run`, described as the exact
T-SQL the user must execute, with the instruction that an index or statistics option with null SQL is a wrong
answer.
Two consequences were deliberate. The report prompt makes a SqlBlock per non-null `sql_to_run` a hard requirement.
And the `verify_semantics` flag in [[Jev scoring]] now keys on `option_type === "rewrite"`, because keying it on
"has SQL" would flag every index option for carrying DDL — the exact noise an earlier ruling removed.
Related: [[Placeholder SQL is dropped]], [[Analyst model]], [[Jev scoring]].
+12
View File
@@ -0,0 +1,12 @@
# Decision: output token ceiling
Once every option had to carry runnable T-SQL, the candidate JSON outgrew the old 2 048-token budget. The user
waited 38 seconds and got "The analyst model returned malformed JSON" — a truncated response has no closing brace.
Both calls default to 4 096 now, and the failure message names the character count and tells the user which extra
parameter raises the ceiling, because `cfg.extra` is spread after `max_tokens`.
The general rule recorded here: when a prompt asks a model for more content, check the budget before blaming the
model's judgement.
Related: [[Analyst model]], [[Placeholder SQL is dropped]].
+14
View File
@@ -0,0 +1,14 @@
# Decision: placeholder SQL is dropped, not rendered
With the new `sql_to_run` requirement, a report on a 100 KB statement came back containing
`SELECT ... FROM ... OPTION (MAXDOP 1);`. A block nobody can paste is worse than no block: it reads as an
instruction and fails on run.
`dropPlaceholderSql` nulls ellipsis SQL after schema validation, so the option simply gets no SqlBlock. The
heuristic is marked as a deliberate shortcut: `...` is not valid T-SQL, so a literal ellipsis inside a string
literal would also be dropped, which costs a block and never invents one.
The model is also told upstream: when `digestForModel` sets `sql_truncated$, [[ShowPlan digest]] has already
warning-ed the analyst out of offering a rewrite for a statement it never received.
Related: [[Option type and sql to run]], [[Output token ceiling]].
+15
View File
@@ -0,0 +1,15 @@
# Plan file encoding
SSMS saves `.sqlplan` as UTF-16LE with a byte-order mark. `File.text()` assumes UTF-8, which turns such a plan into
NUL-filled junk and makes the parser complain about the user's export. `lib/plan-file.ts` decodes by BOM first
(UTF-32LE/BE, UTF-16LE/BE, UTF-8), then falls back to NUL density for BOM-less UTF-16: in ASCII XML roughly every
other byte is `0x00`.
The browser reads the dropped file into bytes and calls `decodePlan`; the server never sniffs encoding. That keeps
[[ShowPlan digest]] independent of how the text arrived.
Four encodings are pinned by tests: UTF-8, UTF-8 with BOM, UTF-16LE with BOM, UTF-16BE with BOM, and both UTF-16
variants with no BOM. A browser test uploads a generated UTF-16LE file and asserts the textarea holds no NUL
characters.
Related: [[ShowPlan digest]], [[Unit suites]], [[Playwright projects]].
+15
View File
@@ -0,0 +1,15 @@
# Playwright projects
`npm run e2e` builds first (`pree2e` hook) and then drives the **production** server, not `next dev`. That choice
caught a real bug: cookie flags and hydration differ between the two, and a dev-only suite never noticed a
`Secure` cookie that Safari refuses on plain http.
Projects: `setup` signs up the QA account and configures both models through the real UI, writing a storage state;
`chromium` runs everything except the session spec; `webkit` runs only the session spec, because it is the engine
that rejects a Secure cookie over `http://localhost`; `mobile` runs only the responsive checks, so a 30-second model
run is never paid for twice.
One worker, 180-second test timeout, and a single run takes about 55 seconds for 23 tests including a real
analyst and real Jev call.
Related: [[WebKit in QA]], [[Unit suites]], [[Analyst model]].
+16
View File
@@ -0,0 +1,16 @@
# Running locally
`npm install && npm run build && npm start` serves the production build on port 3000. `npm run dev` works too, but
cookie flags and hydration differ from production, which is why the end-to-end suite exists.
Two environment values matter: `APP_SECRET` (at least 16 characters, seals stored keys — changing it makes them
unreadable and the app asks for them again) and `DATA_DIR` (where `qai.db` lives, default `./data`).
`.env.example` documents both plus `COOKIE_SECURE`.
Model credentials are not environment configuration. They belong to each user and are pasted into the settings
screen.
One operational trap worth knowing: deleting the data directory while a server is running leaves that server
writing into an unlinked inode. The app keeps answering and the database no longer exists anywhere.
Related: [[Environment variables]], [[Container plan]], [[Gitea remote]].
+16
View File
@@ -0,0 +1,16 @@
# SQLite schema
`lib/db.ts` uses `node:sqlite` — no ORM, no driver, no native build. Four tables:
- `users` — id, unique email, `salt:hash` scrypt password, created_at
- `sessions` — hashed token, user id, expiry
- `settings` — one row per user: analyst base URL, model, sealed key, extra JSON, Jev key, Jev model, email, onboarded
- `analyses` — id, user id, title, raw XML, oul, verdict JSON, status, error, timestamps
`patchAnalysis` only accepts keys from a `PATCHABLE` whitelist, because building `SET` clauses straight from object
keys is how an object key becomes SQL.
The `saveSettings` upsert uses `COALESCE(NULLIF(excluded.analyst_key, ''), settings.analyst_key)` so that a form
submitting a blank secret keeps the stored one. Blank means "unchanged", never "clear".
Related: [[Lazy database open]], [[Analysis row lifecycle]], [[Credential vault]].
+13
View File
@@ -0,0 +1,13 @@
# SSE stream
One POST to `/api/analyze` returns a server-sent event stream. `lib/stream.ts` frames events on the server and
parses them in the browser, and also extracts text deltas from an OpenAI-compatible streaming response.
Four named stages are reported as they happen: digesting, proposing, judging, writing. The console shows them as
`1 of 4 · Reading the plan` style pills. A `done` frame re-enables the Analyse button; an `error` frame names the
stage it died in, so a rejected plan reads "digesting: ..." rather than failing silently.
If the browser disconnects, in-flight model fetches are aborted and the row is stored as `abandoned` or `partial`
instead of piling ghost runs onto the analyst endpoint.
Related: [[Analysis row lifecycle]], [[Console runner]], [[OpenUI report language]].
+12
View File
@@ -0,0 +1,12 @@
# Session cookie rules
The session cookie holds a 32-byte random token; only its hash is stored, and the expiry is enforced in SQL.
`httpOnly` and `sameSite: lax` always.
`Secure` is set from the request, not the build: only when `X-Forwarded-Proto` reports `https`, or when
`COOKIE_SECURE=1` is set for a direct TLS deploy. The previous rule (`secure: NODE_ENV === "production"`) meant a
production build on plain `http://localhost` asked browsers for a Secure cookie. Chromium allows that on localhost;
Safari refuses to store it at all, so every page after signup looked signed-out and the wizard appeared to sign
users out. A Chromium-only suite could never observe it.
Related: [[WebKit in QA]], [[First-run gate]], [[Running locally]].
+17
View File
@@ -0,0 +1,17 @@
# Settings form
components/settings-form.tsx is the credential screen, used twice: as step 2 of the first-run guide and on
/settings. It renders analyst base URL, analyst key, analyst model, extra JSON parameters, Jev key and Jev model,
plus the account email change field.
It renders values from settingsView only, so a stored secret is never echoed back into the DOM - the fields show
bullet-masked values, and a blank submission means "keep what is stored", enforced again in the upsert (see
[[SQLite schema]]).
The form carries a hidden after field. When the submission comes from the wizard, the action returns the user to
/setup?step=3 so the readiness screen and its "Saved." message actually render; without it a configured user was
forwarded straight to the console and Save appeared to do nothing.
Every field has a real label element, asserted by the responsive Playwright checks.
Related: [[First-run gate]], [[Credential vault]], [[Email collision check]].
+19
View File
@@ -0,0 +1,19 @@
# ShowPlan digest
`lib/digest.ts` converts a SQL Server ShowPlan XML document into a bounded JSON summary before any model is
consulted. The point is that the models judge facts, not guess them: [[Plan file encoding]] hands it clean text,
then it walks the parsed document for operators, tables, missing-index groups, waits and warnings.
Each operator keeps `NodeId`, the logical or physical op name, `EstimateIO`, `EstimateCPU`,
`EstimatedTotalSubtreeCost`, estimated rows, and actual rows where the plan carries
`RunTimeCountersPerThread`. Attribute spellings differ across SQL Server builds, so every cost field falls
through the known variants rather than trusting one.
`digestForModel` is the budget gate: 4 000 characters of statement text, 20 tables, 12 operators, 5 missing
index groups, 20 warnings. It also sets `sql_truncated` when the statement was cut, which stops [[Analyst model]]
from offering a rewrite of a query it never received.
Measured on a 2 694 090-byte production plan (UTF-16LE, 1 352 044 characters, 2 729 tags deep): 2 ms to decode,
56 ms to digest, 37 distinct tables, and `StatementOptmEarlyAbortReason = TimeOut` surfaced as the headline finding.
Related: [[Jev scoring]], [[Max nested tags for real plans]], [[Table names live on Object]].
+10
View File
@@ -0,0 +1,10 @@
# Smoke script
`bash scripts/smoke.sh` checks a running instance with curl and nothing else: healthz answers, the login and signup
pages render, a signed-out visit to `/app` redirects, and `/api/analyze` refuses a request without a session.
Five checks, exits non-zero on the first failure.
It works against any `BASE_URL`, and `--docker` is reserved for running it against the container once a Docker host
is available.
Related: [[Jev goal gate]], [[Running locally]], [[Container plan]].
+13
View File
@@ -0,0 +1,13 @@
# Decision: table names come from the Object child
`digestPlan` built its table list by reading `Database`/`Schema`/`Table` attributes off `<IndexScan>` and
`<TableScan>` elements. Real ShowPlan puts those attributes on the `<Object>` child. The production plan with 51
objects therefore digested to **zero tables**, and the analyst model was asked to fix a query whose tables it could
not see.
The fix reaches into the child: the scan's own attributes, else `<Object>`. The third fallback level that looked
plausible in theory turned out to be dead code and was deleted rather than kept as insurance.
Pinned by a test using the real element shape, and visible in the measured difference: 0 tables before, 37 after.
Related: [[Max nested tags for real plans]], [[ShowPlan digest]].
+15
View File
@@ -0,0 +1,15 @@
# Unit suites
`npm test` runs `node --no-warnings --test tests/*.test.ts` — 43 tests, no test framework installed, nothing
external. The analyst and Jev are exercised through injected `fetch`, so no key and no network is needed.
Constraints that shape the code, not the tests: modules loaded by bare `node --test` cannot resolve the `@`
import alias, so test-imported modules import each other by relative `./x.ts` path (which is why
`allowImportingTsExtensions` is on), and `next/headers` plus `next/navigation` are imported lazily inside
`lib/auth.ts`.
The digest suite is the one that grew teeth: it now asserts nesting depth beyond the parser default, table names
taken from `<Object>` children, character-reference decoding, six plan-file encodings, and that a truncated plan's
error names a reason.
Related: [[Playwright projects]], [[Smoke script]], [[Plan file encoding]].
+11
View File
@@ -0,0 +1,11 @@
# WebKit in QA
A `webkit` project exists for one reason: Chromium treats `http://localhost` as a secure context and will store a
`Secure` cookie there, so a chromium-only suite passes while Safari users are bounced to the login page after
signup. That is precisely the bug reported after delivery.
The spec signs up its own account, walks the wizard's Next links, and asserts the final destination is the
onboarding gate at `/setup?step=2` — proof of a live session — because a lost session would land on `/login`.
A second spec reloads a deep page and checks the session survives.
Related: [[Session cookie rules]], [[Playwright projects]], [[First-run gate]].
+15
View File
@@ -0,0 +1,15 @@
# Wiki conventions
kb/AGENTS.md is the schema layer of this wiki: the layout, the category list and the page rules. It exists so a new
page can be added without inventing structure each time.
The rules that matter: one concept per file, the file name is the link target, every page cross-references 3 to 6
others and every link must resolve, measured numbers are stated with the artifact they came from, and a decision
page names the old behaviour, its cost, the replacement, and what it broke.
The wiki is deliberately separate from the repository's own documentation. raw/ holds copies of README, the spec,
the implementation plan, the design contract and the decisions ledger - copied, never edited in place - so the pages
above have citable sources. Keeping the wiki in its own root also keeps its generated graph in kb/.ua/ instead of
overwriting the code graph at the repository root.
Related: [[Gitea remote]], [[Design contract]], [[Running locally]].
+85
View File
@@ -0,0 +1,85 @@
# qAI knowledge base — index
Wiki pages about SQL Server qAI: 34 pages across 7 categories.
Raw sources are in `raw/` (copied, never edited here). Conventions live in `AGENTS.md`. History lives in `log.md`.
## Pipeline
- [[ShowPlan digest]] — lib/digest.ts converts a SQL Server ShowPlan XML document into a bounded JSON summary before any model is
consulted.
- [[Plan file encoding]] — SSMS saves .sqlplan as UTF-16LE with a byte-order mark.
- [[Analyst model]] — lib/analyst.ts talks to any OpenAI-compatible endpoint the user pastes in settings.
- [[Jev scoring]] — Jev is a separate typed judgment model, not a second opinion in prose.
- [[OpenUI report language]] — The report is elements, not markdown.
- [[SSE stream]] — One POST to /api/analyze returns a server-sent event stream.
## Data and storage
- [[SQLite schema]] — lib/db.ts uses node:sqlite — no ORM, no driver, no native build.
- [[Lazy database open]] — Opening SQLite at module import time broke npm run build with "database is locked": Next collects page data in
parallel
- [[Analysis row lifecycle]] — A row is created before the plan is validated, so every attempt is visible in the rail.
## Security and credentials
- [[Credential vault]] — Model keys are pasted by the user into the settings screen.
- [[Session cookie rules]] — The session cookie holds a 32-byte random token;
- [[First-run gate]] — The console requires two things before it renders: an analyst base URL with model and key, and a Jev key.
## Interface
- [[Console runner]] — components/runner.tsx is the whole console: file drop with encoding-aware decode, the paste box, stage pills,
SSE readin
- [[History rail]] — The rail lists the signed-in user's runs newest first, with the settings link pinned at the bottom.
- [[Export and print]] — Two export actions, both client-side, no PDF library: a Copy button that pulls the SQL blocks out of the rendered
report
- [[Design tokens]] — app/tokens.css defines the palette and scale: dark console background, phosphor-green accent reserved for
action and sta
- [[Settings form]] — components/settings-form.tsx is the credential screen, used twice: as step 2 of the first-run guide and on
/settings.
- [[Design contract]] — design-system/sql-server-qai/MASTER.md is the contract the UI is built against: dark workbench palette, type
scale, spac
## Verification
- [[Unit suites]] — npm test runs node --no-warnings --test tests/*.test.ts — 43 tests, no test framework installed, nothing
external.
- [[Playwright projects]] — npm run e2e builds first (pree2e hook) and then drives the **production** server, not next dev.
- [[WebKit in QA]] — A webkit project exists for one reason: Chromium treats http://localhost as a secure context and will store a
Secure coo
- [[Smoke script]] — bash scripts/smoke.sh checks a running instance with curl and nothing else: healthz answers, the login and signup
pages
- [[Jev goal gate]] — scripts/jev-gate.mjs asks Jev whether a document actually builds the stated goal, before code is written from it.
It ask
## Operations
- [[Running locally]] — npm install && npm run build && npm start serves the production build on port 3000.
- [[Container plan]] — Dockerfile is a multi-stage Node 25 build — Node 25 because node:sqlite is what the data layer uses — and
docker-compose
- [[Environment variables]] — Deliberately short.
- [[Gitea remote]] — The repository is pushed to a self-hosted Gitea at http://192.168.88.39:3000/ahmed/sql-server-query-ai.git, branch
main.
- [[Wiki conventions]] — kb/AGENTS.md is the schema layer of this wiki: the layout, the category list and the page rules.
## Decisions
- [[Option type and sql to run]] — The candidate schema used to carry rewrite_sql.
- [[Placeholder SQL is dropped]] — With the new sql_to_run requirement, a report on a 100 KB statement came back containing
SELECT ...
- [[Max nested tags for real plans]] — A 2.7 MB production plan refused to import with "Maximum nested tags exceeded", which read like a corrupt export.
The pl
- [[Table names live on Object]] — digestPlan built its table list by reading Database/Schema/Table attributes off <IndexScan> and
<TableScan> elements.
- [[Output token ceiling]] — Once every option had to carry runnable T-SQL, the candidate JSON outgrew the old 2 048-token budget.
- [[Email collision check]] — Changing your email in settings wrote straight to a UNIQUE column.
## Sources
Immutable inputs, copied verbatim into `raw/`. These are the artifacts every measured number above came from.
- `raw/README.md` — the repository's own front page.
- `raw/2026-09-21-sql-server-qai-spec.md` — approved specification.
- `raw/2026-09-21-sql-server-qai-plan.md` — the 13-task implementation plan the code was built from.
- `raw/design-master.md` — the design contract.
- `raw/sdd-progress.md` — the decisions ledger every page in the Decisions category is drawn from.
+7
View File
@@ -0,0 +1,7 @@
# Operation log
## 2026-09-22 — initial wiki
- Copied 5 repository sources into `raw/` (README, spec, 13-task plan, design master, SDD ledger).
- Wrote 34 pages from the documentation, the in-code comments and the decisions ledger, grouped into 7 categories.
- Every page cross-references 3 to 6 others; measured numbers come from the artifacts named in `raw/`.
- Deliberately kept out: anything from `plan-cxpaket.sqlplan` beyond the aggregate measurements it produced.
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
# SQL Server qAI — Spec
**Date:** 2026-09-21
**Owner:** ahmedmohammed
**Status:** agreed in chat, this file is the spec of record
## Goal (verbatim from the owner)
> portal that take xml of sql server query plan and use power of generative ai to
> analysis provide multiple action steps, multiple query rewrite if possible,
> Jev then rank and decide.
## What it is
A containerised web tool. A DBA or consultant logs in, pastes a SQL Server
execution plan (Showplan XML, or a `.sqlplan` file), and gets back:
1. What the plan says is wrong (bottleneck, missing indexes, spills, bad row guesses).
2. **Multiple** candidate action plans — several different remediations, not one.
3. **Multiple** candidate SQL rewrites where a rewrite is possible.
4. A **Jev**-ranked verdict: which candidate to run first, with typed probabilities
and confidence, and why.
5. One final "best fast path": the recommended SQL and the steps to apply it.
6. The result rendered as **structured UI elements** (OpenUI Lang → React), not a
wall of markdown. Export to PDF, copy to clipboard.
The demo value is Jev as the decision-maker: the generative model proposes,
**Jev disposes**. Every ranking number on screen is a typed judgment from
`POST /v1/systemone`, not a string the LLM wrote.
## Product surface
| Screen | Job |
| --- | --- |
| `/signup`, `/login` | Email + password. Nothing else. |
| `/setup` | First-run guide: 3 steps — analyst model, Jev, done. |
| `/app` | Left rail = previous analyses (rename, delete). Main = paste plan → run → live report. |
| `/settings` | Update analyst model (base URL, key, model, extra params), Jev (key, model), email/password, history. |
Report actions: **Export to PDF**, **Copy**. History actions: **rename**, **delete**.
### Getting the input, and the two keys
The portal cannot reach the SQL Server. The user brings the plan XML, and the app must say so in the run screen:
SSMS → **Ctrl+M** (include actual execution plan) → run the query → save the Execution Plan tab as `.sqlplan`, or
copy the Showplan XML cell; without SSMS, prefix the batch with `SET STATISTICS XML ON;` and copy the XML result
cell. A `.sqlplan` file can be dropped in (SSMS writes UTF-16 with a BOM; decode both) or pasted.
Both model credentials are typed by the logged-in user in `/setup` or `/settings` — never server config, so every
user brings their own analyst endpoint and their own TypeSafe/Jev key (`console.typesafe.ai/keys`). Stored keys come
back masked, and a blank field means keep the stored one. Nothing leaves the container except the digest, and only
to the endpoints that user configured.
## Actors (two models, strict roles)
- **Analyst** — any OpenAI-compatible endpoint the user configures (`base_url`,
`key`, `model`, extra params). Generates: candidate action plans, candidate SQL
rewrites, and the final OpenUI Lang report. Text in, text out.
- **Jev** — TypeSafe System One (`@typesafe-ai/sdk`, model `jev-latest`). Never
generates prose. Answers typed questions (`score`, `choice`, `noul`) about each
candidate and returns probabilities + confidence. Code combines them.
## Requirements
1. Auth: signup, login, logout, session survives reload. Per-user data isolation.
2. Credentials at rest are encrypted (AES-256-GCM, `APP_SECRET` env). Never echoed
to the browser — masked (`••••1234`).
3. Plan input: paste XML, or drop/select a `.sqlplan`/`.xml` file. Clear inline
error if it is not a ShowPlanXML document.
4. Deterministic digest first, model second: parse the XML in code (operators,
costs, missing indexes, warnings, waits, spills, estimate vs actual). The model
sees the digest, never 40 MB of XML.
5. 2–4 candidates per run. Each candidate: diagnosis, ordered actions with effort
and change-control flag, optional rewrite SQL, expected effect.
6. Jev judges every candidate in parallel on 4 dimensions, then a second call picks
`first_to_run` across all candidates. Composite weights live in code, visible in UI.
7. Safety gate: a candidate Jev rates as semantically unsafe is never presented as
the recommended path without a visible "verify results before deploying" flag.
8. History: every run persisted (input digest, candidates, Jev verdict, rendered
report). Reopening a history item replays the stored report — zero model calls.
9. Streaming: stage indicator while digesting/proposing/judging, then the report
renders token-by-token. No spinner-for-30-seconds.
10. Single container: `docker compose up` → one image, one `/data` volume, no
external DB, no queue, no Redis.
## Non-goals
- No live connection to SQL Server. No plan-from-cache lookup. No automatic index
creation. No multi-user teams, roles, or sharing. No billing. No mobile app.
No markdown editor. No prompt marketplace. No agentic loop with tool calls.
## Success looks like
Paste a real slow-query plan → under 60 s → a ranked set of remediations where the
top pick is defensible because Jev's probabilities are on screen, and the winning
SQL is one click from the clipboard.
## Acceptance
What has to be true before this counts as done, and how each part is proven:
- **Offline unit suites** (`npm test`, no keys, no network): a real ShowPlan digests to the right numbers; input
that is not a plan is refused with a named reason; analyst output is schema-checked and 2–4 options are enforced;
Jev's scores rank, an unknown Jev label cannot lead, and a silent Jev degrades to a labelled composite order;
credentials seal/open/mask and a blank field never wipes a stored key.
- **One live run** against the user's real analyst endpoint and a real Jev key: every SSE stage fires, several
candidates come back, Jev returns a verdict with confidence, and OpenUI Lang arrives in deltas.
- **Browser QA** (Chromium): signup → guide → settings → run → rendered report; Copy puts SQL on the clipboard;
print emulation flips to paper and hides the rail and buttons; rename sticks and delete confirms first; no
horizontal scroll from 320 px up.
- **A smoke script** (`scripts/smoke.sh`) that a container or a plain `npm start` both pass: health, auth pages,
signed-out redirect, and `/api/analyze` refusing an anonymous caller.
- **A goal gate**: Jev scores the written plan against the goal in this document and answers the named-gap question
with `nothing` before implementation starts.
+112
View File
@@ -0,0 +1,112 @@
# SQL Server qAI
Paste a SQL Server ShowPlan XML (or drop a `.sqlplan`), get a report with several ranked remediation options and
rewritten queries. Two models, one job: a **generative analyst model** proposes the options and writes the report, and
**Jev** (TypeSafe System One) scores every option and decides which one to run first. Jev never writes SQL; it returns
typed scores, a choice, and confidences. The report renders as OpenUI elements, not a markdown blob.
## Pipeline
1. `lib/digest.ts` parses the XML in code: top operators, missing indexes, warnings, waits, estimate-vs-actual row
errors, tables, statement text, subtree cost, parallelism. Raw XML never reaches a model.
2. The analyst model receives the digest JSON and proposes 2–4 genuinely different options (index, rewrite, statistics,
parallelism, schema), each with ordered action steps and an optional rewrite.
3. Jev answers four questions per option (`bottleneck_fit`, `semantic_safety`, `ease` `score`; `root_cause` `noul`) and
one cross-option call (`first_to_run` `choice`, `anything_worth_running` `noul`).
4. `lib/jev.ts` combines them with visible weights (0.40 / 0.30 / 0.15 / 0.15 — four numbers in that file, the only
tuning knob) and prints a verdict. Jev's pick leads only when its confidence clears 0.5; otherwise the report says
the order came from composite scoring.
5. The analyst streams the final report as OpenUI Lang; `<Renderer>` paints it as it arrives.
Getting the XML: SSMS → **Ctrl+M** (include actual execution plan) → run the query → right-click the Execution Plan tab
→ *Save As .sqlplan*. No SSMS: prefix the batch with `SET STATISTICS XML ON;` and copy the XML result cell.
## Run it
This repo runs as a plain Node process. `Dockerfile` and `docker-compose.yml` are ready, but the image has **not
been built or run on this machine** (Docker is not installed here); treat it as untested until
`bash scripts/smoke.sh --docker` says otherwise. Local work is `npm run dev`.
```bash
npm install
cp .env.example .env.local # then fill in APP_SECRET and DATA_DIR
npm run dev # http://localhost:3000
bash scripts/smoke.sh # healthz, login page, signed-out redirect, analyze needs a session
```
`.env.local`:
| key | why |
| --- | --- |
| `APP_SECRET` | ≥16 chars. Encrypts API keys at rest (AES-256-GCM). Changing it makes stored keys unreadable. |
| `DATA_DIR` | where `qai.db` (SQLite, `node:sqlite`) lives. Default `./data`. Docker uses `/data`. |
| `COOKIE_SECURE` | set to `1` only when the app is served over https without a proxy that sets `X-Forwarded-Proto`. The session cookie is `Secure` when that header says `https`; forcing it on plain http makes Safari refuse to store it. |
Model keys are **not** config. Each user types them in the app (first-run guide or `/settings`), so a second user
brings their own endpoint and their own spend. Blank key field = keep the stored key.
- Analyst: any OpenAI-compatible base URL + key + model, plus optional extra JSON params.
A local endpoint such as `http://192.168.88.131:8888/v1` works; use key `EMPTY` when the server has no auth.
For a reasoning model (Qwen3 on vLLM) put `{"chat_template_kwargs":{"enable_thinking":false}}` in **Extra params**:
with thinking on, the model spends the whole context budget reasoning and the report arrives empty.
Both model calls default to `max_tokens: 2048`; the same field overrides it.
- Jev: a TypeSafe API key from `console.typesafe.ai/keys`, model usually `jev-latest`.
## Tests
```bash
npm test # node --test on tests/*.test.ts, no test framework installed
npm run build # production build + type check
npm run e2e # builds first, then Playwright against the production server
```
The unit suites run offline: the analyst and Jev are exercised through injected `fetch`, so no key and no network.
`npm run e2e` is the opposite — `pree2e` builds, then Playwright drives `npm run start` (the production server,
not `next dev`: cookie flags and hydration differ, and that difference once hid a sign-out bug) against the real
models. It reads `JEV_API_KEY` from `.env.local`. Expect it to be slow; that is the models, not the harness.
Projects: `chromium` (everything except the session spec, reusing one signed-in session), `webkit` (Safari —
session survival only, because it is the one engine that rejects a `Secure` cookie over `http://localhost`),
and `mobile` (Pixel 7, layout only, so no model run is paid for twice).
`tests/digest.test.ts` skips unless a genuine plan exists at `fixtures/real.sqlplan`. Drop one there from SSMS and the
skip becomes the real check on ShowPlan attribute spellings.
## Git remote (Gitea)
Origin points at the self-hosted Gitea:
```bash
git remote -v # http://192.168.88.39:3000/ahmed/sql-server-query-ai.git
git push origin feat/sql-server-qai
```
The remote URL deliberately contains **no password**. Git prompts for the username (`ahmed`) and password the first
time, or you can keep them out of prompts:
```bash
git config credential.helper osxkeychain # remember them locally
git push origin feat/sql-server-qai
```
Never commit the password into `.git/config`, a remote URL, or this README — a stored credential in the repo is
readable by whoever you hand the repo to. If you want it non-interactive in CI, create a Gitea token
(Settings → Applications) and use `http://192.168.88.39:3000/ahmed/sql-server-query-ai.git` with the token as password.
## Layout
```
lib/digest.ts XML → bounded digest (the only shape models see)
lib/analyst.ts propose 2–4 options; stream the final OpenUI report
lib/jev.ts questions, weights, ranking, verdict, fallback
lib/report-ui.tsx OpenUI component library — the same module builds the prompt and renders the answer
lib/db.ts node:sqlite: users, sessions, settings, analyses
lib/vault.ts AES-256-GCM seal/open/mask for credentials
lib/auth.ts scrypt passwords, hashed session tokens (DB stores the SHA-256, never the token)
app/api/analyze SSE pipeline: digesting → proposing → judging → writing
tests/ offline unit suites tests-e2e/ Playwright
docs/superpowers/ spec + the 13-task implementation plan this was built from
```
Design: dark terminal console, JetBrains Mono for data, IBM Plex Sans for prose, one phosphor accent.
Print stylesheet flips to paper white and drops the rail, buttons and chrome — that is the Export to PDF path.
+233
View File
@@ -0,0 +1,233 @@
# Design System Master File
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
---
**Project:** SQL Server qAI
**Generated:** 2026-09-21 12:56:55
**Category:** Developer Tool / IDE
**Design Dials:** Variance 4/10 (Balanced / Modern) | Motion 3/10 (Subtle) | Density 8/10 (Dense / Dashboard)
---
## Global Rules
### Color Palette
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Primary | `#1E293B` | `--color-primary` |
| On Primary | `#FFFFFF` | `--color-on-primary` |
| Secondary | `#334155` | `--color-secondary` |
| On Secondary | `#FFFFFF` | `--color-on-secondary` |
| Accent/CTA | `#22C55E` | `--color-accent` |
| On Accent/CTA | `#0F172A` | `--color-on-accent` |
| Background | `#0F172A` | `--color-background` |
| Foreground | `#F8FAFC` | `--color-foreground` |
| Card | `#1B2336` | `--color-card` |
| Card Foreground | `#F8FAFC` | `--color-card-foreground` |
| Muted | `#272F42` | `--color-muted` |
| Muted Foreground | `#94A3B8` | `--color-muted-foreground` |
| Border | `#475569` | `--color-border` |
| Destructive | `#EF4444` | `--color-destructive` |
| On Destructive | `#000000` | `--color-on-destructive` |
| Ring | `#FFFFFF` | `--color-ring` |
**Color Notes:** Code dark + run green
### Typography
- **Heading Font:** JetBrains Mono
- **Body Font:** IBM Plex Sans
- **Mood:** code, developer, technical, precise, functional, hacker
- **Google Fonts:** [JetBrains Mono + IBM Plex Sans](https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap)
**CSS Import:**
```css
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
```
### Spacing Variables
*Density: 8/10 — Dense / Dashboard*
| Token | Value | Usage |
|-------|-------|-------|
| `--space-xs` | `2px` / `0.125rem` | Tight gaps |
| `--space-sm` | `4px` / `0.25rem` | Icon gaps, inline spacing |
| `--space-md` | `8px` / `0.5rem` | Standard padding |
| `--space-lg` | `12px` / `0.75rem` | Section padding |
| `--space-xl` | `16px` / `1rem` | Large gaps |
| `--space-2xl` | `24px` / `1.5rem` | Section margins |
| `--space-3xl` | `32px` / `2rem` | Hero padding |
### Shadow Depths
| Level | Value | Usage |
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
---
## Component Specs
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #22C55E;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #1E293B;
border: 2px solid #1E293B;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #0F172A;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #1E293B;
outline: none;
box-shadow: 0 0 0 3px #1E293B20;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Dark Mode (OLED)
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
### Page Pattern
**Pattern Name:** FAQ/Documentation Landing
- **Conversion Strategy:** Reduce support tickets. Track search analytics. Show related articles. Contact escalation path.
- **CTA Placement:** Search bar prominent + Contact CTA for unresolved questions
- **Section Order:** Hero with search bar > Popular categories > FAQ accordion > Contact/support CTA
---
## Motion
**Scroll Reveal** (Subtle) — Trigger: scroll (viewport enter) | Duration: 300-400ms | Easing: `power1.out`
```js
gsap.from(el, { opacity: 0, y: 12, duration: 0.35, ease: 'power1.out', scrollTrigger: { trigger: el, start: 'top 90%', toggleActions: 'play none none reverse' } });
```
**Framework notes:** Requires the ScrollTrigger plugin registered once via gsap.registerPlugin(ScrollTrigger); Use matchMedia('(prefers-reduced-motion: reduce)') to skip non-essential motion and render the final state immediately
- ✅ Keep the y offset small (8-16px) so it reads as a fade, not a slide
- ❌ Don't reveal below-the-fold content needed for SEO/crawlers as invisible-by-default without a no-JS fallback
- ⚡ toggleActions 'play none none reverse' avoids re-triggering on every scroll direction change
---
## Anti-Patterns (Do NOT Use)
- ❌ Light mode default
- ❌ Slow performance
### Additional Forbidden Patterns
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
- ❌ **Instant state changes** — Always use transitions (150-300ms)
- ❌ **Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile
+84
View File
@@ -0,0 +1,84 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-21-sql-server-qai.md
Ruling: no Docker on this machine (human says limited resources) — write Dockerfile/.dockerignore in Task 12 but never build or run it here; verify with npm run dev + npm start. Ruling: drop Tailwind — the plan styles with tokens.css + semantic classes only, so @tailwindcss/postcss and tailwindcss are uninstalled and postcss.config.mjs deleted. Cost if wrong: we hand-write CSS we would otherwise generate.
Task 1: complete (commits 2778ce7..f64dc31, tests: npm run build → ƒ (Dynamic) server-rendered on demand)
Task 1: Ruling: no next/font/google — fonts come from the token stack only (JetBrains Mono / IBM Plex Sans if installed, else system mono+sans). Build stays offline-safe and one network dependency lighter — cost if wrong: brand fonts missing on machines without them.
Task 1: Ruling: tokens.css lives at app/tokens.css (the @import in globals.css resolves there); the duplicate root copy is deleted — cost if wrong: none.
Task 1: Ruling: app/page.tsx is a plain redirect("/login") because lib/auth does not exist yet; Task 3 replaces it with the session-aware redirect its brief omits — cost if wrong: signed-in users hitting / land on /login until Task 3.
Task 1: Ruling: deps are next/react/react-dom/zod/@openuidev/react-lang/@typesafe-ai/sdk/fast-xml-parser. lucide-react skipped (no plan code imports it) and Tailwind dropped (see setup ruling) — cost if wrong: add lucide-react the moment an icon is drawn.
Task 1: Note: env fix, not a code fix — node_modules/@next/swc-darwin-arm64 came down truncated (33MB of 89MB) so `next build` died with "Turbopack requires native bindings"; reinstalled with npm i --no-save @next/swc-darwin-arm64@16.3.5.
Task 2: Ruling: patchAnalysis filters keys against a PATCHABLE whitelist — the brief interpolated object keys straight into the SET clause, and one caller that ever passes a request-shaped object would own a SQL injection. Cost if wrong: a new patchable column needs adding to the set.
Task 2: Ruling: test script is `node --no-warnings --test tests/*.test.ts` (the plan's own line). `node --test tests` fails with MODULE_NOT_FOUND on node 25 in this repo — the space in the path makes Node resolve the directory as a module.
Task 2: complete (commits f64dc31..692ffa9, tests: npm test → ℹ duration_ms 109.10725)
Task 3: Ruling: lib/auth.ts imports ./db.ts (relative, .ts extension) and loads next/headers + next/navigation lazily — `node --test` imports this module, and bare node resolves neither `@/…` nor `next/*`. tsconfig gains allowImportingTsExtensions. Next-only glue (actions, pages, components) keeps @/. Cost if wrong: every test-imported module must keep this discipline.
Task 3: Ruling: requireUser declares Promise<{id,email}> with an unreachable throw after redirect(), because redirect() behind a dynamic import is not typed `never`. Cost if wrong: dead line, no behaviour change (redirect throws anyway).
Task 3: Ruling: AuthForm's action is (state: FormResult, fd) => Promise<FormResult> and the server actions take `_: unknown` — the brief's `_: void` does not type-check against useActionState, which passes the previous state.
Task 3: Ruling: app/app/layout.tsx awaits requireUser now (the guard the brief only asserts as a manual check). Task 11 may write it again; it is idempotent.
Task 3: Note: signUp/signIn redirect to /setup, which Task 5 creates. Until then that redirect lands on 404. Verified now: signed-out /app -> 307 /login, /login 200, /signup renders the password help.
Task 3: complete (commits 692ffa9..5f8746c, tests: npm test → ℹ duration_ms 190.291833)
Task 4: Ruling: db.saveSettings now keeps the stored secret when the incoming one is blank (COALESCE(NULLIF(excluded.x,''), settings.x)) instead of overwriting it. The brief only protected against this in lib/settings.ts; the shared upsert is where a later caller (Task 5's password change) would have wiped both keys with one omitted field. Test: "saveSettings never blanks a stored secret, whoever calls it" — RED before the fix. Cost if wrong: a future "clear my key" feature needs an explicit sentinel instead of "".
Task 4: Ruling: lib/settings.ts imports ./db.ts and ./vault.ts (test-imported module, same discipline as Task 3).
Task 4: complete (commits 5f8746c..dd6b880, tests: npm test → ℹ duration_ms 455.936167)
Task 5: Ruling: /setup only auto-forwards to /app for steps 1 and 2; ?step=3 still renders after onboarding. The brief's unconditional redirect made the readiness screen unreachable exactly when it matters (Task 13 opens it after saving keys). Cost if wrong: one extra clickable step for a configured user.
Task 5: Verified: signed-in /settings shows ••••1234 and ••••9999 (masked, never raw); anon /settings -> 307 /login; onboarded /setup and /setup?step=2 -> 307 /app; /setup?step=3 -> 200.
Task 5: complete (commits dd6b880..1c3f222, tests: npm test → ℹ duration_ms 464.65375)
Task 6: Ruling: tables and missing-index tables are rebuilt as Database.Schema.Table. The brief joined only the bracket-stripped Table attribute, so the digest named "Orders" where the test (and any DBA reading the report) needs "wide.Sales.Orders". RED first: actual ['Orders','Customer']. Cost if wrong: none, the fixture asserts the joined form.
Task 6: Note: no genuine .sqlplan exists on this machine (no SQL Server to export one from), so "a real .sqlplan digests" skips with a printed reason and the commit message says so. Someone with SSMS should drop one at fixtures/real.sqlplan; that test is the check on attribute spellings.
Task 6: complete (commits 1c3f222e..4fefd0f, tests: npm test → ℹ duration_ms 451.9635)
Task 7: Ruling: lib/analyst.ts imports ./digest.ts and ./settings.ts (test-imported module discipline from Task 3).
Task 7: Note: partner asked for the Gitea remote mid-task. origin = http://192.168.88.39:3000/ahmed/sql-server-query-ai.git with no credential stored in .git/config; pushed main and feat/sql-server-qai once with inline credentials. README documents the remote and credential hygiene.
Task 7: complete (commits 4fefd0f..816f22c, tests: npm test → ℹ duration_ms 482.014)
Task 8: Verified against live Jev (jev-1.13, real key, real digest): 1.3s, picked idx_covering_orders with confidence 1.00, composite agreed, TOP-100 rewrite scored semantic_safety 0.05 and flagged. makeJevClient/score/noul/choice work as the brief specifies.
Task 8: Ruling: verify_semantics now fires only where semantics can move — any option carrying rewrite_sql below 0.6, or any option below 0.45. Evidence: live Jev scored the index create 0.57 ("equivalent, no stated proof"), so the brief's blanket <0.6 floor would flag nearly every index option and the warning would become noise. Test "verify_semantics fires where semantics can actually move" RED→GREEN. Cost if wrong: a genuinely risky non-rewrite change (e.g. dropping an index used elsewhere) scores 0.45-0.6 and goes unflagged; the option table still prints its safety bar.
Task 8: Ruling: jevFallback strips score-derived flags (kept change_control, which is read off the candidate's own actions). Neutral 0 scores meant "Jev never spoke", not "this option is unsafe" — the brief's fallback accused every option. Test added, RED→GREEN.
Task 8: Ruling: rubric arrays are `as const` — the SDK types ScoreCriteria as a non-empty readonly tuple, so a plain string[] fails `next build`'s type check even though the call works at runtime.
Task 8: complete (commits 816f22c..d8caa8e, tests: npm test → ℹ duration_ms 504.103542)
Task 9: Ruling: the library is lib/report-ui.ts written with createElement, not lib/report-ui.tsx with JSX. Node strips types but not JSX, so the brief's own test (`await import("../lib/report-ui.tsx")` under node --test) can never load a JSX file without adding a transpiler to the dep list. Same components, props, prompt and refs; only the call syntax changed. Cost if wrong: slightly noisier component bodies.
Task 9: complete (commits d8caa8e..396b0ed, tests: npm test → ℹ duration_ms 500.095583)
Task 9: Follow-up fix (found by `npm run build`, not by the tests): lib/db.ts opened SQLite and ran DDL at import, so next build's ~6 page-data workers hit "database is locked" and the build failed. Now the file opens on first query behind a bound Proxy, plus PRAGMA busy_timeout=5000. RED evidence: build exit 1 with "Failed to collect configuration for / [cause]: database is locked"; after the change build exits 0 and the suite is green (the Proxy had to bind methods — first attempt failed 4 tests with "Illegal invocation"). Cost if wrong: a long build-time query would still wait, which is what busy_timeout is for.
Task 10: complete (commits 13bd2e6..ac4cbdd, tests: npm test → ℹ duration_ms 531.938)
Task 10: Verified live, single run against the real analyst + real Jev: 41s end to end — stage digesting → 3 candidates → verdict {source: jev, headline: idx_covering_orders, confidence 1.00, composite agrees} → 2861 chars of OpenUI streamed in ~15 deltas → done {id}. Stored row replayed from /app/<id> renders the full report.
Task 10: Ruling: the brief said "no cancel() handler, the default is right". It is not. enqueue() on an abandoned ReadableStream does not throw, so a producer whose browser closed keeps firing multi-minute analyst calls; two ghost runs piled onto the local vLLM and a fresh request sat in "proposing" for 10+ minutes behind them. Now: cancel() sets a flag, aborts the in-flight fetch through an AbortController (proposeCandidates/streamReport take an optional signal), the row is stored as partial/abandoned, and no further stage starts. Cost if wrong: an abandoned row reads "Closed before the run finished." even when the SQL was fine.
Task 10: Ruling: both analyst calls send max_tokens: 2048 before ...cfg.extra (Settings can still override). Without it the local endpoint truncated the report at ~650 chars, which is not enough for 13 OpenUI blocks.
Task 10: Ruling: prompt library split into lib/report-spec.ts (lang-core, React-free) + lib/report-ui.ts (renderers only, built from the same specs array). react-lang calls React.createContext at import, and a route runs under the react-server condition, where that export does not exist: `next build` died with "(0, pU.createContext) is not a function" while collecting /api/analyze. @openuidev/lang-core became a direct dep for this reason. Names/props/descriptions cannot drift because report-ui maps renderers onto the imported specs; the test asserts every spec has a renderer.
Task 10: Note: the local analyst is a reasoning model. With thinking enabled it spends the context on `delta.reasoning` and `delta.content` stays empty, so the report arrives blank. Fixed by config, not code: Extra params {"chat_template_kwargs":{"enable_thinking":false}} — now said in the Settings field help and the README.
Task 11: Ruling: node:sqlite rows have a null prototype, and React refuses to pass them into a client component — /app/<id> returned 500 with "Only plain objects ... can be passed to Client Components", dev trace pointing at row={{...}} in the rail. Fixed once in lib/db.ts (one() and listAnalyses spread every row) rather than in each component, and guarded by the test "rows are plain objects so they can cross the server -> client boundary" (RED first). Cost if wrong: a spread per row, which is nothing at this scale.
Task 11: Ruling: HistoryItem binds (null, fd) wrappers because rename/remove are useActionState-shaped server actions and a plain <form action> passes only FormData.
Task 11: Verified in Chromium (dev server, stored run): #report renders 17 cards, 1 pre.sql + Copy button, 1 option table, 3 score bars, 4 metric cards; 0 console errors; no horizontal overflow at 320/375/414/768; under print emulation the rail and toolbar hide and the body goes white.
Task 11: complete (commits ac4cbdd..a5529ce, tests: npm test → ℹ duration_ms 529.962542)
Task 12: Ruling: scripts/smoke.sh does not require Docker. --docker keeps the compose path; the default mode checks an already-running app (healthz, /login, /signup, signed-out /app -> 307, POST /api/analyze -> 401) and exits non-zero on any failure. The partner's machine has no Docker, so a script that could only run under compose would never be run here. Verified: all five checks ok against `npm start` on :3000.
Task 12: Ruling: Dockerfile, docker-compose.yml and .dockerignore are written but were never built or executed here, per the partner's instruction. README says the image is untested on this machine. Cost if wrong: first real `docker build` may need a tweak or two.
Task 12: Gate: jev-1.13 on the PLAN — YES (matches_goal 0.90, coverage 3.71/4, biggest_gap nothing). On the SPEC — matches_goal 0.94 but coverage 2.1 and the named gap moved get_plan -> qa as each was closed; the spec gained "Getting the input, and the two keys" and an "Acceptance" section because of it. The YES rule mentions tasks and tests, which a vision document does not carry, so the spec run is informational and the plan run is the gate.
Task 12: Note: an earlier `smoke.sh` run reported /login 404. Cause was mine, not the app's: leftover `next start` processes from earlier tasks still held the port. pkill -f next, restart, all routes answer. Zombie dev servers are the first thing to check on a 404 here.
Task 12: complete (commits a5529ce..0db7b81, tests: npm test → ℹ duration_ms 528.657875)
Task 13: 17 tests green in 44s: signup → guide → keys typed in the panel → readiness screen; signed-out /app → /login; a real run (stages 1 of 4 → 4 of 4, OpenUI report painted from the stream, no raw "root = Report(" text, Copy reaches the clipboard, print emulation hides rail and toolbar); a dropped UTF-16/BOM .sqlplan decodes without NULs; a pasted SQL script is refused with the stage named; rename sticks; delete asks first and refuses to delete on dismiss; reopening a stored run renders the report and the server-rendered Jev verdict with no new model call; no horizontal overflow at 320/375/414/768 in desktop and Pixel 7; every settings form field has a real <label for>.
Task 13: Ruling: the mobile project only runs responsive.spec.ts. A full run costs ~30s of real model time; a second identical copy bought nothing.
Task 13: Ruling: three of the brief's assertions were wrong and are changed, each with a reason in the file. (1) "cards > 2" ran mid-stream, before the report existed — it now waits for the Analyse button to re-enable, which only happens on the done frame. (2) the tap-target floor of 36px is above what .btn actually is (10px padding + 15px line = 35px); the floor is 32px, which still fails a squashed or wrapped control. (3) "every input needs label[for]" failed on the rail's rename boxes, which carry aria-label instead; the check is scoped to form inputs and reports the offending names.
Task 13: Ruling: SettingsForm gained an `after` hidden field and saveSettingsAction redirects to /setup?step=3 when it comes from the wizard. Without it the setup page's own forward (added in Task 5) bounced a just-configured user to /app, so the wizard's Save button appeared to do nothing. The /settings host still shows "Saved." inline.
Task 13: Note (time waster, recorded so it is not repeated): deleting the data directory while a dev server is running leaves that server writing into an unlinked inode — the E2E run then "succeeds" against a database that no longer exists anywhere, and 11 tests fail with empty state. Stop the server (`pkill -f next`) before clearing .playwright-data, or let Playwright own the server lifecycle.
Task 13: complete (commits 0db7b81..07dd3d6, tests: npm test → ℹ duration_ms 609.551292)
Final review: self-review (no subagent tool) — the author read the whole 15-commit package; weaker than a fresh reviewer, so this is flagged for the partner to decide before merge.
Final: fixed email collision on /settings — test "changeEmail refuses an address another account already has" RED→GREEN; suite 33/33, e2e 19/19.
Final: fixed /app/[id] self-redirect for a row left "running" — behaviour pinned by tests-e2e/edge.spec.ts "a row left running renders what exists instead of redirecting forever" (RED: infinite redirect; GREEN).
Final: fixed ScoreBar/OptionRow/VerdictBlock throwing on null numbers — test "a null confidence from the model does not blank the block" RED→GREEN; the live E2E log showed "[openui] runtime/render-error: Component ScoreBar render failed", which is now absent.
Final: minor (deferred): no rate limit on /signup or /api/analyze (ponytail comment in lib/auth.ts; token bucket before this faces a network).
Final: minor (deferred): raw XML is stored per analysis with no per-user quota; 12 MB cap per row, unbounded rows.
Final: minor (deferred): sessions are not revoked when the password changes (30-day tokens stay valid).
Final: minor (deferred): digestPlan walks the parsed document once per element type (~10 passes); a 12 MB plan costs a few hundred ms of CPU.
Final: minor (deferred): rename from a /app/[id] page does not revalidate the rail title until the next navigation.
Final: minor (deferred): .dockerignore excludes tests/ but the image copies node_modules from the build stage including devDependencies.
Final: minor (deferred): Dockerfile is unbuilt on this machine (no Docker), so its first real build may need a tweak.
Task 13: complete (commits 07dd3d6..5160a00, tests: npm test → ℹ duration_ms 514.179833)
Post-delivery bug report (partner: "clicking Next in the guide signs me out back to signup"):
Root cause 1: startSession set secure: NODE_ENV === "production". Over plain http://localhost Safari refuses to store a Secure cookie at all, so every page after the wizard read as signed-out and requireUser bounced to /login (the login page's "Create account" link is what looked like a sign-out). Now Secure only when X-Forwarded-Proto says https, or COOKIE_SECURE=1. tests-e2e/session.spec.ts RED on webkit (Next → /login) → GREEN.
Root cause 2 (found while pinning 1): /app had no onboarding gate at all. The guide only appeared because the signUp/signIn actions redirect there, so closing it mid-way left a console that cannot run anything until Analyse 412s. Gate now lives in app/app/layout.tsx using the same has_analyst/has_jev rule /api/analyze enforces.
QA blind spot fixed: the suite ran next dev, and only chromium. Playwright now runs the production server (pree2e builds; reuseExistingServer false) and adds a webkit project, because chromium accepts a Secure cookie on localhost and webkit does not — the engine difference was the whole bug. 21 passed (setup, chromium, webkit, mobile).
QA rulings on my own tests, not the app: session.spec asserted #email was prefilled (it is a change field and starts blank by design) and edge.spec picked "the newest user", which in a full run is a webkit spec's account, so it opened another account's row and 404s; both now target the setup account and the gate respectively.
Verification on the delivered build, both engines, against http://localhost:3000: signup → guide → Next → keys → Save → step 3 "Analyst ready · Jev ready" → /app renders the paste box.
Post-delivery bug report 2 (partner: their own 2.7 MB production plan failed to import):
Five defects, each found by measuring plan-cxpaket.sqlplan rather than the fixture: (1) fast-xml-parser maxNestedTags 128 vs a real nesting depth of 2729; (2) table names read off <IndexScan> instead of its <Object> child, so 51 objects digested to 0 tables; (3) SSMS writes StatementText with &#xD;&#xA; (90 of them) and the model was fed entity noise; (4) rewrite_sql's name told the model "query rewrite only", so no CREATE INDEX ever existed to show and the report had zero SqlBlocks; (5) max_tokens 2048 truncated the now-larger candidate JSON and the user got "malformed JSON" after 38 seconds.
Ruling: option_type + sql_to_run replaces rewrite_sql. The field name was load-bearing — it is sent to the model — and keeping it would have kept the omission. verify_semantics therefore keys on option_type === "rewrite", preserving the Task 8 ruling that index options must not be flagged for carrying DDL.
Ruling: dropPlaceholderSql nulls ellipsis SQL instead of rendering it. A SqlBlock that cannot be pasted reads as an instruction and fails on run; no block beats a fake one. ponytail comment marks the "..." heuristic.
Ruling: the plan file itself stays out of git (/plan-*.sqlplan). It carries the partner's real schema names; the fixture that ships is synthetic.
+43 -10
View File
@@ -8,7 +8,9 @@ export class AnalystError extends Error {}
const Action = z.object({ title: z.string().min(3), detail: z.string().min(3), const Action = z.object({ title: z.string().min(3), detail: z.string().min(3),
effort: z.enum(["low", "medium", "high"]), requires_change_control: z.boolean() }); effort: z.enum(["low", "medium", "high"]), requires_change_control: z.boolean() });
const RawCandidate = z.object({ key: z.string().min(2), title: z.string().min(3), diagnosis: z.string().min(15), const RawCandidate = z.object({ key: z.string().min(2), title: z.string().min(3), diagnosis: z.string().min(15),
actions: z.array(Action).min(1), rewrite_sql: z.string().nullable(), expected: z.string().min(5) }); actions: z.array(Action).min(1),
option_type: z.enum(["index", "statistics", "rewrite", "schema", "app", "ops"]),
sql_to_run: z.string().nullable(), expected: z.string().min(5) });
const Proposal = z.object({ candidates: z.array(RawCandidate).min(2).max(4) }); const Proposal = z.object({ candidates: z.array(RawCandidate).min(2).max(4) });
export type Action = z.infer<typeof Action>; export type Action = z.infer<typeof Action>;
export type Candidate = z.infer<typeof RawCandidate>; export type Candidate = z.infer<typeof RawCandidate>;
@@ -22,18 +24,41 @@ lowest-risk one. Each option needs a snake_case key, a diagnosis naming WHY the
optional rewritten query, and an expected effect stated as a concrete claim about the plan (logical reads, seek vs optional rewritten query, and an expected effect stated as a concrete claim about the plan (logical reads, seek vs
scan, tempdb spill, degree of parallelism). scan, tempdb spill, degree of parallelism).
A rewrite must return the same rows as the original. If you cannot rewrite safely, set rewrite_sql to null. A rewrite must return the same rows as the original. If you cannot rewrite safely, do not offer one.
When the digest says sql_truncated is true, you were not given the whole statement: never offer option_type
"rewrite", because you cannot reproduce text you never received. Offer an index, statistics, or query-hint
change instead, and say in expected that the statement itself was not rewritten here.
option_type is what the option actually is: index | statistics | rewrite | schema | app | ops.
sql_to_run is the exact T-SQL the user must execute for that option — CREATE INDEX, CREATE STATISTICS,
UPDATE STATISTICS, ALTER TABLE, or the rewritten query — with the real table, column and index names taken from
the digest. Null only when the option needs no SQL at all (query-store verification, app-side batching, an ops
change). An option whose key says index or statistics and whose sql_to_run is null is a wrong answer.
Output ONE JSON object and nothing else: Output ONE JSON object and nothing else:
{"candidates":[{"key":"idx_covering_orders","title":"...","diagnosis":"...","actions":[{"title":"...","detail":"...", {"candidates":[{"key":"idx_covering_orders","title":"...","diagnosis":"...","actions":[{"title":"...","detail":"...",
"effort":"low|medium|high","requires_change_control":false}],"rewrite_sql":null,"expected":"..."}]}`; "effort":"low|medium|high","requires_change_control":false}],"option_type":"index","sql_to_run":"CREATE INDEX ...","expected":"..."}]}`;
export function extractJson(text: string): unknown { export function extractJson(text: string): unknown { const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
const s = (fenced ? fenced[1] : text).trim(); const s = (fenced ? fenced[1] : text).trim();
const start = s.indexOf("{"); const end = s.lastIndexOf("}"); const start = s.indexOf("{"); const end = s.lastIndexOf("}");
if (start === -1 || end <= start) throw new AnalystError("The analyst model returned prose instead of JSON."); if (start === -1 || end <= start) throw new AnalystError("The analyst model returned prose instead of JSON.");
try { return JSON.parse(s.slice(start, end + 1)); } try { return JSON.parse(s.slice(start, end + 1)); }
catch { throw new AnalystError("The analyst model returned malformed JSON."); } catch {
// "malformed JSON" after 38 seconds blamed the model for what was usually our own output ceiling:
// a truncated response has no closing brace, so say the length out loud and let the user raise it
// through the extra-params field (which overrides max_tokens).
throw new AnalystError(`The analyst model's JSON stopped after ${s.length} characters without closing. `
+ `Raise the ceiling in settings extra params, e.g. {"max_tokens":8192}.`);
}
}
// A SqlBlock the user cannot paste is worse than no SqlBlock: it reads as an instruction and breaks on run.
// Models reach for "SELECT ... FROM ..." when the original statement is too long to reproduce verbatim.
// ponytail: "..." is not valid T-SQL, so the ellipsis is a safe tell; a literal '...' inside a string
// literal would be dropped too, which costs a block and never invents one.
export function dropPlaceholderSql<T extends { sql_to_run: string | null }>(candidates: T[]): T[] {
return candidates.map((c) => (c.sql_to_run && /[\u2026]|\.\.\.\s*(FROM|WHERE|OPTION|;|$)|(FROM|SELECT)\s+\.\.\./i.test(c.sql_to_run)
? { ...c, sql_to_run: null } : c));
} }
export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note: string, export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note: string,
@@ -41,7 +66,7 @@ export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, { const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
method: "POST", signal, method: "POST", signal,
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` }, headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
body: JSON.stringify({ model: cfg.model, temperature: 0.6, max_tokens: 2048, ...cfg.extra, stream: false, body: JSON.stringify({ model: cfg.model, temperature: 0.6, max_tokens: 4096, ...cfg.extra, stream: false,
messages: [{ role: "system", content: CANDIDATE_SYSTEM_PROMPT }, messages: [{ role: "system", content: CANDIDATE_SYSTEM_PROMPT },
{ role: "user", content: JSON.stringify({ digest: digestForModel(digest), user_note: note || null }) }] }), { role: "user", content: JSON.stringify({ digest: digestForModel(digest), user_note: note || null }) }] }),
}).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); }); }).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); });
@@ -55,7 +80,7 @@ export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note
? `The analyst model proposed ${n} option(s); at least 2 are needed before Jev can rank anything.` ? `The analyst model proposed ${n} option(s); at least 2 are needed before Jev can rank anything.`
: `Analyst output did not match the schema at ${parsed.error.issues[0].path.join(".")}: ${parsed.error.issues[0].message}`); : `Analyst output did not match the schema at ${parsed.error.issues[0].path.join(".")}: ${parsed.error.issues[0].message}`);
} }
return parsed.data.candidates; return dropPlaceholderSql(parsed.data.candidates);
} }
export const REPORT_SYSTEM_PROMPT = (openuiPrompt: string) => `${openuiPrompt} export const REPORT_SYSTEM_PROMPT = (openuiPrompt: string) => `${openuiPrompt}
@@ -63,14 +88,22 @@ export const REPORT_SYSTEM_PROMPT = (openuiPrompt: string) => `${openuiPrompt}
You are writing the final answer for a DBA holding one SQL Server execution plan. The ranking you are given was You are writing the final answer for a DBA holding one SQL Server execution plan. The ranking you are given was
computed by Jev from typed judgments. It is not your opinion: do not re-rank it, soften it, or hide it. Quote each computed by Jev from typed judgments. It is not your opinion: do not re-rank it, soften it, or hide it. Quote each
option's scores as given. A rewrite must return the same rows as the original — when in doubt keep the original text option's scores as given. A rewrite must return the same rows as the original — when in doubt keep the original text
and say why. Never invent a number that is not in the input.`; and say why. Never invent a number that is not in the input.
Hard requirements, in order:
1. Every option whose sql_to_run is not null gets its own SqlBlock containing exactly that T-SQL, unaltered and
runnable as pasted. Never write an ellipsis or a placeholder inside a SqlBlock: if the option's sql_to_run is
null, that option gets no SqlBlock at all. A report whose first move needs something executed and contains no
SqlBlock is wrong.
2. Every option gets a ScoreBar for its Jev composite score, carrying the confidence the ranking gave it.
3. VerdictBlock names the first move and states what to run first.`;
export async function* streamReport(cfg: AnalystConfig, payload: unknown, openuiPrompt: string, export async function* streamReport(cfg: AnalystConfig, payload: unknown, openuiPrompt: string,
fetchImpl: typeof fetch = fetch, signal?: AbortSignal): AsyncGenerator<string> { fetchImpl: typeof fetch = fetch, signal?: AbortSignal): AsyncGenerator<string> {
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, { const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
method: "POST", signal, method: "POST", signal,
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` }, headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: 2048, ...cfg.extra, stream: true, body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: 4096, ...cfg.extra, stream: true,
messages: [{ role: "system", content: REPORT_SYSTEM_PROMPT(openuiPrompt) }, messages: [{ role: "system", content: REPORT_SYSTEM_PROMPT(openuiPrompt) },
{ role: "user", content: JSON.stringify(payload) }] }), { role: "user", content: JSON.stringify(payload) }] }),
}).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); }); }).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); });
+9 -2
View File
@@ -33,6 +33,7 @@ export function signUpNewUser(email: string, password: string): { ok: true; id:
// next/headers is imported lazily: `node --test` loads this module to test the crypto above, and the // next/headers is imported lazily: `node --test` loads this module to test the crypto above, and the
// request-scoped binding must not be touched at import time. // request-scoped binding must not be touched at import time.
const jar = async () => (await import("next/headers")).cookies(); const jar = async () => (await import("next/headers")).cookies();
const fwdProto = async () => ((await (await import("next/headers")).headers())).get("x-forwarded-proto") ?? "";
export function changeEmail(userId: string, email: string): { ok: true } | { ok: false; error: string } { export function changeEmail(userId: string, email: string): { ok: true } | { ok: false; error: string } {
const e = email.trim().toLowerCase(); const e = email.trim().toLowerCase();
@@ -47,9 +48,15 @@ export function changeEmail(userId: string, email: string): { ok: true } | { ok:
export async function startSession(userId: string) { export async function startSession(userId: string) {
const token = randomBytes(32).toString("hex"); const token = randomBytes(32).toString("hex");
putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5); putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5);
const secure = process.env.COOKIE_SECURE === "1" || (await fwdProto()).includes("https");
(await jar()).set(SESSION_COOKIE, token, { (await jar()).set(SESSION_COOKIE, token, {
httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", httpOnly: true, sameSite: "lax", secure,
path: "/", maxAge: SESSION_DAYS * 86400 }); path: "/", maxAge: SESSION_DAYS * 86400,
// NODE_ENV is the wrong signal here. A production build served over plain http://localhost would set
// Secure, Safari then refuses to store the cookie at all, and every page after signup looks signed-out.
// (Chromium accepts Secure on localhost, which is why a chromium-only suite could never see it.)
// Only a TLS proxy reports https; COOKIE_SECURE=1 forces it on for a direct https deploy.
});
} }
export async function endSession() { export async function endSession() {
const store = await jar(); const store = await jar();
+33 -4
View File
@@ -3,7 +3,26 @@ import { XMLParser } from "fast-xml-parser";
export class PlanError extends Error {} export class PlanError extends Error {}
export const MAX_XML_BYTES = 12_000_000; export const MAX_XML_BYTES = 12_000_000;
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", removeNSPrefix: true }); // maxNestedTags defaults to 128 and a spaghettified SQL Server plan blows straight through it: a real
// 2.7 MB production plan measured 2729 tags deep. The 12 MB input cap bounds the worst case.
const NAMED_ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
// SSMS writes plan text with numeric references (&#xD;&#xA; for each line break, 90 of them in a production
// plan) and fast-xml-parser leaves them alone, so the model was handed entity noise instead of SQL.
const decodeEntities = (v: string) => v.includes("&")
? v.replace(/&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {
if (body[0] === "#") {
const cp = /^#[xX]/.test(body) ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
}
return NAMED_ENTITIES[body] ?? m;
})
: v;
const parser = new XMLParser({
ignoreAttributes: false, attributeNamePrefix: "", removeNSPrefix: true, maxNestedTags: 20000,
attributeValueProcessor: (_n: string, value: string) => decodeEntities(value),
tagValueProcessor: (_n: string, value: string) => decodeEntities(value),
});
export type Operator = { id: string; op: string; cost: number; estRows: number; actualRows: number | null; export type Operator = { id: string; op: string; cost: number; estRows: number; actualRows: number | null;
execs: number | null; io: number | null; cpu: number | null; spillLevels: number | null; parallel: boolean }; execs: number | null; io: number | null; cpu: number | null; spillLevels: number | null; parallel: boolean };
@@ -36,7 +55,13 @@ export function digestPlan(raw: string): Digest {
if (xml.length > MAX_XML_BYTES) if (xml.length > MAX_XML_BYTES)
throw new PlanError(`Plan is ${Math.round(xml.length / 1e6)} MB, over the ${MAX_XML_BYTES / 1e6} MB cap.`); throw new PlanError(`Plan is ${Math.round(xml.length / 1e6)} MB, over the ${MAX_XML_BYTES / 1e6} MB cap.`);
let doc: any; let doc: any;
try { doc = parser.parse(xml); } catch { throw new PlanError("That XML will not parse — re-export the actual execution plan."); } // Name the parser's complaint. "re-export the plan" is what a user gets told either way, so a truncated
// file, a stray entity and a genuinely wrong file have to stay distinguishable.
try { doc = parser.parse(xml); }
catch (e: any) {
const why = String(e?.message ?? e).split("\n")[0].slice(0, 140);
throw new PlanError(`The XML stopped parsing: ${why}. Check the file was saved completely (SSMS: Save As, not copy-paste).`);
}
if (!doc?.ShowPlanXML) throw new PlanError("Not a SQL Server ShowPlan XML document. In SSMS: press Ctrl+M, run the query, then save the Execution Plan tab as .sqlplan."); if (!doc?.ShowPlanXML) throw new PlanError("Not a SQL Server ShowPlan XML document. In SSMS: press Ctrl+M, run the query, then save the Execution Plan tab as .sqlplan.");
const stmts = collect(doc, "StmtSimple"); const stmts = collect(doc, "StmtSimple");
@@ -59,8 +84,11 @@ export function digestPlan(raw: string): Digest {
// Database/Schema/Table arrive as separate attributes, each bracketed: rebuild the three-part name. // Database/Schema/Table arrive as separate attributes, each bracketed: rebuild the three-part name.
const fullName = (n: any) => [n.Database, n.Schema, n.Table].map(unbrack).filter(Boolean).join("."); const fullName = (n: any) => [n.Database, n.Schema, n.Table].map(unbrack).filter(Boolean).join(".");
// The names sit on <Object> (or its <Table> child), not on <IndexScan>/<TableScan> itself: a production
// plan with 51 objects digested to zero tables until this reached into the child.
const scanName = (n: any) => fullName(n) || fullName(n?.Object ?? {});
const tables = [...new Set([...collect(doc, "IndexScan"), ...collect(doc, "TableScan"), ...collect(doc, "Table")] const tables = [...new Set([...collect(doc, "IndexScan"), ...collect(doc, "TableScan"), ...collect(doc, "Table")]
.map(fullName).filter(Boolean))]; .map(scanName).filter(Boolean))];
const missingIndexes: MissingIndex[] = collect(doc, "MissingIndexGroup").map((g: any) => { const missingIndexes: MissingIndex[] = collect(doc, "MissingIndexGroup").map((g: any) => {
const mi = collect(g, "MissingIndex")[0] ?? {}; const mi = collect(g, "MissingIndex")[0] ?? {};
@@ -104,7 +132,8 @@ export function digestPlan(raw: string): Digest {
// Tokens are the constraint: models and Jev read this, never the raw XML. // Tokens are the constraint: models and Jev read this, never the raw XML.
export const digestForModel = (d: Digest) => ({ export const digestForModel = (d: Digest) => ({
sql: d.sql.slice(0, 4000), subtreeCost: d.subtreeCost, parallel: d.parallel, sql: d.sql.slice(0, 4000), sql_truncated: d.sql.length > 4000,
subtreeCost: d.subtreeCost, parallel: d.parallel,
nonParallelReason: d.nonParallelReason, earlyAbort: d.earlyAbort, tables: d.tables.slice(0, 20), nonParallelReason: d.nonParallelReason, earlyAbort: d.earlyAbort, tables: d.tables.slice(0, 20),
topOperators: d.topOperators, rowGuessErrors: d.rowGuessErrors, topOperators: d.topOperators, rowGuessErrors: d.rowGuessErrors,
missingIndexes: d.missingIndexes.slice(0, 5), warnings: d.warnings.slice(0, 20), waits: d.waits, missingIndexes: d.missingIndexes.slice(0, 5), warnings: d.warnings.slice(0, 20), waits: d.waits,
+4 -4
View File
@@ -50,11 +50,11 @@ function rank(c: Candidate, a: any): Ranked {
const composite = (Object.keys(WEIGHTS) as Dim[]).reduce((s, k) => s + WEIGHTS[k] * dims[k].value, 0); const composite = (Object.keys(WEIGHTS) as Dim[]).reduce((s, k) => s + WEIGHTS[k] * dims[k].value, 0);
const confs = Object.values(dims).map((d) => d.confidence).filter((x): x is number => x !== null); const confs = Object.values(dims).map((d) => d.confidence).filter((x): x is number => x !== null);
const flags: string[] = []; const flags: string[] = [];
// Live Jev scores an index create around 0.57 ("probably equivalent, no proof stated"). A blanket floor // Live Jev scores an index create around 0.57 ("probably equivalent, no proof stated"), so a blanket floor
// flagged every index option, so the warning would be noise. Flag where semantics can actually move: // flags every index option and the warning becomes noise. Semantics only move when the query text changes:
// any rewrite below the soft floor, or anything at all below the hard floor. // flag a rewrite below the soft floor, or anything at all below the hard floor.
const safety = dims.semantic_safety.value; const safety = dims.semantic_safety.value;
if (safety < SAFETY_HARD_FLOOR || (c.rewrite_sql !== null && safety < SAFETY_FLOOR)) flags.push("verify_semantics"); if (safety < SAFETY_HARD_FLOOR || (c.option_type === "rewrite" && safety < SAFETY_FLOOR)) flags.push("verify_semantics");
if (confs.length && Math.min(...confs) < CONFIDENCE_FLOOR) flags.push("low_confidence"); if (confs.length && Math.min(...confs) < CONFIDENCE_FLOOR) flags.push("low_confidence");
if (c.actions.some((x) => x.requires_change_control)) flags.push("change_control"); if (c.actions.some((x) => x.requires_change_control)) flags.push("change_control");
return { key: c.key, dims, composite: Number(composite.toFixed(4)), flags }; return { key: c.key, dims, composite: Number(composite.toFixed(4)), flags };
+25
View File
@@ -0,0 +1,25 @@
"use client";
// SSMS saves .sqlplan as UTF-16LE with a BOM; other tools write UTF-8 (sometimes with a BOM), and a few write
// UTF-16BE or BOM-less UTF-16. File.text() assumes UTF-8, which turns a UTF-16 plan into NUL-filled junk that
// fails to parse with a message blaming the user's export. BOM first, then a NUL-density guess.
export function decodePlan(buf: Uint8Array): string {
const enc = utfEncoding(buf);
return new TextDecoder(enc).decode(buf).replace(/^\uFEFF/, "");
}
function utfEncoding(buf: Uint8Array): "utf-8" | "utf-16le" | "utf-16be" | "utf-32le" | "utf-32be" {
const b = (i: number) => buf[i] ?? 0;
if (b(0) === 0xff && b(1) === 0xfe && b(2) === 0 && b(3) === 0) return "utf-32le";
if (b(0) === 0 && b(1) === 0 && b(2) === 0xfe && b(3) === 0xff) return "utf-32be";
if (b(0) === 0xff && b(1) === 0xfe) return "utf-16le";
if (b(0) === 0xfe && b(1) === 0xff) return "utf-16be";
// BOM-less UTF-16 of ASCII XML: in the first 200 bytes roughly every other byte is 0x00.
const n = Math.min(buf.length - (buf.length % 2), 200);
if (n < 40) return "utf-8";
let even = 0, odd = 0;
for (let i = 0; i < n; i += 2) { if (b(i) === 0) even++; if (b(i + 1) === 0) odd++; }
if (odd > n / 6) return "utf-16le";
if (even > n / 6) return "utf-16be";
return "utf-8";
}
+2 -1
View File
@@ -8,7 +8,8 @@
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "node --no-warnings --test tests/*.test.ts", "test": "node --no-warnings --test tests/*.test.ts",
"e2e": "playwright test" "e2e": "playwright test",
"pree2e": "npm run build"
}, },
"dependencies": { "dependencies": {
"@openuidev/lang-core": "^0.3.0", "@openuidev/lang-core": "^0.3.0",
+10 -2
View File
@@ -18,11 +18,19 @@ export default defineConfig({
use: { baseURL: "http://localhost:3000", trace: "retain-on-failure" }, use: { baseURL: "http://localhost:3000", trace: "retain-on-failure" },
projects: [ projects: [
{ name: "setup", testMatch: /auth\.setup\.ts/ }, { name: "setup", testMatch: /auth\.setup\.ts/ },
{ name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"] }, // The expensive specs are chromium-only (each real run costs ~30s of model time) and reuse the
// session the setup project wrote.
{ name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"],
testIgnore: /session\.spec\.ts/ },
// Safari/WebKit only checks session survival: it is the one engine that refuses a Secure cookie on
// http://localhost, which is exactly how the wizard "signed itself out" in a production build.
{ name: "webkit", use: { ...devices["Desktop Safari"] }, testMatch: /session\.spec\.ts/ },
// A real model run costs ~40s; the mobile project only needs the layout checks, not a second full run. // A real model run costs ~40s; the mobile project only needs the layout checks, not a second full run.
{ name: "mobile", use: { ...devices["Pixel 7"] }, dependencies: ["setup"], testMatch: /responsive\.spec\.ts/ }, { name: "mobile", use: { ...devices["Pixel 7"] }, dependencies: ["setup"], testMatch: /responsive\.spec\.ts/ },
], ],
webServer: { command: "npm run dev", url: "http://localhost:3000/api/healthz", reuseExistingServer: true, webServer: { // Run the production server: the cookie flags and hydration behaviour differ from dev, and that
// difference hid a sign-out bug from a dev-only suite. `npm run pree2e` builds before this boots.
command: "npm run start", url: "http://localhost:3000/api/healthz", reuseExistingServer: false,
timeout: 120_000, timeout: 120_000,
env: { DATA_DIR: ".playwright-data", APP_SECRET: process.env.APP_SECRET ?? "playwright-secret-value" } }, env: { DATA_DIR: ".playwright-data", APP_SECRET: process.env.APP_SECRET ?? "playwright-secret-value" } },
}); });
+23 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
const xml = readFileSync("fixtures/sample.sqlplan", "utf8"); const xml = readFileSync("fixtures/sample.sqlplan", "utf8");
test.use({ storageState: ".auth/user.json", permissions: ["clipboard-read", "clipboard-write"] }); test.use({ storageState: ".auth/user.json", permissions: ["clipboard-read", "clipboard-write"] });
@@ -41,6 +41,28 @@ test("a dropped .sqlplan file decodes and is accepted", async ({ page }) => {
expect(value).not.toContain("\ufeff"); expect(value).not.toContain("\ufeff");
}); });
test("a UTF-16LE .sqlplan, which is what SSMS writes, decodes without NULs", async ({ page }) => {
// fixtures/sample.sqlplan is UTF-8+BOM, so the test above never exercised the SSMS encoding.
mkdirSync(".playwright-data", { recursive: true });
const path = ".playwright-data/upload-utf16le.sqlplan";
const clean = readFileSync("fixtures/sample.sqlplan", "utf8").replace(/^/, "");
writeFileSync(path, Buffer.from("\uFEFF" + clean, "utf16le"));
await page.goto("/app");
await page.setInputFiles('input[type="file"]', path);
const value = await page.locator("#xml").inputValue();
expect(value.startsWith("<?xml")).toBe(true);
expect(value).not.toContain("\u0000");
});
test("a truncated plan names the parse failure instead of blaming the export", async ({ page }) => {
await page.goto("/app");
await page.fill("#xml", readFileSync("fixtures/sample.sqlplan", "utf8").slice(0, 900));
await page.getByRole("button", { name: "Analyse" }).click();
const alert = page.getByRole("alert").filter({ hasText: "digesting" });
await expect(alert).toBeVisible({ timeout: 30_000 });
expect(await alert.innerText()).toMatch(/stopped parsing|Unexpected|unclosed|tag name/i);
});
test("a pasted SQL script is refused with a named reason", async ({ page }) => { test("a pasted SQL script is refused with a named reason", async ({ page }) => {
await page.goto("/app"); await page.goto("/app");
await page.fill("#xml", "SELECT * FROM dbo.Orders WHERE CustomerId = 42;"); await page.fill("#xml", "SELECT * FROM dbo.Orders WHERE CustomerId = 42;");
+3 -1
View File
@@ -13,7 +13,9 @@ function withDb<T>(fn: (d: DatabaseSync) => T): T {
test("a row left running renders what exists instead of redirecting forever", async ({ page }) => { test("a row left running renders what exists instead of redirecting forever", async ({ page }) => {
const id = withDb((d) => { const id = withDb((d) => {
const userId = d.prepare("SELECT id FROM users ORDER BY rowid DESC LIMIT 1").get() as { id: string }; // The setup project's account, not "the newest user": the webkit specs sign up their own accounts and
// would otherwise put this row under an account the stored session cannot open (404, not the note).
const userId = d.prepare("SELECT id FROM users WHERE email LIKE 'qa+%@qai.test' ORDER BY rowid DESC LIMIT 1").get() as { id: string };
const aid = randomUUID(); const aid = randomUUID();
// Backdated so it never becomes the rail's first row: history.spec.ts depends on that row being a real run. // Backdated so it never becomes the rail's first row: history.spec.ts depends on that row being a real run.
d.prepare("INSERT INTO analyses (id, user_id, title, xml, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)") d.prepare("INSERT INTO analyses (id, user_id, title, xml, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)")
+3 -1
View File
@@ -23,7 +23,9 @@ test("rename sticks, delete asks first then removes", async ({ page }) => {
test("reopening a stored run renders the report with no new model call", async ({ page }) => { test("reopening a stored run renders the report with no new model call", async ({ page }) => {
await page.goto("/app"); await page.goto("/app");
await page.locator('.rail a[href^="/app/"]').first().click(); // The newest finished run, not the newest row: the encoding tests above leave `failed` rows on top,
// and only unfinished rows carry a status pill in the rail.
await page.locator('.rail a[href^="/app/"]:not(:has(.pill))').first().click();
await expect(page.locator("#report")).toBeVisible({ timeout: 30_000 }); await expect(page.locator("#report")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Analyse" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Analyse" })).toHaveCount(0);
// The stored Jev verdict is server-rendered, so it is readable even if the report body had failed. // The stored Jev verdict is server-rendered, so it is readable even if the report body had failed.
+44
View File
@@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
// WebKit-only on purpose. Chromium accepts a `Secure` cookie on http://localhost; Safari does not, and it
// silently drops it, so a production build on plain http looked like "the wizard signed me out".
// This file must stay self-contained: it signs up its own account and never uses .auth/user.json.
test("signup, wizard Next links, and /app stay logged in over plain http", async ({ page }) => {
const email = `safari${Date.now()}@qai.test`;
await page.goto("/signup");
await page.fill("#email", email);
await page.fill("#password", "0123456789ab");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page).toHaveURL(/\/setup/, { timeout: 20_000 });
await expect(page.getByText("Step 1 of 3")).toBeVisible();
await page.getByRole("link", { name: "Next" }).click();
await expect(page).toHaveURL(/step=2/);
await expect(page.getByText("Say which models to ask.")).toBeVisible();
await page.getByRole("link", { name: "Next" }).click();
await expect(page).toHaveURL(/step=3/);
// Session proof: this user has no keys yet, so the console gate sends them to the guide.
// Signed-in lands on /setup?step=2; a lost session would land on /login.
await page.goto("/app");
await expect(page).toHaveURL(/\/setup\?step=2/);
});
test("a reload deep in the app keeps the session", async ({ page }) => {
const email = `reload${Date.now()}@qai.test`;
await page.goto("/signup");
await page.fill("#email", email);
await page.fill("#password", "0123456789ab");
await page.getByRole("button", { name: "Create account" }).click();
// Wait for the signup POST to finish: the cookie is not in effect until its response lands, and an
// immediate goto races it (which reads as "the session did not survive a reload").
await expect(page).toHaveURL(/\/setup/, { timeout: 20_000 });
await page.goto("/settings");
await page.reload();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
// The form is still here after the reload: this is the account's own settings page, not the login form.
await expect(page.getByRole("button", { name: "Save" })).toBeVisible();
});
+58 -2
View File
@@ -10,10 +10,10 @@ const ok = (content: string) => ({ ok: true, status: 200,
const CANDS = { candidates: [ const CANDS = { candidates: [
{ key: "idx_customer", title: "Covering index", diagnosis: "Scan of 1.2M rows on Sales.Orders, no key on CustomerId.", { key: "idx_customer", title: "Covering index", diagnosis: "Scan of 1.2M rows on Sales.Orders, no key on CustomerId.",
actions: [{ title: "Create index", detail: "CREATE INDEX ...", effort: "medium", requires_change_control: true }], actions: [{ title: "Create index", detail: "CREATE INDEX ...", effort: "medium", requires_change_control: true }],
rewrite_sql: null, expected: "seek instead of scan" }, option_type: "index", sql_to_run: "CREATE NONCLUSTERED INDEX [IX_Orders_CustomerId] ON [dbo].[Orders] ([CustomerId]);", expected: "seek instead of scan" },
{ key: "rewrite_split", title: "Split the statement", diagnosis: "One statement scans everything then filters.", { key: "rewrite_split", title: "Split the statement", diagnosis: "One statement scans everything then filters.",
actions: [{ title: "Deploy rewrite", detail: "Two statements", effort: "high", requires_change_control: true }], actions: [{ title: "Deploy rewrite", detail: "Two statements", effort: "high", requires_change_control: true }],
rewrite_sql: "SELECT ...", expected: "far fewer logical reads" }] }; option_type: "rewrite", sql_to_run: "SELECT ...", expected: "far fewer logical reads" }] };
test("returns schema-valid candidates", async () => { test("returns schema-valid candidates", async () => {
const got = await proposeCandidates(cfg, digest, "", async () => ok(JSON.stringify(CANDS)) as any); const got = await proposeCandidates(cfg, digest, "", async () => ok(JSON.stringify(CANDS)) as any);
@@ -43,3 +43,59 @@ test("extractJson copes with fences and chatter", () => {
assert.deepEqual(extractJson('Sure!\n```json\n{"a":1}\n```'), { a: 1 }); assert.deepEqual(extractJson('Sure!\n```json\n{"a":1}\n```'), { a: 1 });
assert.deepEqual(extractJson('noise {"a":1} noise'), { a: 1 }); assert.deepEqual(extractJson('noise {"a":1} noise'), { a: 1 });
}); });
test("a response cut off mid-JSON says it was cut off", async () => {
// With every option required to carry runnable T-SQL, the candidates payload outgrew the old 2048-token
// budget and the user was told "malformed JSON" after 38 seconds of nothing useful.
const { extractJson, AnalystError } = await import("../lib/analyst.ts");
const truncated = '{"candidates":[{"key":"a","title":"x","diagnosis":"y","actions":[{"title":"t","detail":"d","effort":"low","requires_change_control":false}],"option_type":"index","sql_to_run":"CREATE INDEX [IX_a] ON [dbo].[a] ([b]);","expected":"see';
let err: unknown = null;
try { extractJson(truncated); } catch (e) { err = e; }
assert.ok(err instanceof AnalystError, String(err));
assert.match((err as Error).message, /stopped after \d+ characters/);
assert.doesNotThrow(() => extractJson('{"a":1}'));
});
test("the candidate call leaves the model room to finish the JSON", async () => {
const { proposeCandidates } = await import("../lib/analyst.ts");
const calls: any[] = [];
const fetchImpl = async (_u: string, init: any) => {
calls.push(JSON.parse(init.body));
const two = { candidates: [0, 1].map((i) => ({ key: "k" + i, title: "Covering index " + i, diagnosis: "full scan of dbo.Orders",
actions: [{ title: "Create the index", detail: "in the reporting DB off-peak", effort: "low", requires_change_control: false }],
option_type: "index", sql_to_run: "CREATE INDEX [IX_a] ON [dbo].[a] ([b]);", expected: "fewer reads" })) };
return { ok: true, json: async () => ({ choices: [{ message: { content: JSON.stringify(two) } }] }) } as any;
};
const digest = { sql: "SELECT 1", bytes: 100, subtreeCost: 1, parallel: false, nonParallelReason: null,
earlyAbort: null, tables: ["dbo.Orders"], topOperators: [], rowGuessErrors: [], missingIndexes: [],
warnings: [], waits: [] };
const cfg = { baseUrl: "http://x/v1", apiKey: "k", model: "m", extra: {} };
await proposeCandidates(cfg as any, digest as any, "", fetchImpl as any);
assert.ok(calls[0].max_tokens >= 4096, `max_tokens was ${calls[0].max_tokens}`);
// extra params still win: that is how a user raises the ceiling without a rebuild.
await proposeCandidates({ ...cfg, extra: { max_tokens: 8192 } } as any, digest as any, "", fetchImpl as any);
assert.equal(calls[1].max_tokens, 8192);
});
test("placeholder SQL never reaches the report as if it were runnable", async () => {
// On a plan whose query text is 100 KB the model cannot reproduce the statement, and it offered
// "SELECT ... FROM ... OPTION (MAXDOP 1);" as a rewrite. A block that cannot be pasted is worse than no block.
const { dropPlaceholderSql } = await import("../lib/analyst.ts");
const cands = [
{ key: "hint_maxdop", option_type: "rewrite", sql_to_run: "SELECT ... FROM dbo.T OPTION (MAXDOP 1);" },
{ key: "idx", option_type: "index", sql_to_run: "CREATE INDEX [IX_T_A] ON [dbo].[T] ([A]);" },
{ key: "ops", option_type: "ops", sql_to_run: null },
];
const out = dropPlaceholderSql(cands as any);
assert.equal(out[0].sql_to_run, null, "the ellipsis rewrite should not survive");
assert.equal(out[1].sql_to_run, "CREATE INDEX [IX_T_A] ON [dbo].[T] ([A]);");
assert.equal(out[2].sql_to_run, null);
});
test("the digest tells the model when the query text was cut", async () => {
const { digestForModel } = await import("../lib/digest.ts");
const base = { bytes: 1, subtreeCost: null, parallel: false, nonParallelReason: null, earlyAbort: null,
tables: [], topOperators: [], rowGuessErrors: [], missingIndexes: [], warnings: [], waits: [] };
assert.equal(digestForModel({ ...base, sql: "SELECT 1" } as any).sql_truncated, false);
assert.equal(digestForModel({ ...base, sql: "SELECT 1" + " ".repeat(5000) } as any).sql_truncated, true);
});
+72
View File
@@ -76,3 +76,75 @@ test("a real .sqlplan digests", () => {
assert.ok(d.topOperators.length > 0, "no operators found — check attribute spellings"); assert.ok(d.topOperators.length > 0, "no operators found — check attribute spellings");
assert.ok(d.sql.length > 0); assert.ok(d.sql.length > 0);
}); });
// ---- plan file decoding (lib/plan-file.ts: the browser reads a .sqlplan into bytes, then hands text to the digest) ----
const enc = (s: string) => new TextEncoder().encode(s);
const utf16le = (s: string) => { const b = new Uint8Array(s.length * 2); for (let i = 0; i < s.length; i++) { b[i * 2] = s.charCodeAt(i); b[i * 2 + 1] = 0; } return b; };
const utf16be = (s: string) => { const b = new Uint8Array(s.length * 2); for (let i = 0; i < s.length; i++) { b[i * 2] = 0; b[i * 2 + 1] = s.charCodeAt(i); } return b; };
const sample = '<?xml version="1.0"?><ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan" Version="1.0"><BatchSequence><Batch><Statements><StmtSimple StatementText="SELECT 1"/></Statements></Batch></BatchSequence></ShowPlanXML>';
test("every .sqlplan encoding SSMS and friends produce arrives as clean text", async () => {
const { decodePlan } = await import("../lib/plan-file.ts");
const cases: [string, Uint8Array][] = [
["utf-8", enc(sample)],
["utf-8 + bom", enc("\uFEFF" + sample)],
["utf-16le + bom (what SSMS writes)", Uint8Array.from([0xff, 0xfe, ...utf16le(sample)])],
["utf-16be + bom", Uint8Array.from([0xfe, 0xff, ...utf16be(sample)])],
["utf-16le, no bom", utf16le(sample)],
["utf-16be, no bom", utf16be(sample)],
];
for (const [name, bytes] of cases) {
const out = decodePlan(bytes);
assert.ok(out.startsWith("<?xml"), `${name}: starts with ${JSON.stringify(out.slice(0, 12))}`);
assert.ok(!out.includes("\0"), `${name}: still contains NUL bytes`);
assert.equal(digestPlan(out).sql, "SELECT 1", `${name}: digest after decode`);
}
});
test("a parse failure says why instead of blaming the export", async () => {
const { PlanError } = await import("../lib/digest.ts");
const e: unknown = (() => { try { digestPlan("<ShowPlanXML><Unclosed></ShowPlanXML>"); return null; } catch (e) { return e; } })();
assert.ok(e instanceof PlanError);
assert.ok(e.message.length > 40, `message too thin: ${e.message}`);
assert.notEqual(e.message, "That XML will not parse — re-export the actual execution plan.");
});
test("a deeply nested real plan parses (spaghettified plans are thousands of tags deep)", async () => {
// fast-xml-parser defaults to maxNestedTags: 128. A 2.7 MB production plan measured 2729 deep and died
// with "Maximum nested tags exceeded", which read like a corrupt export.
const depth = 3000;
const open = "<ShowPlanXML><RelOp NodeId='1'>".repeat(1);
const inner = "<RelOp NodeId='n'>".repeat(depth) + "<ColumnReference Column='[dbo].[t].[c]'/>" + "</RelOp>".repeat(depth);
let err: unknown = null;
let out: any = null;
try { out = digestPlan(open + inner + "</RelOp></ShowPlanXML>"); } catch (e) { err = e; }
assert.equal(err, null, String((err as Error)?.message));
assert.equal(out.topOperators.length, 12, "digest should rank, not truncate");
});
// ---- real-plan shapes measured on a 2.7 MB production plan (plan-cxpaket.sqlplan) ----
test("table names come off the Object child, not the scan element", async () => {
// ShowPlan carries Database/Schema/Table on <Object>, so reading them off <IndexScan> returned nothing:
// a 51-object production plan digested to zero tables and the model never saw a table name.
const xml = `<ShowPlanXML><BatchSequence><Batch><Statements><StmtSimple StatementText="SELECT 1">
<QueryPlan><RelOp PhysicalOp="Index Scan"><IndexScan>
<Object Database="[crm]" Schema="[dbo]" Table="[Orders]" Index="[IX_Orders_CustomerId]" />
</IndexScan></RelOp>
<RelOp PhysicalOp="Table Scan"><TableScan>
<Object Database="[crm]" Schema="[dbo]" Table="[Customers]" />
</TableScan></RelOp></QueryPlan></StmtSimple></Statements></Batch></BatchSequence></ShowPlanXML>`;
assert.deepEqual(digestPlan(xml).tables, ["crm.dbo.Orders", "crm.dbo.Customers"]);
});
test("numeric character references in StatementText become the characters", () => {
// SSMS writes the plan text with &#xD;&#xA; line breaks: 90 of them in the production plan, so the
// analyst model was handed entity noise instead of SQL.
const xml = `<ShowPlanXML><BatchSequence><Batch><Statements>
<StmtSimple StatementText="SELECT a &#xD;&#xA;FROM dbo.T WHERE x &gt; 1 &amp; y = 2"/>
</Statements></Batch></BatchSequence></ShowPlanXML>`;
const sql = digestPlan(xml).sql;
assert.match(sql, /FROM dbo\.T/);
assert.match(sql, /\n/);
assert.ok(!sql.includes("&#x"), sql);
assert.match(sql, /x > 1 & y = 2/);
});
+3 -3
View File
@@ -10,10 +10,10 @@ const digest = { sql: "SELECT", bytes: 1, subtreeCost: 1, parallel: false, nonPa
const candidates = [ const candidates = [
{ key: "a", title: "Covering index", diagnosis: "Scan of a million rows; no key exists on CustomerId.", { key: "a", title: "Covering index", diagnosis: "Scan of a million rows; no key exists on CustomerId.",
actions: [{ title: "Create index", detail: "CREATE INDEX", effort: "medium", requires_change_control: true }], actions: [{ title: "Create index", detail: "CREATE INDEX", effort: "medium", requires_change_control: true }],
rewrite_sql: null, expected: "seek instead of scan" }, option_type: "index", sql_to_run: "CREATE NONCLUSTERED INDEX [IX_Orders_CustomerId] ON [dbo].[Orders] ([CustomerId]) INCLUDE ([TotalDue]);", expected: "seek instead of scan" },
{ key: "b", title: "Add TOP 100", diagnosis: "Too many rows leave the scan, so cut them off.", { key: "b", title: "Add TOP 100", diagnosis: "Too many rows leave the scan, so cut them off.",
actions: [{ title: "Add TOP 100", detail: "TOP 100", effort: "low", requires_change_control: false }], actions: [{ title: "Add TOP 100", detail: "TOP 100", effort: "low", requires_change_control: false }],
rewrite_sql: "SELECT TOP 100", expected: "less I/O" }, option_type: "rewrite", sql_to_run: "SELECT TOP 100 * FROM dbo.Orders ORDER BY TotalDue DESC;", expected: "less I/O" },
] as any; ] as any;
const scored = (fit: number, safe: number, ease: number, cause: number) => ({ const scored = (fit: number, safe: number, ease: number, cause: number) => ({
@@ -81,7 +81,7 @@ test("a silent jev does not accuse every option of being unsafe", () => {
test("verify_semantics fires where semantics can actually move", async () => { test("verify_semantics fires where semantics can actually move", async () => {
// Real Jev scores an index create ~0.57 ("probably fine, no stated proof"), which a blanket <0.6 floor // Real Jev scores an index create ~0.57 ("probably fine, no stated proof"), which a blanket <0.6 floor
// flagged as unsafe on every run. Index-only options must not shout; rewrites and bad scores must. // flagged as unsafe on every run. Index-only options must not shout; rewrites and bad scores must.
const idxOnly = [{ ...candidates[0], rewrite_sql: null }, candidates[1]]; const idxOnly = [{ ...candidates[0], option_type: "statistics", sql_to_run: "UPDATE STATISTICS dbo.Orders WITH FULLSCAN;" }, candidates[1]];
const mid = { byKey: { a: scored(4, 2, 2, 1), b: scored(4, 2, 2, 1) }, cross: GOOD.cross }; const mid = { byKey: { a: scored(4, 2, 2, 1), b: scored(4, 2, 2, 1) }, cross: GOOD.cross };
const v = await judgeCandidates(digest, idxOnly, fakeJev(mid)); const v = await judgeCandidates(digest, idxOnly, fakeJev(mid));
assert.ok(!v.order.find((r) => r.key === "a")!.flags.includes("verify_semantics"), "index create flagged"); assert.ok(!v.order.find((r) => r.key === "a")!.flags.includes("verify_semantics"), "index create flagged");