Files
wtclaude 1ed617736e
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 27s
feat(template): a module that builds and loads — Phase 5 slice 1
The kit's `template/`: a complete, minimal Runic Gateway module a reader copies,
renames, and runs before reading a chapter. Slice 0 landed the workflow that runs
it; this is the tree that workflow was written against, so the `template` job
arms itself with no edit to the guard.

Installed into a real core it adds one public page at `/examplegame/status`, a nav
row pointing at it, one API route described in an OpenAPI fragment core merges,
one table created by an idempotent schema fragment and dropped by a purge file,
and both lifecycle hooks. That is deliberately less than a real module does; what
it is complete about is the shape — every seam used once, with the reasoning next
to it.

Four decisions, settled with the org lead:

1. **Public tier only, plus the lifecycle hooks.** §2.11.1 d1's "one public route",
   plus enough to show the whole vertical seam once. Admin and player tiers become
   worked examples quoted from module-uo in chapter 2 rather than two thirds of a
   tree the reader deletes on day one.
2. **The release workflow ships as a file, in BOTH flavours** — `.gitea/` and
   `.github/`. Neither runs where it sits (a workflow is only read from a
   repository root) and each arms itself when the reader's copy is its own repo.
   Packaging is the part of a module that cannot be guessed at, and the kit's
   audience is outside this org, so assuming Gitea would have been assuming our
   own deployment. Core installs from a URL and does not care where the release
   lives — only that the host is on the operator's `MODULE_SOURCE_HOSTS`.
3. **A rename checklist that CI verifies**, not a rename script. `template/README.md`
   carries the table; `scripts/checkRenameSites.js` holds it against the tree in
   both directions — an unlisted file that still carries the placeholder fails, and
   so does a listed file that no longer does. The second half is the one usually
   left out and the more valuable: a row that has stopped matching reads as
   instructions to edit something that is not there. Same rule core's identifier
   check follows about its own exemptions. It has its own ten-test suite, run by
   CI as `node --test`, because a check that has never been shown to fail is a
   check nobody knows the state of.
4. **A neutral invented game.** One deviation from the literal answer, forced by
   decision 3: the id is `examplegame`, not `example`. The checklist check is a
   text search, and `example` occurs in ordinary English ("for example") all over
   prose that is not a rename site — a placeholder that cannot occur by accident is
   what makes the check answerable instead of a source of false alarms someone
   learns to ignore.

**The pin moves to the 1.4.0 bump** (website `edge` 1b692bf), which is what
`template/module.json` declares as `coreApi`. Slice 0 pinned its parent, before
1.4.0 existed, so `checkCoreApi.js` arms for the first time here — it asserts
EQUALITY, and its failing on the next contract bump is the system working.

Also in CI: the client tests now run AFTER the build (two of them read the built
chunk and skip without one — run first, the job reports green while asking nothing
about the artifact that ships), and `check:swagger` verifies the committed
fragment is current.

## The finding: an UPDATE that changes nothing does not touch ON UPDATE CURRENT_TIMESTAMP

Every suite passed, both guards passed, the chunk built, the module loaded into a
real core and the page rendered correctly. Two hours later the same page said the
world was offline, and it was wrong.

`updated_at` was declared `ON UPDATE CURRENT_TIMESTAMP`, and MariaDB fires that
only when an UPDATE actually CHANGES a value. The boot refresh writes the same
numbers every thirty seconds — which is exactly what a quiet game looks like — so
the timestamp froze at the first write, the row crossed the freshness window, and
the model correctly reported a stale row as offline. Verified against the live
database: two hours of refreshes, `updated_at` still the boot timestamp.

No test in this repo could see it. The model takes its clock as an argument, and
nothing in a suite runs the same UPDATE twice against a real database. It is only
visible as a page that was right when you looked at it and wrong an hour later.

The writer now sets `updated_at = CURRENT_TIMESTAMP` explicitly and the column
drops the clause that was not doing what it looked like it was doing; both carry
the reasoning. Re-verified end to end: the timestamp advances every interval and
the API reports fresh.

Falling out of the fix, the schema fragment gained the rule the reader hits next:
**changing a table is an ALTER, never an edit to its CREATE** — `CREATE TABLE IF
NOT EXISTS` does nothing when the table exists, so an edited column definition
takes effect on a fresh install and on no existing one, which is the worst
possible split because your development database is usually the fresh one.

