// ── The schema fragment, checked against §2.6's rules ───────────────────── // // Core validates the fragment at LOAD time and refuses to mount a module that // breaks a rule — with no tables created and no routes served. That is the right // behaviour and a slow way to find a typo, so the same rules are checked here. // // **This is also the suite that catches a half-finished rename.** Change the id // in `module.json` and forget a table name, and the prefix assertion below fails // immediately rather than at an operator's first boot. const test = require('node:test') const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') const manifest = require('../../module.json') const read = (rel) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8') /** * Split a SQL file into statements the way core does. * * Core's own splitter is shared code (`utils/sqlStatements.js`) used by both the * loader and the schema replay — this is a small stand-in for a test, and it is * deliberately simple because the fragment it reads is deliberately simple. If * your schema grows a stored procedure or a string containing a semicolon, stop * trusting this and read the fragment a different way. */ function statements(sql) { return sql .split('\n') .filter((line) => !line.trim().startsWith('--')) .join('\n') .split(';') .map((s) => s.trim()) .filter(Boolean) } const schema = statements(read(manifest.schema)) const purge = statements(read(manifest.purge)) // The allowlist core enforces. Note it is an ALLOWLIST and not a `DROP` denylist: // this file replays on every boot, so TRUNCATE or DELETE would empty a table on // every restart — which no denylist naming only DROP would have caught. const ALLOWED_VERBS = ['CREATE', 'ALTER', 'INSERT', 'UPDATE'] test('every statement starts with an allowed verb', () => { for (const statement of schema) { const verb = statement.split(/\s+/)[0].toUpperCase() assert.ok(ALLOWED_VERBS.includes(verb), `"${verb}" is not one of ${ALLOWED_VERBS.join(', ')}`) } }) test('every table is prefixed with the module id', () => { for (const statement of schema) { const match = /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(statement) if (!match) continue assert.ok( match[1].startsWith(`${manifest.id}_`), `table "${match[1]}" is not prefixed "${manifest.id}_" — core will refuse to load this module`, ) } }) test('the fragment is idempotent — it replays on every boot', () => { for (const statement of schema) { if (/^CREATE\s+TABLE/i.test(statement)) { assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'CREATE TABLE without IF NOT EXISTS') } if (/^ALTER\s+TABLE/i.test(statement) && /ADD\s+COLUMN/i.test(statement)) { assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'ADD COLUMN without IF NOT EXISTS') } if (/^INSERT\s+INTO/i.test(statement)) { // A plain INSERT succeeds once and then fails the whole replay on the next // boot with a duplicate key — the classic "worked until I restarted it". assert.ok( /INSERT\s+IGNORE/i.test(statement) || /ON\s+DUPLICATE\s+KEY/i.test(statement), 'INSERT must be IGNORE or carry ON DUPLICATE KEY — it runs again every boot', ) } } }) test('purge drops every table the schema creates', () => { const created = schema .map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s)) .filter(Boolean) .map((m) => m[1]) const dropped = purge .map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s)) .filter(Boolean) .map((m) => m[1]) for (const table of created) { assert.ok(dropped.includes(table), `${table} is created but never dropped — purge would orphan it`) } for (const table of dropped) { assert.ok(created.includes(table), `${table} is dropped but never created`) } }) test('purge drops in the reverse of creation order', () => { // With one table this proves nothing; with a parent and its children it is the // difference between a clean teardown and a purge that fails halfway, leaving // exactly the orphaned data it exists to remove. const created = schema .map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s)) .filter(Boolean) .map((m) => m[1]) const dropped = purge .map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s)) .filter(Boolean) .map((m) => m[1]) assert.deepStrictEqual(dropped, [...created].reverse()) })