diff --git a/app/Platform/Operations/Database/SchemaConsolidationGuard.php b/app/Platform/Operations/Database/SchemaConsolidationGuard.php index 027bc78f..9e5ae92d 100644 --- a/app/Platform/Operations/Database/SchemaConsolidationGuard.php +++ b/app/Platform/Operations/Database/SchemaConsolidationGuard.php @@ -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 + */ + 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'; - } } diff --git a/database/migrations/2026_01_01_000000_consolidate_base_schema.php b/database/migrations/2026_01_01_000000_consolidate_base_schema.php index 83088b42..620f0ee9 100644 --- a/database/migrations/2026_01_01_000000_consolidate_base_schema.php +++ b/database/migrations/2026_01_01_000000_consolidate_base_schema.php @@ -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. */ diff --git a/tests/Feature/Setup/BaseSchemaConsolidationTest.php b/tests/Feature/Setup/BaseSchemaConsolidationTest.php index 8baaf5bc..f9a5c986 100644 --- a/tests/Feature/Setup/BaseSchemaConsolidationTest.php +++ b/tests/Feature/Setup/BaseSchemaConsolidationTest.php @@ -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([]); +});