Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions api/fix-query-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
export const promptTemplate = `You are analyzing SQLite query performance.

You will receive one or more query metric objects. For each query, use:

- SQL text
- cumulative timing metrics
- max single-run latency
- EXPLAIN QUERY PLAN rows
- sqlite3_stmt_status counters

Your job is to identify the most likely causes of slowness and propose concrete
fixes.

Important SQLite interpretation rules:

1. EXPLAIN QUERY PLAN is a tree encoded as rows with:
- id: node id
- parent: parent node id
- detail: human-readable plan detail
2. Treat the EQP detail text as advisory, not as a perfectly stable machine
interface.
3. Meanings to use:
- SCAN usually means a full scan or full traversal
- SEARCH usually means indexed subset access
- USING COVERING INDEX is generally better than non-covering index access
- USE TEMP B-TREE FOR ORDER BY / GROUP BY / DISTINCT usually means extra
sorting or temp work
- SCALAR SUBQUERY / CORRELATED SCALAR SUBQUERY / MATERIALIZE / CO-ROUTINE can
indicate extra execution layers or repeated work
- MULTI-INDEX OR can be valid, but may still be expensive depending on
cardinality and repetitions
4. Meanings of sqlite3_stmt_status counters:
- fullscanStep: number of forward steps in full table scans; high values
often suggest missing or ineffective indexes
- sort: number of sort operations; non-zero may suggest missing index support
for ORDER BY / GROUP BY / DISTINCT
- autoindex: rows inserted into transient automatic indexes; non-zero may
suggest a permanent index should exist
- vmStep: proxy for total work done by the prepared statement
- reprepare: statement was automatically regenerated due to schema changes or
parameter changes that affect the plan
- run: number of statement runs
- filterHit / filterMiss: Bloom filter counters for joins; usually secondary
diagnostics, not primary optimization targets
5. Be careful:
- Do not claim certainty from EQP text alone
- Do not recommend indexes that duplicate an obviously existing useful index
unless you explain why
- Distinguish between “high total cost because it runs often” and “high
single-run latency”
- Prefer practical, minimal index suggestions over speculative rewrites
- If the query already appears well-indexed, say that and focus on workload
frequency, result size, or query shape

For each query, produce:

1. Summary
- one sentence on why this query matters
- classify it as primarily:
- high frequency
- high single-run latency
- heavy scan work
- sort/temp-btree heavy
- join/index issue
- subquery/correlation issue
2. Evidence
- cite the most relevant metrics and EQP nodes
- explicitly mention which plan rows matter
3. Likely causes
- explain the top 1 to 3 causes
4. Suggested fixes
- propose concrete indexes in SQL when justified
- propose query rewrites when justified
- propose schema/query-shape changes only when supported by evidence
5. Confidence
- High / Medium / Low
- explain what additional info would confirm the diagnosis
6. Priority
- rank the query as P1 / P2 / P3 for optimization effort

Index recommendation rules:

- If EQP shows SCAN on a filtered table and fullscanStep is high, consider an
index on the WHERE columns in filter order.
- If EQP shows USE TEMP B-TREE FOR ORDER BY and the query filters first,
consider an index starting with selective WHERE columns followed by ORDER BY
columns.
- If EQP shows USE TEMP B-TREE FOR GROUP BY or DISTINCT, consider an index that
matches the grouping/distinct keys if it fits the query shape.
- If autoindex is non-zero for joins, suggest a permanent index on the join key
columns.
- If a correlated subquery appears and the query runs many times or max latency
is high, consider rewriting to JOIN / EXISTS / pre-aggregation when
semantically safe.
- Always mention tradeoffs: extra indexes improve reads but increase write cost
and storage.

Output format: Return valid markdown with these sections:

## Top optimization opportunities

A short ranked list across all queries.

## Query analysis

For each query:

### Query N

- Summary:
- Evidence:
- Likely causes:
- Suggested fixes:
- Example index SQL:
- Example rewrite:
- Confidence:
- Priority:

When you suggest an index, use a concrete CREATE INDEX statement if the target
columns are inferable. When the columns are not inferable with confidence,
describe the desired index pattern instead of inventing names. When no strong
optimization is justified, say so.

Now analyze this data:

{{QUERY_DETAILS_JSON}}
`
40 changes: 40 additions & 0 deletions api/fix-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render } from '@deno/gfm'
import { promptTemplate } from '/api/fix-query-prompt.ts'
import { GEMINI_API_KEY, GEMINI_MODEL } from '/api/lib/env.ts'

const GEMINI_URL =
`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:streamGenerateContent?alt=json&key=${GEMINI_API_KEY}`

export async function analyzeQueryWithAI(metric: unknown, schema: unknown) {
const payload = JSON.stringify({ schema, metrics: [metric] }, null, 2)
const prompt = promptTemplate.replace('{{QUERY_DETAILS_JSON}}', payload)

const res = await fetch(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: {
thinkingConfig: { thinkingLevel: 'MINIMAL' },
},
}),
})

if (!res.ok) {
const body = await res.text()
throw new Error(`Gemini API error ${res.status}: ${body}`)
}

