Files
Module-uo/server/test/schemaFragment.test.js
wtclaude f7bb3d912e fix(db): own the two settings seeds, and repair the protocol-3 one-shot
Core seeded `game_account_signup` and `uo_link_protocol_3_migrated`, two keys
that name a game concept. That made core's schema declare a module's settings,
which is the structural half of what Phase 3 removes (MODULE_SYSTEM.md §2.7.1,
slice 4). Both INSERTs move here. The keys are deliberately unchanged: they are
live rows on every existing install and renaming one silently resets an
operator's choice to the default.

The marker is not just a tidy-up. It and the `UPDATE uo_link_config SET
protocol = 3` it makes one-shot were adjacent in core's schema.sql until slice 1
moved the UPDATE here and left the INSERT behind — and the two files do not run
together: core's schema is replayed in full before any module fragment. So the
marker existed before the UPDATE ever read it, the NOT EXISTS guard was false on
every boot of an upgraded install, and the migration could never fire. An
install carrying a protocol-2 row would have stayed pinned at 2 against a v3
sidecar, 409ing every REST call — the exact failure the migration prevents.
Latent rather than live: it bites only an install that first boots a
post-slice-1 build while already holding a uo_link_config row, and `edge` has
not cut over.

`schemaFragment.test.js` asserts the order, plus the fragment rules core
validates at load time (leading-verb allowlist, IF NOT EXISTS, grandfathered
table prefixes) — restated here for the same reason manifest.test.js restates
the manifest rules. Its statement splitter is a character walk, because a
comment in this file contains quotes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:27:52 -05:00

158 lines
6.0 KiB
JavaScript

// `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/)
})