feat(modules): installed_modules and the module state machine (phase 2, PR 1) #128

Merged
whitlocktech merged 1 commits from feat/modules-state into edge 2026-08-10 19:05:00 +00:00
Member

Phase 2, PR 1 of the module system — §2.7's first item, the installed_modules table and the §2.4 state machine.

The table and the machine only. No loader, no routes, no boot wiring, nothing imports the new model yet. npm run routes:manifest -- --check reports 229 routes, up to date — Phase 2's exit criterion holds trivially here, and it should keep holding through PR 9.

What's here

File
server/db/schema.sql installed_modules — one row per installed module, keyed by its module.json id
server/src/model/modules/modules.db.js the SQL, no rules
server/src/model/modules/modules.model.js the state machine and everything that makes it mean something
server/test/modules.model.test.js 22 tests over an in-memory fake

The two decisions this PR settles

Both approved by the org lead before implementation; §2.4 left them open.

1. One state column, not a policy flag beside a runtime one. installed → enabled → started, with disabled and startup_failed recoverable — stored exactly as §2.4 words it, so the doc and the column say the same thing.

2. A startup_failed module is retried on every restart. Each boot resets every non-disabled row to enabled and clears its recorded failure; the load that follows writes this boot's outcome. An operator who fixes the 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. §2.5 already makes restart the install step, so "retry" in the panel is just "restart".

Three consequences fall out, and each has a test:

  • disabled is the only state a boot leaves alone. It is the operator's decision; everything else is an outcome and is recomputed.
  • A disabled module's failure is a no-op, never a re-enable. Recording an outcome over that decision would silently switch it back on at the next boot.
  • A running module can never display a stale reason, because every non-failing transition clears failure_stage/failure_reason.

What the row is not

It never decides which routes exist. 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 — which is what lets routes.manifest.json be generated against a dead database, and why a disabled module stays mounted and guarded (§4.5) rather than being unmounted.

An illegal transition throws rather than writing a row that misrepresents what happened — except on the boot path, where a disabled row and an unrecognised failure stage are softened instead, because one module's failure must never become everybody's (§4.4).

Verification

  • 22 new model tests pass, over an in-memory fake of modules.db (the pages.model.test.js pattern — no database).
  • The SQL and the DDL were round-tripped against a real MariaDB, separately from the unit tests, since those stub the db layer: ensureSchema() applies the table through its own comment-strip + ; splitter, and install → enable → start → fail → boot-reset → purge all behave as the model tests claim.
  • Route manifest: zero-line diff.

Docs: RunicGateway/docs#125.


  • AI-assisted: written with Claude Code (Claude Opus 5)

🤖 Generated with Claude Code

Phase 2, PR 1 of the [module system](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md) — §2.7's first item, the `installed_modules` table and the §2.4 state machine. **The table and the machine only.** No loader, no routes, no boot wiring, nothing imports the new model yet. `npm run routes:manifest -- --check` reports **229 routes, up to date** — Phase 2's exit criterion holds trivially here, and it should keep holding through PR 9. ## What's here | File | | | --- | --- | | `server/db/schema.sql` | `installed_modules` — one row per installed module, keyed by its `module.json` id | | `server/src/model/modules/modules.db.js` | the SQL, no rules | | `server/src/model/modules/modules.model.js` | the state machine and everything that makes it mean something | | `server/test/modules.model.test.js` | 22 tests over an in-memory fake | ## The two decisions this PR settles Both approved by the org lead before implementation; §2.4 left them open. **1. One `state` column, not a policy flag beside a runtime one.** `installed → enabled → started`, with `disabled` and `startup_failed` recoverable — stored exactly as §2.4 words it, so the doc and the column say the same thing. **2. A `startup_failed` module is retried on every restart.** Each boot resets every non-disabled row to `enabled` and clears its recorded failure; the load that follows writes *this* boot's outcome. An operator who fixes the 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. §2.5 already makes restart the install step, so "retry" in the panel is just "restart". Three consequences fall out, and each has a test: - **`disabled` is the only state a boot leaves alone.** It is the operator's decision; everything else is an outcome and is recomputed. - **A disabled module's failure is a no-op, never a re-enable.** Recording an outcome over that decision would silently switch it back on at the next boot. - **A running module can never display a stale reason**, because every non-failing transition clears `failure_stage`/`failure_reason`. ## What the row is not **It never decides which routes exist.** The loader scans the filesystem at require time, before the database is reachable ([`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md) §4.1), so the URL surface is a property of the volume — which is what lets `routes.manifest.json` be generated against a dead database, and why a disabled module stays mounted and guarded (§4.5) rather than being unmounted. An illegal transition throws rather than writing a row that misrepresents what happened — except on the boot path, where a disabled row and an unrecognised failure stage are softened instead, because one module's failure must never become everybody's (§4.4). ## Verification - **22 new model tests** pass, over an in-memory fake of `modules.db` (the `pages.model.test.js` pattern — no database). - **The SQL and the DDL were round-tripped against a real MariaDB**, separately from the unit tests, since those stub the db layer: `ensureSchema()` applies the table through its own comment-strip + `;` splitter, and install → enable → start → fail → boot-reset → purge all behave as the model tests claim. - **Route manifest: zero-line diff.** Docs: RunicGateway/docs#125. --- - [x] AI-assisted: written with Claude Code (Claude Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-10 11:36:40 +00:00
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
3add0063bf
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>
whitlocktech merged commit dcf6ef1886 into edge 2026-08-10 19:05:00 +00:00
whitlocktech deleted branch feat/modules-state 2026-08-10 19:05:01 +00:00
Sign in to join this conversation.
No description provided.