The protocol-4 cutover moved `link`'s PROTOCOL_VERSION, the overlay's `overlay.toml` and this module's ingest — `guild.roster` and `guild.leave` landed with the Teams cutover — but left both of this module's pin sites at 3. A fresh install therefore came up speaking 3 to a protocol-4 sidecar, and a sidecar answers a stale client with `409 protocol version mismatch` rather than mis-parsing it. The failure is total and silent: every REST read fails, the WS closes on ws.hello, and the operator sees an empty marketplace, an empty guild board and no shard status, with the cause only in the server log. It cleared only when an admin edited the number by hand in Admin → Shard. Found while standing up a demo deployment for the marketing site's screenshots. - `DEFAULT_PROTOCOL` → 4 (the constant used before an admin has saved anything) - the `uo_link_config.protocol` column default → 4, at both declaration sites - a protocol-4 one-shot mirroring the protocol-3 one, guarded by its own marker so an operator who deliberately pins an older sidecar stays pinned, and written `protocol < 4` so an install that never took the protocol-3 migration is carried the whole way rather than one step - three regression tests: the column default, the marker ordering, and the `< 4` predicate Co-Authored-By: Claude <noreply@anthropic.com>
213 lines
8.3 KiB
JavaScript
213 lines
8.3 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',
|
|
'uo_link_protocol_4_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)
|
|
}
|
|
})
|
|
|
|
|
|
// ── The protocol pin ────────────────────────────────────────────────────────
|
|
//
|
|
// Two declaration sites and one constant have to agree, and for a while they did
|
|
// not: the protocol-4 cutover moved `link`, the overlay and this module's ingest,
|
|
// and left both pins here at 3. A fresh install then spoke 3 to a protocol-4
|
|
// sidecar, which 409s every REST call — an install that reads nothing from its
|
|
// shard, with the cause only in the log. These tests are the guard.
|
|
|
|
test('the column default pins the protocol this build speaks', () => {
|
|
const create = statements.find((s) => /CREATE TABLE.*uo_link_config/is.test(s))
|
|
assert.ok(create, 'uo_link_config is gone')
|
|
assert.match(
|
|
create,
|
|
/protocol\s+INT\s+NOT NULL DEFAULT 4/i,
|
|
'the CREATE TABLE default must name the protocol this build speaks',
|
|
)
|
|
|
|
// The last MODIFY wins on replay, so it is the one that decides an existing
|
|
// database's default.
|
|
const modifies = statements.filter((s) =>
|
|
/^ALTER TABLE\s+uo_link_config\s+MODIFY COLUMN protocol/i.test(s),
|
|
)
|
|
assert.ok(modifies.length > 0, 'the default-fixing MODIFY is gone')
|
|
assert.match(modifies[modifies.length - 1], /DEFAULT 4/i)
|
|
})
|
|
|
|
test('the protocol-4 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_4_migrated'),
|
|
)
|
|
const marker = statements.findIndex(
|
|
(s) => /^INSERT/i.test(s) && s.includes("'uo_link_protocol_4_migrated'"),
|
|
)
|
|
assert.ok(update >= 0, 'the protocol-4 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')
|
|
})
|
|
|
|
test('the protocol-4 one-shot carries an install forward from any older pin', () => {
|
|
const update = statements.find(
|
|
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes('uo_link_protocol_4_migrated'),
|
|
)
|
|
assert.match(
|
|
update,
|
|
/protocol\s*<\s*4/,
|
|
'must be `protocol < 4`, not `= 3`: an install that never took the protocol-3 ' +
|
|
'migration has to be carried the whole way rather than one step',
|
|
)
|
|
})
|
|
|
|
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/)
|
|
})
|