// `schema.sql` is replayed by core on EVERY boot, and core validates it before // anything mounts. The rules are core's (MODULE_API.md §2.6, loader.js), and are // restated here for the same reason `manifest.test.js` restates the manifest // rules: a mistake should fail in this repo's CI, which can say what is wrong, // rather than on an install, where the symptom is a module that is simply absent. // // The test this file exists for is the ORDER one. Two statements that read each // other were adjacent in core's schema.sql until slice 1 moved one of them here // and left the other behind — and because core's schema is replayed in full // before any module fragment, the marker was written before the migration that // reads it and the one-shot could never fire. Nothing caught it: both files were // individually valid SQL, both replayed cleanly, and the failure only shows on an // upgraded install talking to a real sidecar. An assertion about order is the // only thing that would have. const test = require('node:test') const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') const SCHEMA = path.join(__dirname, '..', 'db', 'schema.sql') const sql = fs.readFileSync(SCHEMA, 'utf8') /** * Split into statements the way core's `utils/sqlStatements.js` does: a * character walk, not a regexp. * * Comments are stripped before quotes are considered, because a comment may * contain quotes — line 508 of this very file is `-- '' when randomised per * activation`, and a stripper that opened a string there would swallow the rest * of the file. The reverse case (a `--` inside a string literal) is handled by * the same walk, since a quote opened outside a comment stays open. */ function splitStatements(text) { const out = [] let buf = '' let quote = null for (let i = 0; i < text.length; i++) { const c = text[i] if (quote) { buf += c if (c === '\\') { buf += text[++i] ?? '' } else if (c === quote) { quote = null } continue } if (c === '-' && text[i + 1] === '-') { while (i < text.length && text[i] !== '\n') i++ buf += '\n' continue } if (c === "'" || c === '"' || c === '`') { quote = c buf += c continue } if (c === ';') { if (buf.trim()) out.push(buf.trim()) buf = '' continue } buf += c } if (buf.trim()) out.push(buf.trim()) return out } const statements = splitStatements(sql) // Core's leading-verb allowlist. Not a DROP denylist: this file replays on every // boot, so a TRUNCATE or DELETE would empty a table at each restart. const ALLOWED = ['CREATE', 'ALTER', 'INSERT', 'UPDATE'] test('the splitter survives a comment that contains quotes', () => { const parts = splitStatements("SELECT 1; -- '' a quote in a comment\nSELECT 2;") assert.deepEqual( parts.map((s) => s.trim().split('\n')[0].trim()), ['SELECT 1', 'SELECT 2'], ) }) test('the splitter does not treat a -- inside a string as a comment', () => { const parts = splitStatements("INSERT INTO t VALUES ('a--b');") assert.equal(parts.length, 1) assert.match(parts[0], /'a--b'/) }) test('every statement leads with a verb core allows', () => { for (const statement of statements) { const verb = statement.trim().split(/\s+/)[0].toUpperCase() assert.ok(ALLOWED.includes(verb), `statement leads with "${verb}": ${statement.slice(0, 70)}`) } }) test('every CREATE TABLE is IF NOT EXISTS', () => { for (const statement of statements) { if (!/^CREATE\s+TABLE/i.test(statement)) continue assert.match(statement, /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS/i, statement.slice(0, 70)) } }) test('every table this fragment declares is prefixed shard_ or uo_link_', () => { // The two prefixes core grandfathers to this module by name // (loader.js LEGACY_TABLE_PREFIXES). A module written after this one prefixes // with its own id instead. const CREATE_TABLE = /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+`?([A-Za-z0-9_]+)`?/gi const tables = [] for (const statement of statements) { for (const m of statement.matchAll(CREATE_TABLE)) tables.push(m[1].toLowerCase()) } assert.ok(tables.length > 20, `expected the module's tables, found ${tables.length}`) for (const table of tables) { assert.ok( table.startsWith('shard_') || table.startsWith('uo_link_'), `table "${table}" carries neither grandfathered prefix`, ) } }) // ── The settings rows this module owns ────────────────────────────────────── const SETTINGS_KEYS = ['game_account_signup', 'uo_link_protocol_3_migrated'] test('both settings seeds are INSERT IGNORE, so a replay never resets a value', () => { for (const key of SETTINGS_KEYS) { const seed = statements.find((s) => /^INSERT/i.test(s) && s.includes(`'${key}'`)) assert.ok(seed, `no seed for "${key}"`) assert.match(seed, /^INSERT\s+IGNORE\s+INTO\s+settings/i, key) } }) test('the protocol-3 marker is written AFTER the update that reads it', () => { const update = statements.findIndex( (s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes('uo_link_protocol_3_migrated'), ) const marker = statements.findIndex( (s) => /^INSERT/i.test(s) && s.includes("'uo_link_protocol_3_migrated'"), ) assert.ok(update >= 0, 'the protocol-3 migration is gone') assert.ok(marker >= 0, 'the one-shot marker is gone') assert.ok( marker > update, 'the marker is written before the UPDATE reads it — the one-shot can never fire. ' + 'This is the shape of the defect slice 1 introduced by leaving the marker in core, ' + 'whose schema replays first.', ) }) test('the migration is guarded on the marker, not on the column value alone', () => { const update = statements.find((s) => /^UPDATE\s+uo_link_config/i.test(s)) assert.match(update, /NOT\s+EXISTS\s*\(\s*SELECT/i) // Without the guard an operator who deliberately pins an older sidecar in // Admin → Shard is silently re-bumped on the next restart. assert.match(update, /uo_link_protocol_3_migrated/) })