pediatric-ai-scribe-v3/src/db/database.js

524 lines
24 KiB
JavaScript

const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://pedscribe:pedscribe_secret_change_me@localhost:5432/pedscribe';
const pool = new Pool({
connectionString: DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
});
pool.query('SELECT NOW()')
.then(function() { console.log('✅ PostgreSQL: connected'); })
.catch(function(err) { console.error('❌ PostgreSQL: connection failed:', err.message); });
async function initDatabase() {
var client = await pool.connect();
try {
// Enable pgvector extension for embeddings
try {
await client.query('CREATE EXTENSION IF NOT EXISTS vector');
console.log('✅ pgvector extension: enabled');
} catch (err) {
console.warn('⚠️ pgvector extension not available. Vector search disabled. Install: apt-get install postgresql-16-pgvector');
}
// Collation-drift check — btree indexes on text become silently corrupt
// when the ICU / glibc library version bundled with the postgres image
// changes. pg_database tracks the collation version used when the DB
// was created/last refreshed; if it differs from what the library now
// reports, REINDEX and REFRESH to clear the flag.
try {
var dbDrift = await client.query(
"SELECT datcollversion AS recorded, " +
"pg_database_collation_actual_version(oid) AS actual " +
"FROM pg_database WHERE datname = current_database()"
);
var row = dbDrift.rows[0] || {};
if (row.recorded && row.actual && row.recorded !== row.actual) {
console.warn('⚠️ Collation drift: DB recorded ' + row.recorded + ', library reports ' + row.actual + '. REINDEX-ing to prevent silent index corruption...');
await client.query('REINDEX DATABASE pedscribe');
await client.query('ALTER DATABASE pedscribe REFRESH COLLATION VERSION').catch(function() {});
console.log('✅ REINDEX complete. Collation version refreshed.');
} else {
console.log('✅ Collation versions: aligned');
}
} catch (e) {
console.warn('⚠️ Collation check skipped:', e.message);
}
// Convert lookup-only text indexes to COLLATE "C" so a future ICU/glibc
// version bump cannot silently corrupt them. Idempotent — driven by a
// flag in app_settings so we only do the DROP/CREATE once.
try {
var done = await client.query(
"SELECT value FROM app_settings WHERE key = 'migration.text_indexes_c'"
).catch(function() { return { rows: [] }; });
if (!done.rows.length || done.rows[0].value !== 'true') {
console.log('Migrating text lookup indexes to COLLATE "C"...');
var indexMigrations = [
// users.email — primary auth lookup
'DROP INDEX IF EXISTS idx_users_email',
'CREATE INDEX idx_users_email ON users (email COLLATE "C")',
// user_sessions.token_hash — session auth
'DROP INDEX IF EXISTS idx_sessions_token_hash',
'CREATE INDEX idx_sessions_token_hash ON user_sessions (token_hash COLLATE "C")'
];
for (var i = 0; i < indexMigrations.length; i++) {
await client.query(indexMigrations[i]);
}
// Mark complete — create the setting row if the table exists.
await client.query(
"INSERT INTO app_settings (key, value) VALUES ('migration.text_indexes_c', 'true') " +
"ON CONFLICT (key) DO UPDATE SET value = 'true'"
).catch(function() {});
console.log('✅ Text indexes: migrated to COLLATE "C" (ICU-drift immune)');
}
} catch (e) {
console.warn('⚠️ Index migration skipped:', e.message);
}
await client.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT DEFAULT 'user',
email_verified BOOLEAN DEFAULT false,
verify_token TEXT,
verify_expires BIGINT,
totp_secret TEXT,
totp_enabled BOOLEAN DEFAULT false,
passkey_credentials TEXT,
nextcloud_url TEXT,
nextcloud_user TEXT,
nextcloud_token TEXT,
nextcloud_folder TEXT DEFAULT '/PediatricScribe',
reset_token TEXT,
reset_expires BIGINT,
disabled BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
category TEXT DEFAULT 'general',
details TEXT,
ip_address TEXT,
user_agent TEXT,
model_used TEXT,
tokens_used INTEGER,
duration_ms INTEGER,
status TEXT DEFAULT 'success',
timestamp TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS api_log (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
endpoint TEXT NOT NULL,
method TEXT,
status_code INTEGER,
request_size INTEGER,
response_size INTEGER,
model_used TEXT,
tokens_input INTEGER,
tokens_output INTEGER,
cost_estimate REAL,
duration_ms INTEGER,
ip_address TEXT,
error TEXT,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS access_log (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
success BOOLEAN DEFAULT true,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_api_user ON api_log(user_id);
CREATE INDEX IF NOT EXISTS idx_api_ts ON api_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_access_ts ON access_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS saved_encounters (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT 'Untitled',
enc_type TEXT NOT NULL DEFAULT 'encounter',
transcript TEXT DEFAULT '',
generated_note TEXT DEFAULT '',
partial_data TEXT DEFAULT '{}',
status TEXT DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days'
);
CREATE TABLE IF NOT EXISTS user_memories (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'custom',
name TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id);
CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at);
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id);
CREATE INDEX IF NOT EXISTS idx_memories_user_cat ON user_memories(user_id, category);
-- Phone extensions / pagers (per-user personal directory)
-- Soft-delete via trashed_at: null = active, timestamp = in trash.
CREATE TABLE IF NOT EXISTS user_phone_extensions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
location TEXT NOT NULL,
name TEXT NOT NULL,
number TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('extension', 'pager')),
notes TEXT DEFAULT '',
trashed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ext_user_active ON user_phone_extensions(user_id) WHERE trashed_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_ext_user_trash ON user_phone_extensions(user_id) WHERE trashed_at IS NOT NULL;
-- Learning Hub tables
CREATE TABLE IF NOT EXISTS learning_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS learning_content (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
body TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL,
subject TEXT DEFAULT '',
content_type TEXT DEFAULT 'article',
published BOOLEAN DEFAULT false,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS learning_questions (
id SERIAL PRIMARY KEY,
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
question_text TEXT NOT NULL,
question_type TEXT NOT NULL DEFAULT 'mcq',
explanation TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS learning_options (
id SERIAL PRIMARY KEY,
question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE,
option_text TEXT NOT NULL,
is_correct BOOLEAN DEFAULT false,
explanation TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS learning_progress (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
score INTEGER,
total INTEGER,
completed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id);
CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published);
CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id);
CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id);
CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id);
`);
// Add columns if upgrading
var migrations = [
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_sub TEXT",
"CREATE TABLE IF NOT EXISTS saved_encounters (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, label TEXT NOT NULL DEFAULT 'Untitled', enc_type TEXT NOT NULL DEFAULT 'encounter', transcript TEXT DEFAULT '', generated_note TEXT DEFAULT '', partial_data TEXT DEFAULT '{}', status TEXT DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days')",
"CREATE TABLE IF NOT EXISTS user_memories (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, category TEXT NOT NULL DEFAULT 'custom', name TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id)",
"CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at)",
"CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id)",
"CREATE TABLE IF NOT EXISTS learning_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE TABLE IF NOT EXISTS learning_content (id SERIAL PRIMARY KEY, title TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, body TEXT NOT NULL DEFAULT '', category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL, subject TEXT DEFAULT '', content_type TEXT DEFAULT 'article', published BOOLEAN DEFAULT false, author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE TABLE IF NOT EXISTS learning_questions (id SERIAL PRIMARY KEY, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, question_text TEXT NOT NULL, question_type TEXT NOT NULL DEFAULT 'mcq', explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS learning_options (id SERIAL PRIMARY KEY, question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE, option_text TEXT NOT NULL, is_correct BOOLEAN DEFAULT false, explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS learning_progress (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, score INTEGER, total INTEGER, completed_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id)",
"CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published)",
"CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id)",
"CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id)",
"CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id)"
];
for (var i = 0; i < migrations.length; i++) {
try { await client.query(migrations[i]); } catch(e) {}
}
// Add updated_by column to app_settings if upgrading
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
// Add webdav_learning_path to users for Learning Hub file browser
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
// Add user preferences for STT model and TTS voice
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS stt_model TEXT DEFAULT NULL"); } catch(e) {}
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS tts_voice TEXT DEFAULT NULL"); } catch(e) {}
// 2FA backup codes — JSON array of bcrypt hashes; entries consumed on use
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT DEFAULT NULL"); } catch(e) {}
// Add idempotency_key for duplicate prevention
try { await client.query("ALTER TABLE saved_encounters ADD COLUMN IF NOT EXISTS idempotency_key TEXT"); } catch(e) {}
try { await client.query("CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_enc_idemp ON saved_encounters(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL"); } catch(e) {}
// Audio backups table — server-side, auto-deleted after 24 hours
try { await client.query(`
CREATE TABLE IF NOT EXISTS audio_backups (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
module TEXT NOT NULL DEFAULT 'encounter',
mime_type TEXT DEFAULT 'audio/webm',
size_bytes INTEGER DEFAULT 0,
compressed_bytes INTEGER DEFAULT 0,
audio_data BYTEA NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX IF NOT EXISTS idx_audio_backups_user ON audio_backups(user_id);
CREATE INDEX IF NOT EXISTS idx_audio_backups_expires ON audio_backups(expires_at);
`); } catch(e) {}
// User documents table for S3 storage
try { await client.query(`
CREATE TABLE IF NOT EXISTS user_documents (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
s3_key TEXT NOT NULL,
filename TEXT NOT NULL,
mime_type TEXT DEFAULT 'application/octet-stream',
size_bytes INTEGER DEFAULT 0,
description TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_docs_user ON user_documents(user_id);
`); } catch(e) {}
// User sessions for session management (revocable login sessions)
try { await client.query(`
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
device_label TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_activity TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON user_sessions(token_hash);
`); } catch(e) {}
// Add embedding column to learning_content for vector search (768 dims = Vertex AI text-embedding-005)
try {
await client.query('ALTER TABLE learning_content ADD COLUMN IF NOT EXISTS embedding vector(768)');
console.log('✅ learning_content.embedding: added');
} catch(e) {
console.warn('⚠️ Could not add embedding column:', e.message);
}
// Developmental milestones table
try { await client.query(`
CREATE TABLE IF NOT EXISTS developmental_milestones (
id SERIAL PRIMARY KEY,
age_group TEXT NOT NULL,
domain TEXT NOT NULL,
milestone_text TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_milestones_age_domain ON developmental_milestones(age_group, domain);
`);
console.log('✅ developmental_milestones: table ready');
} catch(e) {
console.warn('⚠️ Could not create milestones table:', e.message);
}
// Create IVFFLAT index for fast similarity search (after data is populated)
try {
var indexExists = await client.query(
"SELECT 1 FROM pg_indexes WHERE indexname = 'idx_learning_content_embedding'"
);
if (indexExists.rows.length === 0) {
// Check if we have at least some embeddings before creating index
var embCount = await client.query('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL');
if (parseInt(embCount.rows[0]?.count || 0) >= 10) {
// IVFFLAT requires lists parameter — use sqrt(rows) as heuristic
var lists = Math.max(10, Math.floor(Math.sqrt(embCount.rows[0].count)));
await client.query(
`CREATE INDEX idx_learning_content_embedding ON learning_content
USING ivfflat (embedding vector_cosine_ops) WITH (lists = ${lists})`
);
console.log('✅ Vector search index: created');
}
}
} catch(e) {
// Index creation can fail if not enough data or pgvector not installed — safe to ignore
}
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
var defaults = [
// Core
['registration_enabled', 'true'],
// Announcements
['announcement.enabled', 'false'],
['announcement.text', ''],
['announcement.type', 'info'],
// Feature flags
['feature.read_aloud', 'true'],
['feature.nextcloud', 'true'],
// Email — verify
['email.verify.subject', 'Verify your Pediatric AI Scribe account'],
['email.verify.body', 'Thank you for signing up! Click the button below to verify your email address. This link expires in 24 hours.'],
// Email — reset
['email.reset.subject', 'Reset your password — Pediatric AI Scribe'],
['email.reset.body', 'Someone requested a password reset for your Pediatric AI Scribe account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request a password reset, no action is needed — your password has not been changed.'],
['feature.memories', 'true'],
['site.name', 'Pediatric AI Scribe'],
['site.auto_delete_days', '7'],
];
for (var d = 0; d < defaults.length; d++) {
await client.query(
"INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING",
defaults[d]
);
}
console.log('✅ Database tables: ready');
} catch (err) {
console.error('❌ Database init error:', err.message);
} finally {
client.release();
}
}
// Boot sequence: inline init (idempotent baseline) → node-pg-migrate for
// incremental changes. Migrations live in /app/migrations/.
async function bootstrapDatabase() {
await initDatabase();
try {
var { runMigrations } = require('./migrate');
await runMigrations();
} catch (e) {
// runMigrations already logs the fatal; don't hide it but don't crash
// the whole process here either — server.js health check catches a
// broken DB separately.
console.error('[DB] Migration runner failed:', e.message);
}
}
bootstrapDatabase();
// Clean up expired saved encounters and audio backups
async function cleanupExpired() {
try {
var enc = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
if (enc.rowCount > 0) console.log('[DB] Cleaned up ' + enc.rowCount + ' expired encounters');
} catch(e) { console.error('[DB] Encounter cleanup error:', e.message); }
try {
var audio = await pool.query('DELETE FROM audio_backups WHERE expires_at < NOW()');
if (audio.rowCount > 0) console.log('[DB] Cleaned up ' + audio.rowCount + ' expired audio backups');
} catch(e) { /* table may not exist yet */ }
try {
var sess = await pool.query("DELETE FROM user_sessions WHERE created_at < NOW() - INTERVAL '7 days'");
if (sess.rowCount > 0) console.log('[DB] Cleaned up ' + sess.rowCount + ' expired sessions');
} catch(e) { /* table may not exist yet */ }
}
// Interval handle is exposed so server.js can clearInterval() it during
// graceful shutdown — otherwise Node's event loop stays alive past the
// 9-second shutdown deadline waiting for the next tick of this timer,
// and Docker SIGKILLs us mid-cleanup at the 10s hard limit.
var _cleanupInterval = setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h)
setTimeout(cleanupExpired, 10000); // also run 10s after startup
// Query helpers
var db = {
get: async function(sql, params) {
var result = await pool.query(convertPlaceholders(sql), params);
return result.rows[0] || null;
},
all: async function(sql, params) {
var result = await pool.query(convertPlaceholders(sql), params);
return result.rows;
},
run: async function(sql, params) {
var pgSql = convertPlaceholders(sql);
if (sql.trim().toUpperCase().startsWith('INSERT') && pgSql.toUpperCase().indexOf('RETURNING') === -1) {
pgSql += ' RETURNING id';
}
var result = await pool.query(pgSql, params);
return {
lastInsertRowid: result.rows[0] ? result.rows[0].id : null,
changes: result.rowCount
};
},
query: async function(sql, params) {
return pool.query(convertPlaceholders(sql), params);
},
// Settings helpers
getSetting: async function(key) {
var row = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key]);
return row.rows[0] ? row.rows[0].value : null;
},
setSetting: async function(key, value) {
await pool.query(
'INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
[key, value]
);
},
pool: pool
};
function convertPlaceholders(sql) {
var index = 0;
return sql.replace(/\?/g, function() {
index++;
return '$' + index;
});
}
// Expose the cleanup-interval handle so the shutdown path can clear it.
db._cleanupInterval = _cleanupInterval;
module.exports = db;