fix(modules): stop a module before purging its tables #146

Merged
whitlocktech merged 1 commits from fix/module-uninstall-stop-before-purge into edge 2026-08-12 14:18:50 +00:00
Member

Phase 4 slice 4's website half. Found while proving acceptance criterion 2 — "uninstall leaves the data, purge removes it" — against the real module-uo v0.3.0 release on a brand-new empty database. The criterion passes; this is what running it turned up.

The defect

DELETE /admin/modules/:id?purge=true ran in the order purge → stop → removeDir. So purge.sql dropped 27 tables while the module was still started, and it stayed started until lifecycle.stop() finished — up to the five-second onShutdown budget. In that window the module is serving and ingesting against a schema that no longer exists: module-uo's uo-link WebSocket keeps writing shard events into dropped tables, and requests in flight answer 500 where a stopped module answers 404.

Why the old order was not required

The comment justified it as "purge while the SQL is still readable". But install.purgeFile() reads module.json off disk and lifecycle.stop() touches no filesystem — removeDir is the only destructive step, and it is last either way. So purge only ever had to precede the delete, never the stop.

New order: resolve the purge file → stop → purge → removeDir → removeRow. The 400 for a module that ships no purge.sql is resolved before anything is stopped, so a request that is going to be refused leaves the module exactly as it found it — previously it would have been a 400 that took the module down as a side effect (it happened not to, only because the check preceded the stop by accident of position).

Trade-off, stated

If the purge now fails, the module is left stopped-and-disabled rather than untouched. That is recoverable from the panel (Enable, then Restart); a live module on a half-dropped schema is not.

Tests

The existing order test now pins both orderings in one assertion — ['stop', 'purge', 'removeDir', 'removeRow'] — with the reason each is one line away from being wrong written next to it, plus a new test that a refused purge stops nothing.

  • 742 server tests (+1), all green
  • routes.manifest.json and swagger/swagger-output.json regenerated: byte-identical

The smoke this came out of

Real Gitea release URL, empty purge_smoke database, isolated modules volume:

Step Result
Install + restart 27 tables created, 37 schema statements, module started
Uninstall without purge, reinstall, enable, restart Marker row still there, 27 tables intact — the data survived
Uninstall with purge purged: 27, every table gone, row gone, directory gone
Reinstall + restart Tables recreated empty — marker row gone
Standalone purge while started / while disabled 409 / 200, directory kept, row left disabled

AI disclosure

  • This contribution was AI-assisted (Claude Code).

🤖 Generated with Claude Code

https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8

Phase 4 slice 4's website half. Found while proving acceptance **criterion 2** — "uninstall leaves the data, purge removes it" — against the real `module-uo` v0.3.0 release on a brand-new empty database. The criterion passes; this is what running it turned up. ### The defect `DELETE /admin/modules/:id?purge=true` ran in the order **purge → stop → removeDir**. So `purge.sql` dropped 27 tables while the module was still `started`, and it stayed started until `lifecycle.stop()` finished — up to the five-second `onShutdown` budget. In that window the module is serving and ingesting against a schema that no longer exists: module-uo's uo-link WebSocket keeps writing shard events into dropped tables, and requests in flight answer **500** where a stopped module answers **404**. ### Why the old order was not required The comment justified it as *"purge while the SQL is still readable"*. But `install.purgeFile()` reads `module.json` off disk and `lifecycle.stop()` touches no filesystem — `removeDir` is the only destructive step, and it is last either way. So purge only ever had to precede the delete, never the stop. New order: **resolve the purge file → stop → purge → removeDir → removeRow**. The 400 for a module that ships no `purge.sql` is resolved *before* anything is stopped, so a request that is going to be refused leaves the module exactly as it found it — previously it would have been a 400 that took the module down as a side effect (it happened not to, only because the check preceded the stop by accident of position). ### Trade-off, stated If the purge now fails, the module is left stopped-and-disabled rather than untouched. That is recoverable from the panel (Enable, then Restart); a live module on a half-dropped schema is not. ### Tests The existing order test now pins **both** orderings in one assertion — `['stop', 'purge', 'removeDir', 'removeRow']` — with the reason each is one line away from being wrong written next to it, plus a new test that a refused purge stops nothing. - 742 server tests (+1), all green - `routes.manifest.json` and `swagger/swagger-output.json` regenerated: **byte-identical** ### The smoke this came out of Real Gitea release URL, empty `purge_smoke` database, isolated modules volume: | Step | Result | | --- | --- | | Install + restart | 27 tables created, 37 schema statements, module `started` | | Uninstall **without** purge, reinstall, enable, restart | Marker row still there, 27 tables intact — the data survived | | Uninstall **with** purge | `purged: 27`, every table gone, row gone, directory gone | | Reinstall + restart | Tables recreated **empty** — marker row gone | | Standalone purge while `started` / while `disabled` | 409 / 200, directory kept, row left `disabled` | ### AI disclosure - [x] This contribution was AI-assisted (Claude Code). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8
wtclaude added 1 commit 2026-08-12 14:05:49 +00:00
fix(modules): stop a module before purging its tables
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 33s
a4da1cc438
Uninstall-with-purge ran purge.sql while the module was still started: the
tables went, and the module kept serving and ingesting against a schema that no
longer existed until lifecycle.stop() finished — up to the five-second hook
budget. For module-uo that is the uo-link WebSocket writing shard events into
dropped tables, and requests in flight answering 500 where a stopped module
answers 404.

Nothing required the old order. The comment justified it as "purge while the SQL
is still readable", but removeDir is the only step that touches the filesystem,
so purge.sql stays readable until after the stop. The 400 for a module that
ships no purge.sql is now resolved before anything is stopped, so a refused
request leaves the module exactly as it found it.

Found while proving Phase 4's acceptance criterion 2 against the real
module-uo v0.3.0 release on an empty database (MODULE_SYSTEM.md §2.7.2).

742 server tests (+1); routes.manifest.json and swagger-output.json byte-identical.

AI disclosure: this contribution was AI-assisted (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit c3120ea3da into edge 2026-08-12 14:18:50 +00:00
whitlocktech deleted branch fix/module-uninstall-stop-before-purge 2026-08-12 14:18:50 +00:00
Sign in to join this conversation.
No description provided.