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
|
* `HasUuids`. So every insert blew up with
|
||||||
* "Incorrect integer value: '<uuid>' for column 'id'"
|
* "Incorrect integer value: '<uuid>' for column 'id'"
|
||||||
*
|
*
|
||||||
* On installs that ran the buggy migrations, the tables exist with
|
* Conversion is non-destructive:
|
||||||
* bigint keys. Some hosts have data inside (e.g. roles seeded by app
|
* 1. Each affected table is snapshotted into a sibling
|
||||||
* code that bypassed the model insert path). This migration converts
|
* `<table>_bak_bigint_uuid_2026_04_29` with `CREATE TABLE LIKE` +
|
||||||
* those tables in place: keeps the data, swaps PK + FK columns to
|
* `INSERT … SELECT *` BEFORE any DDL runs.
|
||||||
* CHAR(36), and rebuilds all foreign-key constraints.
|
* 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
|
* Idempotent. Each phase checks whether the conversion has already
|
||||||
* been applied (by reading the column type from
|
* been applied and skips when the schema is already correct, so
|
||||||
* INFORMATION_SCHEMA) and skips when the schema is already correct,
|
* re-running is safe and fresh installs (whose newer create migration
|
||||||
* so re-running is safe and fresh installs (whose newer create
|
* already produces UUIDs) take a fast no-op path that doesn't even
|
||||||
* migration already produces UUIDs) take a fast no-op path.
|
* create snapshot tables.
|
||||||
*
|
*
|
||||||
* Caveats:
|
* Caveats:
|
||||||
* - DDL is auto-commit on MySQL; failures mid-run leave a partial
|
* - MySQL only. SQLite hosts get the correct schema directly from
|
||||||
* conversion. Take a backup before deploying.
|
* 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
|
* - Morph id columns become CHAR(36). Hosts whose entities use bigint
|
||||||
* PKs (e.g. User on this app) have their ints stringified; Laravel's
|
* PKs (e.g. User on this app) have their ints stringified; Laravel's
|
||||||
* morph relations compare loosely, so this is transparent.
|
* morph relations compare loosely, so this is transparent.
|
||||||
*/
|
*/
|
||||||
return new class extends Migration
|
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
|
public function up(): void
|
||||||
{
|
{
|
||||||
// The fix-up only runs on MySQL — that's the driver where the
|
// 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'),
|
'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
|
// Phase 1 — parent tables (permissions, roles): convert PK from
|
||||||
// bigint → uuid AND propagate the new ids into every dependent
|
// bigint → uuid AND propagate the new ids into every dependent
|
||||||
// table's FK column in the same step, so dependents are valid
|
// 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'], 'entity_id');
|
||||||
$this->convertMorphIdColumn($tables['accesses'], 'source_id');
|
$this->convertMorphIdColumn($tables['accesses'], 'source_id');
|
||||||
$this->convertMorphIdColumn($tables['accesses'], 'accessible_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
|
public function down(): void
|
||||||
|
|
@ -315,6 +389,63 @@ return new class extends Migration
|
||||||
return $info && $info['nullable'];
|
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
|
private function dropForeignKeyIfExists(string $table, string $fkName): void
|
||||||
{
|
{
|
||||||
// SQLite doesn't support DROP FOREIGN KEY at all and instead
|
// 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->registerMigrations();
|
||||||
|
|
||||||
$this->registerModelBindings();
|
$this->registerModelBindings();
|
||||||
|
|
||||||
|
if ($this->app->runningInConsole()) {
|
||||||
|
$this->commands([
|
||||||
|
\Blax\Roles\Console\DropUuidMigrationBackupTablesCommand::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue