99
1010import Database from 'better-sqlite3' ;
1111import path from 'path' ;
12- import { mkdirSync , existsSync } from 'fs' ;
12+ import { mkdirSync , existsSync , writeFileSync , readFileSync } from 'fs' ;
1313import { fileURLToPath } from 'url' ;
1414import { countryToContinent } from './countries.js' ;
15+ import { RAW_DIR } from './constants.js' ;
16+
17+ // ─── Raw-JSON file offload ───────────────────────────────────────────────────
18+ // The full JSON.stringify(result) blob is no longer stored inline in the
19+ // results.raw_json column. Instead each result's blob is written to a per-run
20+ // file: results/raw/run-<run_id>/<result_id>.json. The column is bound NULL
21+ // going forward; readers transparently rehydrate from the file so the REST
22+ // contract (er.raw_json as a JSON string) stays byte-identical.
23+
24+ /** Deterministic path to a result's raw-json file. */
25+ function rawJsonPath ( run_id , result_id ) {
26+ return path . join ( RAW_DIR , `run-${ run_id } ` , `${ result_id } .json` ) ;
27+ }
28+
29+ /**
30+ * Write the raw-json blob for a single result to its per-run file.
31+ * Logs but never throws — the file is a backup, not load-bearing for the insert
32+ * (mirrors pipeline.js raw-write philosophy). Works for :memory: DBs too since
33+ * the path is independent of the DB backend.
34+ *
35+ * @param {number } run_id
36+ * @param {number|bigint } result_id
37+ * @param {string } json - the JSON.stringify(result) string
38+ */
39+ function writeRawJson ( run_id , result_id , json ) {
40+ try {
41+ const dir = path . join ( RAW_DIR , `run-${ run_id } ` ) ;
42+ mkdirSync ( dir , { recursive : true } ) ;
43+ writeFileSync ( path . join ( dir , `${ result_id } .json` ) , json ) ;
44+ } catch ( e ) {
45+ console . error ( '[db] raw_json file write failed:' , e . message ) ;
46+ }
47+ }
48+
49+ /**
50+ * Read the raw-json blob for a result from its per-run file.
51+ *
52+ * @param {number } run_id
53+ * @param {number|bigint } result_id
54+ * @returns {string|null } the JSON string, or null if missing/unreadable
55+ */
56+ export function readRawJson ( run_id , result_id ) {
57+ if ( run_id == null || result_id == null ) return null ;
58+ try {
59+ return readFileSync ( rawJsonPath ( run_id , result_id ) , 'utf8' ) ;
60+ } catch {
61+ // ENOENT or any read error → no blob available.
62+ return null ;
63+ }
64+ }
65+
66+ /**
67+ * Mutate result rows in place: when raw_json is NULL/empty but run_id and id
68+ * are present, rehydrate raw_json from the per-run file. Keeps er.raw_json a
69+ * JSON string exactly as before the offload.
70+ *
71+ * @param {object[] } rows
72+ * @returns {object[] } the same array (mutated)
73+ */
74+ function rehydrateRawJson ( rows ) {
75+ for ( const row of rows ) {
76+ if ( ! row || ( row . raw_json != null && row . raw_json !== '' ) ) continue ;
77+ // Prefer result_id: getNodeErrors joins error_logs (el.*), so row.id is the
78+ // error_log id while result_id is the FK to results.id (the file key).
79+ // Plain results-table rows have only `id`, so fall back to it.
80+ const id = row . result_id ?? row . id ;
81+ if ( row . run_id != null && id != null ) {
82+ row . raw_json = readRawJson ( row . run_id , id ) ;
83+ }
84+ }
85+ return rows ;
86+ }
1587
1688const __dirname = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
1789const DATA_DIR = path . join ( __dirname , '..' , 'data' ) ;
@@ -502,7 +574,11 @@ function mapResultToRow(run_id, r) {
502574 error_code : r . errorCode || null ,
503575 error_message : r . error || null ,
504576 tested_at,
505- raw_json : JSON . stringify ( r ) ,
577+ // raw_json is offloaded to a per-run file after insert; the column is
578+ // bound NULL going forward. _rawJson carries the blob to the writer but is
579+ // NOT a column param (SQLite ignores extra named props it doesn't bind).
580+ raw_json : null ,
581+ _rawJson : JSON . stringify ( r ) ,
506582 pass,
507583 stage,
508584 sdk : r . sdk || null ,
@@ -533,7 +609,9 @@ const _insertResultSql = `
533609export function insertResult ( run_id , result , which ) {
534610 const db = getDb ( which ) ;
535611 const row = mapResultToRow ( run_id , result ) ;
536- const info = db . prepare ( _insertResultSql ) . run ( row ) ;
612+ const { _rawJson, ...bind } = row ;
613+ const info = db . prepare ( _insertResultSql ) . run ( bind ) ;
614+ writeRawJson ( run_id , info . lastInsertRowid , _rawJson ) ;
537615 return info . lastInsertRowid ;
538616}
539617
@@ -548,7 +626,11 @@ export function insertResultsBatch(run_id, results, which) {
548626 const db = getDb ( which ) ;
549627 const stmt = db . prepare ( _insertResultSql ) ;
550628 const insert = db . transaction ( ( rows ) => {
551- for ( const r of rows ) stmt . run ( r ) ;
629+ for ( const row of rows ) {
630+ const { _rawJson, ...bind } = row ;
631+ const info = stmt . run ( bind ) ;
632+ writeRawJson ( run_id , info . lastInsertRowid , _rawJson ) ;
633+ }
552634 } ) ;
553635 insert ( results . map ( r => mapResultToRow ( run_id , r ) ) ) ;
554636}
@@ -703,9 +785,10 @@ export function getLatestResultPerNode({ q = null, country = null, limit = 200,
703785 * @returns {object[] }
704786 */
705787export function getNodeHistory ( nodeAddr , { limit = 50 } = { } , which ) {
706- return getDb ( which ) . prepare (
788+ const rows = getDb ( which ) . prepare (
707789 'SELECT * FROM results WHERE node_addr = @node_addr ORDER BY tested_at DESC LIMIT @limit' ,
708790 ) . all ( { node_addr : nodeAddr , limit } ) ;
791+ return rehydrateRawJson ( rows ) ;
709792}
710793
711794// ─── Aggregate Stats ──────────────────────────────────────────────────────────
@@ -1059,12 +1142,14 @@ export function getNodeDetail(addr, { historyLimit = 100 } = {}, which) {
10591142 LIMIT 1
10601143 ` ) . get ( { addr } ) ;
10611144
1062- const history = db . prepare ( `
1145+ if ( node ) rehydrateRawJson ( [ node ] ) ;
1146+
1147+ const history = rehydrateRawJson ( db . prepare ( `
10631148 SELECT * FROM results
10641149 WHERE node_addr = @addr
10651150 ORDER BY tested_at DESC
10661151 LIMIT @limit
1067- ` ) . all ( { addr, limit : historyLimit } ) ;
1152+ ` ) . all ( { addr, limit : historyLimit } ) ) ;
10681153
10691154 const errors = db . prepare ( `
10701155 SELECT el.*, r.tested_at, r.actual_mbps, r.node_addr, r.moniker
@@ -1115,7 +1200,7 @@ export function getNodeErrors(addr, { limit = 50, stage = null } = {}, which) {
11151200 ORDER BY el.captured_at DESC
11161201 LIMIT @limit
11171202 ` ) . all ( params ) ;
1118- if ( rows . length > 0 ) return rows ;
1203+ if ( rows . length > 0 ) return rehydrateRawJson ( rows ) ;
11191204
11201205 // Fallback: no error_logs row exists for this node yet (race between
11211206 // upsertResult writing the result and insertErrorLog completing, or an old
@@ -1154,7 +1239,7 @@ export function getNodeErrors(addr, { limit = 50, stage = null } = {}, which) {
11541239 ORDER BY r.tested_at DESC
11551240 LIMIT @limit
11561241 ` ) . all ( params ) ;
1157- if ( fallback . length > 0 ) return fallback ;
1242+ if ( fallback . length > 0 ) return rehydrateRawJson ( fallback ) ;
11581243
11591244 // Second fallback: continuous-loop (public) runs persist per node ONLY to
11601245 // batch_results — never to results/error_logs — so a node that failed only
0 commit comments