Skip to content

feat(database): reserve an ordinal band for package migrations, and defend it - #2430

Merged
glennmichael123 merged 1 commit into
mainfrom
feat/package-migration-band
Sep 4, 2026
Merged

feat(database): reserve an ordinal band for package migrations, and defend it#2430
glennmichael123 merged 1 commit into
mainfrom
feat/package-migration-band

Conversation

@glennmichael123

Copy link
Copy Markdown
Member

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 + 1

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 (user_id, team_id) and never the reverse, because the app predates whatever it installed. A REFERENCES "users" on a table created before users fails 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:

what it would do
nextMigrationNumber maxes over every file, so one staged file drags every future app migration into the band behind it
historicalBoundary takes the max among unmarked files, and a staged file is unmarked, so migrate:regenerate numbers the regenerated app corpus above the package's
startAt falls back to the max among preserved files, same result by another route

And two that delete rather than renumber

  • preprocessSqliteMigrations prunes duplicate create-tables by keeping the earliest filename and deleteMigration()-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.
  • regenerateMigrationCorpus sets deletable = existing under replaceUnmarked or onlyExistingTables, 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

  • 5 new tests
  • core/database: 880 pass, 1 fail; that failure is on clean main too
  • root suite: 402 pass, 0 fail
  • framework typecheck: 4 errors, all pre-existing @stacksjs/tlsx
  • ./buddy lint clean apart from the pre-existing untracked storage/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

…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>
@github-actions github-actions Bot added database @stacksjs/datbase storage @stacksjs/storage core labels Sep 4, 2026
@glennmichael123
glennmichael123 merged commit 30058cf into main Sep 4, 2026
10 of 11 checks passed
@glennmichael123
glennmichael123 deleted the feat/package-migration-band branch September 4, 2026 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core database @stacksjs/datbase storage @stacksjs/storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant