backlens 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,7 +47,9 @@ backlens: 2 new issues. Fix them, or accept with --update-baseline.
47
47
 
48
48
  ## Missing indexes
49
49
 
50
- For each distinct SELECT, backlens asks Postgres for its plan with `EXPLAIN (GENERIC_PLAN)` (Postgres 16+). Plain EXPLAIN never runs the query, and the generic plan needs no parameter values. When a table is read row by row while filtering on columns that no index starts with, the Issues tab names the table and columns and gives the `CREATE INDEX` to add. It checks whether an index exists, not how fast the query was, so it is right on a small dev database too.
50
+ For each distinct SELECT, backlens asks Postgres for its plan with `EXPLAIN (GENERIC_PLAN)` (Postgres 16+). Plain EXPLAIN never runs the query, and the generic plan needs no parameter values. When a table is read row by row while filtering on columns that no index starts with, the Issues tab names the table and columns and gives the `CREATE INDEX` to add. It checks whether an index exists, not how fast the query was, so it is right on a small dev database too. When the query also sorts (`ORDER BY … LIMIT`), the sort columns go after the filter columns, so Postgres reads rows already in order.
51
+
52
+ **Create on this database** builds the index on the database your app is connected to, through the app's own connection: backlens never sees a database password. It shows which database first, builds with `CONCURRENTLY` so writes are not blocked, checks that Postgres can use the index for the query, and **Undo** drops it again. Only an index on the columns backlens checked can be created this way, never arbitrary SQL. Use it on a development database, then add the SQL to a migration.
51
53
 
52
54
  ## Before and after
53
55
 
@@ -55,7 +57,7 @@ In the Compare tab, save a snapshot, change your code, restart, use the same pag
55
57
 
56
58
  ## Explain and fix, with proof
57
59
 
58
- Every issue has **Explain** and **Suggest fix**. A fix goes: a preview of exactly what would be sent and to whom, the model's answer as a diff, **Apply and check** (only on files with no uncommitted changes, with a backup), then backlens rebuilds if needed, restarts your app, replays the request that showed the issue and runs your tests. Only a fix that removes the issue and keeps the tests green is marked proven. Keep it, or revert to the exact files you had.
60
+ Every issue has **Explain** and **Suggest fix**. A fix goes: a preview of exactly what would be sent and to whom, the model's answer as a diff, **Apply and check** (with an exact copy of each file kept first, uncommitted changes included; Revert refuses to overwrite edits you made after the fix was applied), then backlens rebuilds if needed, restarts your app, replays the request that showed the issue and runs your tests. Only a fix that removes the issue and keeps the tests green is marked proven. Keep it, or revert to the exact files you had.
59
61
 
60
62
  Who answers is set up in **AI settings**, all from the dashboard. The first-run setup asks too.
61
63
 
package/ai/fixes.js CHANGED
@@ -14,8 +14,8 @@ import { promiseAll } from './templates.js';
14
14
  /**
15
15
  * Explain and Fix. A fix runs like a careful developer at a terminal, and every step is written to
16
16
  * a live feed the dashboard shows: read the code, ask the model, check each edit fits the code
17
- * (sending problems back, up to twice), apply only to files with no uncommitted changes (with a
18
- * backup), typecheck (sending type errors back, up to twice), rebuild if needed, restart, replay
17
+ * (sending problems back, up to twice), apply (keeping an exact copy of each file as it was, uncommitted
18
+ * changes included), typecheck (sending type errors back, up to twice), rebuild if needed, restart, replay
19
19
  * the request that showed the issue, run the tests. Only then is it called proven.
20
20
  */
21
21
 
@@ -139,7 +139,7 @@ export function createAI(deps) {
139
139
  }
140
140
 