// streamGenerateContent with alt=json returns a JSON array of response chunks
const chunks: {
candidates?: { content?: { parts?: { text?: string }[] } }[]
}[] = await res.json()

const markdown = chunks
.flatMap((chunk) => chunk.candidates ?? [])
.flatMap((candidate) => candidate.content?.parts ?? [])
.map((part) => part.text ?? '')
.join('')

return render(markdown)
}
7 changes: 7 additions & 0 deletions api/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ export const DB_SCHEMA_REFRESH_MS = Number(

export const STORE_URL = ENV('STORE_URL')
export const STORE_SECRET = ENV('STORE_SECRET')

export const GEMINI_API_KEY = ENV('GEMINI_API_KEY')

export const GEMINI_MODEL = ENV(
'GEMINI_MODEL',
'gemini-3.1-flash-lite-preview',
)
82 changes: 54 additions & 28 deletions api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,33 @@ import {
} from '/api/sql.ts'
import { Log } from '@01edu/api/log'
import { get, getOne } from './lmdb-store.ts'
import { analyzeQueryWithAI } from '/api/fix-query.ts'

const MetricSchema = OBJ({
query: STR('The SQL query text'),
count: NUM('How many times the query has run'),
duration: NUM('Total time spent running the query in milliseconds'),
max: NUM('Longest single query execution in milliseconds'),
explain: ARR(
OBJ({
id: NUM('Query plan node id'),
parent: NUM('Parent query plan node id'),
detail: STR('Human-readable query plan detail'),
}),
'SQLite EXPLAIN QUERY PLAN rows',
),
status: OBJ({
fullscanStep: NUM('Number of full table scan steps'),
sort: NUM('Number of sort operations'),
autoindex: NUM('Rows inserted into transient auto-indices'),
vmStep: NUM('Number of virtual machine operations'),
reprepare: NUM('Number of automatic statement reprepares'),
run: NUM('Number of statement runs'),
filterHit: NUM('Bloom filter bypass hits'),
filterMiss: NUM('Bloom filter misses'),
memused: NUM('Peak memory usage in bytes'),
}, 'SQLite sqlite3_stmt_status counters'),
})

const withUserSession = async ({ cookies }: RequestContext) => {
const session = await decodeSession(cookies.session)
Expand Down Expand Up @@ -693,36 +720,35 @@ const defs = {
input: OBJ({
deployment: STR("The deployment's URL"),
}),
output: ARR(
OBJ({
query: STR('The SQL query text'),
count: NUM('How many times the query has run'),
duration: NUM('Total time spent running the query in milliseconds'),
max: NUM('Longest single query execution in milliseconds'),
explain: ARR(
OBJ({
id: NUM('Query plan node id'),
parent: NUM('Parent query plan node id'),
detail: STR('Human-readable query plan detail'),
}),
'SQLite EXPLAIN QUERY PLAN rows',
),
status: OBJ({
fullscanStep: NUM('Number of full table scan steps'),
sort: NUM('Number of sort operations'),
autoindex: NUM('Rows inserted into transient auto-indices'),
vmStep: NUM('Number of virtual machine operations'),
reprepare: NUM('Number of automatic statement reprepares'),
run: NUM('Number of statement runs'),
filterHit: NUM('Bloom filter bypass hits'),
filterMiss: NUM('Bloom filter misses'),
memused: NUM('Peak memory usage in bytes'),
}, 'SQLite sqlite3_stmt_status counters'),
}),
'Collected query metrics',
),
output: ARR(MetricSchema, 'Collected query metrics'),
description: 'Get SQL metrics from the deployment',
}),
'POST/api/deployment/fix-query': route({
authorize: withUserSession,
fn: async (ctx, { id, deployment, metric }) => {
await withDeploymentTableAccess(ctx, deployment)
const schema = DatabaseSchemasCollection.get(deployment)
try {
const analysis = await analyzeQueryWithAI(metric, schema)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would convert markdown to HTML here in the backend instead of doing it in the front end

return { id, analysis }
} catch (err) {
throw respond.InternalServerError({
message: err instanceof Error ? err.message : String(err),
})
}
},
input: OBJ({
id: STR('The metric ID'),
deployment: STR("The deployment's URL"),
metric: MetricSchema,
}),
output: OBJ({
id: STR('The metric ID'),
analysis: STR('AI-generated markdown analysis of the query'),
}),
description:
'Analyze a SQL query metric with Gemini AI and suggest optimizations',
}),
} as const

export type RouteDefinitions = typeof defs
Expand Down
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
"@tailwindcss/vite": "npm:@tailwindcss/vite@^4.1.17",
"tailwindcss": "npm:tailwindcss@^4.1.17",
"daisyui": "npm:daisyui@^5.5.8",
"lucide-preact": "npm:lucide-preact@^0.525.0"
"lucide-preact": "npm:lucide-preact@^0.525.0",
"@deno/gfm": "jsr:@deno/gfm@0.12.0"
},
"fmt": {
"useTabs": false,
Expand Down
Loading
Loading