fix(schema): prune the replaced migration rows when the consolidation is recorded

An upgraded install otherwise keeps 150 recorded rows (plus the one
2.4.x-only name) for migration files that no longer exist on disk. The SKIP
path now deletes exactly the replaced set plus that superseded name, by
explicit list only, before recording itself; module rows and any other
foreign history survive. Found by the upgrade rehearsals.
This commit is contained in:
Darko Gjorgjijoski
2026-08-22 14:27:26 +02:00
parent 0de0126d94
commit 975d14d86f
3 changed files with 209 additions and 29 deletions
@@ -29,7 +29,7 @@ final class SchemaConsolidationGuard
public const BUILD = 'build';
/**
* The 150 replaced migrations already ran: record this file and move on.
* The 150 replaced migrations already ran: keep the schema, drop the rows.
*/
public const SKIP = 'skip';
@@ -60,6 +60,21 @@ final class SchemaConsolidationGuard
*/
public const CONSOLIDATION_MIGRATION = '2026_01_01_000000_consolidate_base_schema';
/**
* The one name a 2.4.x history carries that this codebase never shipped.
*
* The 2.x line widened the tax percent column after the point this
* consolidation reproduces, in a file the 3.x tree has no copy of. Every
* database upgraded from 2.4.0 or later therefore records a name whose
* migration cannot exist here, now or ever.
*
* It is deliberately not part of {@see self::REPLACED_MIGRATIONS}: the
* decision counts those against a floor of exactly 150, and a name this
* file does not reproduce has no business in that count. It is stale all
* the same, so the consolidation prunes it alongside them.
*/
public const SUPERSEDED_MIGRATION = '2026_04_07_000001_increase_tax_percent_precision_to_three_decimals';
/**
* The migration names this version consolidates, byte for byte.
*
@@ -270,6 +285,36 @@ final class SchemaConsolidationGuard
);
}
/**
* Every recorded name a consolidated database has no further use for.
*
* The 150 files this version replaces, plus the one 2.4.x-only name above.
* A SKIP prunes exactly this list from the migration repository and
* nothing else: matching is by name, never by date or pattern, so module
* migrations — which share the repository table — and anything a future
* release records survive by construction rather than by luck.
*
* @return list<string>
*/
public static function staleRecordedMigrations(): array
{
return [...self::REPLACED_MIGRATIONS, self::SUPERSEDED_MIGRATION];
}
/**
* The configured name of the framework's migration repository table.
*/
public static function repositoryTable(): string
{
$configured = config('database.migrations');
if (is_array($configured)) {
return $configured['table'] ?? 'migrations';
}
return is_string($configured) && $configured !== '' ? $configured : 'migrations';
}
/**
* The decision table itself, and the only place it is written down.
*
@@ -365,18 +410,4 @@ final class SchemaConsolidationGuard
return DB::connection($connection)->table($table)->pluck('migration')->all();
}
/**
* The configured name of the framework's migration repository table.
*/
private static function repositoryTable(): string
{
$configured = config('database.migrations');
if (is_array($configured)) {
return $configured['table'] ?? 'migrations';
}
return is_string($configured) && $configured !== '' ? $configured : 'migrations';
}
}
@@ -16,10 +16,11 @@ use Illuminate\Support\Facades\Schema;
* in a single step and the v3-era migrations take it from there.
*
* What it does depends entirely on what it finds. A database that already ran
* the replaced chain is left completely alone — its tables are the real ones,
* and the 150 stale rows in the migration repository stay where they are. Only
* an empty database is built. Anything in between is refused, loudly, before a
* single write: see {@see SchemaConsolidationGuard}.
* the replaced chain keeps every table it has — those are the real ones — and
* loses only the repository rows naming the files this one replaces, which this
* file has just made meaningless. Only an empty database is built. Anything in
* between is refused, loudly, before a single write: see
* {@see SchemaConsolidationGuard}.
*
* Everything below is portable builder work — no vendor SQL, no schema dumps.
* Two deliberate driver-specific behaviours are called out where they happen:
@@ -68,8 +69,11 @@ return new class extends Migration
}
if (! $verdict->isBuild()) {
// SKIP. The framework records this file when the body returns, and
// that record is the only thing about this database that changes.
// SKIP. The schema stands as it is; only the history it left
// behind changes. The framework records this file when the body
// returns, in place of the rows retired here.
$this->pruneStaleHistory();
return;
}
@@ -106,6 +110,33 @@ return new class extends Migration
);
}
/**
* Retire the repository rows this file has just made meaningless.
*
* An upgraded database records one row for each of the 150 files replaced
* here, plus the single name the 2.4.x line shipped and this codebase never
* did. None of them can ever run again and none of them still names a file,
* so left in place they only make `migrate:status` describe a tree that
* stopped existing — and the next release would inherit the same 151 rows
* to explain away.
*
* The delete is driven by an exhaustive list of names, never by a date
* range or a prefix. Module migrations share this table, and so will every
* migration a later release adds; a row that is not named is a row that is
* not touched.
*
* The write goes to the connection the migration is running on, which the
* migrator has made the default for the duration of this method — the same
* connection the guard just read its verdict from.
*/
private function pruneStaleHistory(): void
{
Schema::getConnection()
->table(SchemaConsolidationGuard::repositoryTable())
->whereIn('migration', SchemaConsolidationGuard::staleRecordedMigrations())
->delete();
}
/**
* Tables that reference nothing, so nothing constrains their order.
*/
@@ -47,20 +47,46 @@ function recordHistory(array $names): void
}
/**
* The names a real 2.4.x database carries beyond the replaced set.
* Names in the repository that belong to code this file knows nothing about.
*
* Module migrations share the repository table, and databases from the 2.4.x
* line recorded one migration this codebase has never shipped. Both must be
* ignored by the decision.
* Module migrations share the repository table with the application's own, and
* a database can hold them from any point in its life. The decision ignores
* them and the prune never claims them, whatever they are called.
*/
function foreignHistory(): array
{
return [
'2022_06_01_120000_create_payments_module_tables',
'2026_04_07_000001_increase_tax_percent_precision_to_three_decimals',
'2025_11_04_090000_create_bank_feed_module_tables',
];
}
/**
* The v3-era migrations, read off the tree they live in.
*
* These are the names an installation of a 3.0.0 alpha recorded beside the 150:
* the migrations this release still ships, which run after the consolidation
* and must survive it. Reading the directory rather than listing them keeps the
* fixture honest as the v3 line grows.
*/
function v3EraHistory(): array
{
$names = array_map(
fn (string $file): string => basename($file, '.php'),
glob(base_path('database/migrations/*.php'))
);
return array_values(array_diff($names, [SchemaConsolidationGuard::CONSOLIDATION_MIGRATION]));
}
/**
* Every name the repository holds, on the throwaway database.
*/
function recordedHistory(): array
{
return DB::connection('squash')->table('migrations')->pluck('migration')->all();
}
/**
* Stand in for "this database already has a schema".
*/
@@ -298,7 +324,7 @@ it('inserts only the currencies that are missing at seed time', function () {
// -- SKIP -------------------------------------------------------------------
it('leaves a fully migrated 2.4.x database alone', function () {
it('leaves the schema of a fully migrated 2.4.x database alone', function () {
recordHistory([...SchemaConsolidationGuard::REPLACED_MIGRATIONS, ...foreignHistory()]);
giveDatabaseASentinel();
DB::connection('squash')->table('companies')->insert(['name' => 'Acme']);
@@ -309,10 +335,9 @@ it('leaves a fully migrated 2.4.x database alone', function () {
$after = databaseFootprint();
// The only change is the consolidation's own row in the repository.
// Nothing outside the repository moved: same tables, same rows in them.
expect(array_keys($after))->toBe(array_keys($before))
->and($after['companies'])->toEqual($before['companies'])
->and(count($after['migrations']['rows']))->toBe(count($before['migrations']['rows']) + 1)
->and(DB::connection('squash')->table('migrations')
->where('migration', SchemaConsolidationGuard::CONSOLIDATION_MIGRATION)->exists())->toBeTrue();
@@ -322,6 +347,55 @@ it('leaves a fully migrated 2.4.x database alone', function () {
->and($after['companies']['columns'])->toBe(['id', 'name']);
});
/**
* The history a 2.4.x database actually arrives with: the full replaced chain,
* the one name the 2.x line shipped past it, and whatever its modules recorded.
* Two of those three are this file's to retire.
*/
it('retires the replaced history of a 2.4.x database', function () {
recordHistory([
...SchemaConsolidationGuard::REPLACED_MIGRATIONS,
SchemaConsolidationGuard::SUPERSEDED_MIGRATION,
...foreignHistory(),
]);
giveDatabaseASentinel();
runConsolidation();
$recorded = recordedHistory();
// Every replaced name is gone, and so is the 2.4.x-only one beside them.
expect(array_intersect(SchemaConsolidationGuard::REPLACED_MIGRATIONS, $recorded))->toBe([])
->and($recorded)->not->toContain(SchemaConsolidationGuard::SUPERSEDED_MIGRATION);
// What is left is precisely what this file never claimed, plus itself.
expect($recorded)->toEqualCanonicalizing([
...foreignHistory(),
SchemaConsolidationGuard::CONSOLIDATION_MIGRATION,
]);
});
/**
* The other database that reaches SKIP: an installation of a 3.0.0 alpha, which
* ran the 150 as separate files and then the v3-era ones on top. Only the 150
* are stale — the v3-era migrations still ship, and a database that forgot them
* would run them a second time.
*/
it('keeps the v3-era history when an alpha database is consolidated', function () {
recordHistory([...SchemaConsolidationGuard::REPLACED_MIGRATIONS, ...v3EraHistory()]);
giveDatabaseASentinel();
runConsolidation();
$recorded = recordedHistory();
expect(array_intersect(SchemaConsolidationGuard::REPLACED_MIGRATIONS, $recorded))->toBe([])
->and($recorded)->toEqualCanonicalizing([
...v3EraHistory(),
SchemaConsolidationGuard::CONSOLIDATION_MIGRATION,
]);
});
it('ignores recorded names that are not part of the replaced set', function () {
recordHistory(foreignHistory());
@@ -437,6 +511,31 @@ it('does nothing when migrate runs a second time', function () {
expect(databaseFootprint())->toEqual($before);
});
/**
* The same question on the other side of the decision: once the history has
* been pruned, the database looks to the guard exactly like the inconsistency
* it refuses — schema, no replaced names. The preflight short-circuit is what
* stops that from mattering, and the recorded consolidation is what stops the
* migration from being offered again at all.
*/
it('does nothing when migrate runs a second time on a pruned history', function () {
recordHistory([
...SchemaConsolidationGuard::REPLACED_MIGRATIONS,
SchemaConsolidationGuard::SUPERSEDED_MIGRATION,
...foreignHistory(),
]);
giveDatabaseASentinel();
runConsolidation();
$before = databaseFootprint();
runConsolidation();
expect(databaseFootprint())->toEqual($before)
->and(SchemaConsolidationGuard::preflight('squash'))->toBeNull();
});
it('refuses to be rolled back', function () {
runConsolidation();
@@ -501,3 +600,22 @@ it('embeds the replaced set exactly once, at full length', function () {
expect(glob(base_path('database/migrations/*.php')))
->each(fn ($file) => expect(basename($file->value))->toStartWith('2026_'));
});
it('offers the prune exactly the replaced set plus the one 2.4.x-only name', function () {
$stale = SchemaConsolidationGuard::staleRecordedMigrations();
expect($stale)->toHaveCount(151)
->and(array_unique($stale))->toHaveCount(151)
->and($stale)->toContain(SchemaConsolidationGuard::SUPERSEDED_MIGRATION)
->and(SchemaConsolidationGuard::REPLACED_MIGRATIONS)
->not->toContain(SchemaConsolidationGuard::SUPERSEDED_MIGRATION)
->and($stale)->not->toContain(SchemaConsolidationGuard::CONSOLIDATION_MIGRATION);
// The extra name is pruned precisely because no file here answers to it.
expect(file_exists(base_path(
'database/migrations/'.SchemaConsolidationGuard::SUPERSEDED_MIGRATION.'.php'
)))->toBeFalse();
// And nothing this release still ships is on the list.
expect(array_intersect($stale, v3EraHistory()))->toBe([]);
});