diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 27d7b34..4e8aefa 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -15,8 +15,14 @@ # tree, in both directions — an unlisted file that still carries the # placeholder, and a listed file that no longer does, are both failures. That # checklist is the only instruction a reader has for the first thing they do -# with the template, and it is prose, so it rots the way prose does. The two -# checks in `scripts/` have their own unit tests, run in the same job. +# with the template, and it is prose, so it rots the way prose does. +# +# And it checks that every path the book names in backticks still exists. The +# chapters teach out of `template/`, none of those mentions is a markdown link, +# and nothing else in this repo would ever look at them — so renaming one +# template file would leave four chapters quietly pointing at nothing. That is +# the cheap half of "is the book still true"; the other half is a reviewer's. +# All three checks in `scripts/` have their own unit tests, run in the same job. # # • `template` — the interesting one, and the anti-rot mechanism of the whole # repo (MODULE_SYSTEM.md §2.11.1 d2). It clones CORE at the ref pinned in @@ -86,11 +92,17 @@ jobs: - name: Check the rename checklist against the template run: node scripts/checkRenameSites.js + - name: Check every path the book names still exists + run: node scripts/checkChapterPaths.js + # The checks, checked. A check that has never been shown to fail is a check - # nobody knows the state of — and this one gates the instructions for the - # first thing a reader does. + # nobody knows the state of — and these gate the instructions for the first + # thing a reader does. Named file by file rather than `node --test scripts/`: + # directory mode is not portable across the Node versions people run this on. - name: Test the checks themselves - run: node --test scripts/checkRenameSites.test.js + run: | + node --test scripts/checkRenameSites.test.js + node --test scripts/checkChapterPaths.test.js template: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 4de4eaf..9346eb4 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,14 @@ scripts/ the checks CI runs over both CI clones core at a **pinned commit**, asserts the version the template declares still matches that core's `MODULE_API_VERSION`, builds the template and runs its -guards, checks every link in the book, and holds the template's rename checklist -against the template's own tree. So a change to the contract breaks this repo's -build loudly instead of leaving a chapter quietly wrong. +guards, checks every link in the book, holds the template's rename checklist +against the template's own tree, and checks that every path a chapter names is +still there. So a change to the contract breaks this repo's build loudly instead +of leaving a chapter quietly wrong. + +None of that can tell you whether a paragraph has become untrue about a file that +still exists. That is a reviewer's job on every pull request, and a +`MODULE_API_VERSION` bump is when it is owed in full. ## Licence diff --git a/book/01-first-module.md b/book/01-first-module.md new file mode 100644 index 0000000..3a26408 --- /dev/null +++ b/book/01-first-module.md @@ -0,0 +1,244 @@ +# 1. Your first module in twenty minutes + +No theory in this chapter. You will copy a module that already works, rename it, +build it, install it into a running core, and load a page it serves. Everything +after this chapter is a change to something that runs, rather than a step toward +something that might. + +That order is deliberate. The module system has a lot of seams — a server entry +point, a client chunk, a schema fragment, a nav registration, an OpenAPI fragment +— and each one is easy to understand and unpleasant to debug in the abstract. Get +all of them working at once with almost no content in them, and you can then break +exactly one at a time on purpose. + +**What you need:** a Runic Gateway core you can restart, Node 20 or newer, and +about twenty minutes. You do not need core's source, and you should not read it — +if this chapter cannot be followed without it, that is a bug in this chapter and +[worth telling us about][issues]. + +--- + +## The pieces you are about to copy + +`template/` is a whole module, in the shape a real one has. Nine things matter and +the rest is filling: + +| Piece | What it is | +| --- | --- | +| `template/module.json` | The first thing core reads. Your id, your version, the core API range you need, and a declaration of every prefix you will mount. | +| `template/server/index.js` | The server-side handshake: one exported function, called once with `(ctx, api)`. | +| `template/server/core.js` | Lazy accessors over `ctx`, so the rest of your server code can reach core the way ordinary code reaches a library. | +| `template/server/boot.js` | `onBoot` and `onShutdown` — where anything needing a live database goes. | +| `template/server/db/schema.sql` | Your tables. Idempotent, replayed at every boot. | +| `template/server/db/purge.sql` | The same tables, dropped. Run only when an operator explicitly purges you. | +| `template/client/src/entry.jsx` | The client-side handshake: registers your routes and your nav rows into core's SPA. | +| `template/client/vite.config.js` | The library build that produces the chunk core serves — and the aliases that make your React core's React. | +| `template/swagger-fragment.json` | Generated. Core merges it into its own API documentation. | + +Two of those have a reputation. `vite.config.js` is the highest-risk mechanical +detail in the whole system and chapter 2 spends real time on why; `module.json`'s +`mounts` is the one field people fill in wrong and discover at boot. Neither +matters yet — the template has both right. + +## Copy it, and make it yours + +```bash +cp -r template/ ~/my-module +cd ~/my-module +``` + +Your module id is the single most load-bearing string in it: it is the directory +core loads you from, the key in core's database, the URL segment every one of your +pages hangs under, and the prefix every one of your tables must carry. It must +match `^[a-z][a-z0-9-]{1,31}$`, and you want no hyphen in it unless you enjoy +backticking table names. + +Change `id` in `module.json` first, then work down the checklist in +`template/README.md` — it names every file that still carries the placeholder, +and it is [verified by CI][renamecheck] in both directions, so it is not the kind +of checklist that is wrong by the second edit. + +**The placeholder is `examplegame`, not `example`, and that is not an aesthetic +choice.** A check for a leftover `example` fires on the phrase "for example" in +ordinary prose, and a check that cries wolf is a check people learn to ignore. If +you build your own checks later, pick placeholder names that cannot occur by +accident. + +## Build it + +```bash +npm ci --prefix server +npm test --prefix server + +npm ci --prefix client +npm run build --prefix client # → client/dist/entry.js +npm test --prefix client +``` + +Build **before** you run the client tests. Two of them read the built chunk and +skip when there is none, so a run in the other order passes while asking nothing +about the artifact that actually ships. That ordering has bitten this project +twice in two different repositories, which is why it is called out here rather +than left to a CI file. + +What you have now is `client/dist/entry.js` — a prebuilt ES module — and a server +tree that has never been compiled at all, because it does not need to be. + +**An operator never builds anything.** That is the constraint the whole delivery +path is designed around: a module arrives as a tarball with the chunk already in +it, and core serves that file untouched. Your build machine is the only place a +bundler ever runs. + +## Install it + +Three supported ways, and for the next twenty minutes you want the third: + +1. **Admin → Modules**, pasting the URL of an install manifest — the JSON your + release workflow publishes beside your tarball. This is how a real operator + installs you. +2. **The `MODULES` environment variable**, `@=`, for a + deployment that declares its module set instead of clicking it. +3. **A directory on the volume.** Copy your whole module tree to + `/modules//` and restart core. + +```bash +cp -r ~/my-module /modules/my-id +# restart core +``` + +**Copy it. Do not symlink it.** The loader lists directory entries and asks each +whether it is a directory; a symlink answers no, and your module is skipped in +complete silence. This is the single most common way a first install appears to do +nothing at all. + +Two more things that look like your module failing and are not: + +- If core is running in a container, your files have to be on the volume core sees + — `MODULES_DIR` (`/app/modules` under the shipped Compose file), not the + repository directory next to it. +- A core with a fresh database boots in **maintenance mode**, and public module + pages sit behind the same maintenance gate core's own do. Your page will look + broken while the site is not live yet. + +## What you should see + +Restart core and read the log. A module that loaded says so: + +``` +INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world"} +INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world"]}} +INFO [modules] schema ensured for module "examplegame" {"statements":2} +INFO [examplegame:boot] booted {"refreshMs":30000} +INFO [modules] module "examplegame" started +``` + +Your two lines and core's three, interleaved: core narrates each step of your load +in its own `[modules]` namespace, and your logger is namespaced with your id. That +alternation is the quickest way to see how far a load got. + +Then, in the browser: + +- **`/examplegame/status`** renders your page, with a **World** row in the public + header pointing at it. That row is now an ordinary nav row: an operator can + reorder it, relabel it or hide it from the nav editor exactly as they can core's. +- **`/api/v1/public/world/status`** answers JSON. +- **`/api/v1/public/modules`** lists you, with the `capabilities` array from your + `module.json`. This is how a client — core's SPA, the Android app, anything — + feature-detects you. +- **`/api/docs`** shows your route under its own tag, merged out of the OpenAPI + fragment you committed. (`/api/docs.json` is the raw merged document, if you + would rather grep it.) +- **Admin → Modules** shows you as `started`. + +Open the browser console while you are there. Your entry logs the core API version +it registered against, and any complaint the client half has to make will be sitting +next to it. + +## The state your module is in + +Core keeps one row per module and its `state` column has five values. Four are +outcomes and one is an operator's decision: + +| State | Means | +| --- | --- | +| `installed` | Files are on the volume; the row was just created. | +| `enabled` | Cleared for this boot to try. Every non-disabled row is reset to this at each boot. | +| `started` | Loaded, registered, schema replayed, `onBoot` returned. This is the one you want. | +| `startup_failed` | Something went wrong; the panel shows the stage and the reason. The site came up anyway. | +| `disabled` | An operator switched you off. Nothing else — not a failure, not a reinstall — moves this. | + +The important half of that table is what it implies: **a module that fails to +load never takes the site down.** Core try/catches your entire lifecycle, records +where you broke, and serves everything else. You are debugging from an admin +screen, not from a stack trace in a crash loop. + +**A retry is a restart.** Every boot resets non-disabled rows to `enabled` and +writes that boot's outcome, so the panel always describes the run you are looking +at rather than a run from last week. + +## The four ways it fails + +When something is wrong, the shape of the failure tells you where to look before +you read a single message. + +**1. Your module is not in the panel at all.** The loader never saw a directory +worth scanning. It is a symlink; or it is in the wrong place; or it has no +`module.json` at the top of it. Note the bundle shape here — a release tarball's +top-level directory is `-`, so an unpacked bundle copied wholesale +leaves core looking at a directory with nothing in it but another directory. + +**2. It is `startup_failed`, and your routes and nav are simply absent.** The +failure happened before anything was mounted: a malformed `module.json`, an +unsatisfiable `coreApi`, a prefix that collides with core's, a schema fragment +breaking a rule. Nothing of yours is on the URL surface, so nothing of yours can +half-work. + +**3. It is `startup_failed`, and your routes answer `503`.** The failure happened +after mounting — the database rejected a statement in your fragment, or your +`onBoot` threw. Your routes stay mounted deliberately: the URL surface is a +property of what is installed, not of whether a boot hook succeeded on this +machine. A module that failed to warm up says it is down; it does not serve half +its data. + +**4. It answers `404` everywhere.** Someone disabled you. Same mechanism — mounted +and guarded, never unmounted. + +The panel names the stage each failure happened in, and the stages are the +loader's own validation steps, listed in [`MODULE_API.md`][api] §4.3 and §4.4. Read +the stage first; it is usually enough. Core logs the same thing at boot — +`module "…" failed to load — continuing without it {"stage":…,"reason":…}` — so you +do not need the panel to debug this. + +**In all four cases you disappear from `/api/v1/public/modules`.** That endpoint +answers what this backend is *serving*, so a client feature-detecting your +capability renders a site without it rather than one advertising something that +`503`s. It is also a quick check with no login: if you are not in that list, you +are not running, whatever the page looks like. + +## What to do next + +You have a module. Now break it on purpose, once each, and watch what the panel +says: + +- Add a prefix to `module.json`'s `mounts` and do not register it. → stage + `register`, *"declared public/extra but never registered it"*. What you declared + and what you registered must match, in both directions. +- Rename one of your tables so it no longer starts with your id. → stage `schema`, + at **load** time, before anything is mounted: your routes answer `404`. +- Throw inside `onBoot`. → after mounting, so the same route answers `503` with + *"Module unavailable"* instead of vanishing. + +Those are the three outcomes above, and the messages are what this core actually +prints for them — they were run to write this paragraph rather than predicted. + +Twenty minutes of that is worth more than any chapter, because every one of those +failures is one you will cause accidentally later, and you will recognise it. + +Then read [chapter 2](02-website-module.md), which is the same module explained — +what `ctx` hands you and why it is handed rather than imported, what each +`register*` call is for, why the client half is built the way it is, and what a +module must never do. + +[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md +[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues +[renamecheck]: ../scripts/checkRenameSites.js diff --git a/book/02-website-module.md b/book/02-website-module.md new file mode 100644 index 0000000..51d9d3c --- /dev/null +++ b/book/02-website-module.md @@ -0,0 +1,487 @@ +# 2. The website module + +The module you built in [chapter 1](01-first-module.md), explained. This is the +longest chapter in the book because the website module is most of the work, and +because almost every part of it is shaped by a constraint that is invisible until +you hit it. + +Nothing here is normative. [`MODULE_API.md`][api] is the contract; where this +chapter and the contract disagree, the contract is right and this chapter has a +bug. What is here is the reasoning — which is exactly what a contract cannot carry +without becoming unreadable. + +--- + +## The shape of the whole thing + +A module is a directory core reads at boot. Core loads it, hands it two objects, +and takes back whatever it registers. + +``` +core boots (its own schema and seed have already run) + └─ scans modules/*/module.json + └─ validates yours ← nothing mounted yet: a failure here leaves + │ nothing of yours on the URL surface at all + └─ require(server entry) + └─ register(ctx, api) ← your one synchronous handshake + └─ second pass: mounts everything that survived + └─ replays your schema fragment + └─ onBoot(ctx) ← the first moment a database exists + └─ HTTP listener binds +``` + +Two properties of that sequence explain most of the rules that follow. + +**It is synchronous and it happens during `require`.** Core's route-manifest +generator and its OpenAPI generator both require the app with the database pool +pointed at a dead port — that is how they introspect a real Express app without a +database. So `register()` may not `await` and may not query. A module that did +would hang both build tools, and the symptom would be a CI job that never +finishes rather than an error anyone can read. + +**Mounting is a second pass.** Every module is validated before any module is +mounted. If mounting happened inside the scan loop, the first module's layers +would be sitting on the tier router while the second was validated — +indistinguishable from core's own — and the second would be told it collided with +*core*, naming the wrong culprit. You will never see this; it is why the failure +messages you do see are trustworthy. + +## `module.json` + +Every field is documented in [§2.1][api]. Three of them decide whether your module +loads at all. + +**`id`** is the directory core loads you from, the key of your database row, the +URL segment your pages hang under, and the required prefix of every table you +create. It must equal its own directory name — a module renamed by copying it to a +different directory is rejected rather than quietly mounted under a name nothing +else agrees with. + +**`coreApi`** is a semver range against core's `MODULE_API_VERSION`. Set it to the +version you developed against and let it drift upward deliberately. This is the +one number that decides whether a module written today loads against a core +shipped next year, and a range that is too loose does not fail — it half-works. + +**`mounts`** declares every prefix you will register, per tier. **The loader +compares it with what you actually register and rejects a mismatch in both +directions.** A prefix you declared and never registered fails just as loudly as a +route you registered without declaring. That is the point: the file is a statement +of your URL surface that cannot rot, because it is checked against reality at +every boot. + +**Choosing prefixes is the part to slow down on.** They share one namespace with +core's own, so `/status` is not available to you — and the loader's collision +probe cannot see all of core's, because several of core's endpoints are mounted at +the tier root rather than under a prefix of their own. `template/server/index.js` +carries the current list of what core answers on the public tier in a comment +beside the registration. Read it before you choose, and choose a noun from your +own domain rather than a generic one. + +`capabilities` is the opposite kind of field: opaque strings core never +interprets, published by `GET /api/v1/public/modules` while you are `started`, so +that a client — the SPA, the Android app — can feature-detect you. Two modules may +declare the same one. A client must treat an unknown capability as absent, and must +never infer a URL from one. + +## The server entry point + +One exported function, called once: `register(ctx, api)`. `ctx` is what core hands +you; `api` is what you hand back. Read `template/server/index.js` — it is short, +and every comment in it is load-bearing. + +### Why `ctx` is handed over rather than imported + +Your module lives at `/modules//`, outside core's `server/`. Node's +resolver walks *up* from a file looking for `node_modules`, so it never reaches +core's — and `require('express')` from inside a module simply fails. + +That is the mechanical reason, and it is the shallow one. The real reason is that +there is exactly one of certain things in the process and core owns them: one +express, so there is one `Router` prototype; one database pool; one logger; one +session reader. A second express resolved from your own dependencies would work +for about a week and then produce a routing bug nobody can reproduce. + +So the rule generalises past the two obvious cases: **anything shared between core +and a module is owned by core and handed over, never resolved by the module.** On +the server that is `express` and `express-validator`; on the client it is React, +`react-dom`, `react-router-dom` and the JSX runtime. Both halves of the system +have one mechanism for it, and it is the same rule twice. + +[§2.3][api] lists every member of `ctx`. It is a curated list, not core's +internals: `ctx.auth` is one function rather than core's whole auth facade, +because minting sessions is core's job and a module that needs an identity needs +to *read* one. `ctx.settings` is three functions rather than a settings model with +two dozen. Expect the narrowing, and expect to occasionally want something that is +not there — that is a conversation about a minor version bump, not a reason to +reach around it. + +### The lazy-accessor pattern, and the require order it forces + +`ctx` exists only from the moment `register()` is called. But the code underneath +— models, controllers, routers — is ordinary Node that requires its dependencies +at file scope, and *that* runs before `register()` does. + +`template/server/core.js` is what makes both true at once: every member is an +accessor that resolves `ctx` **when it is called**, so a model can write +`const { query } = require('../../core')` at the top of the file, exactly as +ordinary code does. + +Two consequences, and both have cost this project time: + +**Require order is load-bearing.** A router writes `const express = core.express` +at *its* file scope, and that runs the moment the router is required. So +`core.init(ctx)` has to happen before the first `require` of anything under +`router/`. This is why `template/server/index.js` requires its routers *inside* +the register function instead of at the top of the file. Hoist them and the module +breaks with an error about a missing `ctx`, thrown from a file that never mentions +one. + +**Never destructure a getter at init time.** Core is free to hand over an accessor +rather than a value — `ctx.site.baseUrl` is one — and a value captured once at +startup is a value that cannot change afterwards. + +`template/server/core.js` is also deliberately a *narrowing*: it re-exports only +what the module actually uses. Copy that discipline. It makes the file an honest +statement of your dependencies, and it makes a test double for it — see +`template/server/test/_fakes.js` — a complete one rather than a guess. + +## What you register + +Seven calls, all synchronous, all documented in [§2.4][api]. What is worth knowing +is not their signatures but the model behind them. + +**Every call stages; nothing is committed until your whole module is known good.** +The shape of a claim is checked at the call, so a malformed one throws with your +own stack trace. Whether a *name* is taken can only be answered once your batch is +complete, and is checked when the loader commits it. So a module that registers two +notification streams and then throws leaves nothing behind. That matters more than +it sounds: a half-registered catalog is a stream a user can subscribe to and +nothing will ever publish to, which is worse than a missing one because it looks +like it works. + +### Routes + +`api.registerRoutes({ public, admin, player })` — one router per prefix per tier. + +**The tier gate is already applied.** A router registered under `admin` sits +behind core's own `noindex, isLoggedIn, requireRole(...)`; under `player`, behind +`noindex, requireAuth`; under `public`, behind nothing, by design. You add +per-route gates on top of that and you never re-implement the tier gate. A module +cannot supply its own auth wrapper, and that restriction is one of the few places +the boundary is genuinely load-bearing rather than organisational: the server's +route table and the client's sidebar have to agree about who may see what, and +they only do if one thing decides. + +Your router is mounted *inside* the tier router, so it structurally cannot reach +above its own prefix. This is not enforcement by review; there is no path +expressible from inside your router that escapes it. + +### Extension slots + +Sometimes what you have to add is not a page of your own but a section of core's. +An operator looking at a user in the admin panel wants that user's characters +right there, not on a separate screen. + +`api.registerExtension(slot, router)` mounts your routes under a core resource, +and its client twin renders your component inside a core page. **Only core may +declare a slot; a module may only fill one**, and one module per slot. + +The naming rule is worth internalising, because it is what keeps a game-agnostic +core game-agnostic: **a slot is named for a PLACE, never for a meaning.** +`site.footer.status` is "the status-ish spot in the footer" — not a declaration +that core knows what a game server's status is. Core supplies the position and the +styling; the module owns the label, the target, the data, and whether it renders +anything at all. The moment core types a slot by its content, it has re-acquired +the semantics the module system exists to remove. + +### Notification streams, announce legs, post hooks + +Three registries for three genuinely different things, and the distinctions are +easy to get wrong: + +- **A notification stream** is a subscribable channel. You register the catalog + entry — id, label, whether it is personal, whether it needs a linked game + account — and core uses it for the subscribe endpoint and its gates. You publish + to it yourself with `ctx.push.publish`. Core never maps your events to your + streams; you have already resolved the id, and it follows that the safety rule + about which of your events may reach a *public* stream lives in your module too + — which is right, because the event kinds, the stream list and the filter are + then one file that moves together. +- **An announce leg** is one-shot delivery with retry. Core's CMS publishes a post, + every registered leg tries to deliver it somewhere, and your `classify` maps your + own result to `done` / `retry` / `terminal`. A leg that throws is caught, + classified as a retry, and never blocks another leg. +- **A post hook** maintains idempotent state, runs on delete as well as save, and + refreshes silently on an edit. + +The last two fire on the same transition and are deliberately not one call. A leg +that must not be retried and a `classify` that means nothing would be the cost of +merging them. + +Every hook is awaited and none may throw past core: a subscriber's failure costs +neither another subscriber nor the save itself. A hiccup in your sidecar breaking +somebody's blog post edit would be a worse bug than a stale mirror. + +### The lifecycle hooks + +`api.onBoot(fn)` runs after core's schema, after your schema fragment, and +**before the HTTP listener binds**. It is the first moment a database exists, so +it is where everything that needs one goes: warming a cache, backfilling, +connecting to your sidecar. + +**`onBoot` has no timeout, deliberately.** A slow boot delays the listener, and +that is the guarantee rather than a problem to be timed out — a module that must +not serve traffic before it has warmed up gets exactly that. If your `onBoot` +throws, you are `startup_failed`: your routes stay mounted and answer `503`, and +the site comes up without you. + +`api.onShutdown(fn)` runs while core's pool, push dispatcher and event fan-out are +all still open, because flushing through them is the only thing it is for. It has +a five-second budget and is abandoned past it — the process is exiting anyway, and +the alternative is a host where stopping the service waits for a kill. + +A module whose `onBoot` threw gets **no** `onShutdown`. It is part-way through a +warm-up it never finished, and handing it a half-built world to tear down is worse +than not closing cleanly. A module with no hooks at all still reaches `started`: +having nothing to warm up is not the same as never having started. + +## The schema fragment + +`template/server/db/schema.sql` is your tables. It is replayed **in full, at every +boot**, statement by statement, right after core's own schema. + +**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. What you get in exchange is that a module's schema is a single readable +statement of what its tables are, with no ordering history to reconstruct and no +migration table to get out of step with the tables themselves. + +What it costs you is that **changing a table is an `ALTER`, never an edit to its +`CREATE`.** `CREATE TABLE IF NOT EXISTS` no-ops against an existing table, so an +edited column definition lands on fresh installs only — and your development +database is usually the fresh one, which is what makes this bite six months later +on somebody else's instance. Add the column with +`ALTER TABLE … ADD COLUMN IF NOT EXISTS`, leave the `CREATE` alone, and both paths +converge. + +Two rules the loader enforces before your module is mounted at all: + +**Leading verbs are an allowlist: `CREATE`, `ALTER`, `INSERT`, `UPDATE`.** Not a +`DROP` denylist — because the file is replayed at every boot, `TRUNCATE` and +`DELETE` would empty a table at every restart and `RENAME` would fail at the +second one. A denylist only ever bans what somebody thought of. + +**Table names are namespaced `_` and collision-checked** against core's tables +and every other module's. A `CREATE TABLE` missing `IF NOT EXISTS` is rejected on +the same grounds as the rest: it succeeds exactly once and fails every boot after, +which presents to an operator as a module that broke on restart. + +Both are checked by *reading the file*, before anything mounts, and that split is +the design: everything knowable without a database costs you the mount, so a +rule-breaking fragment never half-applies; what only a database can answer — an +unknown column type, a bad foreign key — happens later and answers `503`. + +`purge.sql` is the destructive counterpart, and it is required whenever you ship a +schema. It runs **only** when an operator explicitly purges you, never on +uninstall. A module that can create tables and cannot drop them leaves an operator +with orphaned data and no supported way to remove it. + +One more thing about a file that replays: **a guard and the statement it guards +must live in the same file.** If you write a one-shot data fix conditioned on a +marker, put both the marker and the fix in your own fragment. Core's schema +replays in full before any module's, so a marker core writes has already been +written by the time your guard reads it — a real defect this project shipped and +did not notice, because it is latent until the day someone installs on an older +version. + +## The client half + +Your client half is a **prebuilt ES module**. Core serves it from your module's +directory as a same-origin script and injects a `