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>
184 lines
6.6 KiB
JavaScript
184 lines
6.6 KiB
JavaScript
// 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,
|
|
}
|