Compare commits

...
7 Commits
Author SHA1 Message Date
ahmed mohammedandClaude Opus 4.7 816f22c227 feat: analyst proposes multiple options; README with gitea remote
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:44:01 +03:00
ahmed mohammedandClaude Opus 4.7 4fefd0f6da feat: showplan digest
No fixtures/real.sqlplan available (no SQL Server reachable from this machine); the real-plan test skips and says so.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:39:01 +03:00
ahmed mohammedandClaude Opus 4.7 1c3f222f9d feat: setup wizard + settings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:36:55 +03:00
ahmed mohammedandClaude Opus 4.7 dd6b880f49 feat: encrypted credential vault
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:34:01 +03:00
ahmed mohammedandClaude Opus 4.7 5f8746cfae feat: email+password auth with db-backed sessions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:32:07 +03:00
ahmed mohammedandClaude Opus 4.7 692ffa97c5 feat: sqlite layer via node:sqlite
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:23:20 +03:00
ahmed mohammedandClaude Opus 4.7 f64dc3184b feat: tokens, shell, healthz
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:21:42 +03:00
41 changed files with 6810 additions and 9 deletions
+2
View File
@@ -0,0 +1,2 @@
APP_SECRET=
DATA_DIR=/data
+22 -9
View File
@@ -1,11 +1,24 @@
node_modules # next
.next /node_modules
out /.next
build /out
data /build
.env next-env.d.ts
.env.local
.env*.local
*.tsbuildinfo *.tsbuildinfo
# env + secrets
.env*
!.env.example
# runtime data (sqlite)
/data
/.playwright-data
# e2e artifacts
/.auth
/test-results
/playwright-report
# local fixtures
/tests/__fixtures__
.DS_Store .DS_Store
.vercel
+1
View File
@@ -0,0 +1 @@
[{ "date": "2026-09-21", "macrostructure": "Workbench", "theme": "Terminal", "enrichment": "none", "brief": "SQL Server qAI \u00b7 plan triage console" }]
+101
View File
@@ -0,0 +1,101 @@
# 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. Containerization is written (`Dockerfile`, Task 12 of the plan) but is not
required for local work — `npm run dev` is the normal path on a small machine.
```bash
npm install
cp .env.example .env.local # then fill in APP_SECRET and DATA_DIR
npm run dev # http://localhost:3000
```
`.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`. |
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.
- 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 # Playwright: signup, settings, real analyst + real Jev run, copy, print, history, 4 widths
```
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
`.env.local`. Expect it to be slow; that is the models, not the harness.
`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.
+6
View File
@@ -0,0 +1,6 @@
export default function Auth({ children }: { children: React.ReactNode }) {
return <div style={{ maxWidth: 420, margin: "0 auto", padding: "var(--qai-space-3xl) var(--qai-space-l)" }}>
<p className="label">SQL Server qAI</p>{children}
<p className="colophon">ShowPlan XML in · Jev-ranked remediation out · self-hosted</p>
</div>;
}
+11
View File
@@ -0,0 +1,11 @@
import { signIn } from "@/lib/actions";
import { AuthForm } from "@/components/auth-form";
export default function Login() {
return <>
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)" }}>Sign in</h1>
<AuthForm action={signIn} submit="Sign in" fields={[
{ name: "email", label: "Email", type: "email", autoComplete: "email" },
{ name: "password", label: "Password", type: "password", autoComplete: "current-password" }]} />
<p><a href="/signup">Create an account</a></p>
</>;
}
+12
View File
@@ -0,0 +1,12 @@
import { signUp } from "@/lib/actions";
import { AuthForm } from "@/components/auth-form";
export default function Signup() {
return <>
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)" }}>Create account</h1>
<AuthForm action={signUp} submit="Create account" fields={[
{ name: "email", label: "Email", type: "email", autoComplete: "email" },
{ name: "password", label: "Password", type: "password", autoComplete: "new-password",
help: "10 characters minimum." }]} />
<p><a href="/login">Sign in instead</a></p>
</>;
}
+2
View File
@@ -0,0 +1,2 @@
export const dynamic = "force-dynamic";
export const GET = () => Response.json({ ok: true });
+9
View File
@@ -0,0 +1,9 @@
import { requireUser } from "@/lib/auth";
export default async function Console({ children }: { children: React.ReactNode }) {
await requireUser();
return <div className="shell">
<aside className="rail" aria-label="Previous analyses">qAI</aside>
<main className="main">{children}</main>
</div>;
}
+1
View File
@@ -0,0 +1 @@
export default function NewAnalysis() { return <h1 style={{ fontFamily: "var(--qai-font-display)" }}>New analysis</h1>; }
+9
View File
@@ -0,0 +1,9 @@
@import "./tokens.css";
@import "./ui.css";
html, body { overflow-x: clip; }
body { background: var(--qai-paper); color: var(--qai-ink); font-family: var(--qai-font-body);
font-size: var(--qai-text-base); line-height: 1.5; margin: 0; }
* { box-sizing: border-box; }
:focus-visible { outline: 2px solid var(--qai-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
+9
View File
@@ -0,0 +1,9 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = { title: "SQL Server qAI", description: "Query plan triage, ranked by Jev." };
export const viewport: Viewport = { width: "device-width", initialScale: 1 };
export default function Root({ children }: { children: React.ReactNode }) {
return <html lang="en"><body>{children}</body></html>;
}
+3
View File
@@ -0,0 +1,3 @@
import { redirect } from "next/navigation";
import { sessionUser } from "@/lib/auth";
export default async function Home() { redirect((await sessionUser()) ? "/app" : "/login"); }
+19
View File
@@ -0,0 +1,19 @@
import { requireUser } from "@/lib/auth";
import { settingsView } from "@/lib/settings";
import { listAnalyses } from "@/lib/db";
import { saveSettingsAction, signOut } from "@/lib/actions";
import { SettingsForm } from "@/components/settings-form";
export default async function Settings() {
const u = await requireUser();
const rows = listAnalyses(u.id);
return <main className="main" style={{ maxWidth: 640, margin: "0 auto" }}>
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)" }}>Settings</h1>
<SettingsForm action={saveSettingsAction} view={settingsView(u.id)} account />
<section className="card">
<p className="label">History · {rows.length}</p>
<p style={{ fontSize: "var(--qai-text-sm)" }}>Rename or delete individual runs from the left rail. Nothing here deletes them in bulk — that is on purpose.</p>
</section>
<form action={signOut}><button className="btn">Sign out</button></form>
</main>;
}
+33
View File
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";
import { settingsView } from "@/lib/settings";
import { getSettings } from "@/lib/db";
import { saveSettingsAction } from "@/lib/actions";
import { SettingsForm } from "@/components/settings-form";
const TITLES = { 1: "Paste a plan. Get a ranked fix.", 2: "Say which models to ask.", 3: "Ready." } as const;
export default async function Setup({ searchParams }: { searchParams: Promise<{ step?: string }> }) {
const u = await requireUser();
const step = (await searchParams).step ?? "";
const n = (["1", "2", "3"].includes(step) ? step : "1") as "1" | "2" | "3";
const view = settingsView(u.id);
// Step 3 stays reachable after onboarding: it is the "are both models actually ready?" screen.
if (n !== "3" && getSettings(u.id)?.onboarded === 1 && view.has_analyst && view.has_jev) redirect("/app");
return <main style={{ maxWidth: 560, margin: "0 auto", padding: "var(--qai-space-3xl) var(--qai-space-l)",
display: "grid", gap: "var(--qai-space-xl)" }}>
<p className="label">Step {n} of 3</p>
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-display)", letterSpacing: "-0.02em" }}>
{TITLES[n]}</h1>
{n === "1" && <p>qAI reads a SQL Server ShowPlan XML, works out the bottleneck in code, asks your model for several ways to fix it, then has Jev score each one. You configure nothing on this screen.</p>}
{n === "2" && <SettingsForm action={saveSettingsAction} view={view} step="Analyst model and Jev key" />}
{n === "3" && <>
<p>One run = one digest, a few model calls, four Jev questions per option. If the analyst or Jev is unreachable, the report says so instead of inventing a verdict.</p>
<p className="label">Analyst {view.has_analyst ? "ready" : "missing"} · Jev {view.has_jev ? "ready" : "missing"}</p>
<a className="btn btn-primary" href="/app" style={{ justifySelf: "start", textDecoration: "none" }}>Go</a>
</>}
<p className="colophon">Keys stay in this container, encrypted with APP_SECRET · <a href="/settings">settings</a></p>
{n !== "3" && <a className="btn" href={`/setup?step=${Number(n) + 1}`}
style={{ justifySelf: "start", textDecoration: "none" }}>Next</a>}
</main>;
}
+33
View File
@@ -0,0 +1,33 @@
/* Hallmark · genre: atmospheric · macrostructure: Workbench · theme: Terminal
* enrichment: none (typography only) · nav: N3 side-rail · footer: Ft4 dense colophon
* inferred: audience = DBAs/consultants · use = analyse one plan · tone = technical-utilitarian
* axes: paper band dark (L<30%) · display mono · accent phosphor green
*/
:root {
--qai-paper: oklch(17% 0.014 255);
--qai-paper-2: oklch(21% 0.015 258);
--qai-paper-3: oklch(26% 0.016 260);
--qai-ink: oklch(96% 0.004 250);
--qai-ink-muted: oklch(72% 0.02 250);
--qai-accent: oklch(80% 0.19 152);
--qai-on-accent: oklch(20% 0.04 152);
--qai-warn: oklch(82% 0.15 86);
--qai-danger: oklch(66% 0.21 25);
--qai-info: oklch(77% 0.11 232);
--qai-rule: oklch(31% 0.016 258);
--qai-focus: var(--qai-accent);
--qai-font-display: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--qai-font-body: "IBM Plex Sans", system-ui, sans-serif;
--qai-space-xs: 4px; --qai-space-s: 8px; --qai-space-md: 12px; --qai-space-l: 16px;
--qai-space-xl: 24px; --qai-space-2xl: 32px; --qai-space-3xl: 48px;
--qai-text-xs: 12px; --qai-text-sm: 13px; --qai-text-base: 15px; --qai-text-lg: 18px;
--qai-text-xl: 22px; --qai-text-2xl: 28px; --qai-text-display: 34px;
--qai-ease-out: cubic-bezier(0.2, 0.7, 0.2, 1);
--qai-dur-1: 120ms; --qai-dur-2: 220ms;
--qai-radius: 6px;
}
@media print {
:root { --qai-paper:#fff; --qai-paper-2:#f4f5f7; --qai-paper-3:#e9ebef; --qai-ink:#14161a;
--qai-ink-muted:#4a5160; --qai-rule:#c9ced8; --qai-accent:#0a7d3c; --qai-on-accent:#fff;
--qai-warn:#8a5a00; --qai-danger:#a11212; --qai-info:#1a4fb8; }
}
+32
View File
@@ -0,0 +1,32 @@
/* same stamp as tokens.css — one design record, two files */
.shell { display: grid; grid-template-columns: 260px minmax(0,1fr); min-height: 100dvh; }
.rail { background: var(--qai-paper-2); border-inline-end: 1px solid var(--qai-rule);
padding: var(--qai-space-l); display: flex; flex-direction: column; gap: var(--qai-space-md);
font-family: var(--qai-font-display); min-width: 0; }
.main { min-width: 0; padding: var(--qai-space-2xl); display: flex; flex-direction: column; gap: var(--qai-space-xl); }
.card { background: var(--qai-paper-2); border-radius: var(--qai-radius); padding: var(--qai-space-l); min-width: 0; }
.btn { font: 500 var(--qai-text-sm)/1 var(--qai-font-display); padding: 10px 14px; border-radius: var(--qai-radius);
border: 1px solid var(--qai-rule); background: var(--qai-paper-3); color: var(--qai-ink); cursor: pointer;
transition: background var(--qai-dur-1) var(--qai-ease-out), transform var(--qai-dur-1) var(--qai-ease-out); }
.btn:hover { background: var(--qai-rule); } .btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--qai-accent); color: var(--qai-on-accent); border-color: transparent; }
.field { width: 100%; background: var(--qai-paper); color: var(--qai-ink); border: 1px solid var(--qai-rule);
border-radius: var(--qai-radius); padding: 10px 12px; font-family: var(--qai-font-display); font-size: var(--qai-text-sm); }
.label { font: 500 var(--qai-text-xs)/1 var(--qai-font-display); color: var(--qai-ink-muted);
text-transform: uppercase; letter-spacing: .04em; }
.pill { font: 500 var(--qai-text-xs)/1 var(--qai-font-display); padding: 4px 8px; border-radius: 999px;
border: 1px solid var(--qai-rule); color: var(--qai-ink-muted); }
.colophon { border-top: 1px solid var(--qai-rule); padding: var(--qai-space-l) 0;
font: 400 var(--qai-text-xs)/1.6 var(--qai-font-display); color: var(--qai-ink-muted); }
.report { display: flex; flex-direction: column; gap: var(--qai-space-l); min-width: 0; }
.grid-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--qai-space-md); }
.sql { overflow-x: auto; font-family: var(--qai-font-display); font-size: var(--qai-text-sm); white-space: pre; }
.bar { height: 6px; border-radius: 999px; background: var(--qai-accent); max-width: 100%; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
[data-level="danger"] { border-inline-start: 3px solid var(--qai-danger); }
[data-level="warn"] { border-inline-start: 3px solid var(--qai-warn); }
[data-verdict] { border-inline-start: 3px solid var(--qai-accent); }
@media (max-width: 860px) { .shell { grid-template-columns: minmax(0,1fr); } .rail { border-inline-end: 0; } }
@media print { .rail, .runner, .toolbar, .colophon, .no-print { display: none !important; }
.shell { grid-template-columns: minmax(0,1fr); } .main { padding: 0; } }
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useActionState } from "react";
export type FormResult = { ok?: boolean; error?: string } | null;
export function AuthForm({ action, submit, fields }: {
action: (state: FormResult, fd: FormData) => Promise<FormResult>;
submit: string;
fields: { name: string; label: string; type: string; autoComplete?: string; help?: string }[];
}) {
const [state, formAction, pending] = useActionState<FormResult, FormData>(action, null);
return <form action={formAction} className="card" style={{ display: "grid", gap: "var(--qai-space-md)" }}>
{fields.map((f) => <div key={f.name} style={{ display: "grid", gap: "var(--qai-space-xs)" }}>
<label className="label" htmlFor={f.name}>{f.label}</label>
<input className="field" id={f.name} name={f.name} type={f.type} autoComplete={f.autoComplete} required />
{f.help && <span style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{f.help}</span>}
</div>)}
{state && !state.ok && <p role="alert" style={{ color: "var(--qai-danger)" }}>{state.error}</p>}
<button className="btn btn-primary" disabled={pending}>{submit}</button>
</form>;
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useActionState } from "react";
import type { SettingsView } from "@/lib/settings";
type Result = { ok?: boolean; error?: string } | null;
export function SettingsForm({ action, view, step, account }: {
action: (state: Result, fd: FormData) => Promise<Result>;
view: SettingsView; step?: string; account?: boolean;
}) {
const [state, formAction, pending] = useActionState<Result, FormData>(action, null);
const F = (name: string, label: string, value: string, help: string, type = "text") => (
<div key={name} style={{ display: "grid", gap: "var(--qai-space-xs)" }}>
<label className="label" htmlFor={name}>{label}</label>
<input className="field" id={name} name={name} type={type} defaultValue={value} />
<span style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{help}</span>
</div>);
const Group = ({ title, children }: { title: string; children: React.ReactNode }) => (
<fieldset style={{ border: 0, padding: 0, margin: 0, display: "grid", gap: "var(--qai-space-md)" }}>
<legend className="label">{title}</legend>{children}</fieldset>);
return <form action={formAction} className="card" style={{ display: "grid", gap: "var(--qai-space-l)" }}>
{step && <p className="label">{step}</p>}
<Group title="Analyst model — OpenAI-compatible">
{F("analyst_base_url", "Base URL", view.analyst_base_url, "e.g. https://api.openai.com/v1")}
{F("analyst_key", "API key", "", view.has_analyst
? `Saved ${view.analyst_key_masked} — leave blank to keep it` : "Encrypted with APP_SECRET before it hits disk")}
{F("analyst_model", "Model name", view.analyst_model, "e.g. gpt-4o-mini, llama-3.3-70b")}
{F("analyst_extra", "Extra params (JSON)", view.analyst_extra, 'Merged into the request body, e.g. {"temperature":0.2}')}
</Group>
<Group title="Jev — TypeSafe System One">
{F("jev_key", "TypeSafe API key", "", view.has_jev
? `Saved ${view.jev_key_masked} — leave blank to keep it` : "console.typesafe.ai/keys")}
{F("jev_model", "Jev model", view.jev_model, "jev-latest unless you know better")}
</Group>
{account && <Group title="Account">
{F("email", "Email", "", "Used to sign in", "email")}
{F("password", "New password", "", "Leave blank to keep the current one", "password")}
</Group>}
{state && !state.ok && <p role="alert" style={{ color: "var(--qai-danger)" }}>{state.error}</p>}
{state?.ok && <p role="status" style={{ color: "var(--qai-accent)" }}>Saved.</p>}
<button className="btn btn-primary" disabled={pending}>Save</button>
</form>;
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+48
View File
@@ -0,0 +1,48 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { userByEmail, getSettings, renameAnalysis, deleteAnalysis, db } from "@/lib/db";
import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, emailOk, hashPassword } from "@/lib/auth";
import { saveSettingsForm } from "@/lib/settings";
export async function signUp(_: unknown, fd: FormData) {
const r = signUpNewUser(String(fd.get("email") ?? ""), String(fd.get("password") ?? ""));
if (!r.ok) return r;
await startSession(r.id);
redirect("/setup");
}
export async function signIn(_: unknown, fd: FormData) {
const e = String(fd.get("email") ?? "").trim().toLowerCase();
const u = emailOk(e) ? userByEmail(e) : null;
if (!u || !verifyPassword(String(fd.get("password") ?? ""), u.pass))
return { ok: false, error: "Email or password is wrong." };
await startSession(u.id);
redirect(getSettings(u.id)?.onboarded === 1 ? "/app" : "/setup");
}
export async function signOut() { await endSession(); redirect("/login"); }
export async function rename(_: unknown, fd: FormData) {
const u = await requireUser();
const id = String(fd.get("id") ?? "");
const title = String(fd.get("title") ?? "").trim().slice(0, 120);
if (id && title) renameAnalysis(id, u.id, title);
revalidatePath("/app");
}
export async function remove(_: unknown, fd: FormData) {
const u = await requireUser();
deleteAnalysis(String(fd.get("id") ?? ""), u.id);
revalidatePath("/app");
}
export async function saveSettingsAction(_: unknown, fd: FormData) {
const u = await requireUser();
const email = String(fd.get("email") ?? "").trim().toLowerCase();
const password = String(fd.get("password") ?? "");
if (email && !emailOk(email)) return { ok: false, error: "Enter a valid email address." };
if (password && password.length < 10) return { ok: false, error: "Use at least 10 characters." };
saveSettingsForm(u.id, fd);
if (password) db.prepare("UPDATE users SET pass = ? WHERE id = ?").run(hashPassword(password), u.id);
if (email) db.prepare("UPDATE users SET email = ? WHERE id = ?").run(email, u.id);
revalidatePath("/settings");
return { ok: true };
}
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod/v4";
import { digestForModel, type Digest } from "./digest.ts";
import type { AnalystConfig } from "./settings.ts";
export class AnalystError extends Error {}
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() });
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) });
const Proposal = z.object({ candidates: z.array(RawCandidate).min(2).max(4) });
export type Action = z.infer<typeof Action>;
export type Candidate = z.infer<typeof RawCandidate>;
export const CANDIDATE_SYSTEM_PROMPT = `You are a SQL Server performance engineer. You receive a JSON digest of one
execution plan. Judge only what the digest contains — never invent operators, waits, or row counts you were not given.
Return 2 to 4 genuinely different remediation options, not one option restated. Real differences: an index change, a
rewritten query, a statistics or parallelism fix, a batching or schema change. At least one option must be the
lowest-risk one. Each option needs a snake_case key, a diagnosis naming WHY the plan is slow, 1-4 ordered actions, an
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).
A rewrite must return the same rows as the original. If you cannot rewrite safely, set rewrite_sql to null.
Output ONE JSON object and nothing else:
{"candidates":[{"key":"idx_covering_orders","title":"...","diagnosis":"...","actions":[{"title":"...","detail":"...",
"effort":"low|medium|high","requires_change_control":false}],"rewrite_sql":null,"expected":"..."}]}`;
export function extractJson(text: string): unknown {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
const s = (fenced ? fenced[1] : text).trim();
const start = s.indexOf("{"); const end = s.lastIndexOf("}");
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)); }
catch { throw new AnalystError("The analyst model returned malformed JSON."); }
}
export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note: string,
fetchImpl: typeof fetch = fetch): Promise<Candidate[]> {
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
body: JSON.stringify({ model: cfg.model, temperature: 0.6, ...cfg.extra, stream: false,
messages: [{ role: "system", content: CANDIDATE_SYSTEM_PROMPT },
{ 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}`); });
if (!res.ok) throw new AnalystError(`Analyst model returned HTTP ${res.status}. ${(await res.text()).slice(0, 300)}`);
const content: string = ((await res.json() as any)?.choices?.[0]?.message?.content) ?? "";
const parsed = Proposal.safeParse(extractJson(content));
if (!parsed.success) {
const n = (extractJson(content) as any)?.candidates?.length ?? 0;
throw new AnalystError(n < 2
? `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}`);
}
return parsed.data.candidates;
}
+64
View File
@@ -0,0 +1,64 @@
import { randomBytes, scryptSync, createHash, timingSafeEqual } from "node:crypto";
import { createUser, userByEmail, userEmail, putSession, sessionUserId, dropSession, getSettings } from "./db.ts";
export const SESSION_COOKIE = "qai_s";
export const SESSION_DAYS = 30;
export const emailOk = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e);
export const passwordOk = (p: string) => p.length >= 10; // trust boundary: signup + password change only
export function hashPassword(pw: string): string {
const salt = randomBytes(16);
return `${salt.toString("hex")}:${scryptSync(pw, salt, 64).toString("hex")}`;
}
export function verifyPassword(pw: string, stored: string): boolean {
const [salt, hash] = stored.split(":");
if (!salt || !hash) return false;
try {
const a = scryptSync(pw, Buffer.from(salt, "hex"), 64);
const b = Buffer.from(hash, "hex");
return a.length === b.length && timingSafeEqual(a, b);
} catch { return false; }
}
export const hashToken = (t: string) => createHash("sha256").update(t).digest("hex");
export function signUpNewUser(email: string, password: string): { ok: true; id: string } | { ok: false; error: string } {
const e = email.trim().toLowerCase();
if (!emailOk(e)) return { ok: false, error: "Enter a valid email address." };
if (!passwordOk(password)) return { ok: false, error: "Use at least 10 characters." };
if (userByEmail(e)) return { ok: false, error: "That email already has an account." };
// ponytail: no rate limiting — single-container tool. Add a token bucket before exposing this to a network.
return { ok: true, id: createUser(e, hashPassword(password)) };
}
// 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.
const jar = async () => (await import("next/headers")).cookies();
export async function startSession(userId: string) {
const token = randomBytes(32).toString("hex");
putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5);
(await jar()).set(SESSION_COOKIE, token, {
httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production",
path: "/", maxAge: SESSION_DAYS * 86400 });
}
export async function endSession() {
const store = await jar();
const t = store.get(SESSION_COOKIE)?.value;
if (t) dropSession(hashToken(t));
store.delete(SESSION_COOKIE);
}
export async function sessionUser(): Promise<{ id: string; email: string } | null> {
const t = (await jar()).get(SESSION_COOKIE)?.value;
if (!t) return null;
const id = sessionUserId(hashToken(t));
if (!id) return null;
const row = userEmail(id);
return row ? { id, email: row.email } : null;
}
export async function requireUser(): Promise<{ id: string; email: string }> {
const { redirect } = await import("next/navigation"); // lazy for the same reason as jar()
const u = await sessionUser();
if (!u) { redirect("/login"); throw new Error("redirecting to /login"); } // redirect throws; this only satisfies the type
return u;
}
export const needsOnboarding = (userId: string) => getSettings(userId)?.onboarded !== 1;
+101
View File
@@ -0,0 +1,101 @@
import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
import { randomUUID } from "node:crypto";
const dir = process.env.DATA_DIR ?? "data";
mkdirSync(dir, { recursive: true });
const g = globalThis as { __qaiDb?: DatabaseSync };
export const db = (g.__qaiDb ??= (() => {
const d = new DatabaseSync(`${dir}/qai.db`);
d.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, pass TEXT NOT NULL, created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS settings (
user_id TEXT PRIMARY KEY, analyst_base_url TEXT NOT NULL DEFAULT '', analyst_key TEXT NOT NULL DEFAULT '',
analyst_model TEXT NOT NULL DEFAULT '', analyst_extra TEXT NOT NULL DEFAULT '{}',
jev_key TEXT NOT NULL DEFAULT '', jev_model TEXT NOT NULL DEFAULT 'jev-latest',
onboarded INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS analyses (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, xml TEXT NOT NULL,
digest TEXT, candidates TEXT, verdict TEXT, oul TEXT,
status TEXT NOT NULL DEFAULT 'running', error TEXT,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS ix_analyses_user ON analyses(user_id, created_at DESC);`);
return d;
})());
const now = () => Date.now();
type Row = string | number | null;
const one = <T>(sql: string, ...p: Row[]): T | null => (db.prepare(sql).get(...p) as T | undefined) ?? null;
// node:sqlite binds null/number/string/bigint/Buffer only: pass 0/1, never booleans.
export type SettingsInput = { analyst_base_url: string; analyst_key: string; analyst_model: string;
analyst_extra: string; jev_key: string; jev_model: string; onboarded: number };
export type Analysis = { id: string; title: string; xml: string; digest: string | null; candidates: string | null;
verdict: string | null; oul: string | null; status: string; error: string | null; created_at: number };
export function createUser(email: string, passHash: string): string {
const id = randomUUID();
db.prepare("INSERT INTO users (id, email, pass, created_at) VALUES (?,?,?,?)").run(id, email, passHash, now());
return id;
}
export const userByEmail = (email: string) =>
one<{ id: string; pass: string }>("SELECT id, pass FROM users WHERE email = ?", email);
export const userEmail = (id: string) => one<{ email: string }>("SELECT email FROM users WHERE id = ?", id);
export function putSession(tokenHash: string, userId: string, expiresAt: number) {
db.prepare("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?,?,?,?)")
.run(tokenHash, userId, now(), expiresAt);
}
export function sessionUserId(tokenHash: string): string | null {
return one<{ user_id: string }>("SELECT user_id FROM sessions WHERE token_hash = ? AND expires_at > ?",
tokenHash, now())?.user_id ?? null;
}
export const dropSession = (tokenHash: string) =>
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
export const getSettings = (userId: string) =>
one<SettingsInput & { onboarded: number }>("SELECT * FROM settings WHERE user_id = ?", userId);
export function saveSettings(userId: string, s: SettingsInput) {
// A blank secret means "keep what is stored" — enforced here, not only in the form layer, so no caller
// (password change, onboarding re-save, a future import) can blank a stored key by omitting it.
db.prepare(`INSERT INTO settings (user_id, analyst_base_url, analyst_key, analyst_model, analyst_extra,
jev_key, jev_model, onboarded, updated_at) VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET analyst_base_url=excluded.analyst_base_url,
analyst_key=COALESCE(NULLIF(excluded.analyst_key, ''), settings.analyst_key),
analyst_model=excluded.analyst_model, analyst_extra=excluded.analyst_extra,
jev_key=COALESCE(NULLIF(excluded.jev_key, ''), settings.jev_key),
jev_model=excluded.jev_model, onboarded=excluded.onboarded, updated_at=excluded.updated_at`)
.run(userId, s.analyst_base_url, s.analyst_key, s.analyst_model, s.analyst_extra, s.jev_key, s.jev_model,
s.onboarded, now());
}
export function newAnalysis(userId: string, title: string, xml: string): string {
const id = randomUUID();
db.prepare("INSERT INTO analyses (id, user_id, title, xml, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)")
.run(id, userId, title, xml, "running", now(), now());
return id;
}
// patchAnalysis builds its SET list from object keys, so the keys have to be checked, not trusted.
const PATCHABLE = new Set(["digest", "candidates", "verdict", "oul", "status", "error", "title"]);
export function patchAnalysis(id: string, p: Record<string, string>) {
const keys = Object.keys(p).filter((k) => PATCHABLE.has(k));
if (!keys.length) return;
db.prepare(`UPDATE analyses SET ${keys.map((k) => `${k} = ?`).join(", ")}, updated_at = ? WHERE id = ?`)
.run(...keys.map((k) => p[k]), now(), id);
}
export const listAnalyses = (userId: string) =>
db.prepare("SELECT id, title, status, created_at FROM analyses WHERE user_id = ? ORDER BY created_at DESC")
.all(userId) as { id: string; title: string; status: string; created_at: number }[];
export const getAnalysis = (id: string, userId: string) =>
one<Analysis>("SELECT * FROM analyses WHERE id = ? AND user_id = ?", id, userId);
export const renameAnalysis = (id: string, userId: string, title: string) =>
db.prepare("UPDATE analyses SET title = ?, updated_at = ? WHERE id = ? AND user_id = ?").run(title, now(), id, userId);
export const deleteAnalysis = (id: string, userId: string) =>
db.prepare("DELETE FROM analyses WHERE id = ? AND user_id = ?").run(id, userId);
+111
View File
@@ -0,0 +1,111 @@
import { XMLParser } from "fast-xml-parser";
export class PlanError extends Error {}
export const MAX_XML_BYTES = 12_000_000;
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", removeNSPrefix: true });
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 };
export type RowGuess = { id: string; op: string; est: number; actual: number; ratio: number };
export type MissingIndex = { table: string; equality: string[]; inequality: string[]; included: string[]; impact: number };
export type Wait = { type: string; ms: number };
export type Digest = { sql: string; bytes: number; subtreeCost: number | null; parallel: boolean;
nonParallelReason: string | null; earlyAbort: string | null; tables: string[]; topOperators: Operator[];
rowGuessErrors: RowGuess[]; missingIndexes: MissingIndex[]; warnings: string[]; waits: Wait[] };
function collect(node: any, name: string, out: any[] = []): any[] {
if (Array.isArray(node)) { for (const n of node) collect(n, name, out); return out; }
if (node && typeof node === "object") for (const [k, v] of Object.entries(node)) {
if (k === "#text") continue;
if (k === name) (Array.isArray(v) ? v : [v]).forEach((n) => out.push(n));
collect(v, name, out);
}
return out;
}
const num = (v: any): number | null =>
(v === undefined || v === null || v === "" || Number.isNaN(Number(v)) ? null : Number(v));
const text = (v: any): string | null =>
(typeof v === "object" && v !== null ? (String(v["#text"] ?? "") || null) : (v == null ? null : String(v)));
const unbrack = (v: any) => String(v ?? "").replace(/[[\]]/g, "");
const colNames = (node: any) => collect(node, "Column").map((c: any) => unbrack(c.Name)).filter(Boolean);
export function digestPlan(raw: string): Digest {
const xml = raw.replace(/^\uFEFF/, ""); // SSMS writes a BOM; the XML parser does not want it
if (!xml || xml.trim().length < 40) throw new PlanError("Empty input. Paste the ShowPlan XML, or drop a .sqlplan file.");
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.`);
let doc: any;
try { doc = parser.parse(xml); } catch { throw new PlanError("That XML will not parse — re-export the actual execution plan."); }
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 stmt = stmts.find((s: any) => s?.QueryBlock || s?.RelOp) ?? stmts[0] ?? {};
const operators: Operator[] = collect(doc, "RelOp").map((o: any) => {
const rt = collect(o, "RunTimeCountersPerThread")[0];
return {
id: String(o.NodeId ?? o["Node_id"] ?? "?"),
op: String(o.LogicalOp ?? o.PhysicalOp ?? "Unknown"),
// Real ShowPlan spells these EstimateIO / EstimateCPU; builds differ, so fall through every spelling.
cost: num(o.EstimatedOperatorCost) ?? num(o.EstimatedTotalSubtreeCost) ?? num(o.EstimateIO) ?? 0,
estRows: num(o.EstimateRows) ?? 0,
actualRows: rt ? num(rt.ActualRows) : null,
execs: rt ? num(rt.ActualExecs) : null,
io: num(o.EstimateIO) ?? num(o.EstimatedIO), cpu: num(o.EstimateCPU) ?? num(o.EstimatedCPU),
spillLevels: num(collect(o, "SpillToTempDb")[0]?.SpillLevel),
parallel: o.Parallel === "true",
};
});
// 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 tables = [...new Set([...collect(doc, "IndexScan"), ...collect(doc, "TableScan"), ...collect(doc, "Table")]
.map(fullName).filter(Boolean))];
const missingIndexes: MissingIndex[] = collect(doc, "MissingIndexGroup").map((g: any) => {
const mi = collect(g, "MissingIndex")[0] ?? {};
const byUsage = (u: string) => collect(mi, "ColumnGroup").filter((c: any) => c.Usage === u).flatMap(colNames);
return {
table: fullName(mi),
equality: byUsage("EQUALITY"), inequality: byUsage("INEQUALITY"),
included: collect(mi, "IncludedColumns").flatMap(colNames),
impact: num(g.Impact) ?? 0,
};
}).sort((a, b) => b.impact - a.impact);
// Generic on purpose: SQL Server adds warning elements over time, unknown ones still surface.
const warnings = [...new Set(collect(doc, "Warnings").flatMap((w: any) =>
Object.entries(w).filter(([k]) => k !== "#text").map(([k, v]: [string, any]) => {
const attrs = v && typeof v === "object"
? Object.entries(v).filter(([, x]) => typeof x !== "object").map(([ak, x]) => `${ak}=${x}`).join(" ")
: "";
return attrs ? `${k} ${attrs}` : k;
})))];
const waits: Wait[] = collect(doc, "WaitTime")
.map((w: any) => ({ type: String(w.WaitType ?? "unknown"), ms: num(w.WaitTime) ?? 0 }))
.filter((w) => w.ms > 0).sort((a, b) => b.ms - a.ms).slice(0, 5);
const rowGuessErrors: RowGuess[] = operators
.filter((o) => o.actualRows !== null && o.estRows > 0)
.map((o) => ({ id: o.id, op: o.op, est: o.estRows, actual: o.actualRows as number,
ratio: Math.max(o.estRows, o.actualRows as number) / Math.min(o.estRows, o.actualRows as number) }))
.filter((r) => r.ratio >= 10).sort((a, b) => b.ratio - a.ratio).slice(0, 5);
return {
sql: text(stmt.StatementText) ?? "", bytes: xml.length,
subtreeCost: num(stmt.StatementSubtreeCost), parallel: operators.some((o) => o.parallel),
nonParallelReason: text(collect(doc, "NonParallelPlanReason")[0]),
earlyAbort: text(collect(doc, "StatementOptmEarlyAbortReason")[0]),
tables, topOperators: operators.sort((a, b) => b.cost - a.cost).slice(0, 12),
rowGuessErrors, missingIndexes, warnings, waits,
};
}
// Tokens are the constraint: models and Jev read this, never the raw XML.
export const digestForModel = (d: Digest) => ({
sql: d.sql.slice(0, 4000), subtreeCost: d.subtreeCost, parallel: d.parallel,
nonParallelReason: d.nonParallelReason, earlyAbort: d.earlyAbort, tables: d.tables.slice(0, 20),
topOperators: d.topOperators, rowGuessErrors: d.rowGuessErrors,
missingIndexes: d.missingIndexes.slice(0, 5), warnings: d.warnings.slice(0, 20), waits: d.waits,
});
+45
View File
@@ -0,0 +1,45 @@
import { getSettings, saveSettings, type SettingsInput } from "./db.ts";
import { seal, open, mask } from "./vault.ts";
export type AnalystConfig = { baseUrl: string; apiKey: string; model: string; extra: Record<string, unknown> };
const str = (fd: FormData, k: string) => String(fd.get(k) ?? "").trim();
const kept = (fd: FormData, k: string, existing: string) => { const v = str(fd, k); return v ? seal(v) : existing; };
export function saveSettingsForm(userId: string, fd: FormData, onboarded = 1) {
const cur = getSettings(userId);
let extra = str(fd, "analyst_extra") || "{}";
try { JSON.parse(extra); } catch { extra = "{}"; }
saveSettings(userId, {
analyst_base_url: str(fd, "analyst_base_url").replace(/\/+$/, ""),
analyst_key: kept(fd, "analyst_key", cur?.analyst_key ?? ""),
analyst_model: str(fd, "analyst_model"),
analyst_extra: extra,
jev_key: kept(fd, "jev_key", cur?.jev_key ?? ""),
jev_model: str(fd, "jev_model") || "jev-latest",
onboarded: cur?.onboarded === 1 ? 1 : onboarded,
} satisfies SettingsInput);
}
export function analystConfig(userId: string): AnalystConfig | null {
const s = getSettings(userId);
const baseUrl = s?.analyst_base_url ?? "";
const apiKey = open(s?.analyst_key ?? "");
const model = s?.analyst_model ?? "";
if (!baseUrl || !apiKey || !model) return null;
let extra: Record<string, unknown> = {};
try { extra = JSON.parse(s!.analyst_extra) as Record<string, unknown>; } catch { /* bad JSON means no extra params */ }
return { baseUrl, apiKey, model, extra };
}
export const jevKey = (userId: string) => open(getSettings(userId)?.jev_key ?? "");
export const jevModel = (userId: string) => getSettings(userId)?.jev_model || "jev-latest";
export function settingsView(userId: string) {
const s = getSettings(userId);
const analystKey = open(s?.analyst_key ?? "");
const jev = open(s?.jev_key ?? "");
return {
analyst_base_url: s?.analyst_base_url ?? "", analyst_model: s?.analyst_model ?? "",
analyst_extra: s?.analyst_extra ?? "{}", jev_model: s?.jev_model ?? "jev-latest",
analyst_key_masked: mask(analystKey), jev_key_masked: mask(jev),
has_analyst: !!(s?.analyst_base_url && s?.analyst_model && analystKey), has_jev: !!jev,
};
}
export type SettingsView = ReturnType<typeof settingsView>;
+24
View File
@@ -0,0 +1,24 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
function key() {
const s = process.env.APP_SECRET;
if (!s || s.length < 16) throw new Error("APP_SECRET is missing or shorter than 16 chars — set it in .env");
return scryptSync(s, "qai-vault-v1", 32);
}
export function seal(plain: string): string {
if (!plain) return "";
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", key(), iv);
const ct = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return `${iv.toString("hex")}.${c.getAuthTag().toString("hex")}.${ct.toString("hex")}`;
}
export function open(sealed: string): string {
if (!sealed) return "";
try {
const [iv, tag, ct] = sealed.split(".");
const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "hex"), { authTagLength: 16 });
d.setAuthTag(Buffer.from(tag, "hex"));
return Buffer.concat([d.update(Buffer.from(ct, "hex")), d.final()]).toString("utf8");
} catch { return ""; }
}
export const mask = (v: string) => (v ? `••••${v.slice(-4)}` : "");
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+5658
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "sql-server-qai",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "node --no-warnings --test tests/*.test.ts"
},
"dependencies": {
"@openuidev/react-lang": "^0.3.0",
"@typesafe-ai/sdk": "^0.6.0",
"fast-xml-parser": "^5.7.3",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/node": "^24.13.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"typescript": "^5"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+45
View File
@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
const { proposeCandidates, extractJson, AnalystError } = await import("../lib/analyst.ts");
const cfg = { baseUrl: "https://api.test/v1", apiKey: "k", model: "m", extra: {} };
const digest = { sql: "SELECT 1", bytes: 8, subtreeCost: 1, parallel: false, nonParallelReason: null,
earlyAbort: null, tables: ["t"], topOperators: [], rowGuessErrors: [], missingIndexes: [], warnings: [], waits: [] } as any;
const ok = (content: string) => ({ ok: true, status: 200,
json: async () => ({ choices: [{ message: { content } }] }) });
const CANDS = { candidates: [
{ 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 }],
rewrite_sql: null, expected: "seek instead of scan" },
{ 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 }],
rewrite_sql: "SELECT ...", expected: "far fewer logical reads" }] };
test("returns schema-valid candidates", async () => {
const got = await proposeCandidates(cfg, digest, "", async () => ok(JSON.stringify(CANDS)) as any);
assert.equal(got.length, 2);
assert.equal(got[0].key, "idx_customer");
});
test("survives code fences and sends extra params", async () => {
let sent: any;
const fetchImpl = async (_u: any, init: any) => { sent = JSON.parse(init.body);
return ok("```json\n" + JSON.stringify(CANDS) + "\n```") as any; };
const got = await proposeCandidates({ ...cfg, extra: { temperature: 0.1 } }, digest, "index exists already", fetchImpl as any);
assert.equal(got.length, 2);
assert.equal(sent.temperature, 0.1);
assert.match(sent.messages[0].content, /candidates/);
assert.match(sent.messages[1].content, /index exists already/);
});
test("names the stage on auth, prose, and shape failures", async () => {
await assert.rejects(() => proposeCandidates(cfg, digest, "",
async () => ({ ok: false, status: 401, text: async () => "bad key" }) as any),
(e: any) => e instanceof AnalystError && /401/.test(e.message));
await assert.rejects(() => proposeCandidates(cfg, digest, "", async () => ok("no json here") as any), AnalystError);
await assert.rejects(() => proposeCandidates(cfg, digest, "",
async () => ok(JSON.stringify({ candidates: [CANDS.candidates[0]] })) as any),
(e: any) => /at least 2/.test(e.message));
});
test("extractJson copes with fences and chatter", () => {
assert.deepEqual(extractJson('Sure!\n```json\n{"a":1}\n```'), { a: 1 });
assert.deepEqual(extractJson('noise {"a":1} noise'), { a: 1 });
});
+20
View File
@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-auth-`);
const { hashPassword, verifyPassword, hashToken } = await import("../lib/auth.ts");
test("password hash round-trips and rejects", () => {
const h = hashPassword("correct horse battery staple");
assert.ok(h.includes(":"), "salt:hash shape");
assert.ok(verifyPassword("correct horse battery staple", h));
assert.ok(!verifyPassword("wrong", h));
});
test("malformed stored hash never verifies", () => {
for (const bad of ["", "nosep", "a:", ":b", "zz:zz"]) assert.ok(!verifyPassword("x", bad));
});
test("token hash is stable, 64 hex", () => {
assert.equal(hashToken("abc"), hashToken("abc"));
assert.match(hashToken("abc"), /^[0-9a-f]{64}$/);
});
+39
View File
@@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-`);
const q = await import("../lib/db.ts");
test("user, settings upsert, analysis lifecycle", () => {
const id = q.createUser("a@b.c", "salt:hash");
assert.equal(q.userByEmail("a@b.c")?.id, id);
assert.equal(q.userByEmail("nope@x.y"), null);
assert.equal(q.userEmail(id)?.email, "a@b.c");
const s = { analyst_base_url: "http://x/v1", analyst_key: "k1", analyst_model: "m",
analyst_extra: "{}", jev_key: "j", jev_model: "jev-latest", onboarded: 1 };
q.saveSettings(id, s);
q.saveSettings(id, { ...s, analyst_key: "k2" });
assert.equal(q.getSettings(id)?.analyst_key, "k2", "upsert not insert");
const aid = q.newAnalysis(id, "Orders scan", "<xml/>");
q.patchAnalysis(aid, { status: "done", oul: "root = Report([])", digest: "{}" });
assert.equal(q.listAnalyses(id)[0].title, "Orders scan");
assert.equal(q.getAnalysis(aid, id)?.status, "done");
q.renameAnalysis(aid, id, "Renamed");
assert.equal(q.getAnalysis(aid, id)?.title, "Renamed");
q.deleteAnalysis(aid, id);
assert.equal(q.getAnalysis(aid, id), null);
});
test("session resolves only while unexpired", () => {
const id = q.createUser("s@b.c", "h");
q.putSession("tok", id, Date.now() + 1000);
assert.equal(q.sessionUserId("tok"), id);
q.putSession("old", id, Date.now() - 1);
assert.equal(q.sessionUserId("old"), null);
q.dropSession("tok");
assert.equal(q.sessionUserId("tok"), null);
});
+78
View File
@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const { digestPlan, PlanError, digestForModel } = await import("../lib/digest.ts");
export const PLAN = `<?xml version="1.0" encoding="utf-16"?>
<ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan" Version="1.6" Build="16.0.4085.2">
<BatchSequence><Batch><Statements>
<StmtSimple StatementId="1" StatementSubtreeCost="4.871" StatementOptmLevel="FULL"
StatementText="SELECT o.Id FROM Sales.Orders o WHERE o.CustomerId = @c1 AND o.Total &gt; 500">
<StatementOptmEarlyAbortReason>GoodEnoughPlanFound</StatementOptmEarlyAbortReason>
<QueryBlock QueryHash="0x1A" QueryPlanHash="0x2B">
<RelOp NodeId="0" PhysicalOp="Clustered Index Scan" LogicalOp="Clustered Index Scan" EstimateRows="1000"
EstimateIO="41.6" EstimateCPU="39.0" EstimatedTotalSubtreeCost="4.10" Parallel="false">
<RunTimeInformation><RunTimeCountersPerThread Thread="0" ActualRows="1243500" ActualExecs="1"
ActualElapsedms="2411" ActualCPUms="2210"/></RunTimeInformation>
<IndexScan Database="[wide]" Schema="[Sales]" Table="[Orders]"/>
<Warnings><NoJoinPredicate/><SpillToTempDb SpillLevel="2"/>
<WaitTime WaitType="PAGEIOLATCH_SH" WaitTime="812"/></Warnings>
</RelOp>
<RelOp NodeId="1" PhysicalOp="Index Seek" LogicalOp="Index Seek" EstimateRows="3"
EstimateIO="0.7" EstimateCPU="0.02" EstimatedTotalSubtreeCost="0.77" Parallel="true">
<RunTimeInformation><RunTimeCountersPerThread Thread="0" ActualRows="4" ActualExecs="2"/></RunTimeInformation>
<IndexScan Database="[wide]" Schema="[Sales]" Table="[Customer]"/>
</RelOp>
<MissingIndexes><MissingIndexGroup Impact="94.17">
<MissingIndex Database="[wide]" Schema="[Sales]" Table="[Orders]">
<ColumnGroup Usage="EQUALITY"><Column Name="[CustomerId]"/></ColumnGroup>
<ColumnGroup Usage="INEQUALITY"><Column Name="[Total]"/></ColumnGroup>
<IncludedColumns><Column Name="[Id]"/></IncludedColumns>
</MissingIndex></MissingIndexGroup></MissingIndexes>
<NonParallelPlanReason>Could Not Generate Valid Parallel Plan</NonParallelPlanReason>
</QueryBlock>
</StmtSimple>
</Statements></Batch></BatchSequence>
</ShowPlanXML>`;
test("digests what matters in a showplan", () => {
const d = digestPlan(PLAN);
assert.match(d.sql, /o\.Total > 500/, "XML entities decoded");
assert.equal(d.subtreeCost, 4.871);
assert.equal(d.earlyAbort, "GoodEnoughPlanFound");
assert.equal(d.nonParallelReason, "Could Not Generate Valid Parallel Plan");
assert.deepEqual(d.tables, ["wide.Sales.Orders", "wide.Sales.Customer"]);
assert.equal(d.topOperators[0].op, "Clustered Index Scan");
assert.equal(d.topOperators[0].spillLevels, 2);
assert.equal(d.topOperators[0].actualRows, 1243500);
assert.equal(d.topOperators[0].parallel, false);
assert.deepEqual(d.missingIndexes[0], { table: "wide.Sales.Orders", equality: ["CustomerId"],
inequality: ["Total"], included: ["Id"], impact: 94.17 });
assert.ok(d.warnings.includes("NoJoinPredicate"));
assert.ok(d.warnings.some((w) => w.startsWith("SpillToTempDb")));
assert.deepEqual(d.waits, [{ type: "PAGEIOLATCH_SH", ms: 812 }]);
assert.ok(d.rowGuessErrors[0].ratio > 100);
assert.equal(d.bytes, PLAN.length);
});
test("a BOM-prefixed plan still digests — SSMS writes .sqlplan as UTF-16 with a BOM", () => {
assert.equal(digestPlan("\uFEFF" + PLAN).subtreeCost, 4.871);
});
test("rejects input that is not a plan", () => {
for (const bad of ["", "SELECT 1", "<html></html>", "<ShowPlanXML><trunc"])
assert.throws(() => digestPlan(bad), PlanError);
});
test("digestForModel stays bounded on a fat plan", () => {
const big = `<ShowPlanXML><Batch><Statements><StmtSimple StatementText="q">` +
Array.from({ length: 400 }, (_, i) =>
`<RelOp NodeId="${i}" LogicalOp="Scan" EstimateRows="1" EstimatedTotalSubtreeCost="${i}"/>`).join("") +
`</StmtSimple></Statements></Batch></Batch></ShowPlanXML>`;
const v = digestForModel(digestPlan(big)) as { topOperators: unknown[]; tables: unknown[] };
assert.ok(v.topOperators.length <= 12);
});
test("a real .sqlplan digests", () => {
let xml: string;
try { xml = readFileSync("fixtures/real.sqlplan", "utf8"); } catch { return console.log("skip: no fixtures/real.sqlplan"); }
const d = digestPlan(xml);
assert.ok(d.topOperators.length > 0, "no operators found — check attribute spellings");
assert.ok(d.sql.length > 0);
});
+48
View File
@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
process.env.APP_SECRET = "unit-test-secret-value";
process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-vault-`);
const { seal, open, mask } = await import("../lib/vault.ts");
const db = await import("../lib/db.ts");
const { saveSettingsForm, analystConfig, settingsView, jevKey } = await import("../lib/settings.ts");
test("seal/open round-trips; junk and tamper yield empty", () => {
assert.equal(open(seal("sk-abc123")), "sk-abc123");
assert.equal(open("deadbeef.00.zzz"), "");
assert.equal(open(""), "");
assert.equal(mask("sk-abcdefgh1234"), "••••1234");
assert.equal(mask(""), "");
});
test("blank secret keeps the stored key", () => {
const u = db.createUser("v@x.y", "h");
const fd = (o: Record<string, string>) => { const f = new FormData();
Object.entries(o).forEach(([k, v]) => f.set(k, v)); return f; };
saveSettingsForm(u, fd({ analyst_base_url: "https://api/x/v1/", analyst_key: "sk-1111", analyst_model: "gpt",
analyst_extra: '{"temperature":0.2}', jev_key: "ts-1", jev_model: "jev-latest" }));
assert.equal(analystConfig(u)?.apiKey, "sk-1111");
saveSettingsForm(u, fd({ analyst_base_url: "https://api/y/v1", analyst_key: "", analyst_model: "gpt",
analyst_extra: "not json", jev_key: "", jev_model: "" }));
const c = analystConfig(u)!;
assert.equal(c.baseUrl, "https://api/y/v1", "trailing slash trimmed");
assert.equal(c.apiKey, "sk-1111", "blank must not wipe the key");
assert.deepEqual(c.extra, {}, "bad JSON falls back to empty params");
assert.equal(jevKey(u), "ts-1");
assert.equal(settingsView(u).has_analyst, true);
assert.equal(settingsView(u).analyst_key_masked, "••••1111");
assert.equal((settingsView(u) as Record<string, unknown>).analyst_key, undefined, "no raw key to the client");
});
test("saveSettings never blanks a stored secret, whoever calls it", () => {
const u = db.createUser("k@x.y", "h");
const base = { analyst_base_url: "b", analyst_key: "sealed", analyst_model: "m", analyst_extra: "{}",
jev_key: "jevsealed", jev_model: "j", onboarded: 1 };
db.saveSettings(u, base);
db.saveSettings(u, { ...base, analyst_key: "", jev_key: "" });
assert.equal(db.getSettings(u)?.analyst_key, "sealed");
assert.equal(db.getSettings(u)?.jev_key, "jevsealed");
assert.equal(db.getSettings(u)?.analyst_base_url, "b", "non-secret fields still update");
});
+43
View File
@@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}