Phase 2 PR 1 of the module system (docs/website/MODULE_SYSTEM.md 2.7). The table and the state machine only: no loader, no routes, no boot wiring, so nothing an operator or a client can see changes and the route manifest diff is zero lines. The five states of 2.4 live in one `state` column: installed -> enabled -> started, with disabled and startup_failed as the recoverable ones. The row is a record of what happened, never the source of truth for what is mounted -- the loader scans the filesystem at require time, before the database is reachable (MODULE_API.md 4.1), which is what keeps routes.manifest.json generatable against a dead database. Two rules the model owns and the boot path will lean on: - Every boot resets each non-disabled row to `enabled` and clears its recorded failure, so a startup_failed module is retried on the next restart and a fixed one recovers with no admin-panel visit. `disabled` is the one operator decision rather than outcome, so it survives untouched -- and a disabled module's failure is a no-op, never a re-enable. - A failure carries the stage it happened at, and every non-failing transition clears it, so a running module can never show a stale reason. An illegal move throws instead of writing a row that misrepresents the state, except on the two boot-path softenings noted above, because one module's failure must never become everybody's. 22 model tests over an in-memory fake; the SQL and the DDL were round-tripped against a real MariaDB separately. Co-Authored-By: Claude <noreply@anthropic.com>
296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
// Point the DB pool at a dead port before it's built; every modules.db method is
|
||
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const { test, beforeEach, after } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
const pool = require('../src/utils/db')
|
||
after(() => pool.close())
|
||
|
||
// Unit-test the module state machine (docs/website/MODULE_SYSTEM.md §2.4) against an
|
||
// in-memory fake by monkeypatching modules.db (no DB). What is locked here is
|
||
// everything the boot path and the admin panel will lean on in later phases:
|
||
// - `installed` is transient and a re-install never overwrites the operator's
|
||
// enable/disable decision;
|
||
// - beginBoot() recomputes outcomes and leaves `disabled` alone — the property that
|
||
// makes a fixed module recover on a restart with no panel visit;
|
||
// - a failure carries its stage and reason, and every non-failing transition clears
|
||
// them, so a running module can never display a stale reason;
|
||
// - a disabled module's failure is a no-op, because the boot path must never turn
|
||
// one module's failure into a re-enable of a module the operator switched off;
|
||
// - an illegal move throws instead of writing a row that misrepresents the state.
|
||
const modulesDb = require('../src/model/modules/modules.db')
|
||
const modules = require('../src/model/modules/modules.model')
|
||
|
||
let rows // id → row, snake_case exactly as modules.db returns it
|
||
|
||
const saved = { ...modulesDb }
|
||
|
||
function reset() {
|
||
rows = new Map()
|
||
}
|
||
|
||
modulesDb.listAll = async () => [...rows.values()].sort((a, b) => a.id.localeCompare(b.id))
|
||
|
||
modulesDb.getOne = async (id) => (rows.has(id) ? [rows.get(id)] : [])
|
||
|
||
modulesDb.upsert = async ({ id, name, version, source, sha256 }) => {
|
||
const existing = rows.get(id)
|
||
if (existing) {
|
||
Object.assign(existing, { name, version, source: source ?? null, sha256: sha256 ?? null })
|
||
return { affectedRows: 1 }
|
||
}
|
||
rows.set(id, {
|
||
id,
|
||
name,
|
||
version,
|
||
state: 'installed',
|
||
failure_stage: null,
|
||
failure_reason: null,
|
||
source: source ?? null,
|
||
sha256: sha256 ?? null,
|
||
installed_at: '2026-08-10T00:00:00Z',
|
||
started_at: null,
|
||
updated_at: '2026-08-10T00:00:00Z',
|
||
})
|
||
return { affectedRows: 1 }
|
||
}
|
||
|
||
modulesDb.setState = async ({ id, state, failureStage, failureReason, stampStarted }) => {
|
||
const row = rows.get(id)
|
||
if (!row) return { affectedRows: 0 }
|
||
row.state = state
|
||
row.failure_stage = failureStage ?? null
|
||
row.failure_reason = failureReason ?? null
|
||
if (stampStarted) row.started_at = '2026-08-10T12:00:00Z'
|
||
return { affectedRows: 1 }
|
||
}
|
||
|
||
modulesDb.resetForBoot = async () => {
|
||
let n = 0
|
||
for (const row of rows.values()) {
|
||
if (row.state === 'disabled') continue
|
||
row.state = 'enabled'
|
||
row.failure_stage = null
|
||
row.failure_reason = null
|
||
n += 1
|
||
}
|
||
return { affectedRows: n }
|
||
}
|
||
|
||
modulesDb.remove = async (id) => ({ affectedRows: rows.delete(id) ? 1 : 0 })
|
||
|
||
after(() => Object.assign(modulesDb, saved))
|
||
|
||
beforeEach(reset)
|
||
|
||
// Install one module and put it in a given state, bypassing the machine so a test
|
||
// can start from any state without asserting its way there.
|
||
async function seed(id, state = 'installed', extra = {}) {
|
||
await modules.recordInstalled({ id, name: `Module ${id}`, version: '1.0.0', ...extra })
|
||
rows.get(id).state = state
|
||
return modules.get(id)
|
||
}
|
||
|
||
// ── recordInstalled ───────────────────────────────────────────────────
|
||
|
||
test('a new install lands in the transient installed state', async () => {
|
||
const mod = await modules.recordInstalled({
|
||
id: 'uo',
|
||
name: 'Ultima Online',
|
||
version: '1.2.0',
|
||
source: 'https://gitea.example/releases/module-uo-1.2.0.tar.gz',
|
||
sha256: 'a'.repeat(64),
|
||
})
|
||
assert.equal(mod.state, 'installed')
|
||
assert.equal(mod.version, '1.2.0')
|
||
assert.equal(mod.sha256, 'a'.repeat(64))
|
||
assert.equal(mod.startedAt, null)
|
||
assert.equal(mod.failureReason, null)
|
||
})
|
||
|
||
test('a hand-placed module records with no provenance', async () => {
|
||
const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '1.0.0' })
|
||
assert.equal(mod.source, null)
|
||
assert.equal(mod.sha256, null)
|
||
})
|
||
|
||
test('recordInstalled refuses a manifest missing id, name or version', async () => {
|
||
await assert.rejects(
|
||
() => modules.recordInstalled({ id: 'uo', version: '1.0.0' }),
|
||
(err) => err.code === 'invalid_module',
|
||
)
|
||
})
|
||
|
||
test('an upgrade refreshes metadata and leaves the operator decision alone', async () => {
|
||
await seed('uo', 'disabled')
|
||
const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '2.0.0' })
|
||
assert.equal(mod.version, '2.0.0')
|
||
assert.equal(mod.name, 'Ultima Online')
|
||
assert.equal(mod.state, 'disabled', 're-installing must not silently re-enable')
|
||
})
|
||
|
||
test('an upgrade of a started module does not switch it off', async () => {
|
||
await seed('uo', 'started')
|
||
const mod = await modules.recordInstalled({ id: 'uo', name: 'Module uo', version: '1.1.0' })
|
||
assert.equal(mod.state, 'started')
|
||
})
|
||
|
||
// ── the machine ───────────────────────────────────────────────────────
|
||
|
||
test('installed → enabled → started, stamping the start', async () => {
|
||
await seed('uo')
|
||
assert.equal((await modules.enable('uo')).state, 'enabled')
|
||
const started = await modules.markStarted('uo')
|
||
assert.equal(started.state, 'started')
|
||
assert.ok(started.startedAt, 'a successful start is stamped')
|
||
})
|
||
|
||
test('a first boot may start a module straight from installed', async () => {
|
||
await seed('uo')
|
||
assert.equal((await modules.markStarted('uo')).state, 'started')
|
||
})
|
||
|
||
test('a disabled module can be re-enabled', async () => {
|
||
await seed('uo', 'disabled')
|
||
assert.equal((await modules.enable('uo')).state, 'enabled')
|
||
})
|
||
|
||
test('a failed module is retried by enabling it, which clears the reason', async () => {
|
||
await seed('uo', 'enabled')
|
||
await modules.markStartupFailed('uo', { stage: 'boot', reason: 'atlas refresh threw' })
|
||
const retried = await modules.enable('uo')
|
||
assert.equal(retried.state, 'enabled')
|
||
assert.equal(retried.failureStage, null)
|
||
assert.equal(retried.failureReason, null)
|
||
})
|
||
|
||
test('a running module can be disabled', async () => {
|
||
await seed('uo', 'started')
|
||
assert.equal((await modules.disable('uo')).state, 'disabled')
|
||
})
|
||
|
||
test('a disabled module is never started — that would be a core bug, so it throws', async () => {
|
||
await seed('uo', 'disabled')
|
||
await assert.rejects(
|
||
() => modules.markStarted('uo'),
|
||
(err) => err.name === 'ModuleStateError' && err.code === 'illegal_transition',
|
||
)
|
||
assert.equal((await modules.get('uo')).state, 'disabled')
|
||
})
|
||
|
||
test('a transition on a module with no row writes nothing and returns null', async () => {
|
||
assert.equal(await modules.enable('ghost'), null)
|
||
assert.equal(await modules.markStarted('ghost'), null)
|
||
assert.equal(rows.size, 0)
|
||
})
|
||
|
||
// ── failures ──────────────────────────────────────────────────────────
|
||
|
||
test('a failure records its stage and reason', async () => {
|
||
await seed('uo', 'enabled')
|
||
const failed = await modules.markStartupFailed('uo', {
|
||
stage: 'core_api',
|
||
reason: "module 'uo' needs coreApi ^2.0.0, core provides 1.0.0",
|
||
})
|
||
assert.equal(failed.state, 'startup_failed')
|
||
assert.equal(failed.failureStage, 'core_api')
|
||
assert.match(failed.failureReason, /coreApi/)
|
||
})
|
||
|
||
test('an unrecognised stage is still recorded, as require', async () => {
|
||
await seed('uo', 'enabled')
|
||
const failed = await modules.markStartupFailed('uo', { stage: 'nonsense', reason: 'boom' })
|
||
assert.equal(failed.failureStage, 'require')
|
||
assert.equal(failed.failureReason, 'boom')
|
||
})
|
||
|
||
test('a missing reason still produces a displayable one', async () => {
|
||
await seed('uo', 'enabled')
|
||
const failed = await modules.markStartupFailed('uo', { stage: 'register' })
|
||
assert.equal(failed.failureReason, 'unknown error')
|
||
})
|
||
|
||
test('a runaway reason is truncated rather than refused', async () => {
|
||
await seed('uo', 'enabled')
|
||
const failed = await modules.markStartupFailed('uo', { stage: 'boot', reason: 'x'.repeat(9000) })
|
||
assert.equal(failed.failureReason.length, 4000)
|
||
})
|
||
|
||
test("a disabled module's failure is a no-op, not a re-enable", async () => {
|
||
await seed('uo', 'disabled')
|
||
const unchanged = await modules.markStartupFailed('uo', { stage: 'require', reason: 'broken' })
|
||
assert.equal(unchanged.state, 'disabled')
|
||
assert.equal(unchanged.failureReason, null)
|
||
})
|
||
|
||
test('starting successfully clears the previous failure', async () => {
|
||
await seed('uo', 'enabled')
|
||
await modules.markStartupFailed('uo', { stage: 'schema', reason: 'bad fragment' })
|
||
await modules.enable('uo')
|
||
const started = await modules.markStarted('uo')
|
||
assert.equal(started.failureStage, null)
|
||
assert.equal(started.failureReason, null)
|
||
})
|
||
|
||
// ── boot ──────────────────────────────────────────────────────────────
|
||
|
||
test('beginBoot recomputes outcomes and leaves disabled alone', async () => {
|
||
await seed('a', 'started')
|
||
await seed('b', 'startup_failed')
|
||
await seed('c', 'disabled')
|
||
await seed('d', 'installed')
|
||
rows.get('b').failure_reason = 'last boot blew up'
|
||
rows.get('b').failure_stage = 'boot'
|
||
|
||
assert.equal(await modules.beginBoot(), 3)
|
||
|
||
const byId = Object.fromEntries((await modules.list()).map((m) => [m.id, m]))
|
||
assert.equal(byId.a.state, 'enabled')
|
||
assert.equal(byId.b.state, 'enabled', 'a failed module is retried on the next boot')
|
||
assert.equal(byId.b.failureReason, null, 'and last boot’s reason is cleared')
|
||
assert.equal(byId.c.state, 'disabled', 'the operator decision survives a boot')
|
||
assert.equal(byId.d.state, 'enabled')
|
||
})
|
||
|
||
test('beginBoot keeps the start stamp of a module that was running', async () => {
|
||
await seed('uo', 'enabled')
|
||
await modules.markStarted('uo')
|
||
await modules.beginBoot()
|
||
assert.ok((await modules.get('uo')).startedAt, 'started_at is the last successful start')
|
||
})
|
||
|
||
// ── list / purge ──────────────────────────────────────────────────────
|
||
|
||
test('list returns every module, id-ordered and serialized', async () => {
|
||
await seed('zzz')
|
||
await seed('aaa')
|
||
const all = await modules.list()
|
||
assert.deepEqual(
|
||
all.map((m) => m.id),
|
||
['aaa', 'zzz'],
|
||
)
|
||
assert.deepEqual(Object.keys(all[0]).sort(), [
|
||
'failureReason',
|
||
'failureStage',
|
||
'id',
|
||
'installedAt',
|
||
'name',
|
||
'sha256',
|
||
'source',
|
||
'startedAt',
|
||
'state',
|
||
'updatedAt',
|
||
'version',
|
||
])
|
||
})
|
||
|
||
test('purge drops the row; uninstall is a disable and keeps it', async () => {
|
||
await seed('uo', 'started')
|
||
await modules.disable('uo')
|
||
assert.ok(await modules.get('uo'), 'uninstall keeps the row and its data')
|
||
await modules.remove('uo')
|
||
assert.equal(await modules.get('uo'), null)
|
||
})
|