Skip to content

Commit 30058cf

Browse files
feat(database): reserve an ordinal band for package migrations, and defend it (#2430)
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>
1 parent d2cbc37 commit 30058cf

4 files changed

Lines changed: 111 additions & 5 deletions

File tree

storage/framework/core/database/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export * from './migration-ledger'
152152
// Model resolution for the generator: userland + framework defaults, flattened
153153
// because bun-query-builder's loadModels reads only the top level of a dir.
154154
export * from './model-sources'
155+
export * from './package-migrations'
155156
export * from './package-models'
156157
export * from './shadowed-models'
157158

storage/framework/core/database/src/migrations.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import type { UnsafeRowsResult } from './utils'
99
import type { Result } from '@stacksjs/error-handling'
1010
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
11+
import { isPackageMigration } from './package-migrations'
1112
import { dirname, isAbsolute, join, resolve } from 'node:path'
1213
import { log as _log } from '@stacksjs/logging'
1314

@@ -711,7 +712,11 @@ export function preprocessSqliteMigrations(): void {
711712
if (createTableMatch && createTableMatch[1]) {
712713
const tableName = createTableMatch[1]
713714
const earliest = createTableEarliest.get(tableName)
714-
if (earliest && earliest !== file) {
715+
// Never the package's copy. Its ordinal is high, so it loses an
716+
// earliest-filename-wins comparison against anything the generator
717+
// emitted for the same table - and deleting it would take the package's
718+
// own schema with it.
719+
if (earliest && earliest !== file && !isPackageMigration(file)) {
715720
deleteMigration(file, filePath, `duplicate create-table for "${tableName}" (kept ${earliest})`)
716721
continue
717722
}
@@ -3153,9 +3158,15 @@ export async function regenerateMigrationCorpus(options: {
31533158
options.replaceUnmarked || options.onlyExistingTables ? [] : historicallyRootedTables(dir, existing),
31543159
)
31553160
const outOfScope = new Set(migrationsOutsideCorpus(dir, existing, createdTablesOf(statements)))
3156-
const deletable = options.replaceUnmarked || options.onlyExistingTables
3161+
// `replaceUnmarked` and `onlyExistingTables` delete every file regardless
3162+
// of marker. A package's migrations are not this corpus's to rewrite, and
3163+
// the package would not put them back: it ships them, it does not generate
3164+
// them. Excluded from the deletable set rather than rescued afterwards, so
3165+
// no later filter can put them back in.
3166+
const deletable = (options.replaceUnmarked || options.onlyExistingTables
31573167
? existing
3158-
: existing.filter(file => isGeneratedMigration(dir, file))
3168+
: existing.filter(file => isGeneratedMigration(dir, file)))
3169+
.filter(file => !isPackageMigration(file))
31593170
const removed = deletable.filter(file => {
31603171
return !outOfScope.has(file) && !migrationTouchesRootedTable(dir, file, rootedTables)
31613172
})
@@ -3205,12 +3216,17 @@ export async function regenerateMigrationCorpus(options: {
32053216
// such as SQLite table rebuilds. Skip their occupied ordinals instead of
32063217
// renumbering them, since authored backfills may depend on those filenames'
32073218
// relative position.
3219+
// Package files are unmarked and preserved, so both of these would other-
3220+
// wise take their maximum from the reserved band and number the whole
3221+
// regenerated application corpus above them.
32083222
const historicalBoundary = existing
3209-
.filter(file => !isGeneratedMigration(dir, file))
3223+
.filter(file => !isGeneratedMigration(dir, file) && !isPackageMigration(file))
32103224
.reduce((max, file) => Math.max(max, migrationOrdinal(file)), 0)
32113225
const startAt = rootedTables.size > 0
32123226
? historicalBoundary + 1
3213-
: preserved.reduce((max, file) => Math.max(max, migrationOrdinal(file)), 0) + 1
3227+
: preserved
3228+
.filter(file => !isPackageMigration(file))
3229+
.reduce((max, file) => Math.max(max, migrationOrdinal(file)), 0) + 1
32143230
const reservedOrdinals = new Set(preserved.map(migrationOrdinal))
32153231
const ordinals = allocateMigrationOrdinals(writableGroups.length, startAt, reservedOrdinals)
32163232

@@ -3559,6 +3575,11 @@ function nextMigrationNumber(migrationsDir: string): number {
35593575
let max = 0
35603576
try {
35613577
for (const f of readdirSync(migrationsDir)) {
3578+
// A package's migrations sit in a reserved high band. Counting them here
3579+
// would push the application's next ordinal into that band, and every
3580+
// application migration after it would then run AFTER the package's.
3581+
if (isPackageMigration(f))
3582+
continue
35623583
const m = f.match(/^(\d+)-/)
35633584
if (m?.[1]) max = Math.max(max, Number.parseInt(m[1], 10))
35643585
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* The ordinal band reserved for migrations a discovered package brings.
3+
*
4+
* Migrations run in the order `readdirSync(dir).sort()` returns, so the leading
5+
* ordinal in a filename IS the run order. A package's tables carry foreign keys
6+
* into the application's (`user_id`, `team_id`) and never the reverse, because
7+
* the application predates whatever it installed. A `REFERENCES "users"` on a
8+
* table created before `users` fails on Postgres and MySQL while SQLite
9+
* tolerates it, so getting this order wrong is green locally and red on deploy.
10+
*
11+
* A reserved high band rather than `max + 1`, because three separate ordinal
12+
* computations would otherwise invert the order: `migrate:regenerate`
13+
* renumbers the application corpus from 1 while preserving unmarked files,
14+
* `historicalBoundary` takes the maximum ordinal among unmarked files, and
15+
* `nextMigrationNumber` maxes over every file on disk. Each of them would
16+
* either number an application migration above a package's or drag the whole
17+
* application corpus up into the band.
18+
*
19+
* Still ten digits, so lexicographic order and numeric order agree.
20+
*/
21+
export const PACKAGE_MIGRATION_BAND = 9_000_000_000
22+
23+
/**
24+
* Whether a migration filename belongs to a discovered package.
25+
*
26+
* Read from the ordinal rather than from the file's contents: every guard that
27+
* needs this answer is deciding whether to renumber or delete the file, and
28+
* those run in loops over a directory listing where opening each file would be
29+
* the expensive part.
30+
*/
31+
export function isPackageMigration(file: string): boolean {
32+
const ordinal = /^(\d+)-/.exec(file)?.[1]
33+
return ordinal !== undefined && Number.parseInt(ordinal, 10) >= PACKAGE_MIGRATION_BAND
34+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* The ordinal band reserved for a discovered package's migrations.
3+
*
4+
* Nothing stages package migrations yet. These guards land first on purpose:
5+
* every one of them is a place that would renumber or delete a staged file the
6+
* moment one existed, and installing them afterwards would mean shipping a
7+
* window in which a package's schema could be deleted by the application's own
8+
* generator.
9+
*
10+
* All four are no-ops on a corpus with no band files, which is every corpus
11+
* today, so this is inert until the staging change arrives.
12+
*/
13+
import { describe, expect, test } from 'bun:test'
14+
import { isPackageMigration, PACKAGE_MIGRATION_BAND } from '../src/package-migrations'
15+
16+
describe('the reserved package migration band', () => {
17+
test('is ten digits, so lexicographic order still equals numeric order', () => {
18+
// Run order is `readdirSync().sort()` on the basename. An eleven-digit
19+
// ordinal would sort before a ten-digit one and the band would invert.
20+
expect(String(PACKAGE_MIGRATION_BAND)).toHaveLength(10)
21+
expect(String(PACKAGE_MIGRATION_BAND).padStart(10, '0')).toBe('9000000000')
22+
})
23+
24+
test('sorts after every plausible application ordinal', () => {
25+
const app = String(999_999).padStart(10, '0')
26+
const pkg = String(PACKAGE_MIGRATION_BAND + 1).padStart(10, '0')
27+
28+
expect([`${pkg}-loghq.sql`, `${app}-create-users-table.sql`].sort())
29+
.toEqual([`${app}-create-users-table.sql`, `${pkg}-loghq.sql`])
30+
})
31+
32+
test('recognises a file in the band', () => {
33+
expect(isPackageMigration('9000000001-loghq__create-log-entries-table.sql')).toBe(true)
34+
expect(isPackageMigration('9999999999-bughq__create-issues-table.sql')).toBe(true)
35+
})
36+
37+
test('leaves every application migration alone', () => {
38+
expect(isPackageMigration('0000000001-create-users-table.sql')).toBe(false)
39+
expect(isPackageMigration('0000000133-add-orthomosaic-to-missions.sql')).toBe(false)
40+
expect(isPackageMigration('8999999999-still-the-application.sql')).toBe(false)
41+
})
42+
43+
test('is not fooled by a filename carrying no ordinal', () => {
44+
// The corpus has always been ordinal-prefixed, but a hand-written file or
45+
// a stray `.sql` must not be mistaken for a package's and made undeletable.
46+
expect(isPackageMigration('create-users-table.sql')).toBe(false)
47+
expect(isPackageMigration('README.md')).toBe(false)
48+
expect(isPackageMigration('')).toBe(false)
49+
})
50+
})

0 commit comments

Comments
 (0)