Writing a migration
How migrations run
Section titled “How migrations run”- Files live in
src/db/migrations/, namedNNNN-short-description.sql(0024-…next). They are plain SQL; there is no JavaScript migration step. src/db/migrate.jsapplies every file not yet listed inschema_migrations, in filename order, each in its own transaction together with itsschema_migrationsrow. A failing file rolls back completely and the server does not start.src/db/index.jsopens the database withjournal_mode = WALandforeign_keys = ONbefore running migrations. Inside the transactionPRAGMA foreign_keyscannot be changed — which is what a rebuild migration is for (rule 6).- Each applied file’s
sha256(line endings normalised, so a Windows checkout agrees with the server) is stored inschema_migrations.checksum(migration 0020). On every boot the runner compares: a file changed after it was applied is a warning in development and a refusal to start in production, with the file named. A file renamed without changing its text is recognised by its checksum and its record follows the new name instead of running twice.scripts/restore.jsuses the same records, so renaming a file does not make older backups “newer schema”. - Migrations run at every startup of every installation: the first one, a school that updates from a
release a year old, a brand-new empty database, and every test (
createTestDb()intest/helpers/buildTestApp.jsruns them on:memory:).
-
Forward only. There are no down migrations. To undo something, add a new file that corrects it. Never edit a file that has been released — installations that already applied it will never see the change, and the ones that did not will diverge. Since 0020 the runner enforces this: production refuses to start on a changed applied file. (0006, 0009 and the since-removed 0013 were edited one last time in the release that added the checksums — every installation records their final text on its first boot with 0020, so no warning follows.) Removing a file is allowed once every installation that needed it has applied it: the runner ignores an applied file that is gone (
test/dbMigrateMissingFile.test.js). Never renumber to close the gap — that is how0013left the tree. -
One transaction, one purpose. A file is atomic; keep it to one logical change so a failure is easy to reason about. Put a comment header on top saying what and why (see any existing migration).
-
Prefer additive changes.
ALTER TABLE … ADD COLUMN, new tables, new indexes. A rollback of the application image keeps the newer database (see release-process.md), so an older release must still work against it: do not drop or rename a column that a released version still reads in the same release that stops reading it. -
Work on any database. A fresh database has no pages, no settings and no users; an old one may have anything. Use
INSERT OR IGNORE,WHERE NOT EXISTS,COALESCE, and never assume a row exists. -
No installation-specific seeds. Nothing in a migration may write one school’s data into every school’s database, and no migration carries a particular installation’s content at all: a school’s texts belong in its own database, written through
/adminor the setup wizard. 0006 and 0009 still hold the older guard for the first installation’s legacy content (the pageustanovchi-doc) — frozen with their text, not a pattern to copy. A generic seed, one every school gets, fills only what is absent (AND NOT EXISTS (…)).test/neutralTemplate.test.jschecks a fresh database stays empty. -
Rebuilding a table. SQLite cannot drop a column constraint or widen a
CHECKin place, so a table is rebuilt:CREATE TABLE x_new→INSERT INTO x_new SELECT …→DROP TABLE x→ALTER TABLE x_new RENAME TO x→ recreate indexes and triggers.0019-pages-rebuild.sqlis the worked example;0022-library-groups.sqlrebuildspagesonce more (thepage_groupforeign key) and follows it step by step.Referenced tables need
-- @rebuild. Withforeign_keys = ON,DROP TABLE xis an implicitDELETE FROM xthat fires every child’sON DELETE(cascading posts, albums, people and menu items away) or fails onRESTRICT.pages,files,users,newsandauthorsare all referenced. A file whose first non-empty line is-- @rebuildis run by the runner the way SQLite documents (the “twelve-step” procedure, https://www.sqlite.org/lang_altertable.html#otheralter):PRAGMA foreign_keys = OFFoutside any transaction, the whole file and itsschema_migrationsrow in one transaction ending withPRAGMA foreign_key_check— a violation the rebuild introduced rolls everything back — andforeign_keysback on in afinally. The log saysmode: 'rebuild'. Without the directive the same file fails exactly as before. An unreferenced table (0008rebuiltnewswhen nothing referenced it yet) needs no directive, but it does no harm.Checklist for a rebuild file:
-
-- @rebuildas the very first line; the reason in the comment header below it. - The new table’s columns in the same order, types and defaults as
PRAGMA table_info(x)on a migrated database (everyADD COLUMNsince the table was created — read all later migrations), soSELECT *andINSERT … SELECTelsewhere do not shift. Keep every constraint you are not deliberately changing, and everyREFERENCES … ON DELETE …. - Copy with an explicit column list and keep the ids — every foreign key and every
search_indexrowid points at them. - Recreate every index on the table (
SELECT name, sql FROM sqlite_master WHERE tbl_name = 'x' AND type = 'index'on a migrated database; autoindexes come back with theirUNIQUE/PRIMARY KEYconstraints). - Search triggers:
pages,news,documentsandpage_filescarry threesearch_<table>_{ai,au,ad}triggers from0017-search-index.sql, andfilescarriesmedia_search_{ai,au,ad}from0026-files-alt-text.sql;DROP TABLEtakes them silently. Recreate them verbatim after theINSERT … SELECT(created before it, the insert trigger would index every row a second time).DROP TABLEdoes not fire the delete trigger, so with the ids kept the existing index rows still match; a guardedINSERT … WHERE NOT EXISTSfor rows without one is cheap insurance (see 0019). - Other triggers or views that mention the table (
SELECT … FROM sqlite_master WHERE sql LIKE '%x%'). -
test/dbRebuild.test.js: the trigger count (12search_*), the explicit index list,foreign_key_check, row counts before/after ontest/helpers/populatedDb.js, and thelength()CHECKs (review D-08) — extend them for the table you rebuilt. When a table is rebuilt anyway, a limit the forms enforce may become aCHECK— but only after checking that no existing row breaks it, or the migration fails on that installation.
-
-
Store what the code expects. Dates as ISO
YYYY-MM-DDtext, timestamps asstrftime('%Y-%m-%dT%H:%M:%fZ','now'), booleans as0/1with aCHECK. Put limits the forms enforce intoCHECKconstraints too (seealbums,authors,menu_items). -
Soft-deletable content gets
deleted_at TEXTanddeleted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, and every read filtersdeleted_at IS NULL. Wire it intosrc/admin/lib/trash.jsandpurgeTrash.js. -
A new column that points at
filesmust be added tosrc/lib/fileUsages.js— tofileUsages()and toREFERENCED_IDS_SQL(the setcollectFileReferences()builds) — or the media library will show the file as unused and the nightly sweep will bin it. Give it an index (partial,WHERE col IS NOT NULL, if nullable), as 0021 did for the others: SQLite checks every such column on eachDELETE FROM files. -
Settings are not migrated. A new setting key needs no migration: an absent key means the default, defined in code (
src/config/siteSettings.js,theme.js,homeBlocks.js,backupSettings.js). Write a settings row in a migration only to move existing data.
Backups need nothing: the change hash and the manifest enumerate tables from sqlite_master.
Checklist
Section titled “Checklist”- Next free number, descriptive name, English comment header with the reason.
- Runs on an empty database and on a copy of a real one (restore a backup into a scratch
DATA_DIRand start the app against it; in tests,buildLegacyDb()fromtest/helpers/populatedDb.jsis a database at 0018 with rows in every table). - No installation-specific values unguarded.
- Rebuilt tables:
-- @rebuildif anything references them; the rule 6 checklist done. - The file is final before it ships: once applied anywhere, its checksum is recorded.
- A test in
test/db.test.js(or next to the feature) that asserts the new shape or the data move on a fresh in-memory database. -
CLAUDE.md“Data model” and data-model.md updated.