Compare commits
1 Commits
edge
...
feat/modul
| Author | SHA1 | Date | |
|---|---|---|---|
| 3add0063bf |
@@ -1318,6 +1318,49 @@ CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row
|
||||
-- per module the operator has installed onto the modules volume, keyed by the
|
||||
-- module id from its module.json — the same id that names the directory, the URL
|
||||
-- segment and the client registry key.
|
||||
--
|
||||
-- This table 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), so the URL surface is a property of the volume
|
||||
-- and not of a row here. What the row decides is whether a mounted module answers
|
||||
-- (`disabled` ⇒ its guard 404s, §4.5) and what the admin panel shows after a
|
||||
-- failure.
|
||||
--
|
||||
-- `state` is the §2.4 machine in one column: installed → enabled → started, with
|
||||
-- disabled and startup_failed as the recoverable states. `installed` is the
|
||||
-- transient state between an install writing the row and the restart that starts
|
||||
-- it. On every boot each non-disabled row is reset to `enabled` and re-attempted
|
||||
-- (so a fixed module recovers on restart, with no panel visit needed), then the
|
||||
-- load outcome writes `started` or `startup_failed`. Only `disabled` survives a
|
||||
-- boot untouched — it is the operator's decision, not an outcome.
|
||||
--
|
||||
-- failure_stage/failure_reason are §4.4's recorded reason, one of the seven
|
||||
-- validation steps of §4.3 plus `boot`. Both are cleared by every transition that
|
||||
-- is not a failure, so a stale reason can never be shown against a running module.
|
||||
--
|
||||
-- source/sha256 are install provenance (§2.5): the release the bundle came from and
|
||||
-- the digest that was verified before unpacking. Both NULL for a directory placed
|
||||
-- on the volume by hand, which stays supported.
|
||||
CREATE TABLE IF NOT EXISTS installed_modules (
|
||||
id VARCHAR(32) NOT NULL PRIMARY KEY, -- module.json id; names the directory
|
||||
name VARCHAR(128) NOT NULL, -- human label for the admin Modules screen
|
||||
version VARCHAR(32) NOT NULL, -- module.json version (semver)
|
||||
state ENUM('installed','enabled','disabled','started','startup_failed')
|
||||
NOT NULL DEFAULT 'installed',
|
||||
failure_stage VARCHAR(32) NULL, -- manifest|core_api|mounts|extensions|schema|require|register|boot
|
||||
failure_reason TEXT NULL, -- the recorded reason, shown in the admin panel
|
||||
source VARCHAR(255) NULL, -- release URL the bundle came from
|
||||
sha256 CHAR(64) NULL, -- verified bundle digest
|
||||
installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME NULL, -- last successful start
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_installed_modules_state (state)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
59
server/src/model/modules/modules.db.js
Normal file
59
server/src/model/modules/modules.db.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// SQL for installed_modules — the module system's record of what is installed and
|
||||
// what happened to it on the last boot (db/schema.sql, docs/website/MODULE_SYSTEM.md
|
||||
// §2.4). Rows are keyed by module id. All state rules live in modules.model.js;
|
||||
// this file only moves rows.
|
||||
|
||||
const COLS = `id, name, version, state, failure_stage, failure_reason,
|
||||
source, sha256, installed_at, started_at, updated_at`
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM installed_modules ORDER BY id`)
|
||||
|
||||
const getOne = (id) => query(`SELECT ${COLS} FROM installed_modules WHERE id = ?`, [id])
|
||||
|
||||
// Write (or refresh) the row for an installed module. A re-install or an upgrade
|
||||
// updates the metadata and deliberately leaves `state` alone: upgrading an enabled
|
||||
// module must not silently disable it, and re-installing a disabled one must not
|
||||
// silently switch it back on. A brand-new row lands in `installed`, the transient
|
||||
// state the next restart resolves.
|
||||
const upsert = ({ id, name, version, source, sha256 }) =>
|
||||
query(
|
||||
`INSERT INTO installed_modules (id, name, version, source, sha256, state)
|
||||
VALUES (?, ?, ?, ?, ?, 'installed')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
version = VALUES(version),
|
||||
source = VALUES(source),
|
||||
sha256 = VALUES(sha256)`,
|
||||
[id, name, version, source ?? null, sha256 ?? null],
|
||||
)
|
||||
|
||||
// Move one row to a new state. `failureStage`/`failureReason` are written on every
|
||||
// call — a non-failing transition passes nulls, which is what clears a stale reason
|
||||
// off a module that has since come up. `stampStarted` sets started_at to now.
|
||||
const setState = ({ id, state, failureStage = null, failureReason = null, stampStarted = false }) =>
|
||||
query(
|
||||
`UPDATE installed_modules
|
||||
SET state = ?, failure_stage = ?, failure_reason = ?
|
||||
${stampStarted ? ', started_at = CURRENT_TIMESTAMP' : ''}
|
||||
WHERE id = ?`,
|
||||
[state, failureStage, failureReason, id],
|
||||
)
|
||||
|
||||
// Boot reset: every row the operator has not disabled goes back to `enabled` with
|
||||
// no failure recorded, so the load that follows writes this boot's outcome rather
|
||||
// than leaving the last one on display. `disabled` is untouched — it is a decision,
|
||||
// not an outcome.
|
||||
const resetForBoot = () =>
|
||||
query(
|
||||
`UPDATE installed_modules
|
||||
SET state = 'enabled', failure_stage = NULL, failure_reason = NULL
|
||||
WHERE state <> 'disabled'`,
|
||||
)
|
||||
|
||||
// Drop the row entirely. Only the explicit purge does this (§2.5); a plain
|
||||
// uninstall disables the module and keeps its row and its data.
|
||||
const remove = (id) => query('DELETE FROM installed_modules WHERE id = ?', [id])
|
||||
|
||||
module.exports = { listAll, getOne, upsert, setState, resetForBoot, remove }
|
||||
183
server/src/model/modules/modules.model.js
Normal file
183
server/src/model/modules/modules.model.js
Normal file
@@ -0,0 +1,183 @@
|
||||
// The module state machine (docs/website/MODULE_SYSTEM.md §2.4, MODULE_API.md §4.4).
|
||||
//
|
||||
// installed ──► enabled ──► started
|
||||
// │ │
|
||||
// │ └──► startup_failed ──┐
|
||||
// │ │ (retry)
|
||||
// └──────────────► disabled ◄─────────┘
|
||||
//
|
||||
// One row per installed module, one column holding the state. The rules that make
|
||||
// the machine mean anything live here, not in the SQL:
|
||||
//
|
||||
// - `installed` is transient. An install writes the row; the restart that follows
|
||||
// resolves it to `started` or `startup_failed` (§2.5).
|
||||
// - `disabled` is the only state a boot leaves alone. It is the operator's
|
||||
// decision; every other state is an outcome and is recomputed each boot by
|
||||
// beginBoot(). That is what makes a fixed module recover on restart without
|
||||
// anyone visiting the admin panel.
|
||||
// - A failure is recorded with the stage it happened at, and every non-failing
|
||||
// transition clears it — a running module can never show a stale reason.
|
||||
//
|
||||
// What this table does NOT decide is which routes exist. The loader scans the
|
||||
// filesystem at require time, before the database is reachable (MODULE_API.md §4.1),
|
||||
// so a disabled module is still mounted and simply guarded (§4.5). Keeping the URL
|
||||
// surface a property of the volume is what lets routes.manifest.json be generated
|
||||
// off a dead database.
|
||||
|
||||
const db = require('./modules.db')
|
||||
|
||||
const STATES = ['installed', 'enabled', 'disabled', 'started', 'startup_failed']
|
||||
|
||||
// The stage a failure happened at: MODULE_API.md §4.3's seven validation steps,
|
||||
// plus `boot` for an onBoot hook that threw (§2.5).
|
||||
const FAILURE_STAGES = [
|
||||
'manifest',
|
||||
'core_api',
|
||||
'mounts',
|
||||
'extensions',
|
||||
'schema',
|
||||
'require',
|
||||
'register',
|
||||
'boot',
|
||||
]
|
||||
|
||||
class ModuleStateError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message)
|
||||
this.name = 'ModuleStateError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// Legal moves, keyed by target state. Anything not listed is a bug in the caller
|
||||
// and throws rather than writing a row that misrepresents what happened.
|
||||
const ALLOWED_FROM = {
|
||||
// Enabling is the recovery path as well as the first step: a disabled module the
|
||||
// operator switches back on, and a startup_failed one they retry, both land here.
|
||||
enabled: ['installed', 'enabled', 'disabled', 'startup_failed', 'started'],
|
||||
// The operator may disable a module in any state, including one that is running.
|
||||
disabled: STATES,
|
||||
// Reached from `enabled` on a normal boot, and from `installed` on the first boot
|
||||
// after an install (or for a directory placed on the volume by hand, whose row is
|
||||
// written moments earlier in the same boot).
|
||||
started: ['installed', 'enabled'],
|
||||
// Failure always precedes `started` in the lifecycle; `started` is accepted so a
|
||||
// late failure can still be recorded truthfully rather than dropped.
|
||||
startup_failed: ['installed', 'enabled', 'started'],
|
||||
}
|
||||
|
||||
// row → API shape.
|
||||
function serialize(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
version: row.version,
|
||||
state: row.state,
|
||||
failureStage: row.failure_stage ?? null,
|
||||
failureReason: row.failure_reason ?? null,
|
||||
source: row.source ?? null,
|
||||
sha256: row.sha256 ?? null,
|
||||
installedAt: row.installed_at ?? null,
|
||||
startedAt: row.started_at ?? null,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const rows = await db.listAll()
|
||||
return rows.map(serialize)
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
const rows = await db.getOne(id)
|
||||
return serialize(rows[0])
|
||||
}
|
||||
|
||||
// Record an install (or a re-install / upgrade). Metadata is refreshed; the state is
|
||||
// left as it is, so upgrading an enabled module does not switch it off and
|
||||
// re-installing a disabled one does not switch it on. A new row lands in `installed`.
|
||||
async function recordInstalled({ id, name, version, source = null, sha256 = null }) {
|
||||
if (!id || !name || !version) {
|
||||
throw new ModuleStateError('invalid_module', 'id, name and version are required')
|
||||
}
|
||||
await db.upsert({ id, name, version, source, sha256 })
|
||||
return get(id)
|
||||
}
|
||||
|
||||
// Start of boot: clear the last boot's outcomes so what is on display after this
|
||||
// boot is what this boot did. Leaves `disabled` rows alone (see the header).
|
||||
// Returns the number of rows reset.
|
||||
async function beginBoot() {
|
||||
const res = await db.resetForBoot()
|
||||
return res?.affectedRows ?? 0
|
||||
}
|
||||
|
||||
// Apply one transition, after checking it is legal for the row's current state.
|
||||
// A row that does not exist is not an error the caller can act on — a module can be
|
||||
// present on the volume with no row at all — so it returns null and writes nothing.
|
||||
async function transition(id, target, { failureStage = null, failureReason = null } = {}) {
|
||||
const current = await get(id)
|
||||
if (!current) return null
|
||||
|
||||
const allowed = ALLOWED_FROM[target]
|
||||
if (!allowed.includes(current.state)) {
|
||||
throw new ModuleStateError(
|
||||
'illegal_transition',
|
||||
`module '${id}': cannot move from '${current.state}' to '${target}'`,
|
||||
)
|
||||
}
|
||||
|
||||
await db.setState({
|
||||
id,
|
||||
state: target,
|
||||
failureStage,
|
||||
failureReason,
|
||||
stampStarted: target === 'started',
|
||||
})
|
||||
return get(id)
|
||||
}
|
||||
|
||||
const enable = (id) => transition(id, 'enabled')
|
||||
const disable = (id) => transition(id, 'disabled')
|
||||
const markStarted = (id) => transition(id, 'started')
|
||||
|
||||
// Record a failure at a named stage. Two deliberate softenings, both because this is
|
||||
// called from the boot path where throwing would turn one module's failure into
|
||||
// everybody's (MODULE_API.md §4.4 — the failing module fails alone):
|
||||
//
|
||||
// - a `disabled` row is a no-op. The operator switched it off; a broken module
|
||||
// they already disabled is not news, and overwriting their decision with an
|
||||
// outcome would silently re-enable it on the next boot.
|
||||
// - an unrecognised stage is recorded as `require` rather than rejected, so a
|
||||
// miscategorised failure still reaches the admin panel with its reason intact.
|
||||
async function markStartupFailed(id, { stage, reason }) {
|
||||
const current = await get(id)
|
||||
if (!current || current.state === 'disabled') return current
|
||||
|
||||
return transition(id, 'startup_failed', {
|
||||
failureStage: FAILURE_STAGES.includes(stage) ? stage : 'require',
|
||||
failureReason: String(reason ?? 'unknown error').slice(0, 4000),
|
||||
})
|
||||
}
|
||||
|
||||
// Purge only (§2.5). A plain uninstall disables the module and keeps its row, so its
|
||||
// data survives and the admin panel can still show what was there.
|
||||
async function remove(id) {
|
||||
await db.remove(id)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STATES,
|
||||
FAILURE_STAGES,
|
||||
ModuleStateError,
|
||||
list,
|
||||
get,
|
||||
recordInstalled,
|
||||
beginBoot,
|
||||
enable,
|
||||
disable,
|
||||
markStarted,
|
||||
markStartupFailed,
|
||||
remove,
|
||||
}
|
||||
295
server/test/modules.model.test.js
Normal file
295
server/test/modules.model.test.js
Normal file
@@ -0,0 +1,295 @@
|
||||
// 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)
|
||||
})
|
||||
Reference in New Issue
Block a user