feat: support postgres autonomy runtime

This commit is contained in:
ImBenji
2026-08-17 13:43:50 +01:00
parent 4aa2367971
commit f0b598a3b8
11 changed files with 301 additions and 24 deletions
+1
View File
@@ -1,6 +1,7 @@
const AUTONOMY_SCHEMA_VERSION = 2;
function initAutonomySchema(db) {
if (db.dialect === 'postgres') return;
db.exec(`
CREATE TABLE IF NOT EXISTS autonomy_schema (
version INTEGER PRIMARY KEY,
+144
View File
@@ -0,0 +1,144 @@
const { Pool } = require('pg');
const deasync = require('deasync');
const SqliteDatabase = require('better-sqlite3');
const pools = new Map();
function isPostgresEnabled() {
return String(process.env.DURIIN_DB_BACKEND || '').toLowerCase() === 'postgres' || Boolean(process.env.DATABASE_URL && process.env.DURIIN_USE_POSTGRES === 'true');
}
function poolFor(schema) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is required for postgres runtime');
const key = `${connectionString}|${schema}`;
if (!pools.has(key)) {
pools.set(key, new Pool({
connectionString,
max: Math.max(1, Number(process.env.POSTGRES_RUNTIME_POOL_SIZE) || 4),
options: `-c search_path=${schema},public`,
}));
}
return pools.get(key);
}
function querySync(pool, sql, params = []) {
let done = false;
let result;
let error;
pool.query(sql, params).then((value) => { result = value; done = true; }).catch((err) => { error = err; done = true; });
deasync.loopWhile(() => !done);
if (error) throw error;
return result;
}
function normalizeParams(params) {
if (params.length === 1 && params[0] && typeof params[0] === 'object' && !Array.isArray(params[0]) && !Buffer.isBuffer(params[0])) {
return params[0];
}
return params.flat();
}
function rewritePlaceholders(sql, params) {
if (params && !Array.isArray(params)) {
const values = [];
const text = sql.replace(/@([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => {
values.push(params[name]);
return `$${values.length}`;
});
return { sql: text, params: values };
}
let index = 0;
return { sql: sql.replace(/\?/g, () => `$${++index}`), params: params || [] };
}
function rewriteSql(sql, params) {
let text = String(sql).trim();
text = text.replace(/INSERT\s+OR\s+IGNORE\s+INTO/gi, 'INSERT INTO');
text = text.replace(/INSERT\s+OR\s+REPLACE\s+INTO\s+autonomy_outcomes\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)/i,
(match, columns, values) => {
const names = columns.split(',').map((item) => item.trim().replace(/"/g, ''));
const updates = names.filter((name) => name !== 'prediction_id').map((name) => `${name}=EXCLUDED.${name}`).join(', ');
return `INSERT INTO autonomy_outcomes (${columns}) VALUES (${values}) ON CONFLICT (prediction_id) DO UPDATE SET ${updates}`;
});
text = text.replace(/AUTOINCREMENT/gi, 'GENERATED BY DEFAULT AS IDENTITY');
text = text.replace(/INTEGER\s+PRIMARY\s+KEY\s+GENERATED BY DEFAULT AS IDENTITY/gi, 'BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY');
text = text.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, 'BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY');
text = text.replace(/datetime\('now',\s*\?\)/gi, 'CURRENT_TIMESTAMP + (?::interval)');
text = text.replace(/datetime\('now',\s*'\+60 seconds'\)/gi, "CURRENT_TIMESTAMP + interval '60 seconds'");
text = text.replace(/datetime\('now',\s*'([^']+)'\)/gi, "CURRENT_TIMESTAMP + interval '$1'");
text = text.replace(/datetime\('now'\)/gi, 'CURRENT_TIMESTAMP');
text = text.replace(/date\('now'\)/gi, 'CURRENT_DATE');
text = text.replace(/datetime\(COALESCE\(([^)]+)\)\)/gi, 'COALESCE($1)::timestamp');
text = text.replace(/datetime\((p\.information_cutoff),\s*'\+'\s*\|\|\s*(p\.horizon_days)\s*\|\|\s*' days'\)/gi, "($1::timestamp + ($2 || ' days')::interval)");
text = text.replace(/datetime\(([^)]+)\)/gi, '($1)::timestamp');
const rewritten = rewritePlaceholders(text, params);
let finalSql = rewritten.sql;
if (/^INSERT\s+INTO\s+autonomy_jobs\b/i.test(finalSql) && !/ON\s+CONFLICT/i.test(finalSql)) finalSql += ' ON CONFLICT DO NOTHING';
if (/^INSERT\s+INTO\s+autonomy_order_intents\b/i.test(finalSql) && !/ON\s+CONFLICT/i.test(finalSql)) finalSql += ' ON CONFLICT DO NOTHING';
if (/^INSERT\s+INTO\s+autonomy_schema\b/i.test(finalSql) && !/ON\s+CONFLICT/i.test(finalSql)) finalSql += ' ON CONFLICT DO NOTHING';
if (/^INSERT\s+INTO\s+autonomy_proposals\b/i.test(finalSql) && !/RETURNING\s+id/i.test(finalSql)) finalSql += ' RETURNING id';
return { sql: finalSql, params: rewritten.params };
}
class PgCompatDb {
constructor(schema) {
this.schema = schema;
this.dialect = 'postgres';
this.pool = poolFor(schema);
}
pragma() { return undefined; }
prepare(sql) {
const db = this;
return {
get(...rawParams) {
const { sql: text, params } = rewriteSql(sql, normalizeParams(rawParams));
return querySync(db.pool, text, params).rows[0];
},
all(...rawParams) {
const { sql: text, params } = rewriteSql(sql, normalizeParams(rawParams));
return querySync(db.pool, text, params).rows;
},
run(...rawParams) {
const { sql: text, params } = rewriteSql(sql, normalizeParams(rawParams));
const result = querySync(db.pool, text, params);
return { changes: result.rowCount || 0, lastInsertRowid: result.rows?.[0]?.id ?? null };
},
};
}
exec(sql) {
const statements = String(sql).split(';').map((statement) => statement.trim()).filter(Boolean);
for (const statement of statements) {
const { sql: text, params } = rewriteSql(statement, []);
querySync(this.pool, text, params);
}
}
transaction(fn) {
const db = this;
const run = (...args) => {
querySync(db.pool, 'BEGIN', []);
try {
const result = fn(...args);
querySync(db.pool, 'COMMIT', []);
return result;
} catch (error) {
querySync(db.pool, 'ROLLBACK', []);
throw error;
}
};
run.immediate = run;
return run;
}
}
function openRuntimeDb(path, { schema = 'intelligence', readonly = false } = {}) {
if (isPostgresEnabled()) return new PgCompatDb(schema);
return new SqliteDatabase(path, readonly ? { readonly: true } : undefined);
}
module.exports = { openRuntimeDb, isPostgresEnabled, PgCompatDb, rewriteSql };
+6 -5
View File
@@ -5,6 +5,7 @@ const { Worker } = require('node:worker_threads');
const db = require('../db');
const config = require('../config');
const Database = require('better-sqlite3');
const { openRuntimeDb, isPostgresEnabled } = require('../db/runtime');
let idb = null;
let statsSummaryCache = null;
@@ -46,9 +47,9 @@ function getIntelligenceDb() {
? (path.isAbsolute(config.intelligence_db) ? config.intelligence_db : path.resolve(configDir, config.intelligence_db))
: path.resolve(configDir, 'intelligence.sqlite'));
if (!fs.existsSync(rawPath)) return null;
if (!isPostgresEnabled() && !fs.existsSync(rawPath)) return null;
idb = new Database(rawPath);
idb = isPostgresEnabled() ? openRuntimeDb(rawPath, { schema: 'intelligence' }) : new Database(rawPath);
return idb;
}
@@ -153,9 +154,9 @@ async function adminRoutes(fastify) {
if (!checkAuth(request, reply)) return;
const intelligenceDb = getIntelligenceDb();
if (!intelligenceDb) return { enabled: false, reason: 'intelligence database unavailable' };
const hasSchema = intelligenceDb.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='autonomy_jobs'"
).get();
const hasSchema = isPostgresEnabled()
? intelligenceDb.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = ? AND table_name = ?").get('intelligence', 'autonomy_jobs')
: intelligenceDb.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='autonomy_jobs'").get();
if (!hasSchema) return { enabled: false, reason: 'autonomy schema is not initialized' };
const jobs = intelligenceDb.prepare(`
+5 -3
View File
@@ -1,14 +1,16 @@
const path = require('path');
const Database = require('better-sqlite3');
const { openRuntimeDb, isPostgresEnabled } = require('../db/runtime');
const intelligencePath = process.env.INTELLIGENCE_DB || path.resolve(process.cwd(), 'intelligence.sqlite');
const db = new Database(intelligencePath, { readonly: true });
const db = openRuntimeDb(intelligencePath, { schema: 'intelligence', readonly: true });
async function autonomyRoutes(fastify) {
fastify.get('/health', async () => ({ ok: true, service: 'duriin-api' }));
fastify.get('/autonomy/status', async () => {
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='autonomy_jobs'").get();
const exists = isPostgresEnabled()
? db.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = ? AND table_name = ?").get('intelligence', 'autonomy_jobs')
: db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='autonomy_jobs'").get();
if (!exists) return { enabled: false, reason: 'autonomy schema is not initialized' };
const jobs = db.prepare(`
SELECT lane, status, COUNT(*) AS count