Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6248540857 | ||
|
|
3fa436103a | ||
|
|
4b5c19b5d2 | ||
|
|
3d1866367b | ||
|
|
2ebfcfb7a6 |
+10
-2
@@ -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
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ 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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
+43
-10
@@ -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
@@ -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
@@ -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 (
 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
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Binary file not shown.
+10
-2
@@ -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" } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;");
|
||||||
|
|||||||
@@ -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 (?,?,?,?,?,?,?)")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 
 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 
FROM dbo.T WHERE x > 1 & 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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user