feat(modules): installed_modules and the module state machine
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 1m35s

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>
This commit is contained in:
2026-08-10 06:35:22 -05:00
parent f1dda8fe66
commit 3add0063bf
4 changed files with 580 additions and 0 deletions

View 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 }