108 lines
5.7 KiB
JavaScript
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 };
|