Merge pull request 'docs(website): where the module state machine lives, and how boot treats it' (#125) from docs/module-state-machine into main

Reviewed-on: #125
This commit is contained in:
2026-08-10 19:02:48 +00:00
2 changed files with 67 additions and 0 deletions

View File

@@ -665,6 +665,34 @@ JSON. `resolveMany()` returns only ids that resolved to something displayable
`~1_val~` are stripped, since the bridge sends the id and never the property packet that carries the
arguments — and it never throws, because a cliloc lookup is decoration on a character sheet.
### installed_modules — what is installed, and what happened to it (module system)
One row per installed module, keyed by the `id` from its `module.json` — the same id that names its
directory on the modules volume and its URL segment.
| Column | Shape |
|---|---|
| `id` | VARCHAR(32) PK — the module id |
| `name`, `version` | the manifest's label and semver, for the admin Modules screen |
| `state` | ENUM `installed` / `enabled` / `disabled` / `started` / `startup_failed` |
| `failure_stage`, `failure_reason` | the stage a failure happened at (`manifest`, `core_api`, `mounts`, `extensions`, `schema`, `require`, `register`, `boot`) and its recorded reason |
| `source`, `sha256` | the release the bundle came from and the digest verified before unpacking; both NULL for a directory placed on the volume by hand |
| `installed_at`, `started_at`, `updated_at` | `started_at` is the last **successful** start |
**This table never decides which routes exist.** The module loader scans the filesystem at require
time, before the database is reachable, so the URL surface is a property of the volume — which is what
lets `routes.manifest.json` be generated against a dead database. A disabled module stays mounted and
is guarded; the row decides whether it *answers*, not whether it is there.
**Every boot resets each non-disabled row to `enabled`** and clears its recorded failure, then the load
writes that boot's outcome. So a `startup_failed` module is retried on the next restart (an operator
who fixes the cause needs no admin-panel visit), a running module can never display a stale reason,
and `disabled` — the one operator *decision* rather than outcome — survives untouched. A re-install or
upgrade refreshes the metadata and leaves `state` alone.
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
[`MODULE_API.md`](MODULE_API.md) Part 4.
---
## 4. API contract

View File

@@ -337,6 +337,41 @@ marks that one module `startup_failed`, records the reason, and the site comes u
routes and nav absent. `startup_failed` is recoverable from the admin panel — disable, retry, or roll
back to the previous version — with no shell access to the box.
**Where the states live.** One `installed_modules` row per module, keyed by its id, with the machine
held in a single `state` column carrying all five values — the shape this section already describes,
rather than a policy flag beside a runtime one. The table also carries `name`/`version` for the admin
screen, `failure_stage` + `failure_reason` for [`MODULE_API.md`](MODULE_API.md) §4.4's recorded
reason, `source` + `sha256` for the
install provenance of §2.5 below (both null for a directory placed on the volume by hand, which stays
supported), and `installed_at` / `started_at` / `updated_at`. Full column list in
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md) §3.
**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 (API §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, API §4.5) and what the admin panel shows after a failure.
This is also why
`routes.manifest.json` can be generated against a dead database.
**`disabled` is the only state a boot leaves alone.** Every boot resets each non-disabled row to
`enabled`, clearing any recorded failure, and the load that follows writes this boot's outcome —
`started` or `startup_failed`. Three consequences, all deliberate:
- **A `startup_failed` module is retried on every restart.** An operator who fixes the underlying
cause — a truncated file, a missing dependency, a database that was not up yet — gets the module
back by restarting, with no admin-panel visit. The cost is that a deterministically broken module
re-records its failure each boot, which is the honest thing for it to do.
- **A stale reason can never be shown against a running module**, because every non-failing
transition clears the failure columns.
- **Disabling is an operator decision, not an outcome**, so it survives restarts untouched — and a
module the operator switched off is neither started nor re-recorded as failed if it happens to be
broken. `installed` is likewise transient: it is the gap between an install writing the row and the
restart that resolves it.
A re-install or an upgrade refreshes `name`/`version`/provenance and deliberately leaves `state`
alone: upgrading an enabled module must not silently switch it off, and re-installing a disabled one
must not silently switch it on.
### 2.5 Install, uninstall, purge
Modules live on a **mounted volume**, not in the image — the same treatment `uploads` already gets in
@@ -418,6 +453,10 @@ too (API §7.2).
Exit criterion: `routes.manifest.json` diff is zero lines and every existing test passes. If Phase 2
changes one URL, it is wrong.
**Progress: PR 1 done** — `installed_modules` and the state machine, with the stored shape and the
boot rules settled in §2.4 above. No loader, no routes, no boot wiring yet, so it changes nothing an
operator or a client can see.
**Phase 3 — Extract `module-uo`.** Moves out of `website/`: the 8 model directories and their 25
tables; the nine UO `utils/` files plus `newsGump.js`; the 13 router/controller files;
`scripts/importSpawnAtlas.js` and `db/spawnAtlas.art.json`; `usersShard.controller.js` **minus