Public: anyone can read this; the crew can change it.
Schema migrations are identified by name, so two pulls can each add one without colliding #2560
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/783-schema-migrations-are-identified-by-name → main 3 commits fork
15 files
changed +460
−47
CLAUDE.md +6 −3
| @@ -254,9 +254,12 @@ flag exists only in the stdio dev runner and is never reachable over SSH. | ||
| 254 | 254 | whose default export is the SQL — never the next number: `0001`–`0091` |
| 255 | 255 | are the numbered history, and a shared next number is what parallel pulls |
| 256 | 256 | collided on (#1837). Files run in name order, once each, recognised by a |
| 257 | hash of their text in `schema_migration` (`kernel/migrate.ts`), so two | |
| 258 | pulls' migrations land in either order. Never edit an existing migration | |
| 259 | — an edited one is a new one and runs again. It must only add | |
| 257 | hash of their text in `schema_migration` (`kernel/migrate.ts`, which also | |
| 258 | records each name), so two pulls' migrations land in either order, and a | |
| 259 | database with one this build does not know boots and names it. Never edit | |
| 260 | an existing migration — an edited one is a new one and runs again; once | |
| 261 | pinned in `core/migration-pins.json` (`bun scripts/pin-migrations.ts`, run | |
| 262 | by the release agent, never by a pull) the schema test fails on an edit. It must only add | |
| 260 | 263 | (`test/schema-additive.test.ts`); the release agent lists its id in |
| 261 | 264 | `core/releases.json`. |
| 262 | 265 | - **New git read:** put it in `git/read.ts`; reuse the long-lived `cat-file` |
docs/ARCHITECTURE.md +3 −2
| @@ -176,8 +176,9 @@ NOT NULL column without a default, and `test/schema-additive.test.ts` | ||
| 176 | 176 | refuses one in any migration not shipped by both of the two newest releases |
| 177 | 177 | recorded in `src/core/releases.json` (by id: a count, and the stamped ids), |
| 178 | 178 | which the release agent bumps when it cuts one. |
| 179 | An old process that restarts mid-roll refuses a database ahead of it | |
| 180 | (`user_version`), as it always has. | |
| 179 | An old process that restarts mid-roll boots on a database ahead of it and | |
| 180 | logs, by name, the migrations it does not know (`schema_migration` records | |
| 181 | each name, #783) — they only add, so it serves as it did. | |
| 181 | 182 | |
| 182 | 183 | State transitions use compare-and-swap (`UPDATE … WHERE state = ?`), and every |
| 183 | 184 | transition writes its event row in the same transaction. On boot, a |
scripts/pin-migrations.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/pin-migrations.ts`: pin every migration in | |
| 3 | * `src/core/migrations/` that `src/core/migration-pins.json` does not | |
| 4 | * name yet, by its id (`migrationId`). An existing pin is never rewritten: | |
| 5 | * once pinned, a migration's text is history, and | |
| 6 | * `test/schema-migrations.test.ts` fails if it changes (#783). | |
| 7 | * | |
| 8 | * The release agent runs it when it cuts a release, beside bumping | |
| 9 | * `src/core/releases.json`, so pulls never touch the file and two of them | |
| 10 | * adding a migration each never meet in it. | |
| 11 | */ | |
| 12 | ||
| 13 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { migrationFiles } from "../src/core/schema.ts"; | |
| 16 | import { type MigrationPins, pinBreaks, pinned } from "../src/kernel/migrate.ts"; | |
| 17 | ||
| 18 | const file = join(import.meta.dir, "../src/core/migration-pins.json"); | |
| 19 | const pins = JSON.parse(readFileSync(file, "utf8")) as MigrationPins; | |
| 20 | const breaks = pinBreaks(migrationFiles, pins); | |
| 21 | if (breaks.length > 0) { | |
| 22 | for (const line of breaks) console.error(`pin-migrations: ${line}`); | |
| 23 | process.exit(1); | |
| 24 | } | |
| 25 | const next = pinned(migrationFiles, pins); | |
| 26 | const added = Object.keys(next).filter((name) => !(name in pins)); | |
| 27 | writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`); | |
| 28 | // CLI answer for the person at the terminal. | |
| 29 | console.log( | |
| 30 | added.length === 0 | |
| 31 | ? "pin-migrations: every migration is already pinned" | |
| 32 | : `pin-migrations: pinned ${added.join(", ")}`, | |
| 33 | ); |
src/app.ts +2 −2
| @@ -11,7 +11,7 @@ import type { VerifyHuman } from "./core/turnstile.ts"; | ||
| 11 | 11 | import { randomBytes } from "node:crypto"; |
| 12 | 12 | import { mkdirSync } from "node:fs"; |
| 13 | 13 | import { join, resolve } from "node:path"; |
| 14 | import { migrations } from "./core/schema.ts"; | |
| 14 | import { migrationFiles } from "./core/schema.ts"; | |
| 15 | 15 | import { dropLegacyNewAccountGuards } from "./core/schema.ts"; |
| 16 | 16 | import { EventBus } from "./core/events.ts"; |
| 17 | 17 | import { follow } from "./core/following.ts"; |
| @@ -212,7 +212,7 @@ export function createApp(config: ForgeConfig): App { | ||
| 212 | 212 | mkdirSync(reposRoot, { recursive: true }); |
| 213 | 213 | const socketPath = join(dataDir, `forge-${id()}.sock`); |
| 214 | 214 | |
| 215 | const db = openDb(join(dataDir, "forge.db"), migrations); | |
| 215 | const db = openDb(join(dataDir, "forge.db"), migrationFiles); | |
| 216 | 216 | // Hand-installed new-account guards become the `guard_new_accounts` |
| 217 | 217 | // setting (default off): drop the legacy triggers once, here, on boot. |
| 218 | 218 | // Idempotent — a forge without them changes nothing. |
src/chaos/worker.ts +2 −2
| @@ -4,12 +4,12 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { openDb } from "../kernel/db.ts"; |
| 7 | import { migrations } from "../core/schema.ts"; | |
| 7 | import { migrationFiles } from "../core/schema.ts"; | |
| 8 | 8 | import { claimRun, createRun, heartbeatRuns } from "../core/runs.ts"; |
| 9 | 9 | |
| 10 | 10 | const [dbPath, repoId] = process.argv.slice(2); |
| 11 | 11 | if (dbPath === undefined || repoId === undefined) process.exit(2); |
| 12 | const db = openDb(dbPath, migrations); | |
| 12 | const db = openDb(dbPath, migrationFiles); | |
| 13 | 13 | console.log("ready"); |
| 14 | 14 | for (;;) { |
| 15 | 15 | const run = createRun(db, { repoId, kind: "check" }); |
src/core/migration-pins.json added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | { | |
| 2 | "0001-foundation": "b6c433603a3fe1c2e79b9d66fe9bc3bc67f403d0f815a9f81dc16a00ef245da5", | |
| 3 | "0002-conversations": "5af40c718d7c91c0340d2e1609ecc2edd69a91373e33d78ea561f4ef4de700e1", | |
| 4 | "0003-pull-requests": "6a53fb594a0bc023a80948c2cde3500a37a95aafe30a896b157794aee1998094", | |
| 5 | "0004-forks": "9c323d30f8afb5b5e0b26dece297d88e41c758f21bf8155c9cc32d9df7d61609", | |
| 6 | "0005-organizations-and-teams": "90ac9619b147b5818762c74e97da40fba00fdfe77071b642ddf4bb45b279f927", | |
| 7 | "0006-ssh-public-keys": "f5bcf371354ab8e32c71e33c220a5d1e9554b4a6bb5b202922a47a07aa645979", | |
| 8 | "0007-per-user-interface-preferences": "97d11a5e8f8d14bd1b4c8027201f6d286bf480d61c7d116ef3abcfa87d4824e8", | |
| 9 | "0008-origins": "ae3db8d70c0890e72362e4c4e246eb4cf34e66354de622338015b5276b22eefb", | |
| 10 | "0009-oauth-for-origins": "5e8796b669bca4271907987f88fab4f37e230247b5f49465a00ce14fedd1666a", | |
| 11 | "0010-a-short-link-for-a": "37d793621278ccb06b0f0e3c9c4f5996fddc10ac4532a24c5ed5fdee5273a931", | |
| 12 | "0011-counts-on-a-list": "a8d5bac07bdbd8eaa77a73e0344dd3b8233fea0791084b247a4d5c85288d5344", | |
| 13 | "0012-the-indexes-a-busy-forge": "6aa333646f3751566cab4892f957e4aa7ffbfd8750fa3e1b76f514eaa46a6e57", | |
| 14 | "0013-review-states": "54c0f87c1f3bbbabf131b1722d811f915c536a752d2ebbdcec3a0fccbe22b6d7", | |
| 15 | "0014-apps": "3508216ce82bf466b5fe9e07bc69dd10c5ddf1d3cbd7e95c1daa7027ab480747", | |
| 16 | "0015-an-app-s-environment": "8949dbd4c0c38431d02cc9818827b68a3f196265624df9d100afc9a0974f84ad", | |
| 17 | "0016-a-paused-run": "af46f93cd74f18e640fd0a91e02dac0c31a5a52c4d354bcdb2856e21b23e234e", | |
| 18 | "0017-checks": "69ca4560ae58d18ee836d835555d436a5ea2a48e79a1467e51b1f937172b7173", | |
| 19 | "0018-a-run-s-lease": "eca89f69fee4c278d27e8764d08ac49a3e5bdeefc54a29e22098a7110daaac2a", | |
| 20 | "0019-how-many-turns-an-agent": "1217cffaad87574c9b786de2cae43e6638a3803b6df75f0e4df5ea16bb895376", | |
| 21 | "0020-issues-that-depend-on-issues": "5f8a0b647e9060606e21eb4a982d1815225ffc365e145eb9471c297c3343af2f", | |
| 22 | "0021-a-repository-can-run-on": "85a05853ae9e636049597fe9036ac536ba3efac6bca66496b6cd75e52a70fc25", | |
| 23 | "0022-the-decision-ledger": "b5e6e4d873686180dd9e76bab6ce0f9ef402a3444d96f2706d62c07cc18e80a3", | |
| 24 | "0023-the-look": "86890cbc353427bd6abe535a5ea2cad2661d437ed285407099b73d484e72071a", | |
| 25 | "0024-a-variable-has-a-scope": "1f9049bb4d0a0843dee7975f6b6201d396ac2e6bc4a155b8c66d65dff47efad5", | |
| 26 | "0025-what-a-running-app-said": "77a8887bfb3309ca024bbd9430240d716454fae860383a6d02e95d8e47223cfa", | |
| 27 | "0026-subscriptions": "85b5a7e228cefb1689c48f6e7862b9539f80d9c4240263a7e428716ccacf123b", | |
| 28 | "0027-the-moment-a-deploy-started": "e43e6b3d4fadba7e03dfb6bb54406ac90326823e9fb8f56b127a551bf4f8ae5f", | |
| 29 | "0028-a-failed-check-s-own": "8b3ff15d07875635f1a5d378f09b0a432fa5650272b621bf1f1ba47c352c2d79", | |
| 30 | "0029-a-third-width": "b4d8527a8a9e5002d2a2b3012fe064c365c180b680a59bb2d19db718f3ec9ad0", | |
| 31 | "0030-take-it-back": "727ee2c2371a095738a3df1079d8f4f98234b695f84d63466d98444a681f0f31", | |
| 32 | "0031-a-look-knows-which-page": "a62ab5826175af737f7b56c2d209ecf61d1bcad1707d0db625ee152bea8cc70f", | |
| 33 | "0032-an-app-s-own-domains": "fa420e16fc6a7723092944018cea038e29bfed19d1cb4c7729fbcb708bf96b69", | |
| 34 | "0033-where-a-repository-s-next": "321bf75d987ac60b6a0dd58b12d461c1758be11a14e84edc8d071ca49ad3ee0d", | |
| 35 | "0034-forge-level-secrets": "2c31a3db69e0e7cb1167ed3efa2d2a496532da896bc90e39979f4793cb70cd49", | |
| 36 | "0035-what-a-viewer-did-with": "fe01b3e75c1f9d4cae068fdb696949ac2691cde2796edb513a175eed3ef2162c", | |
| 37 | "0036-a-share": "dc1c385f26c13547e79eecafaa830a4adc0f4ff0d4856b95e6a50d075f0c94e9", | |
| 38 | "0037-the-usage-ledger": "d5d815aa3bef5e1f4cceb114771cf1a3e57931fdc21235da9208c0122ab93d7b", | |
| 39 | "0038-a-subscription-s-standing": "2445f7d024399cc6f27248ad91ac6693369f6dfbcbd96702e91f1cf7ff022f59", | |
| 40 | "0039-whether-the-agent-s-shell": "cc3a57567f75490d5607e43097ecefc00bf3dc9529304be8874ae697a6231ee7", | |
| 41 | "0040-a-fleet-operation-s-receipt": "ebe243edc22f30a29b6950e0f8cfce4c7ba346d11f7a4a4c61e4a08298b14965", | |
| 42 | "0041-the-meter": "15b995bb9dcfcb16aa9dbee69372ede8e1dcee56857dfa06e5deb2a583520569", | |
| 43 | "0042-every-credential-is-a-subscription": "f56c85102d0ce700a5a3eb958a0134f47a3a0f0cfa1e60c37e0167d2be802012", | |
| 44 | "0043-an-interrupted-run-is-not": "d9949e799843d1221644af17544e4f80fd4e60232c101186432dae0b7d1d2057", | |
| 45 | "0044-why-an-issue-closed": "4f24a266d54eae3910da18f620e396b29b406ac4d993c13158875ce7590687c9", | |
| 46 | "0045-oauth-clients-registered-dynamically": "ce0cf0b16b9094766362a288b9b984a00e8394b5b4821f370acf863b308ace61", | |
| 47 | "0046-which-address-the-forge-s": "96917061cb4b064779a15dba9cbac82c4cba9ff2aefd71acd229e6aca4273190", | |
| 48 | "0047-a-report-from-outside": "be5336004ba3141e71809194b65eb333866a678c5f2bb65c16be8681c65b63e7", | |
| 49 | "0048-an-account-the-owner-suspended": "21c01ec3f30ef0e7f55a293601e95db31c1437f3da33ecf46036d9a425e5a96d", | |
| 50 | "0049-the-asks-ledger": "a29d814f26ba38e871e7271cd955f14022ff923299b7a3af1ba85866f07221e2", | |
| 51 | "0050-outsiders-may-trigger-the-crew": "28be9094e981e547f7f3bd1eaa4b0fd87acf346ccc1188469341c262901de488", | |
| 52 | "0051-the-front-page-s-picks": "e3e9f27841b17e3951e627680cdfcca30e366bc687d8fd6f4c3b56a9661a5b2a", | |
| 53 | "0052-a-whole-life-budget-on": "922ee458bbc52f0d6a9938e36ff09494066a8cb557bd7788495d259c9b335f39", | |
| 54 | "0053-whether-an-account-s-email": "1e64971a56d0c0e16ec513cc506bc3a637efe843092d8a8cc99753a6c77a32e8", | |
| 55 | "0054-the-people-a-private-app": "7670ecd442c0f1a1e23e878fe5e8accb4fd29a58c6bf3b27841a1c2a062352e6", | |
| 56 | "0055-a-hold-on-a-pull": "8a0da478bdb878d0b0bf29d5824735ac3e67a6a504877abaff81bcf36d282597", | |
| 57 | "0056-what-an-account-has-spent": "be08ec2eab632260848ed4c9951291907ace5bd81c514c4e3e53cd6cd0ab85cb", | |
| 58 | "0057-spend-reported-from-outside": "15000df57c5e4f686dc91bb0c937d9484a4466bc424be5b7570c22d4f820c7d4", | |
| 59 | "0058-a-signed-in-browser-has": "32b86a5f3074b1bce792e99aadc1f432b8ccff508703c9fcf6fa3ae537ad4eb4", | |
| 60 | "0059-every-message-the-forge-sent": "4b7829c5fc97384cb4d5d9005168288eeab9419003552d8051ec5561bc9ef242", | |
| 61 | "0060-who-a-picture-was-taken": "7d2319f66c78239cc662477d4ceda2ac50fd670342ebd69db1b7736041c1ed47", | |
| 62 | "0061-an-agent-never-owns-an": "7d4b174818752723caaef32300a0a21783e1db9aca6b72334a44dd7ca092fd1f", | |
| 63 | "0062-only-an-agent-is-ever": "57c2dc3400fc1e2db9acf86f2eb0231561e8e6ed87b54770a9d79493137cfe0e", | |
| 64 | "0063-the-forge-s-capacity": "7721b2146736a958a08eb3e6f77b5ea6e179fd920dfc94e9a9ae98c88ce4eaba", | |
| 65 | "0064-the-front-page-s-settings": "e15834cc1c7e45a58f4849cbad2ee5b627c3ad895c716030431b4961a47f30e9", | |
| 66 | "0065-a-sealed-issue": "46d20691683748274ff0aeab0cd8d2640bb3f0427a4a3235e6a8b828ab0d2396", | |
| 67 | "0066-a-milestone-a-person-watches": "782bfde34aac27ea01782ae22bbf3152e6f32925443e5ce212594f29402af0ff", | |
| 68 | "0067-every-fork-a-person-made": "c2f5bf838522142e42b047e400b6725f711bd0280536a8c143aa5f30a8f24ad9", | |
| 69 | "0068-the-run-a-comment-was": "1a92482c52858a1a326539c0aa1ed9c94d52dd9a2f274c7645a44c4a8840b2a2", | |
| 70 | "0069-an-event-s-place-in": "b266f8f36f9a9a2382e7c5107dc9c5ce70a0c097ad8f5dc0c3f53f893c7a3476", | |
| 71 | "0070-a-sealed-repository-is-never": "be9c7af107676fee4a652dc45580c817309a917211dc90435eb94fa5e2a46e45", | |
| 72 | "0071-an-optional-anthropic-compatible-api": "f5e6a4df5bd7cca6371d164a7e254ec19d5a5db9986c8b120b2f398d2af0fc05", | |
| 73 | "0072-when-a-mirror-lost-its": "34867bc4869e7d8928fa284027179adb6b86680ae540cd31564d4bb941c6eb94", | |
| 74 | "0073-authorization-codes-for-the-mcp": "a1d0617595b908c81a4aba107a00b600c980b8048ffeea98975b6e724dd385e6", | |
| 75 | "0074-a-look-a-person-attached": "4fd216ce408ee877ffbb8b38cab0234c22242da61377e9011f7ccf3189096d74", | |
| 76 | "0075-a-limb-s-place-among": "bc117e5988500afdbc7b4406a232449d1881cd1e78a6f9e9edbabb09cff9c53f", | |
| 77 | "0076-the-sentinel-s-memory": "ff8882d41e1702253a32d09324b1d9f00f29b21a22511779c914dbd7adfd7a01", | |
| 78 | "0077-openid-connect-over-the-same": "1507c0bc5b5168e4b440d9963be59dcb10b94b14d18da1182ca0da6787f22090", | |
| 79 | "0078-variants": "8c983ce83e8cad50f1e015ee5f4eefe77e503471d46bc46a43e5e3b71180d06d", | |
| 80 | "0079-degraded-history": "6c58267cfafaa53117a3945013e9591ae98a8fcc5f1f3998bc9113e4ea62d892", | |
| 81 | "0080-restore-drills": "35147ba9da81cda49cc77346c5c2d5eb71c72afc108676480237bd8d01f98f06", | |
| 82 | "0081-thoughts": "b8fca6511f61669d33d34eeb180a64767b08b744e41496b2968b19943f4a6574", | |
| 83 | "0082-passkeys": "074a5d91370d9d0ec073d9abbeded908a6541a743dcf55456e758422381614e4", | |
| 84 | "0083-releases": "c0e3680642b226841bf039c48b5ae806c97ac0ce18fc8d71fc8eec49aeefe27c", | |
| 85 | "0084-placements": "60036ee7281d17aad4a034409daaaab027ca6c524297da7d29618ef9eaa957df", | |
| 86 | "0085-a-standing-rule-per-repository": "8c47857982bb540589b1b418926218475036050ef9449f3a5f80ef2ed3f5c8f0", | |
| 87 | "0086-what-a-production-deploy-carries": "62379dffd5a0273355fcd1e5e353e046283c65a8ab7c068958efaa866d5db37d", | |
| 88 | "0087-supersession-and-repeats": "bc2647e3fcb0f32b7dda800cb6a38eb0d5e340243e07bd6ad7cf8291d48efbcb", | |
| 89 | "0088-what-the-machine-used": "afc9a4d24ead17763ccd7bdb4aeae80b9bafc3af769c5ded89bef921c7f622ad", | |
| 90 | "0089-a-parked-pull": "7b564005fff642a38dc60cd58c805e97888845f89ad884af9baf79fa615cf77f", | |
| 91 | "0090-a-green-check-proven": "21f33d1dabaf9722611db3a1630bc1c81aca6535a32398b7bbe7ddc47259994f", | |
| 92 | "0091-where-a-check-ran": "8ac84fa10e0079815a48cb7e57dfbf3e5f67bcf820695e57b7841374d570f943", | |
| 93 | "20261015000100-plans-need-a-nod": "754eb716bfe65056915e5f01bcbd6f15cf6b73b413da952c0a9b6ea1990b26ba", | |
| 94 | "20261015090000-a-repository-put-away": "692ca4ed5c82f4c9a281a74ee0716182fd4067a148a2652575c33e9e2a0055c4", | |
| 95 | "20261015090000-feed-chips": "a6057329aa558e6d25916f1edb88fffb30383eac87e24e966994631051029bfb", | |
| 96 | "20261015120000-check-base-tip": "14e4279c9da82e3f7f9a5e163c0f8c75dcaa436e628586af9cab9c25e2db653d", | |
| 97 | "20261015230000-a-domain-drill": "8db343e16d0bfacda42598908cb7ec113485b5aff30c7f663dffe0198cfbeb5c", | |
| 98 | "20261015230532-telemetry-read": "8106ad063b8b13c22c0115dfc23141dab081cb2a23168a1eb891b2fe30b4cc65", | |
| 99 | "20261016090000-sentinel-kept-settings": "03c30ac6d4e9d3164cc0f32af1c0f9d1f69a02e1147dc25bd1fe1838c077d5aa", | |
| 100 | "20261016120000-kept-is-a-person-s": "d12558f4a7395c91e46b6abddf836719509e0531630a046b6114926c5aad4a91", | |
| 101 | "20261016120000-proposal-state": "f24b95f42c1ceee8ed0f69093500365c218be3ec2c00be47a60ab77383a833f7", | |
| 102 | "20261016130000-sign-in-with-the-parent": "cd2a2f60afac864446fee454e746ca3dac0a29848446bd27697a5dc0ba5a7124", | |
| 103 | "20261016130000-usage-count": "1e715bfd5e4e28709903ee3a380fd1e8bf6b2be0a0321186ffa803676e6d5906", | |
| 104 | "20261016140000-a-model-grant": "7ec2dbdfe5e782eb98b5c42f2ae79a686631c3416c9787ab3708adab8fec0777", | |
| 105 | "20261016140000-drill-counts": "2ec120b90753826a5cbb6c7416b84a563566a0995ccd6d0350e00cf63ea16d45", | |
| 106 | "20261016140000-flag-use": "a3375653f892f6eccff8493aebd2b42084d68b4612e80b595cd1f09dcda988da", | |
| 107 | "20261016140000-machine-temperature": "74754feca643996c3b214f799a57868b0ab298a6ac9d9fa85905cd16e8907e9d", | |
| 108 | "20261016150000-memory-hour": "41077b8c7b9b0421255f82b6c14954b4abad249cb51472e064cb130bc0aa0128", | |
| 109 | "20261016160000-cloud-audit": "a64b2356735e172b21a8c462418b84065de9a54f121fa8371e812d97264d00d1", | |
| 110 | "20261016160000-proposal-tokens": "335e0558843d8c103213ef8b9a1202e52cc8bd0674dc0e5db73f3429d38cc7c9", | |
| 111 | "20261016170000-pull-run-grant": "dca1fa79e48ece7ba962b6e2eabeda513b7b364ed68e41a814ca73e123a9b811", | |
| 112 | "20261016170000-upstream-proposals": "ea8c2b809705a5ac38dd94470e3024a560e61f44f01e4f51a84f7d9699df3338", | |
| 113 | "20261016180000-account-ceiling": "0077901b08d41b6af61fe4b7ad7bf3bb16c923e755b5898c2f6d86837e9a42d0", | |
| 114 | "20261016180000-signin-only-code": "c69edd5906a2f3425d6ce867ef9c7b6d59c40fb582ef6cbea0f4c86a67eb0030", | |
| 115 | "20261016190000-member": "9076848fe032dea6905927a1b454c2381a3129acb3ce357c2726e82f1c161bc4", | |
| 116 | "20261016200000-invite": "72bc7e9be6a7223048d1d1cf0226bc93ee4dbea9f00dbb20a6d457305886cbf9", | |
| 117 | "20261017195831-ceiling-reserved": "1e6756752ae06c78a5920ae839e911e837a14cff3ac766cbc38e117c0a40186b", | |
| 118 | "20261018100000-incident-remembered": "61fefa1d0750f3be617fdc23c1e6e17abc1ba610d980d85aff8824f14bf3e253" | |
| 119 | } |
src/core/releases.json +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "note": "The migrations each release applied, newest first. The release agent adds the new release here when it cuts one: `migrations` counts the migration ids it applied (every file in src/core/migrations/), and `ids` names the stamped ones among them — every file past 0090, its name without .ts (`ls src/core/migrations | grep -v ^0 | sed s/.ts$//`). A release from before #1837 has a count only: the first that many numbered files. Every migration that is not in both of the two newest releases must be additive (test/schema-additive.test.ts, notAdditive in src/kernel/migrate.ts): the old process keeps serving on the same database while the new one rolls in.", | |
| 2 | "note": "The migrations each release applied, newest first. The release agent adds the new release here when it cuts one: `migrations` counts the migration ids it applied (every file in src/core/migrations/), and `ids` names the stamped ones among them — every file past 0090, its name without .ts (`ls src/core/migrations | grep -v ^0 | sed s/.ts$//`) — and runs `bun scripts/pin-migrations.ts`, which pins each new migration's text in src/core/migration-pins.json. A release from before #1837 has a count only: the first that many numbered files. Every migration that is not in both of the two newest releases must be additive (test/schema-additive.test.ts, notAdditive in src/kernel/migrate.ts): the old process keeps serving on the same database while the new one rolls in.", | |
| 3 | 3 | "releases": [ |
| 4 | 4 | { |
| 5 | 5 | "version": "0.7.165", |
src/core/schema.ts +3 −1
| @@ -9,7 +9,9 @@ | ||
| 9 | 9 | * new one is named by the UTC second it was written |
| 10 | 10 | * (`date -u +%Y%m%d%H%M%S`, e.g. `20261015093000-deploy-notes.ts`), so two |
| 11 | 11 | * pulls that each add one add two files and never touch the same line |
| 12 | * (#1837). Each file is one `export default` of SQL. | |
| 12 | * (#1837). Each file is one `export default` of SQL. The database records | |
| 13 | * each applied one by name (#783); `migration-pins.json` holds what each | |
| 14 | * said when it was released, and a test fails if one says something else. | |
| 13 | 15 | */ |
| 14 | 16 | |
| 15 | 17 | import type { Database } from "bun:sqlite"; |
src/kernel/db.ts +14 −5
| @@ -7,7 +7,10 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { Database } from "bun:sqlite"; |
| 9 | 9 | import { existsSync, writeFileSync } from "node:fs"; |
| 10 | import { migrate } from "./migrate.ts"; | |
| 10 | import { createLog } from "./log.ts"; | |
| 11 | import { type Migration, migrate } from "./migrate.ts"; | |
| 12 | ||
| 13 | const log = createLog("db"); | |
| 11 | 14 | |
| 12 | 15 | /** |
| 13 | 16 | * How long a writer waits for another writer before giving up. Long by |
| @@ -34,7 +37,7 @@ const CACHE_KIB = 64 * 1024; | ||
| 34 | 37 | */ |
| 35 | 38 | const MMAP_BYTES = 512 * 1024 * 1024; |
| 36 | 39 | |
| 37 | export function openDb(path: string, migrations: readonly string[]): Database { | |
| 40 | export function openDb(path: string, migrations: readonly Migration[]): Database { | |
| 38 | 41 | const memory = path === ":memory:"; |
| 39 | 42 | const fresh = memory || !existsSync(path); |
| 40 | 43 | const image = fresh ? migratedEmpty.get(migrations) : undefined; |
| @@ -47,8 +50,14 @@ export function openDb(path: string, migrations: readonly string[]): Database { | ||
| 47 | 50 | } |
| 48 | 51 | applyPragmas(db); |
| 49 | 52 | // On a copy every migration is already recorded, so this is a no-op — |
| 50 | // and a list that grew since the copy was taken still runs what is new. | |
| 51 | migrate(db, migrations); | |
| 53 | // and a list that grew since the copy was taken still runs what is new; | |
| 54 | // one behind the copy boots and says what it does not know (#783). | |
| 55 | const { unknown } = migrate(db, migrations); | |
| 56 | if (unknown.length > 0) | |
| 57 | log.warn("database has migrations this build does not know; left as they are", { | |
| 58 | count: unknown.length, | |
| 59 | unknown: unknown.join(","), | |
| 60 | }); | |
| 52 | 61 | if (fresh && image === undefined) migratedEmpty.set(migrations, db.serialize()); |
| 53 | 62 | return db; |
| 54 | 63 | } |
| @@ -63,7 +72,7 @@ export function openDb(path: string, migrations: readonly string[]): Database { | ||
| 63 | 72 | * bytes however it was made: the migrations that draw randomness only |
| 64 | 73 | * rewrite rows an empty database does not have. |
| 65 | 74 | */ |
| 66 | const migratedEmpty = new WeakMap<readonly string[], Uint8Array>(); | |
| 75 | const migratedEmpty = new WeakMap<readonly Migration[], Uint8Array>(); | |
| 67 | 76 | |
| 68 | 77 | /** |
| 69 | 78 | * The image with its header saying "rollback journal" (bytes 18 and 19 of a |
src/kernel/migrate.ts +123 −15
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | * migration is a row in `schema_migration`, keyed by a hash of its text, and |
| 12 | 12 | * a boot runs exactly the ones whose text it has not seen. A second boot is |
| 13 | 13 | * a no-op; a migration that moved is still recognised; a sibling's migration |
| 14 | * this build does not know is left alone. | |
| 14 | * this build does not know is left alone and named, never a refusal to boot | |
| 15 | * (#783) — each row also carries its migration's file name. | |
| 15 | 16 | * |
| 16 | 17 | * Each statement is also run so that meeting its own result is a no-op — |
| 17 | 18 | * `CREATE` gains `IF NOT EXISTS`, an `ADD COLUMN` whose column is there is |
| @@ -77,10 +78,73 @@ export function migrationId(sql: string): string { | ||
| 77 | 78 | return sha256(sql.replace(/\s+/g, " ").trim()); |
| 78 | 79 | } |
| 79 | 80 | |
| 80 | export function migrate(db: Database, migrations: readonly string[]): void { | |
| 81 | /** Each pinned migration's id, by name: what it said when it was pinned. */ | |
| 82 | export type MigrationPins = Readonly<Record<string, string>>; | |
| 83 | ||
| 84 | /** | |
| 85 | * How `files` differ from what was pinned: one line for each pinned | |
| 86 | * migration whose text says something else now, or that is gone. A | |
| 87 | * migration on main is history — an edited one is a new one, and a | |
| 88 | * database that ran the old text runs the new one again on top of it. | |
| 89 | */ | |
| 90 | export function pinBreaks( | |
| 91 | files: readonly MigrationFile[], | |
| 92 | pins: MigrationPins, | |
| 93 | ): string[] { | |
| 94 | const now = new Map(files.map((m) => [m.name, migrationId(m.sql)])); | |
| 95 | const breaks: string[] = []; | |
| 96 | for (const [name, id] of Object.entries(pins)) { | |
| 97 | const current = now.get(name); | |
| 98 | if (current === undefined) breaks.push(`${name} was pinned and is gone`); | |
| 99 | else if (current !== id) | |
| 100 | breaks.push(`${name} was edited after it was pinned — add a new migration instead`); | |
| 101 | } | |
| 102 | return breaks; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * `pins` with every migration in `files` it lacks added; a pin already | |
| 107 | * there is never rewritten. Sorted by name, so the file it is written to | |
| 108 | * changes only where a migration was added. | |
| 109 | */ | |
| 110 | export function pinned( | |
| 111 | files: readonly MigrationFile[], | |
| 112 | pins: MigrationPins, | |
| 113 | ): MigrationPins { | |
| 114 | const next: Record<string, string> = { ...pins }; | |
| 115 | for (const m of files) next[m.name] ??= migrationId(m.sql); | |
| 116 | return Object.fromEntries( | |
| 117 | Object.entries(next).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), | |
| 118 | ); | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * A migration as `migrate` takes it: its file, which names it, or bare SQL — | |
| 123 | * a test's own list — which is known by its position alone. | |
| 124 | */ | |
| 125 | export type Migration = string | MigrationFile; | |
| 126 | ||
| 127 | /** What a boot did to the database, by migration name. */ | |
| 128 | export interface Migrated { | |
| 129 | /** The migrations this boot applied, in the order it applied them. */ | |
| 130 | readonly applied: string[]; | |
| 131 | /** | |
| 132 | * What the database has applied that this build does not know: a newer | |
| 133 | * build's or a sibling branch's, on data copied for a preview. Each by | |
| 134 | * the name its build recorded; one recorded with none by its position and | |
| 135 | * the start of its id. Left alone, never undone. | |
| 136 | */ | |
| 137 | readonly unknown: string[]; | |
| 138 | } | |
| 139 | ||
| 140 | export function migrate(db: Database, list: readonly Migration[]): Migrated { | |
| 141 | const migrations = list.map((m) => (typeof m === "string" ? m : m.sql)); | |
| 142 | const names = list.map((m, i) => | |
| 143 | typeof m === "string" ? `migration ${i + 1}` : `migration ${m.name}`, | |
| 144 | ); | |
| 81 | 145 | const ids = migrations.map((sql, i) => { |
| 82 | 146 | if (sql.trim() === "") |
| 83 | defect("db.migration_missing", `migration ${i + 1} is empty`); | |
| 147 | defect("db.migration_missing", `${names[i]} is empty`); | |
| 84 | 148 | return migrationId(sql); |
| 85 | 149 | }); |
| 86 | 150 | const seen = new Set<string>(); |
| @@ -88,22 +152,19 @@ export function migrate(db: Database, migrations: readonly string[]): void { | ||
| 88 | 152 | if (seen.has(id)) |
| 89 | 153 | defect( |
| 90 | 154 | "db.migration_duplicate", |
| 91 | `migration ${i + 1} repeats an earlier one word for word — it would never run`, | |
| 155 | `${names[i]} repeats an earlier one word for word — it would never run`, | |
| 92 | 156 | ); |
| 93 | 157 | seen.add(id); |
| 94 | 158 | }); |
| 95 | 159 | |
| 96 | 160 | const applied = appliedIds(db, migrations, ids); |
| 97 | if (applied.size > migrations.length) { | |
| 98 | defect( | |
| 99 | "db.future_schema", | |
| 100 | `database has applied ${applied.size} migrations, this build only knows ${migrations.length} — refusing to run`, | |
| 101 | ); | |
| 102 | } | |
| 161 | recordNames(db, list, ids); | |
| 162 | const ran: string[] = []; | |
| 103 | 163 | for (let i = 0; i < migrations.length; i++) { |
| 104 | 164 | const id = ids[i]!; |
| 105 | 165 | if (applied.has(id)) continue; |
| 106 | 166 | const sql = migrations[i]!; |
| 167 | const entry = list[i]!; | |
| 107 | 168 | db.transaction(() => { |
| 108 | 169 | for (const statement of statements(sql)) { |
| 109 | 170 | try { |
| @@ -111,19 +172,63 @@ export function migrate(db: Database, migrations: readonly string[]): void { | ||
| 111 | 172 | } catch (error) { |
| 112 | 173 | defect( |
| 113 | 174 | "db.migration_failed", |
| 114 | `migration ${i + 1} failed at ${quote(statement)}: ${messageOf(error)}`, | |
| 175 | `${names[i]} failed at ${quote(statement)}: ${messageOf(error)}`, | |
| 115 | 176 | error, |
| 116 | 177 | ); |
| 117 | 178 | } |
| 118 | 179 | } |
| 119 | 180 | db.query( |
| 120 | "INSERT INTO schema_migration (id, position, applied_at) VALUES (?1, ?2, ?3)", | |
| 121 | ).run(id, i + 1, new Date().toISOString()); | |
| 181 | "INSERT INTO schema_migration (id, position, applied_at, name) VALUES (?1, ?2, ?3, ?4)", | |
| 182 | ).run( | |
| 183 | id, | |
| 184 | i + 1, | |
| 185 | new Date().toISOString(), | |
| 186 | typeof entry === "string" ? null : entry.name, | |
| 187 | ); | |
| 122 | 188 | applied.add(id); |
| 123 | 189 | // PRAGMA can't be parameterized; the value is a count, not input. |
| 124 | 190 | db.exec(`PRAGMA user_version = ${applied.size}`); |
| 125 | 191 | })(); |
| 192 | ran.push(typeof entry === "string" ? String(i + 1) : entry.name); | |
| 126 | 193 | } |
| 194 | const known = new Set(ids); | |
| 195 | const unknown = db | |
| 196 | .query<{ id: string; position: number; name: string | null }, []>( | |
| 197 | "SELECT id, position, name FROM schema_migration ORDER BY position, id", | |
| 198 | ) | |
| 199 | .all() | |
| 200 | .filter((row) => !known.has(row.id)) | |
| 201 | .map((row) => row.name ?? `#${row.position} (${row.id.slice(0, 12)})`); | |
| 202 | return { applied: ran, unknown }; | |
| 203 | } | |
| 204 | ||
| 205 | /** | |
| 206 | * Write each known migration's name beside its id, where a build from | |
| 207 | * before names were recorded left none — so the database says which | |
| 208 | * migrations it has by name, and a later build that does not know one can | |
| 209 | * say which. A database written by test lists keeps its blanks. | |
| 210 | */ | |
| 211 | function recordNames( | |
| 212 | db: Database, | |
| 213 | list: readonly Migration[], | |
| 214 | ids: readonly string[], | |
| 215 | ): void { | |
| 216 | if (!hasColumn(db, "schema_migration", "name")) | |
| 217 | db.exec("ALTER TABLE schema_migration ADD COLUMN name TEXT"); | |
| 218 | const blank = db | |
| 219 | .query<{ n: number }, []>( | |
| 220 | "SELECT COUNT(*) AS n FROM schema_migration WHERE name IS NULL", | |
| 221 | ) | |
| 222 | .get()!.n; | |
| 223 | if (blank === 0) return; | |
| 224 | const name = db.query( | |
| 225 | "UPDATE schema_migration SET name = ?2 WHERE id = ?1 AND name IS NULL", | |
| 226 | ); | |
| 227 | db.transaction(() => { | |
| 228 | list.forEach((m, i) => { | |
| 229 | if (typeof m !== "string") name.run(ids[i]!, m.name); | |
| 230 | }); | |
| 231 | })(); | |
| 127 | 232 | } |
| 128 | 233 | |
| 129 | 234 | /** |
| @@ -132,7 +237,9 @@ export function migrate(db: Database, migrations: readonly string[]): void { | ||
| 132 | 237 | * taken as applied — what that number always meant — unless one plainly |
| 133 | 238 | * did not run (a table it creates or a column it adds is missing: a |
| 134 | 239 | * branch's migration sat at that number instead). They are written down |
| 135 | * once, after which positions never matter again. | |
| 240 | * once, after which positions never matter again. Such a database ahead of | |
| 241 | * this build is still refused: a count names nothing, so there is no | |
| 242 | * telling which of its migrations this build lacks. | |
| 136 | 243 | */ |
| 137 | 244 | function appliedIds( |
| 138 | 245 | db: Database, |
| @@ -153,7 +260,8 @@ function appliedIds( | ||
| 153 | 260 | db.exec(`CREATE TABLE schema_migration ( |
| 154 | 261 | id TEXT PRIMARY KEY, |
| 155 | 262 | position INTEGER NOT NULL, |
| 156 | applied_at TEXT NOT NULL | |
| 263 | applied_at TEXT NOT NULL, | |
| 264 | name TEXT | |
| 157 | 265 | )`); |
| 158 | 266 | const insert = db.query( |
| 159 | 267 | "INSERT INTO schema_migration (id, position, applied_at) VALUES (?1, ?2, ?3)", |
src/kernel/preflight.ts +9 −4
| @@ -24,7 +24,7 @@ import { join } from "node:path"; | ||
| 24 | 24 | import { parseCidrs } from "./cidr.ts"; |
| 25 | 25 | import { dataHomeRefusal } from "./data_home.ts"; |
| 26 | 26 | import { applyPragmas } from "./db.ts"; |
| 27 | import { migrate } from "./migrate.ts"; | |
| 27 | import { type Migration, migrate } from "./migrate.ts"; | |
| 28 | 28 | |
| 29 | 29 | /** A scratch directory older than this was left by a killed run. */ |
| 30 | 30 | const STALE_MS = 60 * 60 * 1000; |
| @@ -81,13 +81,14 @@ export function configProblem( | ||
| 81 | 81 | export function preflight( |
| 82 | 82 | env: Record<string, string | undefined>, |
| 83 | 83 | treeDir: string, |
| 84 | migrations: readonly string[], | |
| 84 | migrations: readonly Migration[], | |
| 85 | 85 | ): Preflight { |
| 86 | 86 | const bad = configProblem(env, treeDir); |
| 87 | 87 | if (bad !== null) return { ok: false, line: `preflight failed: ${bad}` }; |
| 88 | 88 | |
| 89 | 89 | const dataDir = dataDirOf(env); |
| 90 | 90 | let scratch: string | undefined; |
| 91 | let unknown: string[] = []; | |
| 91 | 92 | try { |
| 92 | 93 | mkdirSync(dataDir, { recursive: true }); |
| 93 | 94 | sweepStale(dataDir); |
| @@ -101,13 +102,17 @@ export function preflight( | ||
| 101 | 102 | const db = new Database(copy, { create: true, strict: true }); |
| 102 | 103 | try { |
| 103 | 104 | applyPragmas(db); |
| 104 | migrate(db, migrations); | |
| 105 | unknown = migrate(db, migrations).unknown; | |
| 105 | 106 | } finally { |
| 106 | 107 | db.close(); |
| 107 | 108 | } |
| 108 | 109 | return { |
| 109 | 110 | ok: true, |
| 110 | line: `preflight ok: ${migrations.length} migrations apply to a copy of the database`, | |
| 111 | line: | |
| 112 | `preflight ok: ${migrations.length} migrations apply to a copy of the database` + | |
| 113 | (unknown.length > 0 | |
| 114 | ? `; it also has ${unknown.length} this build does not know (${unknown.join(", ")})` | |
| 115 | : ""), | |
| 111 | 116 | }; |
| 112 | 117 | } catch (error) { |
| 113 | 118 | const message = (error instanceof Error ? error.message : String(error)) |
src/main.ts +2 −2
| @@ -73,8 +73,8 @@ if (process.argv[2] === "hook") { | ||
| 73 | 73 | // config parsed, migrations run on a copy of the database. No socket, no |
| 74 | 74 | // timers, and the live database is never opened. |
| 75 | 75 | if (process.argv.includes("--preflight")) { |
| 76 | const { migrations } = await import("./core/schema.ts"); | |
| 77 | const result = preflight(process.env, import.meta.dir, migrations); | |
| 76 | const { migrationFiles } = await import("./core/schema.ts"); | |
| 77 | const result = preflight(process.env, import.meta.dir, migrationFiles); | |
| 78 | 78 | // CLI answer on the stream a person or a script reads. |
| 79 | 79 | (result.ok ? console.log : console.error)(result.line); |
| 80 | 80 | process.exit(result.ok ? 0 : 1); |
src/release/rollback.ts +3 −2
| @@ -4,8 +4,9 @@ | ||
| 4 | 4 | * each take one line: the release is not kept, or its schema is behind the |
| 5 | 5 | * live database's (a down-migration is not something this forge has). |
| 6 | 6 | * A release whose schema is ahead of the live database is fine: it migrates |
| 7 | * forward on boot, and the database's own refusal of a future schema is | |
| 8 | * untouched. | |
| 7 | * forward on boot. A release behind it is refused here, not by the | |
| 8 | * database: a build boots on migrations it does not know and only names | |
| 9 | * them (#783). | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { Database } from "bun:sqlite"; |
test/kernel.test.ts +14 −3
| @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync, rmSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { Database } from "bun:sqlite"; | |
| 5 | 6 | import { openDb } from "../src/kernel/db.ts"; |
| 7 | import { migrate } from "../src/kernel/migrate.ts"; | |
| 6 | 8 | import { createUser, verifyPasswordThrottled } from "../src/core/users.ts"; |
| 7 | 9 | import { migrations } from "../src/core/schema.ts"; |
| 8 | 10 | import { CHEAP, cheapened, realHash } from "./helpers/cheap-hash.ts"; |
| @@ -139,13 +141,22 @@ describe("db", () => { | ||
| 139 | 141 | ).toThrow(); |
| 140 | 142 | }); |
| 141 | 143 | |
| 142 | test("future schema refuses to run", () => { | |
| 144 | test("a schema ahead of this build boots and names what it does not know (#783)", () => { | |
| 143 | 145 | const dir = mkdtempSync(join(tmpdir(), "forge-kernel-")); |
| 144 | 146 | try { |
| 145 | 147 | const path = join(dir, "forge.db"); |
| 146 | const db = openDb(path, ["CREATE TABLE a (x INTEGER)"]); | |
| 148 | const a = { name: "20261015110000-a", sql: "CREATE TABLE a (x INTEGER)" }; | |
| 149 | openDb(path, [a]).close(); | |
| 150 | const db = openDb(path, []); | |
| 151 | expect(migrate(db, []).unknown).toEqual(["20261015110000-a"]); | |
| 147 | 152 | db.close(); |
| 148 | expect(() => openDb(path, [])).toThrow(ForgeError); | |
| 153 | // Data from before the tracking table has only a count, which names | |
| 154 | // nothing: ahead of the build, it is still refused. | |
| 155 | const legacy = join(dir, "legacy.db"); | |
| 156 | const old = new Database(legacy, { create: true }); | |
| 157 | old.exec("CREATE TABLE a (x INTEGER); PRAGMA user_version = 1"); | |
| 158 | old.close(); | |
| 159 | expect(() => openDb(legacy, [])).toThrow(ForgeError); | |
| 149 | 160 | } finally { |
| 150 | 161 | rmSync(dir, { recursive: true, force: true }); |
| 151 | 162 | } |
test/schema-migrations.test.ts +126 −5
| @@ -24,9 +24,18 @@ import { Database } from "bun:sqlite"; | ||
| 24 | 24 | import { openDb } from "../src/kernel/db.ts"; |
| 25 | 25 | import { ForgeError } from "../src/kernel/error.ts"; |
| 26 | 26 | import { sha256 } from "../src/kernel/hash.ts"; |
| 27 | import { migrationId, readMigrations, statements } from "../src/kernel/migrate.ts"; | |
| 27 | import { | |
| 28 | type Migration, | |
| 29 | migrate, | |
| 30 | migrationId, | |
| 31 | pinBreaks, | |
| 32 | pinned, | |
| 33 | readMigrations, | |
| 34 | statements, | |
| 35 | } from "../src/kernel/migrate.ts"; | |
| 28 | 36 | import { migrationFiles, migrations, NUMBERED } from "../src/core/schema.ts"; |
| 29 | 37 | import { failureReason } from "../src/apps/process.ts"; |
| 38 | import pins from "../src/core/migration-pins.json" with { type: "json" }; | |
| 30 | 39 | |
| 31 | 40 | const root = mkdtempSync(join(tmpdir(), "forge-schema-")); |
| 32 | 41 | afterAll(() => rmSync(root, { recursive: true, force: true })); |
| @@ -35,7 +44,7 @@ let n = 0; | ||
| 35 | 44 | const fresh = (): string => join(root, `db-${++n}`); |
| 36 | 45 | |
| 37 | 46 | /** Production's data, closed and checkpointed, ready to be copied. */ |
| 38 | function production(list: readonly string[]): string { | |
| 47 | function production(list: readonly Migration[]): string { | |
| 39 | 48 | const path = fresh(); |
| 40 | 49 | const db = openDb(path, list); |
| 41 | 50 | db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); |
| @@ -50,7 +59,7 @@ function copyOf(path: string): string { | ||
| 50 | 59 | return target; |
| 51 | 60 | } |
| 52 | 61 | |
| 53 | function boot(path: string, list: readonly string[]): void { | |
| 62 | function boot(path: string, list: readonly Migration[]): void { | |
| 54 | 63 | openDb(path, list).close(); |
| 55 | 64 | } |
| 56 | 65 | |
| @@ -155,12 +164,78 @@ describe("a preview booting on copied data", () => { | ||
| 155 | 164 | db.close(); |
| 156 | 165 | }); |
| 157 | 166 | |
| 158 | test("data ahead of this build is still refused", () => { | |
| 167 | test("data with one more migration than its code boots and names it (#783)", () => { | |
| 168 | const a = { name: "20261015110000-a", sql: "CREATE TABLE a (x INTEGER)" }; | |
| 169 | const b = { name: "20261015120000-b", sql: "CREATE TABLE b (y INTEGER)" }; | |
| 170 | const path = copyOf(production([a, b])); | |
| 171 | const db = new Database(path, { strict: true }); | |
| 172 | expect(migrate(db, [a])).toEqual({ applied: [], unknown: ["20261015120000-b"] }); | |
| 173 | // What it does not know is left as it is, and so is its record. | |
| 174 | expect(has(db, "b")).toBe(true); | |
| 175 | expect(migrate(db, [a]).unknown).toEqual(["20261015120000-b"]); | |
| 176 | db.close(); | |
| 177 | // openDb boots on it too: a warning, not a refusal. | |
| 178 | boot(path, [a]); | |
| 179 | }); | |
| 180 | ||
| 181 | test("data with one fewer migration than its code takes the missing one", () => { | |
| 182 | const a = { name: "20261015110000-a", sql: "CREATE TABLE a (x INTEGER)" }; | |
| 183 | const b = { name: "20261015120000-b", sql: "CREATE TABLE b (y INTEGER)" }; | |
| 184 | const path = copyOf(production([a])); | |
| 185 | const db = new Database(path, { strict: true }); | |
| 186 | expect(migrate(db, [a, b])).toEqual({ applied: ["20261015120000-b"], unknown: [] }); | |
| 187 | expect(has(db, "b")).toBe(true); | |
| 188 | db.close(); | |
| 189 | }); | |
| 190 | ||
| 191 | test("a migration recorded without a name is named by its position and id", () => { | |
| 159 | 192 | const path = production(["CREATE TABLE a (x INTEGER)", "CREATE TABLE b (y INTEGER)"]); |
| 193 | const db = new Database(path, { strict: true }); | |
| 194 | const [unknown] = migrate(db, ["CREATE TABLE a (x INTEGER)"]).unknown; | |
| 195 | expect(unknown).toBe(`#2 (${migrationId("CREATE TABLE b (y INTEGER)").slice(0, 12)})`); | |
| 196 | db.close(); | |
| 197 | }); | |
| 198 | ||
| 199 | test("data from before the tracking table that is ahead is still refused: a count names nothing", () => { | |
| 200 | const path = fresh(); | |
| 201 | const old = new Database(path, { create: true, strict: true }); | |
| 202 | old.exec("CREATE TABLE a (x INTEGER); CREATE TABLE b (y INTEGER); PRAGMA user_version = 2"); | |
| 203 | old.close(); | |
| 160 | 204 | expect(() => openDb(path, ["CREATE TABLE a (x INTEGER)"])).toThrow(ForgeError); |
| 161 | 205 | }); |
| 162 | 206 | }); |
| 163 | 207 | |
| 208 | describe("the database records migrations by name (#783)", () => { | |
| 209 | const names = (path: string): (string | null)[] => { | |
| 210 | const db = new Database(path, { readonly: true, strict: true }); | |
| 211 | try { | |
| 212 | return db | |
| 213 | .query<{ name: string | null }, []>("SELECT name FROM schema_migration ORDER BY position") | |
| 214 | .all() | |
| 215 | .map((row) => row.name); | |
| 216 | } finally { | |
| 217 | db.close(); | |
| 218 | } | |
| 219 | }; | |
| 220 | ||
| 221 | test("a fresh database applies every migration in order and records each one's name", () => { | |
| 222 | expect(names(production(migrationFiles))).toEqual(migrationFiles.map((m) => m.name)); | |
| 223 | }); | |
| 224 | ||
| 225 | test("a database recorded before names were kept has them written in, and nothing else changes", () => { | |
| 226 | const path = copyOf(production(migrations)); | |
| 227 | // As a build from before #783 left it: no name column at all. | |
| 228 | const old = new Database(path, { strict: true }); | |
| 229 | old.exec("ALTER TABLE schema_migration DROP COLUMN name"); | |
| 230 | const before = old.query("SELECT id, position, applied_at FROM schema_migration ORDER BY id").all(); | |
| 231 | old.close(); | |
| 232 | const db = openDb(path, migrationFiles); | |
| 233 | expect(db.query("SELECT id, position, applied_at FROM schema_migration ORDER BY id").all()).toEqual(before); | |
| 234 | db.close(); | |
| 235 | expect(names(path)).toEqual(migrationFiles.map((m) => m.name)); | |
| 236 | }); | |
| 237 | }); | |
| 238 | ||
| 164 | 239 | describe("a migration that fails", () => { |
| 165 | 240 | test("names the statement that failed, in one line, and applies nothing", () => { |
| 166 | 241 | const path = production(["CREATE TABLE a (x INTEGER)"]); |
| @@ -238,7 +313,7 @@ describe("a migration is a file named by a sortable id (#1837)", () => { | ||
| 238 | 313 | return dir; |
| 239 | 314 | } |
| 240 | 315 | |
| 241 | const listIn = (dir: string): string[] => readMigrations(dir).map((m) => m.sql); | |
| 316 | const listIn = readMigrations; | |
| 242 | 317 | |
| 243 | 318 | /** Everything a schema is but the record of how it got there. */ |
| 244 | 319 | function shape(path: string): unknown[] { |
| @@ -285,6 +360,17 @@ describe("a migration is a file named by a sortable id (#1837)", () => { | ||
| 285 | 360 | expect(recorded(aFirst)).toEqual(recorded(together)); |
| 286 | 361 | expect(recorded(bFirst)).toEqual(recorded(together)); |
| 287 | 362 | expect(recorded(together).length).toBe(migrations.length + 2); |
| 363 | // Each database names both, whichever it took first. | |
| 364 | for (const path of [aFirst, bFirst, together]) { | |
| 365 | const db = new Database(path, { readonly: true, strict: true }); | |
| 366 | const names = db | |
| 367 | .query<{ name: string }, []>("SELECT name FROM schema_migration WHERE name LIKE '2026101511%' OR name LIKE '2026101512%' ORDER BY name") | |
| 368 | .all() | |
| 369 | .map((row) => row.name); | |
| 370 | db.close(); | |
| 371 | expect(names).toContain("20261015110000-user-motto"); | |
| 372 | expect(names).toContain("20261015120000-deploy-note"); | |
| 373 | } | |
| 288 | 374 | const db = openDb(together, both); |
| 289 | 375 | expect(has(db, "deploy_note")).toBe(true); |
| 290 | 376 | db.close(); |
| @@ -333,3 +419,38 @@ describe("a migration is a file named by a sortable id (#1837)", () => { | ||
| 333 | 419 | } |
| 334 | 420 | }); |
| 335 | 421 | }); |
| 422 | ||
| 423 | describe("a migration on main is never edited (#783)", () => { | |
| 424 | test("every pinned migration still says what it said when it was pinned", () => { | |
| 425 | expect(pinBreaks(migrationFiles, pins)).toEqual([]); | |
| 426 | }); | |
| 427 | ||
| 428 | test("every numbered migration is pinned", () => { | |
| 429 | for (const m of migrationFiles.slice(0, NUMBERED)) expect(Object.hasOwn(pins, m.name)).toBe(true); | |
| 430 | }); | |
| 431 | ||
| 432 | test("an edit after its pin is named, a removal too; re-indenting is not an edit", () => { | |
| 433 | const files = [ | |
| 434 | { name: "20261015110000-a", sql: "CREATE TABLE a (x INTEGER)" }, | |
| 435 | { name: "20261015120000-b", sql: "CREATE TABLE b (y INTEGER)" }, | |
| 436 | ]; | |
| 437 | const pin = pinned(files, {}); | |
| 438 | expect(pinBreaks(files, pin)).toEqual([]); | |
| 439 | expect(pinBreaks([files[0]!, { name: "20261015120000-b", sql: "CREATE TABLE b (y TEXT)" }], pin)).toEqual([ | |
| 440 | "20261015120000-b was edited after it was pinned — add a new migration instead", | |
| 441 | ]); | |
| 442 | expect(pinBreaks([files[1]!], pin)).toEqual(["20261015110000-a was pinned and is gone"]); | |
| 443 | expect(pinBreaks([{ name: "20261015110000-a", sql: "\n CREATE TABLE a\n (x INTEGER)\n" }, files[1]!], pin)).toEqual([]); | |
| 444 | }); | |
| 445 | ||
| 446 | test("pinning adds what is new and never rewrites a pin", () => { | |
| 447 | const a = { name: "20261015110000-a", sql: "CREATE TABLE a (x INTEGER)" }; | |
| 448 | const b = { name: "20261015120000-b", sql: "CREATE TABLE b (y INTEGER)" }; | |
| 449 | const pin = pinned([b], {}); | |
| 450 | const edited = { ...b, sql: "CREATE TABLE b (y TEXT)" }; | |
| 451 | const next = pinned([a, edited], pin); | |
| 452 | expect(Object.keys(next)).toEqual([a.name, b.name]); | |
| 453 | expect(next[b.name]).toBe(pin[b.name]!); | |
| 454 | expect(next[a.name]).toBe(migrationId(a.sql)); | |
| 455 | }); | |
| 456 | }); |
new above
offline