feat(database): reserve an ordinal band for package migrations, and defend it - #2430
Merged
Conversation
…efend it Guard rails ahead of the change that needs them. Nothing stages a package's migrations yet; these are the four places that would renumber or delete one the moment it existed, and installing them afterwards would ship a window in which an application's own generator could delete a package's schema. Migrations run in `readdirSync().sort()` order, so the leading ordinal IS the run order. A package's tables carry foreign keys into the application's and never the reverse, so a package's files must sort last. A reserved high band rather than `max + 1`, because three ordinal computations would otherwise invert that: `nextMigrationNumber` maxes over every file, so one staged file would drag every future application migration into the band behind it. `historicalBoundary` takes the maximum among unmarked files, and a staged file is unmarked, so `migrate:regenerate` would number the regenerated application corpus above the package's. `startAt` falls back to the maximum among preserved files, with the same result by a different route. The fourth is a delete rather than a renumber. `preprocessSqliteMigrations` prunes duplicate create-table files by keeping the earliest filename, and a package's ordinal is high by construction, so it loses that comparison against anything the generator emitted for the same table. `regenerateMigrationCorpus` is the other one: under `replaceUnmarked` or `onlyExistingTables` it deletes every file regardless of marker. A package's migrations are not this corpus's to rewrite, and the package would not put them back, since it ships them rather than generating them. The band is ten digits so lexicographic and numeric order still agree, which is the property the whole scheme rests on and which a test pins. All four guards are no-ops on a corpus with no band files, which is every corpus today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Guard rails ahead of the change that needs them. Nothing stages package migrations yet. These are the four places that would renumber or delete a staged file the moment one existed.
Landing them first is deliberate: installing them afterwards means shipping a window in which an application's own generator can delete a package's schema.
Why a reserved band and not
max + 1Migrations run in
readdirSync().sort()order, so the leading ordinal is the run order. A package's tables carry foreign keys into the application's (user_id,team_id) and never the reverse, because the app predates whatever it installed. AREFERENCES "users"on a table created beforeusersfails on Postgres and MySQL while SQLite tolerates it, so getting this wrong is green locally and red on deploy.Three ordinal computations would invert the order under
max + 1:nextMigrationNumberhistoricalBoundarymigrate:regeneratenumbers the regenerated app corpus above the package'sstartAtAnd two that delete rather than renumber
preprocessSqliteMigrationsprunes duplicate create-tables by keeping the earliest filename anddeleteMigration()-ing the rest. A package's ordinal is high by construction, so it loses that comparison against anything the generator emitted for the same table.regenerateMigrationCorpussetsdeletable = existingunderreplaceUnmarkedoronlyExistingTables, ignoring markers entirely. Package files are excluded from the deletable set rather than rescued afterwards, so no later filter can put them back.The band is ten digits so lexicographic and numeric order agree, which is the property the whole scheme rests on. There is a test pinning it, and one pinning that a filename with no ordinal is never mistaken for a package's and made undeletable.
Verification
core/database: 880 pass, 1 fail; that failure is on clean main too@stacksjs/tlsx./buddy lintclean apart from the pre-existing untrackedstorage/framework/libs/entries/All four guards are no-ops on a corpus with no band files, which is every corpus today.
🤖 Generated with Claude Code