1919 * · register orphan snapshot dirs (no fabricated spend)
2020 * · prune runaway 'continuous-loop iteration%' rows
2121 * · backfill file snapshots missing from SQLite
22+ * · DB slim-down (one-time backfill for legacy data):
23+ * – offload inline results.raw_json blobs to
24+ * results/raw/run-<run_id>/<id>.json, then NULL
25+ * the column (write+verify BEFORE null = no loss)
26+ * – cap legacy error_logs.log_snippet at 16 KB tail
27+ * (idempotent backstop to migration v11)
28+ * – prune batch_results to DEFAULT_BATCH_RETENTION
2229 * --purge-orphan-logs (with --fix) quarantine orphan audit logs into
2330 * results/.cleanup-trash/<ts>/ (move, not delete;
2431 * never the active or a recently-written log).
@@ -40,6 +47,7 @@ import Database from 'better-sqlite3';
4047import { readFileSync , writeFileSync , existsSync , readdirSync , statSync , renameSync , mkdirSync , copyFileSync } from 'fs' ;
4148import path from 'path' ;
4249import { fileURLToPath } from 'url' ;
50+ import { RAW_DIR , DEFAULT_BATCH_RETENTION } from '../core/constants.js' ;
4351
4452const __dirname = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
4553const ROOT = path . join ( __dirname , '..' ) ;
@@ -56,6 +64,17 @@ const MATCH_TOLERANCE_MS = 15_000;
5664const BACKFILL_TOLERANCE_MS = 60_000 ; // wider dedup window for backfill (save-time skew)
5765const RECENT_LOG_MS = 24 * 60 * 60 * 1000 ; // never purge a log written in the last day
5866const RUNAWAY_NOTES = 'continuous-loop iteration%' ;
67+ const LOG_SNIPPET_CAP = 16384 ; // bytes — mirrors core/db.js insertErrorLog + migration v11
68+
69+ // Human-readable byte size for the slim-down report.
70+ const humanBytes = n => {
71+ if ( n == null || ! Number . isFinite ( Number ( n ) ) ) return '0 B' ;
72+ let b = Number ( n ) ;
73+ const u = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' ] ;
74+ let i = 0 ;
75+ while ( b >= 1024 && i < u . length - 1 ) { b /= 1024 ; i ++ ; }
76+ return `${ b . toFixed ( i === 0 ? 0 : 1 ) } ${ u [ i ] } ` ;
77+ } ;
5978
6079// ─── reporter ─────────────────────────────────────────────────────────────────
6180// hard = cannot be auto-fixed (integrity / schema / unparseable / write failures)
@@ -207,6 +226,43 @@ if (existsSync(RESULTS_DIR)) {
207226 else { info ( `${ orphanLogs . length } orphan log file(s): ${ orphanLogs . slice ( 0 , 5 ) . join ( ', ' ) } ${ orphanLogs . length > 5 ? ' …' : '' } ` ) ; if ( PURGE_LOGS && ! FIX ) console . log ( C . dim ( ' (--purge-orphan-logs needs --fix to take effect — ignored)' ) ) ; else if ( ! ( FIX && PURGE_LOGS ) ) console . log ( C . dim ( ' (pass --fix --purge-orphan-logs to quarantine these)' ) ) ; }
208227}
209228
229+ // ─── 6. DB slim-down (one-time backfill for legacy data) ──────────────────────
230+ // These mirror the forward-going behaviour that core/db.js now applies on every
231+ // insert: raw_json blobs offloaded to per-run files, log_snippet capped at 16 KB,
232+ // batch_results bounded. Existing rows from before those commits still carry the
233+ // inline blob / oversized snippet / unbounded batch history; this section reports
234+ // (and, under --fix, performs) the one-time migration of that legacy data.
235+ section ( '6. DB slim-down (legacy backfill)' ) ;
236+ let rawJsonRows = 0 ; // results rows still holding an inline raw_json blob
237+ let logSnippetRows = 0 ; // error_logs rows whose snippet exceeds the 16 KB cap
238+ let batchCount = 0 ; // total batches
239+ let batchResultRows = 0 ; // total batch_results rows
240+ if ( rdb ) {
241+ try {
242+ const r = rdb . prepare ( 'SELECT COUNT(*) AS c, COALESCE(SUM(length(raw_json)), 0) AS bytes FROM results WHERE raw_json IS NOT NULL' ) . get ( ) ;
243+ rawJsonRows = r . c ;
244+ rawJsonRows > 0
245+ ? repairable ( `${ rawJsonRows . toLocaleString ( ) } results row(s) still hold an inline raw_json blob (~${ humanBytes ( r . bytes ) } reclaimable → offload to results/raw/run-*/<id>.json + NULL column)` )
246+ : ok ( 'no inline raw_json blobs (already offloaded)' ) ;
247+ } catch ( e ) { hard ( `raw_json scan failed: ${ e . message } ` ) ; }
248+
249+ try {
250+ logSnippetRows = rdb . prepare ( `SELECT COUNT(*) AS c FROM error_logs WHERE log_snippet IS NOT NULL AND length(log_snippet) > ${ LOG_SNIPPET_CAP } ` ) . get ( ) . c ;
251+ logSnippetRows > 0
252+ ? repairable ( `${ logSnippetRows . toLocaleString ( ) } error_logs row(s) exceed the 16 KB log_snippet cap (would truncate to tail)` )
253+ : ok ( 'no oversized log_snippet rows (migration v11 already applied)' ) ;
254+ } catch ( e ) { hard ( `log_snippet scan failed: ${ e . message } ` ) ; }
255+
256+ try {
257+ batchCount = rdb . prepare ( 'SELECT COUNT(*) AS c FROM batches' ) . get ( ) . c ;
258+ batchResultRows = rdb . prepare ( 'SELECT COUNT(*) AS c FROM batch_results' ) . get ( ) . c ;
259+ const overRetention = Math . max ( 0 , batchCount - DEFAULT_BATCH_RETENTION ) ;
260+ overRetention > 0
261+ ? repairable ( `${ batchCount . toLocaleString ( ) } batch(es) / ${ batchResultRows . toLocaleString ( ) } batch_results row(s); ~${ overRetention . toLocaleString ( ) } batch(es) over the ${ DEFAULT_BATCH_RETENTION } retention cap (would prune)` )
262+ : ok ( `${ batchCount . toLocaleString ( ) } batch(es) / ${ batchResultRows . toLocaleString ( ) } batch_results row(s) — within the ${ DEFAULT_BATCH_RETENTION } retention cap` ) ;
263+ } catch ( e ) { hard ( `batch_results scan failed: ${ e . message } ` ) ; }
264+ }
265+
210266// Close our OWN read-only handle before mutating: if it stays open, the WAL
211267// checkpoint below can report `busy` and falsely abort the whole --fix on a
212268// perfectly healthy, server-stopped box.
@@ -296,6 +352,71 @@ if (FIX) {
296352 } catch ( e ) { hard ( `SQLite backfill failed: ${ e . message } ` ) ; }
297353 } else console . log ( ` ${ C . dim ( '↳ backfill skipped (DB writes disabled)' ) } ` ) ;
298354
355+ // ── DB slim-down (one-time legacy backfill) ───────────────────────────────
356+ // Gated on dbWriteOk (audit.db backed up + WAL checkpoint succeeded → server
357+ // stopped). Mirrors core/db.js forward behaviour for pre-slim-down rows.
358+ if ( dbWriteOk ) {
359+ try {
360+ const { getDb, readRawJson, pruneBatchResults } = await import ( '../core/db.js' ) ;
361+ const sdb = getDb ( ) ;
362+
363+ // Action 1: offload inline raw_json blobs to per-run files, then NULL the
364+ // column. Order is write+verify BEFORE null so a crash mid-pass never
365+ // loses a blob — a re-run just re-offloads any row whose column is still
366+ // set. writeRawJson is not exported from core/db.js, so replicate its exact
367+ // path + write (mkdirSync recursive + writeFileSync) inline.
368+ const blobRows = sdb . prepare ( 'SELECT id, run_id, raw_json FROM results WHERE raw_json IS NOT NULL' ) . all ( ) ;
369+ if ( ! blobRows . length ) console . log ( ` ${ C . dim ( '↳ raw_json offload: no inline blobs to migrate' ) } ` ) ;
370+ else {
371+ const nullStmt = sdb . prepare ( 'UPDATE results SET raw_json = NULL WHERE id = ?' ) ;
372+ let offloaded = 0 , skippedWrite = 0 ;
373+ // Stage writes first (file I/O outside the txn), collecting the ids whose
374+ // file is confirmed on disk; null only those, inside a single txn.
375+ const ready = [ ] ;
376+ for ( const row of blobRows ) {
377+ const dir = path . join ( RAW_DIR , `run-${ row . run_id } ` ) ;
378+ const file = path . join ( dir , `${ row . id } .json` ) ;
379+ try {
380+ mkdirSync ( dir , { recursive : true } ) ;
381+ writeFileSync ( file , row . raw_json ) ;
382+ // VERIFY before we agree to null: file exists and is non-empty.
383+ const st = statSync ( file ) ;
384+ if ( st . size > 0 ) ready . push ( row . id ) ;
385+ else { skippedWrite ++ ; hard ( `raw_json offload: ${ file } wrote 0 bytes — left column intact for result ${ row . id } ` ) ; }
386+ } catch ( e ) { skippedWrite ++ ; hard ( `raw_json offload: write failed for result ${ row . id } — left column intact: ${ e . message } ` ) ; }
387+ }
388+ if ( ready . length ) {
389+ sdb . transaction ( ( ) => { for ( const id of ready ) { nullStmt . run ( id ) ; offloaded ++ ; } } ) ( ) ;
390+ }
391+ // Sanity sample: rehydrate the first migrated row from disk.
392+ if ( ready . length ) {
393+ const sample = blobRows . find ( r => r . id === ready [ 0 ] ) ;
394+ const back = readRawJson ( sample . run_id , sample . id ) ;
395+ if ( back == null ) hard ( `raw_json offload: verify-read of run-${ sample . run_id } /${ sample . id } .json returned null after write` ) ;
396+ }
397+ fixd ( `offloaded ${ offloaded . toLocaleString ( ) } raw_json blob(s) to results/raw/run-*/<id>.json + NULLed column${ skippedWrite ? ` (${ skippedWrite } skipped — column left intact, no data loss)` : '' } ` ) ;
398+ }
399+
400+ // Action 2: cap legacy oversized log_snippet (idempotent backstop to v11).
401+ const capRes = sdb . prepare (
402+ `UPDATE error_logs SET log_snippet = substr(log_snippet, length(log_snippet) - ${ LOG_SNIPPET_CAP } + 1) WHERE log_snippet IS NOT NULL AND length(log_snippet) > ${ LOG_SNIPPET_CAP } ` ,
403+ ) . run ( ) ;
404+ capRes . changes > 0
405+ ? fixd ( `capped ${ capRes . changes . toLocaleString ( ) } oversized log_snippet row(s) to 16 KB tail` )
406+ : console . log ( ` ${ C . dim ( '↳ log_snippet cap: nothing to truncate (v11 already applied)' ) } ` ) ;
407+
408+ // Action 3: prune batch_results to retention (reuse core/db.js keep-set).
409+ const pr = pruneBatchResults ( { keepBatches : DEFAULT_BATCH_RETENTION } ) ;
410+ ( pr . deletedBatchResults || pr . deletedBatches )
411+ ? fixd ( `pruned ${ pr . deletedBatchResults . toLocaleString ( ) } batch_results row(s) + ${ pr . deletedBatches . toLocaleString ( ) } batch(es) (kept ${ pr . keptBatches . toLocaleString ( ) } )` )
412+ : console . log ( ` ${ C . dim ( `↳ batch_results prune: within retention (kept ${ pr . keptBatches . toLocaleString ( ) } )` ) } ` ) ;
413+
414+ // Reclaim freed pages and flush the WAL.
415+ try { sdb . pragma ( 'wal_checkpoint(TRUNCATE)' ) ; }
416+ catch ( e ) { hard ( `slim-down checkpoint failed: ${ e . message } ` ) ; }
417+ } catch ( e ) { hard ( `DB slim-down failed (restore audit.db from the backup): ${ e . message } ` ) ; }
418+ } else console . log ( ` ${ C . dim ( '↳ slim-down skipped (DB writes disabled)' ) } ` ) ;
419+
299420 if ( PURGE_LOGS && orphanLogs . length && ! abortAll ) {
300421 const trash = path . join ( RESULTS_DIR , '.cleanup-trash' , String ( ts ) ) ;
301422 try {
0 commit comments