// ── event_run_budget — SQL only ──────────────────────────────────────────── // // EVENTS.md §D/§E, and Phase 6 of EVENTS_PLAN.md. What one run has spent of one // dimension, and the most it may. // // **The whole file exists for one statement.** `spend()` is the conditional // increment §E names as the answer to "two steps spending one cap": // // UPDATE … SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap // // A read-then-write would let two steps drawing on `uo.creatures` in the same // tick each see 28 of 30 and each spend 5. The cap in the WHERE means the second // one changes no rows, and `affectedRows === 0` *is* the refusal — no transaction, // no lock, and no second opinion about the arithmetic. Same shape as the outbox // claim, `runsDb.transition` and Phase 5's gate increment, and the same argument. // // **The SET list here has one assignment for the reason Phase 5's had three.** // MariaDB evaluates an UPDATE's SET assignments left to right, each seeing what // the ones before it assigned — which is how Phase 5's gate closed a firing early // — so nothing in this statement may read `consumed` after it has been written. // The guard is in the WHERE, where it reads the pre-update row, and it must stay // there. const { query } = require('../../utils/db') /** * Seed a run's budget rows. * * **INSERT IGNORE against `uq_evbud_dim`**, so a tick that overruns into the next * one cannot double-seed and cannot reset a cap a run has already spent against — * the idempotence `materialisePhase` and `gates.open` both have, for the same * reason. * * The caps are COPIED here rather than read live at dispatch. A run pins its * version and is reproducible in every other respect; a cap read live would be * the one input to a run's behaviour an admin could change underneath it while * nobody was watching, and the console's meter would answer "what is allowed now" * when the question afterwards is "what was this run allowed". */ async function seed(runId, dimensions) { const rows = Object.entries(dimensions || {}) if (!rows.length) return 0 const values = rows.map(() => '(?, ?, 0, ?, ?)').join(', ') const params = rows.flatMap(([dimension, d]) => [runId, dimension, d.cap, d.from || null]) const result = await query( `INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from) VALUES ${values}`, params, ) return result.affectedRows || 0 } /** * Spend `amount` of one dimension, or refuse. * * Answers `true` when the row moved and `false` when it did not — and `false` has * exactly two causes, both of which mean the same thing to the caller: the spend * would breach the cap, or there is no row for this dimension at all. The second * is not a silent pass: a run whose version names an action that costs a * dimension always has that dimension seeded — **uncapped ones included, as a row * with a NULL cap** — so a missing row means the step is spending something its * own version never declared, and refusing that is the fail-closed direction. * * A zero or negative amount is not a spend and never touches the database. An * action whose `cost()` answers `0` for its params is telling core it consumes * nothing, and pricing that as a query would put one round trip per step behind a * fact the caller already has. */ async function spend(runId, dimension, amount) { if (!(amount > 0)) return true const result = await query( `UPDATE event_run_budget SET consumed = consumed + ? WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`, [amount, runId, dimension, amount], ) return (result.affectedRows || 0) > 0 } /** * Give `amount` back. * * Called on exactly one path: a step that spent several dimensions and was then * refused on a later one. The spends are separate statements — they must be, the * atomicity that matters is per dimension — so a step costing 5 creatures and 2 * bosses can take the creatures and be refused the bosses, and a step that did * not run must not have spent anything. `GREATEST(consumed - ?, 0)` because the * floor is worth more than a refund that is exactly right: a negative `consumed` * would make the cap arithmetic lie in the permissive direction forever after. */ async function refund(runId, dimension, amount) { if (!(amount > 0)) return await query( `UPDATE event_run_budget SET consumed = GREATEST(consumed - ?, 0) WHERE run_id = ? AND dimension = ?`, [amount, runId, dimension], ) } /** Every dimension of one run, for the console's meter. */ async function forRun(runId) { return query( `SELECT dimension, consumed, cap, effective_from FROM event_run_budget WHERE run_id = ? ORDER BY dimension`, [runId], ) } /** The dimensions of several runs at once, keyed by run id — the run LIST's read. */ async function forRuns(runIds) { const ids = [...new Set(runIds || [])].filter(Boolean) if (!ids.length) return new Map() const rows = await query( `SELECT run_id, dimension, consumed, cap, effective_from FROM event_run_budget WHERE run_id IN (${ids.map(() => '?').join(',')}) ORDER BY run_id, dimension`, ids, ) const out = new Map() for (const r of rows) { if (!out.has(r.run_id)) out.set(r.run_id, []) out.get(r.run_id).push({ dimension: r.dimension, consumed: r.consumed, cap: r.cap, effective_from: r.effective_from, }) } return out } module.exports = { seed, spend, refund, forRun, forRuns }