pediatric-ai-scribe-v3/src/utils/promptRevisions.js

108 lines
5.7 KiB
JavaScript

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 };