- New caching_custom_backfill_jobs and caching_custom_backfill_batches tables - Admin API (POST create/cancel, GET list) at admin/api/custom_backfill.php - Admin UI form with preset buttons and job status table on Backfill page - Caching daemon module (custom_backfill.c) processes jobs independently - pg_inbox functions for job/batch claim, progress update, and cancel - Three batch modes: author-batched, id-batched, time-window scan - Fixed done_batches overcount on job completion - Admin config now environment-variable driven (C_RELAY_DB_*) - admin/serve.sh supports separate instances for different databases - make_and_restart_relay.sh only kills relay on target port, not all relays
10 KiB
Cleanup Page — Editable SQL Box with Live Validation
Goal
Add a prominent, editable SQL box to the Event Cleanup page that:
- Shows the SQL query being formed as the user changes the form filters (follows, kinds, date range, max events).
- Allows direct editing of the SQL.
- Live-checks validity as the user types (debounced).
- Border turns red when the SQL is invalid, black when valid.
- The edited SQL drives Preview (must be a
SELECT). - Execute Delete extracts the
WHEREclause from the validated SQL and wraps it in the existing safeDELETE ... id IN (subquery)template — so the destructive path stays fenced to theeventstable withORDER BY/LIMIT.
flowchart LR
F[Form filters] --> B[SQL box content]
B -->|debounce 400ms| V[EXPLAIN validate]
V -->|ok| BC[black border]
V -->|err| RC[red border]
B -->|Preview button| P[Run edited SELECT]
B -->|Execute button| E[Extract WHERE -> safe DELETE template]
E --> D[DELETE FROM events WHERE id IN subquery]
1. New Endpoint: admin/api/cleanup_validate.php
A tiny POST endpoint that runs EXPLAIN on the submitted SQL and reports
validity. EXPLAIN parses + plans without executing, so it's a true,
side-effect-free validity check.
Request:
{ "sql": "SELECT COUNT(*) ... FROM events e WHERE 1=1 AND ..." }
Response (valid):
{ "valid": true }
Response (invalid):
{ "valid": false, "error": "syntax error at or near ..." }
Implementation notes:
require_once __DIR__ . '/../lib/helpers.php';thendb().- Reject non-
SELECTSQL withvalid:false(the box should only ever hold the preview SELECT; the DELETE is never typed by the user). - Wrap
$pdo->query("EXPLAIN " . $sql)in try/catch; onPDOExceptionreturnvalid:false+$e->getMessage(). - Use
json_response().
2. Refactor admin/api/cleanup.php
Add two new request shapes alongside the existing form-filter paths (keep
back-compat for saved-query execute via cleanup_queries.php).
2a. Preview from raw SQL (GET or POST)
Accept a sql parameter (the edited SELECT). Flow:
- Validate it starts with
SELECT(case-insensitive). - Run
EXPLAINfirst; if it fails, return{ error }. - Execute the SELECT. It must return columns
match_countandtotal_size_bytes(same shape as today). To keep this robust, the generated SQL the box shows will always be the standard preview SELECT:The user can add conditions but the SELECT-list stays fixed by convention. We run the user's SQL as-is for the count/size row.SELECT COUNT(*) AS match_count, COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes FROM events e WHERE 1=1 <user conditions> - For the kind breakdown, re-use the same
WHEREby extracting it from the user SQL (regex/\bWHERE\b(.*)/is, stop at end — there is no ORDER BY in the preview SELECT) and substituting into the breakdown template. - Return the same JSON shape as today (
match_count,total_size_bytes,total_size_human,avg_size_per_event,kinds_breakdown,sql_preview).
2b. Execute from raw WHERE (POST)
Accept a where parameter (the extracted WHERE clause, without the
leading WHERE keyword) plus max_events.
Flow:
- Sanitize: the where clause must only reference
events ecolumns / thecaching_followed_pubkeyssubquery. Reject if it contains;,--,/*,DELETE,UPDATE,INSERT,DROP,TRUNCATE,GRANT,REVOKE(case-insensitive) — defense in depth. - Build the safe DELETE (same as today):
DELETE FROM events e WHERE e.id IN ( SELECT e2.id FROM events e2 WHERE 1=1 AND <user where> ORDER BY e2.created_at ASC [LIMIT :max_events] ) - Run a preview count first (same WHERE) so we can report
freed_bytesproportionally, exactly like the current code. - Clear stats cache files, return
deleted_count/freed_bytes/etc.
Keep the existing form-filter POST path working (saved-query execute uses
it via cleanup_queries.php).
3. UI Changes — admin/index.php cleanup section
Replace the collapsible SQL Preview <details> block
(admin/index.php) with a prominent editable box
inside the cleanup-builder-group, right above the action buttons so
the user sees the query as they build it.
<!-- SQL box — live, editable, validated -->
<div class="form-group" id="cleanup-sql-box-group">
<label for="cleanup-sql-box">SQL Query
<span id="cleanup-sql-status" style="font-size:11px;margin-left:8px"></span>
</label>
<textarea id="cleanup-sql-box" rows="10" spellcheck="false"
style="width:100%;font-family:monospace;font-size:12px;
border:2px solid #000;border-radius:4px;padding:8px;
white-space:pre;overflow:auto"></textarea>
<div style="font-size:11px;color:var(--text-muted,#888);margin-top:2px">
Edit the SELECT to refine your query. Border turns red if invalid.
Execute uses your WHERE clause inside a safe DELETE template.
</div>
</div>
- Border default
2px solid #000(black = valid/unknown). #cleanup-sql-statusshows "Valid", "Invalid: ...", or "" while typing.- The old
cleanup-results-sql<details>block is removed (the box replaces it). The results group keeps summary + breakdown.
4. JS Changes — admin/assets/app.js
4a. Rebuild SQL from filters (mirror of PHP build_filters)
Add buildCleanupSql(filters) that returns the preview SELECT string,
duplicating the PHP condition logic on the client:
function buildCleanupSql(f) {
let sql = "SELECT COUNT(*) AS match_count,\n"
+ " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
+ "FROM events e\n"
+ "WHERE 1=1";
const conds = [];
if (f.kinds.length) conds.push("e.kind IN (" + f.kinds.join(",") + ")");
if (f.from_date) {
const cast = /^\d{4}-\d{2}-\d{2}$/.test(f.from_date) ? "::date" : "::timestamp";
conds.push("e.created_at >= EXTRACT(EPOCH FROM '" + f.from_date + "'" + cast + ")::BIGINT");
}
if (f.to_date) { /* same pattern */ }
if (f.follows_filter === 'follows')
conds.push("e.pubkey IN (SELECT pubkey FROM caching_followed_pubkeys)");
else if (f.follows_filter === 'non_follows')
conds.push("e.pubkey NOT IN (SELECT pubkey FROM caching_followed_pubkeys)");
if (conds.length) sql += "\n AND " + conds.join("\n AND ");
return sql;
}
Note: client-built SQL uses literal values (no :param placeholders) since
this string is for display + EXPLAIN + direct execution. The PHP execute
path re-validates server-side.
4b. Live validation (debounced)
let sqlValidateTimer = null;
function onCleanupSqlInput() {
const box = document.getElementById('cleanup-sql-box');
clearTimeout(sqlValidateTimer);
document.getElementById('cleanup-sql-status').textContent = 'checking...';
sqlValidateTimer = setTimeout(validateCleanupSql, 400);
}
async function validateCleanupSql() {
const sql = document.getElementById('cleanup-sql-box').value.trim();
const box = document.getElementById('cleanup-sql-box');
const status = document.getElementById('cleanup-sql-status');
if (!sql) { box.style.borderColor = '#000'; status.textContent = ''; return; }
try {
const res = await fetch('api/cleanup_validate.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({sql})
});
const d = await res.json();
if (d.valid) {
box.style.borderColor = '#000';
status.textContent = 'Valid'; status.style.color = 'green';
} else {
box.style.borderColor = '#c0392b';
status.textContent = 'Invalid: ' + (d.error||'').slice(0,120);
status.style.color = '#c0392b';
}
} catch(e) {
box.style.borderColor = '#c0392b';
status.textContent = 'Validation request failed';
}
}
Wire onCleanupSqlInput to the textarea's input event (in page init).
4c. Form filters regenerate the SQL box
Add refreshCleanupSqlFromForm() that calls readCleanupFilters() then
buildCleanupSql() and sets the textarea value, then triggers validation.
Attach change/input listeners to: follows radios, kinds input,
from/to date+time, max events. Also call it at the end of
newCleanupQuery() and loadSavedCleanupQuery().
4d. Preview uses the edited SQL
Rewrite previewCleanup():
- Read the SQL straight from
#cleanup-sql-box. - POST it to
api/cleanup.phpwith{action:'preview_sql', sql}(or GET with the sql in the body — use POST to avoid URL length limits). - Refuse if border is currently red (alert "Fix the SQL first").
renderCleanupResults()stays the same;sql_previewechoes the box.
4e. Execute extracts WHERE
Rewrite executeCleanup():
- Read SQL from the box, extract the WHERE clause:
const m = sql.match(/\bWHERE\b([\s\S]*)$/i); const where = m ? m[1].trim() : ''; - POST to
api/cleanup.phpwith{action:'execute_where', where, max_events}. - Refuse if border is red.
- Confirmation dialog message uses the preview count (fetch preview first, same as today).
4f. Saved-query flows
loadSavedCleanupQuery() already populates the form; after it does, call
refreshCleanupSqlFromForm() so the box reflects the loaded query.
runSavedCleanupPreview() / runSavedCleanupExecute() continue to work —
they load the form then call previewCleanup() / the execute path, which
now read from the box.
5. Files Touched
| File | Change |
|---|---|
admin/api/cleanup_validate.php |
NEW — EXPLAIN-based validator |
admin/api/cleanup.php |
Add preview_sql + execute_where paths |
admin/index.php |
Replace SQL preview <details> with editable textarea box |
admin/assets/app.js |
buildCleanupSql, debounced validation, form->box sync, Preview/Execute from box |
No DB schema changes. No changes to cleanup_queries.php (saved-query CRUD
unchanged; its execute action still uses the form-filter path, which
remains in cleanup.php).
6. Safety
- Preview only ever runs a
SELECT(enforced server-side + the box is seeded with a SELECT). - Execute never runs user SQL directly; it extracts the WHERE and injects
it into the fixed
DELETE ... id IN (subquery)template, with a denylist of dangerous keywords and comment markers. EXPLAINvalidation has zero side effects.- Existing destructive-path safety (subquery + ORDER BY + LIMIT) preserved.