## Verified

- 29 server tests, 18 client tests, 10 kit-script tests; `check:imports`,
  `check:externals`, `check:swagger` and `checkCoreApi` all green, run in CI's own
  order from a clean `npm ci`.
- Browser smoke (MODULE_API.md §7.7) against a real core built from the pinned
  ref: module `started`, published on `/api/v1/public/modules`, chunk served
  `no-cache` with the right MIME from the entry's directory while `module.json`
  and the server source 404, script tag injected after core's bundle, the page
  rendering inside core's own chrome, the nav row interleaved into the public
  header between Wiki and About, SPA navigation into it from another page, the
  module's path and schema and tag merged into `/api/docs.json`, and
  `[examplegame] registered against core API 1.4.0` in the console with no CSP
  report and no React error.

Refs: MODULE_SYSTEM.md §2.11.1 (slice 1), MODULE_API.md §2.x, §3.x, §5.1, §7.7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 12:56:32 -05:00

71 lines
4.2 KiB
SQL

-- ── The schema fragment ───────────────────────────────────────────────────
--
-- Core replays this file on EVERY boot, statement by statement, immediately
-- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6).
--
-- **There is no migration runner anywhere in this project, and that is a
-- decision rather than an omission.** Core's own schema is one idempotent file
-- replayed the same way. So a module's schema is not a sequence of changes to
-- apply once — it is a statement of what the tables should look like, written so
-- that running it against a database that already matches does nothing.
--
-- Which means: every CREATE TABLE carries IF NOT EXISTS and every ALTER carries
-- IF NOT EXISTS. A statement that succeeds once and fails afterwards presents as
-- a module that worked until the first restart.
--
-- **And it means CHANGING a table is an ALTER, never an edit to its CREATE.**
-- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already
-- there, so an edited column definition takes effect on a fresh install and on no
-- existing one — the worst possible split, because your development database is
-- usually the fresh one. Add the column with
-- `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, below the CREATE, and leave the
-- CREATE describing what a new install gets.
--
-- ── What core checks, and when ────────────────────────────────────────────
--
-- Core validates this file at LOAD time, before your module mounts anything —
-- so a rule broken here costs you the mount entirely rather than leaving you
-- with half-created tables and routes that 503. What is left for replay time is
-- the class only the database can answer: an unknown column type, a bad foreign
-- key. Those are post-mount and do answer 503.
--
-- • **Leading verbs are an allowlist: CREATE, ALTER, INSERT, UPDATE.** Not a
-- DROP denylist. This file replays every boot, so a TRUNCATE or a DELETE
-- would empty a table on every restart.
-- • **Every table you create must be prefixed with your module id** —
-- `examplegame_` here. Nothing else in the database is yours to create.
-- • **No table core declares, and none another module has claimed.**
--
-- A foreign key INTO a core table is allowed, and works because core's schema is
-- already in place when this runs. The reverse is not, and could not be: it
-- would make core's schema depend on your module being installed.
--
-- Teardown is `purge.sql`, which no boot ever runs. See it.
-- ── World status ──────────────────────────────────────────────────────────
-- One row, id 1, holding the last thing the game server said about itself.
--
-- A singleton row rather than a settings key because it is *observed state* and
-- not configuration: it is written by whatever ingests from your sidecar, and an
-- operator never edits it. In a real module the writer is the sidecar ingest;
-- here `boot.js` writes it once so the page has something to render.
-- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That
-- clause fires only when an UPDATE actually CHANGES a value, so a writer sending
-- the same numbers back — which is what a quiet game looks like — leaves the
-- timestamp frozen at the first write, and the row then goes stale while nothing
-- is wrong. The writer sets the column explicitly instead; see
-- `model/worldStatus/worldStatus.db.js`.
CREATE TABLE IF NOT EXISTS examplegame_world_status (
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
online TINYINT(1) NOT NULL DEFAULT 0,
players INT UNSIGNED NOT NULL DEFAULT 0,
world_name VARCHAR(120) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Seed the singleton. `INSERT IGNORE` rather than a plain INSERT: this runs
-- again on every boot, and the second run must be a no-op rather than a
-- duplicate-key error that fails the whole replay.
INSERT IGNORE INTO examplegame_world_status (id, online, players) VALUES (1, 0, 0);