141
141
  const view = (job) => {
142
- const { ctx, issue, backups, convo, abort, afterTrace, ...rest } = job;
142
+ const { ctx, issue, backups, convo, abort, afterTrace, written, ...rest } = job;
143
143
  return { ...rest, now: Date.now(), issueTitle: issue.title, issueRoute: issue.route, spentTodayUsd: spentToday(), budgetUsd: settings.dailyBudgetUsd };
144
144
  };
145
145
 
@@ -296,6 +296,14 @@ export function createAI(deps) {
296
296
  }
297
297
  }
298
298
 
299
+ function readSafe(file) {
300
+ try {
301
+ return readFileSync(file, 'utf8');
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
299
307
  function restoreBackups(job) {
300
308
  for (const b of job.backups || []) copyFileSync(b.backup, join(root, b.file));
301
309
  }
@@ -402,17 +410,17 @@ export function createAI(deps) {
402
410
  return null; // not a git repository: backups are the only undo
403
411
  }
404
412
  const out = execFileSync('git', ['-C', root, 'status', '--porcelain', '--', ...files.map((f) => join(root, f))], { encoding: 'utf8' });
405
- return out.trim().split('\n').filter(Boolean).map((l) => l.slice(3));
413
+ // Porcelain lines are 'XY path': the status columns can start with a space, so no trim before slicing.
414
+ return out.split('\n').filter((l) => l.trim()).map((l) => l.slice(3));
406
415
  }
407
416
 
408
417
  function apply(id) {
409
418
  const job = jobs.get(id);
410
419
  if (!job || job.stage !== 'proposed') throw new Error('There is no suggestion ready to apply.');
411
420
  const files = [...new Set(job.edits.map((e) => e.file))];
412
- const dirty = gitDirty(files);
413
- if (dirty && dirty.length) {
414
- throw new Error(`${dirty.join(', ')} ${dirty.length === 1 ? 'has' : 'have'} uncommitted changes. Commit or stash ${dirty.length === 1 ? 'it' : 'them'} first, so this fix can always be undone cleanly.`);
415
- }
421
+ // Files with uncommitted changes (an earlier kept fix, work in progress) are fine: the backup
422
+ // is an exact copy of the file as it is now, and Revert puts that copy back.
423
+ const dirty = gitDirty(files) || [];
416
424
  const backupDir = join(dir, 'ai', job.id);
417
425
  job.backups = [];
418
426
  for (const f of files) {
@@ -422,7 +430,13 @@ export function createAI(deps) {
422
430
  job.backups.push({ file: f, backup: dest });
423
431
  }
424
432
  writeEdits(job.edits);
425
- ev(job, 'apply', `Applied ${job.edits.length} edit${job.edits.length === 1 ? '' : 's'} to ${files.join(', ')}`, { detail: 'backup kept until you keep or revert' });
433
+ // What backlens wrote, so Revert can tell whether someone edited the file afterwards.
434
+ job.written = Object.fromEntries(files.map((f) => [f, readFileSync(join(root, f), 'utf8')]));
435
+ ev(job, 'apply', `Applied ${job.edits.length} edit${job.edits.length === 1 ? '' : 's'} to ${files.join(', ')}`, {
436
+ detail: dirty.length
437
+ ? `a copy of ${dirty.join(', ')} as it was, uncommitted changes included, is kept until you keep or revert`
438
+ : 'backup kept until you keep or revert',
439
+ });
426
440
  job.stage = 'verifying';
427
441
  deps.pauseWatch(true);
428
442
  verify(job)
@@ -508,6 +522,7 @@ export function createAI(deps) {
508
522
  const ok = await propose(job, `Asking again with the type errors (${round + 1} of ${TYPE_REPAIRS})`);
509
523
  if (!ok.length) return finish(job, false, 'The model could not produce a change that typechecks.');
510
524
  writeEdits(ok);
525
+ job.written = Object.fromEntries(job.backups.map((b) => [b.file, readSafe(join(root, b.file))]));
511
526
  ev(job, 'apply', `Applied ${ok.length} corrected edit${ok.length === 1 ? '' : 's'}`);
512
527
  }
513
528
 
@@ -606,6 +621,11 @@ export function createAI(deps) {
606
621
  async function revert(id) {
607
622
  const job = jobs.get(id);
608
623
  if (!job || !job.backups || !job.backups.length) throw new Error('Nothing to revert.');
624
+ // Never overwrite edits made after the fix was applied.
625
+ const changed = job.backups.filter((b) => job.written && job.written[b.file] !== undefined && readSafe(join(root, b.file)) !== job.written[b.file]);
626
+ if (changed.length) {
627
+ throw new Error(`${changed.map((b) => b.file).join(', ')} changed after the fix was applied, so backlens did not overwrite it. The version from before the fix is at ${changed.map((b) => relative(root, b.backup)).join(', ')}.`);
628
+ }
609
629
  restoreBackups(job);
610
630
  append(historyFile, { at: new Date().toISOString(), type: job.issue.type, tier: job.route.tier || job.route.kind, model: job.route.model, verified: Boolean(job.verified), kept: false });
611
631
  cleanup(job);
package/analyze.js CHANGED
@@ -291,7 +291,10 @@ export function summarize(traces, known = [], advice = [], fixed = new Map()) {
291
291
  perRoute.get(route).lastAt = Math.max(perRoute.get(route).lastAt, trace.at || 0);
292
292
  }
293
293
  const big = a.rows >= 1000;
294
- const name = `${a.table}_${a.columns.join('_')}_idx`.slice(0, 63);
294
+ // Filter columns first, then the ORDER BY columns, so rows come out already sorted.
295
+ const cols = [...a.columns.map((c) => ({ name: c, desc: false })), ...(Array.isArray(a.sort) ? a.sort : []).filter((c) => c && typeof c.name === 'string')];
296
+ const name = `${a.table}_${cols.map((c) => c.name).join('_')}_idx`.slice(0, 63);
297
+ const colText = cols.map((c) => `${c.name}${c.desc ? ' DESC' : ''}`).join(', ');
295
298
  for (const [route, info] of perRoute) {
296
299
  const key = `index|${route}|${sql}|${a.table}`;
297
300
  routes.get(route)?.issueKeys.add(key);
@@ -299,7 +302,8 @@ export function summarize(traces, known = [], advice = [], fixed = new Map()) {
299
302
  type: 'index',
300
303
  severity: big ? 'warn' : 'hint',
301
304
  title: `No index for ${a.table} filtered by ${a.columns.join(', ')}`,
302
- fix: `Postgres reads every row of ${a.table} (${a.rows.toLocaleString('en')} now${big ? '' : ', fine while small, slow as it grows'}). Add: CREATE INDEX CONCURRENTLY ${name} ON ${a.schema === 'public' ? '' : `${a.schema}.`}${a.table} (${a.columns.join(', ')});`,
305
+ fix: `Postgres reads every row of ${a.table} (${a.rows.toLocaleString('en')} now${big ? '' : ', fine while small, slow as it grows'}). Add: CREATE INDEX CONCURRENTLY ${name} ON ${a.schema === 'public' ? '' : `${a.schema}.`}${a.table} (${colText});`,
306
+ index: { schema: a.schema || 'public', table: a.table, name, columns: cols, sql: a.sql },
303
307
  sql,
304
308
  sites: appFrame(info.span.frames) ? [at(appFrame(info.span.frames))] : [],
305
309
  frames: [info.span.frames],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlens",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Run your Node backend through backlens and see every request, every query, and what is wrong with them.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/register.cjs CHANGED
@@ -232,6 +232,10 @@ if (TARGET && !globalThis.__backlens) {
232
232
  if (address && typeof address === 'object') post('/listening', { port: address.port, pid: process.pid, source: SOURCE });
233
233
  } catch {}
234
234
  }
235
+ if (event === 'request' && req && res && req.url === '/__backlens/index' && req.method === 'POST' && tokenOk(req.headers['x-backlens-token'])) {
236
+ handleCommand(req, res);
237
+ return true;
238
+ }
235
239
  if (event !== 'request' || !req || !res || req.headers['x-backlens'] === 'skip' || INTERNAL.test(req.url || '')) return emit.apply(this, arguments);
236
240
  const trace = {
237
241
  id: randomUUID(),
@@ -304,13 +308,25 @@ if (TARGET && !globalThis.__backlens) {
304
308
  return cols;
305
309
  }
306
310
 
307
- function seqScans(node, out = []) {
311
+ /** 'reviews.updated_at DESC' -> { table: 'reviews', name: 'updated_at', desc: true } */
312
+ function sortKey(key) {
313
+ const m = String(key).match(/^(?:"?(\w+)"?\.)?"?([a-z_]\w*)"?(?:\s+(ASC|DESC))?(?:\s+NULLS\s+(?:FIRST|LAST))?$/i);
314
+ return m ? { table: m[1] || null, name: m[2], desc: /desc/i.test(m[3] || '') } : null;
315
+ }
316
+
317
+ // A sort above the scan (ORDER BY … LIMIT) is worth putting in the index after the filter
318
+ // columns: Postgres then reads rows already in order and stops at the limit.
319
+ function seqScans(node, out = [], sort = []) {
308
320
  if (!node) return out;
321
+ if (/Sort$/.test(node['Node Type']) && Array.isArray(node['Sort Key'])) sort = node['Sort Key'].map(sortKey);
309
322
  if (node['Node Type'] === 'Seq Scan' && node['Relation Name'] && node.Filter) {
310
323
  const columns = columnsCompared(node.Filter);
311
- if (columns.length) out.push({ table: node['Relation Name'], schema: node.Schema || 'public', columns, filter: node.Filter });
324
+ const alias = node.Alias || node['Relation Name'];
325
+ const order = sort.every((k) => k && (!k.table || k.table === alias)) ? sort.filter((k) => !columns.includes(k.name)) : [];
326
+ if (columns.length) out.push({ table: node['Relation Name'], schema: node.Schema || 'public', columns, sort: order.map(({ name, desc }) => ({ name, desc })), filter: node.Filter });
312
327
  }
313
- for (const child of node.Plans || []) seqScans(child, out);
328
+ const keep = /^(Sort|Incremental Sort|Limit|Gather|Gather Merge|Result)$/.test(node['Node Type']) ? sort : [];
329
+ for (const child of node.Plans || []) seqScans(child, out, keep);
314
330
  return out;
315
331
  }
316
332
 
@@ -336,10 +352,101 @@ if (TARGET && !globalThis.__backlens) {
336
352
  if (!rows[0]) continue;
337
353
  const leading = rows[0].leading || [];
338
354
  if (scan.columns.some((col) => leading.includes(col))) continue;
339
- post('/advice', { sql: text, table: scan.table, schema: scan.schema, columns: scan.columns, rows: Math.max(0, Number(rows[0].rows)) });
355
+ post('/advice', { sql: text, table: scan.table, schema: scan.schema, columns: scan.columns, sort: scan.sort, rows: Math.max(0, Number(rows[0].rows)) });
340
356
  }
341
357
  }
342
358
 
359
+ // ---- creating the suggested index, when the user asks in the dashboard ----------------
360
+ // The dashboard sends one internal request to the app, with its private token. Only an index
361
+ // on named columns can be created or dropped, built here from checked names, never from SQL
362
+ // text; the plan check only re-explains a statement this process already explained.
363
+ const IDENT = /^[a-z_][a-z0-9_$]{0,62}$/i;
364
+ const q = (name) => `"${name}"`;
365
+
366
+ function tokenOk(value) {
367
+ if (!TOKEN || typeof value !== 'string' || value.length !== TOKEN.length) return false;
368
+ return require('node:crypto').timingSafeEqual(Buffer.from(value), Buffer.from(TOKEN));
369
+ }
370
+
371
+ async function indexCommand(body) {
372
+ if (!advisorPool) throw new Error('The app has not used Postgres yet. Use the page once, then try again.');
373
+ const pool = advisorPool;
374
+ if (body.action === 'info') {
375
+ const { rows } = await pool.query('select current_database() as database, inet_server_addr()::text as address, inet_server_port() as port');
376
+ const o = pool.options || {};
377
+ let host = o.host;
378
+ try {
379
+ if (!host && o.connectionString) host = new URL(o.connectionString).hostname;
380
+ } catch {}
381
+ return { database: rows[0].database, host: host || rows[0].address || 'local socket', port: rows[0].port };
382
+ }
383
+ const { schema, table, name } = body;
384
+ const columns = Array.isArray(body.columns) ? body.columns : [];
385
+ if (![schema, table, name].every((x) => typeof x === 'string' && IDENT.test(x)) || !columns.length || columns.length > 8 || !columns.every((c) => c && typeof c.name === 'string' && IDENT.test(c.name))) {
386
+ throw new Error('That is not an index backlens suggested.');
387
+ }
388
+ if (body.action === 'create') {
389
+ const cols = columns.map((c) => `${q(c.name)}${c.desc ? ' DESC' : ''}`).join(', ');
390
+ // CONCURRENTLY builds without blocking writes; it cannot run inside a transaction, so a
391
+ // plain pool query (its own connection, autocommit) is used.
392
+ await pool.query(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${q(name)} ON ${q(schema)}.${q(table)} (${cols})`);
393
+ const { rows } = await pool.query(
394
+ `select i.indisvalid as valid from pg_class c join pg_index i on i.indexrelid = c.oid join pg_namespace n on n.oid = c.relnamespace where c.relname = $1 and n.nspname = $2`,
395
+ [name, schema],
396
+ );
397
+ if (!rows[0] || !rows[0].valid) throw new Error('Postgres did not finish building the index. Drop it and try again.');
398
+ // Can Postgres use it for the query that showed the issue? On a small table it still
399
+ // prefers reading every row, so the check asks with sequential scans turned off.
400
+ let usable = null;
401
+ if (typeof body.sql === 'string' && explained.has(body.sql)) {
402
+ const client = await pool.connect();
403
+ try {
404
+ await client.query('BEGIN');
405
+ await client.query('SET LOCAL enable_seqscan = off');
406
+ const prefix = /\$\d/.test(body.sql) ? 'EXPLAIN (GENERIC_PLAN, FORMAT JSON) ' : 'EXPLAIN (FORMAT JSON) ';
407
+ const plan = JSON.stringify((await client.query(prefix + body.sql)).rows[0]['QUERY PLAN']);
408
+ usable = plan.includes(`"Index Name":"${name}"`);
409
+ } catch {
410
+ usable = null;
411
+ } finally {
412
+ await client.query('ROLLBACK').catch(() => {});
413
+ client.release();
414
+ }
415
+ }
416
+ return { created: true, usable };
417
+ }
418
+ if (body.action === 'drop') {
419
+ await pool.query(`DROP INDEX CONCURRENTLY IF EXISTS ${q(schema)}.${q(name)}`);
420
+ return { dropped: true };
421
+ }
422
+ throw new Error('Unknown action.');
423
+ }
424
+
425
+ function handleCommand(req, res) {
426
+ let raw = '';
427
+ req.on('data', (c) => {
428
+ raw += c;
429
+ if (raw.length > 64 * 1024) req.destroy();
430
+ });
431
+ req.on('end', () => {
432
+ let body = null;
433
+ try {
434
+ body = JSON.parse(raw);
435
+ } catch {}
436
+ const send = (status, payload) => {
437
+ res.writeHead(status, { 'content-type': 'application/json' });
438
+ res.end(JSON.stringify(payload));
439
+ };
440
+ if (!body) return send(400, { error: 'Bad request.' });
441
+ storage.exit(() =>
442
+ indexCommand(body).then(
443
+ (out) => send(200, out),
444
+ (err) => send(400, { error: String((err && err.message) || err) }),
445
+ ),
446
+ );
447
+ });
448
+ }
449
+
343
450
  function maybeAdvise(text) {
344
451
  if (!advisorPool || advisorOff || explained.has(text) || explained.size >= MAX_EXPLAINS) return;
345
452
  if (!/^\s*(select|with)\b/i.test(text) || text.includes(';') || /\bpg_(class|index|attribute|namespace)\b/.test(text)) return;
package/server.js CHANGED
@@ -32,6 +32,33 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
32
32
  const traces = [];
33
33
  // Issue key -> when its fix was kept (see summarize).
34
34
  const fixedAt = new Map();
35
+ // Issue key -> the index created for it from the dashboard, so Undo still knows it.
36
+ const createdIndexes = new Map();
37
+
38
+ /** Sends one command to the recorder inside the app (see register.cjs). */
39
+ function appCommand(port, body) {
40
+ return new Promise((resolve, reject) => {
41
+ const payload = JSON.stringify(body);
42
+ const r = http.request(
43
+ { host: '127.0.0.1', port, method: 'POST', path: '/__backlens/index', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload), 'x-backlens-token': token } },
44
+ (res) => {
45
+ let raw = '';
46
+ res.on('data', (c) => (raw += c));
47
+ res.on('end', () => {
48
+ let out = null;
49
+ try {
50
+ out = JSON.parse(raw);
51
+ } catch {}
52
+ if (res.statusCode === 200 && out) resolve(out);
53
+ else reject(new Error((out && out.error) || 'The app did not answer. Is it running with backlens?'));
54
+ });
55
+ },
56
+ );
57
+ r.setTimeout(10 * 60 * 1000, () => r.destroy(new Error('Creating the index took over 10 minutes.')));
58
+ r.on('error', (err) => reject(new Error(`Could not reach the app: ${err.message}`)));
59
+ r.end(payload);
60
+ });
61
+ }
35
62
  let received = 0;
36
63
  const known = new Map();
37
64
  const advice = new Map();
@@ -303,6 +330,34 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
303
330
  tests.start(scriptCommand(pm, config.test), root);
304
331
  return json(res, 200, { ok: true });
305
332
  }
333
+ // Creating a suggested index on the database the app is using, through the app itself.
334
+ if (req.method === 'POST' && url.pathname.startsWith('/api/index/')) {
335
+ const action = url.pathname.slice('/api/index/'.length);
336
+ if (!['info', 'create', 'drop'].includes(action)) return json(res, 404, { error: 'not found' });
337
+ if (!app || !app.port) return json(res, 409, { error: 'The app is not running. Start it first.' });
338
+ const body = (await readJson(req)) || {};
339
+ let index = null;
340
+ if (action !== 'info') {
341
+ const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
342
+ const issue = issues.find((i) => i.key === body.key);
343
+ index = issue && issue.index ? issue.index : createdIndexes.get(body.key);
344
+ if (!index) return json(res, 404, { error: 'That suggestion is gone. Refresh the list.' });
345
+ }
346
+ try {
347
+ const out = await appCommand(app.port, { action, ...(index || {}) });
348
+ if (action === 'create') {
349
+ createdIndexes.set(body.key, index);
350
+ fixedAt.set(body.key, Date.now());
351
+ }
352
+ if (action === 'drop') {
353
+ createdIndexes.delete(body.key);
354
+ fixedAt.delete(body.key);
355
+ }
356
+ return json(res, 200, { ...out, index });
357
+ } catch (err) {
358
+ return json(res, 400, { error: err.message });
359
+ }
360
+ }
306
361
  if (url.pathname.startsWith('/api/ai/')) {
307
362
  try {
308
363
  const body = req.method === 'POST' ? (await readJson(req)) || {} : {};
package/ui.html CHANGED
@@ -735,9 +735,10 @@ function renderFix() {
735
735
  <div class="kicker2">fix${j.attempt ? ', second attempt' : ''}</div><h2>${esc(j.issueTitle)}</h2><div class="meta">${esc(j.issueRoute || '')}</div>
736
736
  <div class="chips" style="margin-top:10px"><span class="chip">${esc(r.label)}</span><span class="chip">${r.kind === 'anthropic' ? (j.costUsd ? `spent ${usd(j.costUsd)}` : `about ${usd(r.estimateUsd)}`) : 'free'}</span>${busy ? '<span class="chip live"><span class="spin"></span> working</span>' : ''}</div></div>`;
737
737
  if (j.stage === 'template') {
738
- html += `<ol class="feed"><li class="fi">${'<span class="dot ok">●</span>'}<div><div class="bubble">${esc(j.explanation)}</div></div></li></ol><div class="editor"><div class="editor-head"><span class="dots"><i></i><i></i><i></i></span>migration</div><div class="editor-body"><div class="cl add"><span class="ln">1</span><span class="mk">+</span><span class="cx">${hl(j.sql)}</span></div></div></div><p class="note">${esc(j.notes)}</p>
739
- <div class="fix-actions"><button class="primary" type="button" data-copy="${esc(j.sql)}">Copy SQL</button></div>`;
740
- el.innerHTML = html; return;
738
+ html += `<ol class="feed"><li class="fi">${'<span class="dot ok">●</span>'}<div><div class="bubble">${esc(j.explanation)}</div></div></li></ol><div class="editor"><div class="editor-head"><span class="dots"><i></i><i></i><i></i></span>migration</div><div class="editor-body"><div class="cl add"><span class="ln">1</span><span class="mk">+</span><span class="cx">${hl(j.sql)}</span></div></div></div><p class="note">${esc(j.notes)}</p>${indexPanel(j)}`;
739
+ el.innerHTML = html;
740
+ if (!idx.db && !idx.loading) loadIndexInfo();
741
+ return;
741
742
  }
742
743
  if (j.stage === 'preview') {
743
744
  html += `<section class="plan"><h4>Why this model</h4><ul class="reasons">${r.reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
@@ -756,7 +757,7 @@ function renderFix() {
756
757
  if (j.stage === 'failed') act.push('<button class="primary" type="button" id="fix-revert">Revert</button><button class="ghost" type="button" id="fix-keep">Keep anyway</button>');
757
758
  if (j.stage === 'unverified') act.push('<button class="ghost" type="button" id="fix-keep">Keep</button><button class="ghost" type="button" id="fix-revert">Revert</button>');
758
759
  if (j.stage === 'reverted' || j.stage === 'error') act.push(escalateBtn(j));
759
- if (j.stage === 'proposed') act.push('<span class="note">Only applies if these files have no uncommitted changes, with a backup.</span>');
760
+ if (j.stage === 'proposed') act.push('<span class="note">backlens keeps an exact copy of each file first, uncommitted changes included. Revert puts it back.</span>');
760
761
  if (act.length || fixError) html += `<div class="fix-actions">${act.join('')}<span class="error" role="alert">${esc(fixError)}</span></div>`;
761
762
  el.innerHTML = html;
762
763
  el.querySelectorAll('[data-autoscroll]').forEach((t) => (t.scrollTop = t.scrollHeight));
@@ -784,6 +785,46 @@ function compareCard(j) {
784
785
  </table><p class="note">One request each${c.warmed ? ', the after one measured once the restarted app was warm' : ''}. Query counts are exact; times vary a few ms from run to run.</p></section>`;
785
786
  }
786
787
 
788
+ // ---- creating a suggested index from the drawer ----------------------------------------
789
+ let idx = {};
790
+ async function loadIndexInfo() {
791
+ idx.loading = true;
792
+ try { idx.db = await aiPost('/api/index/info', {}); } catch (err) { idx.dbError = err.message; }
793
+ idx.loading = false;
794
+ renderFix();
795
+ }
796
+
797
+ function indexPanel(j) {
798
+ const where = idx.db ? `<b>${esc(idx.db.database)}</b> on ${esc(idx.db.host)}${idx.db.port ? `:${idx.db.port}` : ''}` : idx.dbError ? '' : 'checking…';
799
+ const steps = [];
800
+ if (idx.result && idx.result.created) {
801
+ steps.push(`<li class="fi"><span class="dot ok">●</span><div><div class="fh"><b>Created</b> ${esc(idx.result.index.name)}</div><div class="sub2">on ${where}, built without blocking writes</div></div></li>`);
802
+ const u = idx.result.usable;
803
+ steps.push(`<li class="fi"><span class="dot ${u ? 'ok' : u === false ? 'bad' : 'skip'}">●</span><div><div class="fh"><b>Plan check</b></div><div class="sub2">${u ? 'Postgres can answer this query from the new index.' : u === false ? 'Postgres would still not use it for this query.' : 'Could not check the plan for this query.'}</div></div></li>`);
804
+ }
805
+ if (idx.dropped) steps.push(`<li class="fi"><span class="dot skip">●</span><div><div class="fh"><b>Dropped</b> the index again</div></div></li>`);
806
+ const actions = idx.result && idx.result.created
807
+ ? `<button class="ghost" type="button" id="idx-drop" ${idx.busy ? 'disabled' : ''}>Undo (drop it)</button><button class="ghost" type="button" data-copy="${esc(j.sql)}">Copy SQL for a migration</button>`
808
+ : `<button class="primary" type="button" id="idx-create" ${idx.busy || !idx.db ? 'disabled' : ''}>${idx.busy ? 'Creating…' : 'Create on this database'}</button><button class="ghost" type="button" data-copy="${esc(j.sql)}">Copy SQL</button>`;
809
+ return `${steps.length ? `<ol class="feed">${steps.join('')}</ol>` : ''}
810
+ <p class="note">${idx.dbError ? esc(idx.dbError) : `Runs on the database your app is connected to right now: ${where}. Only use it on a development database.`}${idx.result && idx.result.created ? ' This changed only this database: add the SQL to a migration so other environments get it too.' : ''}</p>
811
+ <div class="fix-actions">${actions}<span class="error" role="alert">${esc(idx.error || '')}</span></div>`;
812
+ }
813
+
814
+ document.addEventListener('click', async (e) => {
815
+ const create = e.target.closest('#idx-create');
816
+ const drop = e.target.closest('#idx-drop');
817
+ if (!create && !drop) return;
818
+ idx.busy = true; idx.error = ''; renderFix();
819
+ try {
820
+ if (create) { idx.result = await aiPost('/api/index/create', { key: fixJob.issueKey }); idx.dropped = false; }
821
+ else { await aiPost('/api/index/drop', { key: fixJob.issueKey }); idx.result = null; idx.dropped = true; }
822
+ lastTotal = -1; poll();
823
+ } catch (err) { idx.error = err.message; }
824
+ idx.busy = false;
825
+ renderFix();
826
+ });
827
+
787
828
  function escalateBtn(j) {
788
829
  if (j.errorCode === 'billing') return `<a class="btn primary" href="https://console.anthropic.com/settings/billing" target="_blank" rel="noopener">Add credits</a>`;
789
830
  return j.attempt === 0 && j.route.kind === 'anthropic' ? `<button class="ghost" type="button" id="fix-escalate">Try a stronger model</button>` : '';
@@ -915,6 +956,7 @@ document.addEventListener('click', async (e) => {
915
956
  const issue = issueFrom(fx.dataset.aiFix);
916
957
  if (!issue) return;
917
958
  fixError = '';
959
+ idx = {};
918
960
  try { fixJob = await aiPost('/api/ai/fix', { key: issue.key }); }
919
961
  catch (err) { fixJob = null; fixError = err.message; }
920
962
  renderFix();