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:
Fabian @ Blax Software 2026-04-29 13:49:52 +02:00
parent 190c500d86
commit 7e19d4ffbe
3 changed files with 229 additions and 11 deletions

View File

@ -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

View File

@ -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;
}
}

View File

@ -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,
]);
}
}
/**