fix(uuid-migration): non-destructive snapshot + verify, plus cleanup command
The bigint→UUID conversion already preserved data via in-place column
swaps, but operators reasonably wanted stronger guarantees. Added:
- Pre-conversion snapshot per affected table:
CREATE TABLE <name>_bak_bigint_uuid_2026_04_29 LIKE <name>;
INSERT INTO <name>_bak_bigint_uuid_2026_04_29 SELECT * FROM <name>;
Idempotent — a retry after a crash never overwrites the original
snapshot. Snapshots are intentionally not auto-dropped.
- Post-conversion verification:
- row count must match the snapshot exactly per table; mismatch
throws and instructs recovery from the bak table
- FK integrity check: zero orphans in permission_members.permission_id
/ permission_usages.permission_id / role_members.role_id /
roles.parent_id (allowing NULL on the self-FK)
- Early-return if every affected table is already UUID, so reruns
on hosts that already migrated don't even create snapshots.
Plus roles:drop-uuid-migration-backup-tables to clean up the
snapshots once the operator is confident — with --dry-run and
explicit confirmation. Verified end-to-end on a disposable bigint
fixture: 3 rows preserved, 0 orphans, snapshot intact, schema
correctly converted to char(36).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
190c500d86
commit
7e19d4ffbe
|
|
@ -18,27 +18,52 @@ use Illuminate\Support\Str;
|
|||
* `HasUuids`. So every insert blew up with
|
||||
* "Incorrect integer value: '<uuid>' for column 'id'"
|
||||
*
|
||||
* On installs that ran the buggy migrations, the tables exist with
|
||||
* bigint keys. Some hosts have data inside (e.g. roles seeded by app
|
||||
* code that bypassed the model insert path). This migration converts
|
||||
* those tables in place: keeps the data, swaps PK + FK columns to
|
||||
* CHAR(36), and rebuilds all foreign-key constraints.
|
||||
* Conversion is non-destructive:
|
||||
* 1. Each affected table is snapshotted into a sibling
|
||||
* `<table>_bak_bigint_uuid_2026_04_29` with `CREATE TABLE LIKE` +
|
||||
* `INSERT … SELECT *` BEFORE any DDL runs.
|
||||
* 2. PK and FK columns are swapped in place — rows are never deleted,
|
||||
* only their `id` / `*_id` columns are replaced with UUIDs while
|
||||
* every reference is rewritten to point at the new id. No
|
||||
* `DROP TABLE`, no truncation, no row-level mutation other than
|
||||
* the id column rewrite.
|
||||
* 3. After conversion, row counts are compared against the snapshot
|
||||
* and FK integrity is verified (no orphan refs). Any mismatch
|
||||
* throws and the snapshot tables stay around for the operator
|
||||
* to recover from with a manual `INSERT … SELECT` + manual cleanup.
|
||||
*
|
||||
* The snapshot tables are intentionally NOT auto-dropped on success.
|
||||
* After verifying that everything works on the application side, drop
|
||||
* them manually with the `roles:drop-uuid-migration-backup-tables`
|
||||
* command shipped alongside this migration.
|
||||
*
|
||||
* Idempotent. Each phase checks whether the conversion has already
|
||||
* been applied (by reading the column type from
|
||||
* INFORMATION_SCHEMA) and skips when the schema is already correct,
|
||||
* so re-running is safe and fresh installs (whose newer create
|
||||
* migration already produces UUIDs) take a fast no-op path.
|
||||
* been applied and skips when the schema is already correct, so
|
||||
* re-running is safe and fresh installs (whose newer create migration
|
||||
* already produces UUIDs) take a fast no-op path that doesn't even
|
||||
* create snapshot tables.
|
||||
*
|
||||
* Caveats:
|
||||
* - DDL is auto-commit on MySQL; failures mid-run leave a partial
|
||||
* conversion. Take a backup before deploying.
|
||||
* - MySQL only. SQLite hosts get the correct schema directly from
|
||||
* the create migration; the multi-table UPDATE/ALTER syntax we
|
||||
* use here isn't portable.
|
||||
* - DDL is auto-commit on MySQL; a failure mid-run leaves a partial
|
||||
* conversion. The snapshot tables are how you recover from that.
|
||||
* Plus the deploy.sh-level `workkit:db:backup` snapshot — defence
|
||||
* in depth.
|
||||
* - Morph id columns become CHAR(36). Hosts whose entities use bigint
|
||||
* PKs (e.g. User on this app) have their ints stringified; Laravel's
|
||||
* morph relations compare loosely, so this is transparent.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Suffix used for snapshot tables. Fixed (non-timestamped) so a
|
||||
* mid-migration crash followed by a retry doesn't lose the original
|
||||
* snapshot under a different name.
|
||||
*/
|
||||
private const BAK_SUFFIX = '_bak_bigint_uuid_2026_04_29';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
// The fix-up only runs on MySQL — that's the driver where the
|
||||
|
|
@ -60,6 +85,34 @@ return new class extends Migration
|
|||
'accesses' => config('roles.table_names.accesses', 'accesses'),
|
||||
];
|
||||
|
||||
// Bail early if every table already has the correct schema —
|
||||
// saves us a round of snapshot churn on hosts where the migration
|
||||
// already ran (dev / staging) or fresh installs that came up
|
||||
// straight on UUID via the create migration.
|
||||
$needsConversion = array_filter($tables, fn($t) => Schema::hasTable($t) && ! $this->columnIsUuid($t, 'id'));
|
||||
if (! $needsConversion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 1. Snapshot every affected table BEFORE any DDL ──
|
||||
// Gives us a row-perfect copy under {table}_bak_bigint_uuid_2026_04_29
|
||||
// that the operator can use for recovery if anything below blows up.
|
||||
// Bak tables are deliberately not auto-dropped — the operator removes
|
||||
// them once they're confident the conversion succeeded.
|
||||
$rowCounts = [];
|
||||
foreach ($tables as $orig) {
|
||||
if (! Schema::hasTable($orig)) {
|
||||
continue;
|
||||
}
|
||||
$rowCounts[$orig] = (int) DB::table($orig)->count();
|
||||
$this->snapshotTable($orig);
|
||||
}
|
||||
|
||||
// ── 2. Convert ──
|
||||
// (Same in-place column-swap logic as before. Data is preserved
|
||||
// throughout — rows are never deleted, only their id/FK columns
|
||||
// are rewritten with UUIDs. Verification in step 3 confirms.)
|
||||
|
||||
// Phase 1 — parent tables (permissions, roles): convert PK from
|
||||
// bigint → uuid AND propagate the new ids into every dependent
|
||||
// table's FK column in the same step, so dependents are valid
|
||||
|
|
@ -89,6 +142,27 @@ return new class extends Migration
|
|||
$this->convertMorphIdColumn($tables['accesses'], 'entity_id');
|
||||
$this->convertMorphIdColumn($tables['accesses'], 'source_id');
|
||||
$this->convertMorphIdColumn($tables['accesses'], 'accessible_id');
|
||||
|
||||
// ── 3. Verify ──
|
||||
// Row count must match the pre-conversion snapshot exactly. Any
|
||||
// mismatch means the conversion lost or duplicated rows; abort
|
||||
// loudly so the operator can restore from the snapshot table.
|
||||
foreach ($rowCounts as $orig => $expected) {
|
||||
$actual = (int) DB::table($orig)->count();
|
||||
if ($actual !== $expected) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
"Row count mismatch on `%s`: expected %d, got %d after conversion. "
|
||||
. 'Original rows are preserved in `%s%s` — restore from there.',
|
||||
$orig, $expected, $actual, $orig, self::BAK_SUFFIX
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// FK integrity: every child must still reference a row that exists.
|
||||
$this->verifyFkIntegrity($tables['permission_members'], 'permission_id', $tables['permissions']);
|
||||
$this->verifyFkIntegrity($tables['permission_usages'], 'permission_id', $tables['permissions']);
|
||||
$this->verifyFkIntegrity($tables['role_members'], 'role_id', $tables['roles']);
|
||||
$this->verifyFkIntegrity($tables['roles'], 'parent_id', $tables['roles'], allowNull: true);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
|
|
@ -315,6 +389,63 @@ return new class extends Migration
|
|||
return $info && $info['nullable'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a row-perfect copy of $orig into a sibling backup table.
|
||||
* Idempotent: a previous run that successfully snapshotted but then
|
||||
* crashed mid-conversion is preserved on retry — we never overwrite
|
||||
* an existing populated snapshot. The bak table is the operator's
|
||||
* recovery path if the in-place conversion later fails verification.
|
||||
*/
|
||||
private function snapshotTable(string $orig): void
|
||||
{
|
||||
$bak = $orig . self::BAK_SUFFIX;
|
||||
|
||||
if (! Schema::hasTable($bak)) {
|
||||
// Fresh snapshot — copy structure and rows in one pass.
|
||||
DB::statement("CREATE TABLE `{$bak}` LIKE `{$orig}`");
|
||||
DB::statement("INSERT INTO `{$bak}` SELECT * FROM `{$orig}`");
|
||||
return;
|
||||
}
|
||||
|
||||
// Bak table already exists from a prior run. If it's empty,
|
||||
// populate it now (the previous run died right after CREATE).
|
||||
// If it already has rows, leave it alone — that's the original
|
||||
// snapshot we want to preserve.
|
||||
if ((int) DB::table($bak)->count() === 0) {
|
||||
DB::statement("INSERT INTO `{$bak}` SELECT * FROM `{$orig}`");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm every non-null value in $childTable.$fkCol resolves to a
|
||||
* row in $parentTable. Throws on orphan, naming the affected table
|
||||
* so the operator can take it from there. allowNull keeps NULL refs
|
||||
* legal (e.g. roles.parent_id can legitimately be NULL).
|
||||
*/
|
||||
private function verifyFkIntegrity(string $childTable, string $fkCol, string $parentTable, bool $allowNull = false): void
|
||||
{
|
||||
if (! Schema::hasTable($childTable) || ! Schema::hasColumn($childTable, $fkCol)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = DB::table($childTable . ' as c')
|
||||
->leftJoin($parentTable . ' as p', 'c.' . $fkCol, '=', 'p.id')
|
||||
->whereNull('p.id');
|
||||
|
||||
if ($allowNull) {
|
||||
$query->whereNotNull('c.' . $fkCol);
|
||||
}
|
||||
|
||||
$orphans = (int) $query->count();
|
||||
if ($orphans > 0) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
"FK integrity check failed: %d orphan(s) in %s.%s pointing to missing %s rows. "
|
||||
. 'Original rows are preserved in `%s%s`.',
|
||||
$orphans, $childTable, $fkCol, $parentTable, $childTable, self::BAK_SUFFIX
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function dropForeignKeyIfExists(string $table, string $fkName): void
|
||||
{
|
||||
// SQLite doesn't support DROP FOREIGN KEY at all and instead
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Roles\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Drop the snapshot tables created by the bigint→UUID fix-up migration
|
||||
* (2026_04_29_000002_fix_role_tables_to_uuid).
|
||||
*
|
||||
* The migration deliberately doesn't auto-drop them — operators want to
|
||||
* verify the application works against the converted schema before
|
||||
* losing the only on-disk copy of the original rows. Once they're
|
||||
* confident, this command tidies up. Idempotent: a host that never had
|
||||
* snapshot tables (fresh install or already-cleaned) sees a no-op.
|
||||
*/
|
||||
class DropUuidMigrationBackupTablesCommand extends Command
|
||||
{
|
||||
private const BAK_SUFFIX = '_bak_bigint_uuid_2026_04_29';
|
||||
|
||||
protected $signature = 'roles:drop-uuid-migration-backup-tables
|
||||
{--dry-run : List the tables that would be dropped without dropping them}
|
||||
{--force : Skip the confirmation prompt}';
|
||||
|
||||
protected $description = 'Drop the snapshot tables left behind by the bigint→UUID schema migration.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$candidates = [
|
||||
config('roles.table_names.permissions', 'permissions'),
|
||||
config('roles.table_names.permission_member', 'permission_members'),
|
||||
config('roles.table_names.permission_usage', 'permission_usages'),
|
||||
config('roles.table_names.roles', 'roles'),
|
||||
config('roles.table_names.role_member', 'role_members'),
|
||||
config('roles.table_names.accesses', 'accesses'),
|
||||
config('roles.table_names.required_accesses', 'required_accesses'),
|
||||
];
|
||||
|
||||
$bakTables = [];
|
||||
foreach ($candidates as $orig) {
|
||||
$bak = $orig . self::BAK_SUFFIX;
|
||||
if (Schema::hasTable($bak)) {
|
||||
$bakTables[$bak] = (int) DB::table($bak)->count();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $bakTables) {
|
||||
$this->info('No snapshot tables found — nothing to drop.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Found snapshot tables:');
|
||||
foreach ($bakTables as $bak => $rows) {
|
||||
$this->line(sprintf(' %s — %d rows', $bak, $rows));
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('--dry-run: nothing dropped.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force')) {
|
||||
$this->warn('This will permanently delete the pre-migration row snapshots.');
|
||||
$this->warn('Only proceed if the application is verified to work against the converted schema.');
|
||||
if (! $this->confirm('Drop these tables?', false)) {
|
||||
$this->info('Aborted.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($bakTables) as $bak) {
|
||||
DB::statement("DROP TABLE `{$bak}`");
|
||||
$this->line("dropped: {$bak}");
|
||||
}
|
||||
|
||||
$this->info(sprintf('Dropped %d snapshot table(s).', count($bakTables)));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,12 @@ class RolesServiceProvider extends \Illuminate\Support\ServiceProvider
|
|||
$this->registerMigrations();
|
||||
|
||||
$this->registerModelBindings();
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
\Blax\Roles\Console\DropUuidMigrationBackupTablesCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue