-- ── 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); -- ── Clans, and who is in them ───────────────────────────────────────────── -- The module's half of Teams (MODULE_API.md — `api.registerTeamProvider`, and -- TEAMS.md §2.3). A **Team** is core's word and a core table; a **clan** is this -- game's word for the same thing, and these two tables are what the module knows -- about them. Core never reads either — it asks the provider in -- `model/clans/clanProvider.model.js`, which reads these. -- -- **That separation is the point of the whole primitive, and it is worth being -- concrete about.** Core owns `teams`, `team_members`, the reconciler that syncs -- them, the access rules, the forum and the activity feed. This module owns what -- a clan IS, which members exist, and who may look. Nothing here is prefixed -- `team_` because nothing here is core's; §2.6's prefix rule would refuse it -- anyway, and the rule is doing real work in this direction — a module that -- wrote into `team_members` would be a module racing core's reconciler. -- -- In a real module both tables are filled by your sidecar ingest. Here `boot.js` -- seeds two clans so the pages render and the seam is visible. CREATE TABLE IF NOT EXISTS examplegame_clans ( external_id VARCHAR(64) NOT NULL PRIMARY KEY, name VARCHAR(120) NOT NULL, abbr VARCHAR(16) NULL, -- What the game says the clan's roster size is, which is NOT the number of -- rows next door. The two arrive separately in every real ingest, and the -- provider needs both to tell "this clan is empty" from "its roster has not -- landed yet" — the distinction that decides whether it answers or refuses. member_count INT UNSIGNED NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- One row per character in a clan. -- -- `member_key` is the game's own stable id for a character — a serial, a UUID, -- whatever your game keeps — and it is what core stores as the member's identity. -- It must survive a rename, because core reads a changed name as a rename and a -- changed key as a different person. -- -- `user_id` is the site account behind that character, resolved **by this -- module**: the game↔site link table is yours, and a core that resolved it would -- be core reading a module's table by name. NULL is the ordinary case — most -- characters are not linked to an account. CREATE TABLE IF NOT EXISTS examplegame_clan_members ( clan_id VARCHAR(64) NOT NULL, member_key VARCHAR(64) NOT NULL, display_name VARCHAR(120) NULL, rank_label VARCHAR(60) NULL, is_leader TINYINT(1) NOT NULL DEFAULT 0, is_online TINYINT(1) NOT NULL DEFAULT 0, user_id INT UNSIGNED NULL, PRIMARY KEY (clan_id, member_key), CONSTRAINT fk_examplegame_clan_members_clan FOREIGN KEY (clan_id) REFERENCES examplegame_clans (external_id) ON DELETE CASCADE );