const catalog = require('./promptCatalog'); const PROMPTS = require('./prompts'); const published = new Map(); const columns = 'id, created_at AS "createdAt", created_by AS "createdBy", restored_from AS "restoredFrom", was_default AS "wasDefault"'; const failure = (statusCode, message) => Object.assign(new Error(message), { statusCode }); function entryFor(key) { const entry = catalog.find(key); if (!entry) throw failure(404, 'Prompt not found'); return entry; } function revisionId(value) { if (!['string', 'number'].includes(typeof value) || !/^[1-9]\d*$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw failure(400, 'Invalid revision id'); return Number(value); } async function list(db) { // One statement keeps the effective value and revision in the same read snapshot. const rows = await db.all(`SELECT keys.key, s.value, COALESCE((SELECT MAX(id) FROM prompt_revisions WHERE prompt_key = keys.key), 0) AS revision FROM unnest($1::text[]) AS keys(key) LEFT JOIN app_settings s ON s.key = keys.key`, [catalog.entries.map(entry => entry.dbKey)]); const byKey = new Map(rows.map(row => [row.key, row])); return catalog.entries.map(entry => { const row = byKey.get(entry.dbKey) || {}; return { ...entry, value: catalog.effective(entry, row.value).value, revision: Number(row.revision || 0) }; }); } async function history(db, key, limit = 20) { const entry = entryFor(key); if (!['string', 'number'].includes(typeof limit) || !Number.isInteger(Number(limit)) || Number(limit) < 1) throw failure(400, 'Invalid history limit'); const revisions = await db.all(`SELECT ${columns} FROM prompt_revisions WHERE prompt_key = $1 ORDER BY id DESC LIMIT $2`, [entry.dbKey, Math.min(Number(limit), 100)]); return { revisions, revision: revisions.length ? revisions[0].id : 0 }; } async function read(db, key, id) { const entry = entryFor(key); const row = await db.get(`SELECT ${columns}, value FROM prompt_revisions WHERE prompt_key = $1 AND id = $2`, [entry.dbKey, revisionId(id)]); if (!row) throw failure(404, 'Revision not found'); return row; } async function mutate(db, key, options) { const entry = entryFor(key); const { action, expectedRevision, actor } = options; if (!['save', 'reset', 'restore'].includes(action)) throw failure(400, 'Invalid prompt action'); if (expectedRevision !== undefined && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) throw failure(400, 'Invalid expectedRevision'); if (action === 'save' && (typeof options.value !== 'string' || !options.value.trim())) throw failure(400, 'Prompt value must be nonempty text'); const restoredFrom = action === 'restore' ? revisionId(options.revisionId) : null; const client = await db.pool.connect(); let result; try { await client.query('BEGIN'); // Cross-connection serialization, including the first baseline when no row exists. await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [entry.dbKey]); const latest = await client.query('SELECT id FROM prompt_revisions WHERE prompt_key = $1 ORDER BY id DESC LIMIT 1', [entry.dbKey]); const current = latest.rows.length ? latest.rows[0].id : 0; if (expectedRevision !== undefined && expectedRevision !== current) throw failure(409, 'Prompt changed; reload its current revision before saving'); let value = action === 'reset' ? catalog.defaultValue(entry) : options.value; if (action === 'restore') { const restored = await client.query('SELECT value FROM prompt_revisions WHERE prompt_key = $1 AND id = $2', [entry.dbKey, restoredFrom]); if (!restored.rows.length) throw failure(404, 'Revision not found'); // Pin the recorded effective text, even when it was an older shipped default. value = restored.rows[0].value; } const append = (text, wasDefault, createdBy, from) => client.query( 'INSERT INTO prompt_revisions (prompt_key, value, was_default, created_by, restored_from) VALUES ($1, $2, $3, $4, $5) RETURNING id', [entry.dbKey, text, wasDefault, createdBy, from]); if (!current) { const setting = await client.query('SELECT value FROM app_settings WHERE key = $1', [entry.dbKey]); const baseline = catalog.effective(entry, setting.rows[0] && setting.rows[0].value); // The actor of a pre-history setting is unknown, not the admin making this edit. await append(baseline.value, baseline.wasDefault, null, null); } const added = await append(value, action === 'reset', actor == null ? null : actor, restoredFrom); if (action === 'reset') { await client.query('DELETE FROM app_settings WHERE key = $1', [entry.dbKey]); } else { await client.query('INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()', [entry.dbKey, value]); } await client.query('COMMIT'); result = { value, revision: added.rows[0].id }; // Never replace the exported object or publish an uncommitted/older value. if (entry.family === 'scribe' && result.revision > (published.get(entry.dbKey) || 0)) { PROMPTS.updatePrompt(entry.key, value); published.set(entry.dbKey, result.revision); } } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); } return result; } function respondError(res, error) { const status = error.code === '42P01' ? 503 : (error.statusCode || 500); // SQL/provider errors can echo values: do not expose or log them. return res.status(status).json({ error: status === 503 ? 'Prompt history is unavailable; apply the prompt revisions migration.' : error.statusCode ? error.message : 'Prompt request failed' }); } module.exports = { list, history, read, mutate, respondError };