Both defects came out of Phase 13's acceptance walk, and neither was visible to any test. **1. The one-shot rule-group guard was not a guard.** `seedRuleGroup` and `coreRules.seedGroup` both read their settings stamp, inserted the whole group, and wrote the stamp AFTER the loop. Two instances booting in the same moment both read "absent" and both insert -- the walk ended up with 52 module rules where the module ships 26. `docker compose up --scale app=2` and a rolling restart both start two instances on purpose, so this is ordinary rather than exotic. `settings.db` gains `claim(key, value)`: the same `INSERT IGNORE` as `seedDefault`, reporting its own `affectedRows`, so exactly one caller can win a key. The atomicity is the PRIMARY KEY's -- no transaction, no lock, the same bargain `engagementWorker`'s row claim already makes. Both seeders claim before inserting. The trade the code already documented is unchanged, only its order: a process that dies mid-loop leaves the group stamped and partly seeded, and the missing rules are an operator's visit to the "new rule" form. A duplicate rule is two mails per event, for every rule in the group, which both functions' own comments already call the worse outcome. **Why no test caught it:** the stubs supplied `get` and `set` over a Map, which cannot race, so they agreed with the bug -- Phase 4a's `foundRows` finding in a different costume. The fake is now `claim`-shaped and decides without yielding, exactly as the table does, and both suites gained a test that runs two seeders with `Promise.all` and asserts one insert each. Verified by reverting the fix: the module test then reports every rule inserted twice. **2. A rule that names a `digest` body could not be saved.** `registries.js` `checkSeedRule` permits `digest` in as many words -- it is the body `teamDigestWorker` renders for a rule whose email channel an individual set to digest mode, so it never appears in `channels` and never could -- and MODULE_API.md 2.4 tells modules they may point `template_keys` at `notify.digest`. `engagementRules.model.js` then rejected any key that was not one of the rule's channels. So every rule shipping a digest body answered an operator who opened it and pressed Save with a 400 naming a key they had never typed: core's own Team and news rules, and sixteen of module-uo's. The only way to save was to delete the digest body, silently dropping digest support from that rule. The two validators now agree; anything that is neither a channel nor `digest` is still refused, with a test for each direction. No MODULE_API bump: no member is added, removed or changed, and the documented contract is what the code now honours rather than something new. Co-Authored-By: Claude <noreply@anthropic.com>
66 lines
2.6 KiB
JavaScript
66 lines
2.6 KiB
JavaScript
const { query } = require('../../utils/db')
|
|
|
|
async function getAll() {
|
|
return query('SELECT `key`, value, updated_at FROM settings ORDER BY `key`')
|
|
}
|
|
|
|
async function get(key) {
|
|
const rows = await query('SELECT value FROM settings WHERE `key` = ? LIMIT 1', [key])
|
|
return rows[0] ? rows[0].value : null
|
|
}
|
|
|
|
async function set(key, value, updatedBy = null) {
|
|
await query(
|
|
'INSERT INTO settings (`key`, value, updated_by) VALUES (?, ?, ?) ' +
|
|
'ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by)',
|
|
[key, value, updatedBy],
|
|
)
|
|
}
|
|
|
|
// One row WITH its provenance. `updated_by`/`updated_at` are already stored for
|
|
// every key; this is the only reader that needs them, because TEAMS.md §5.5.5
|
|
// makes the uploads acknowledgement a RECORDED consent rather than a displayed
|
|
// one, and "which admin accepted it, and when" is the question that has to be
|
|
// answerable afterwards.
|
|
async function getRow(key) {
|
|
const rows = await query(
|
|
'SELECT s.`key`, s.value, s.updated_by, s.updated_at, u.username AS updated_by_username '
|
|
+ 'FROM settings s LEFT JOIN users u ON u.id = s.updated_by WHERE s.`key` = ? LIMIT 1',
|
|
[key],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
// Insert a default only if the key does not already exist.
|
|
async function seedDefault(key, value) {
|
|
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
|
}
|
|
|
|
/**
|
|
* Take a one-shot guard, atomically. `true` means THIS caller wrote the row.
|
|
*
|
|
* The same `INSERT IGNORE` as `seedDefault`, and the difference is the whole
|
|
* point: this one reports whether it won. A guard read with `get()` and written
|
|
* later with `set()` is not a guard at all under concurrency — two processes
|
|
* both read "absent" and both proceed — and this is used where proceeding twice
|
|
* means seeding a rule group twice, i.e. two mails per event.
|
|
*
|
|
* The atomicity is the PRIMARY KEY's: exactly one INSERT can create a given
|
|
* `key`, so exactly one caller sees `affectedRows === 1`. No transaction and no
|
|
* lock, the same bargain `engagementWorker`'s claim makes.
|
|
*/
|
|
async function claim(key, value) {
|
|
const res = await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
|
return Number(res && res.affectedRows) === 1
|
|
}
|
|
|
|
// Delete a settings row. "Reset to defaults" for the theming/nav keys is the
|
|
// *absence* of a row, not a stored copy of the defaults — see
|
|
// docs/website/THEMING_AND_NAV.md §2. Deleting a key that was never set is a
|
|
// no-op, so reset is idempotent.
|
|
async function remove(key) {
|
|
await query('DELETE FROM settings WHERE `key` = ?', [key])
|
|
}
|
|
|
|
module.exports = { getAll, get, getRow, set, seedDefault, claim, remove }
|