Un relleno es primo de una migración: cambia filas existentes para que encajen con una expectativa nueva, pero suele publicarse como su propio comando en lugar de como una migración, porque necesita una ejecución en seco y la capacidad de parar y reanudar sin dejar los datos a medio migrar.
// app/Console/Commands/ServerMetadataBackfill.php
#[Signature('server:backfill-metadata {--dry-run : Show changes without saving}')]
#[Description('Backfill Server.metadata: drop plan_id, set provider/dedicated/booked. Idempotent.')]
final class ServerMetadataBackfill extends Command
{
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$changed = 0;
$skipped = 0;
Server::query()->chunkById(100, function ($servers) use (&$changed, &$skipped, $dryRun): void {
foreach ($servers as $server) {
$metadata = (array) $server->metadata;
$original = $metadata;
// ... derive the new shape from the old one
if ($metadata === $original) {
$skipped++;
continue;
}
if (! $dryRun) {
$server->metadata = $metadata;
$server->save();
}
$changed++;
}
});
return self::SUCCESS;
}
}
Tres cosas hacen esto seguro contra producción, y ninguna es opcional:
--dry-run: muestra exactamente qué cambiaría sin escribir nada, así puedes revisar la salida antes de fiarte.chunkById(): recorre la tabla en páginas ordenadas en lugar de cargar todas las filas en memoria, así que una tabla de un millón de filas y una de cien se comportan igual.- Saltar si no cambia: comparar el estado con el original antes de escribir significa que ejecutar el comando una segunda vez no hace nada a las filas ya migradas. Mata el proceso a mitad, ejecútalo mañana y retomará exactamente donde lo dejó.
Un relleno que sólo puedes ejecutar una vez es un relleno al que tienes miedo. Uno que puedes interrumpir y volver a lanzar es uno que puedes programar en horario laboral.
Peticiones de borrado y lo que tocan
«Borra mi cuenta» suena a una sola sentencia DELETE. Nunca lo es. Una sola cuenta toca sitios, suscripciones, dispositivos y la propiedad que otras personas tienen sobre recursos compartidos, y una petición de borrado tiene que razonar sobre todo eso antes de razonar sobre la fila de la cuenta.
Delete request arrives
↓
Block if this user pays for an active subscription
↓
Partition owned sites: sole-owned vs shared vs member-only
↓
Block if a sole-owned site has its own active subscription
↓
Transaction: dispatch teardown for sole-owned sites,
force-delete known devices,
soft-delete the user
// app/Livewire/Settings/DeleteUserForm.php
public function deleteUser(Logout $logout): void
{
$user = Auth::user();
$payerSiteIds = $user->subscriptions()->active()->pluck('type')->all();
if (! empty($payerSiteIds)) {
// ... reject, ask them to cancel via billing first
return;
}
$sitesToDelete = collect();
foreach ($user->sites as $site) {
$isOwner = ($site->pivot->role ?? '') === 'owner';
if (! $isOwner) {
continue;
}
$hasAnotherOwner = DB::table('users_sites')
->where('site_id', $site->id)
->where('user_id', '!=', $user->id)
->where('role', 'owner')
->exists();
if (! $hasAnotherOwner) {
// A sole-owned site can still have its own active subscription,
// independent of who pays for it. Don't tear that down silently.
if ($site->activeSubscription()) {
// ... reject, ask them to cancel this site's subscription first
return;
}
$sitesToDelete->push($site);
}
}
DB::transaction(function () use ($logout, $user, $sitesToDelete) {
foreach ($sitesToDelete as $site) {
DeleteSiteJob::dispatch(
siteId: (string) $site->id,
apiKey: $site->server->workspace->token,
)->afterCommit();
}
$user->knownDevices()->delete();
tap($user, $logout(...))->delete();
});
}
Facturación primero: no puedes atender una petición de borrado que dejaría huérfana calladamente una suscripción activa. Esa comprobación corre antes de que nada más toque la base de datos.
Particionar por propiedad: «borra mi cuenta» no es lo mismo que «borra todos los sitios que he tocado». Un sitio con otro propietario, o donde esta persona era sólo colaboradora, pierde la relación, no el sitio. Sólo se desmantelan los sitios de los que es propietaria única.
Bloqueo por suscripción del sitio: la propiedad no es lo único que puede vetar un desmantelamiento. Un sitio de propietario único puede llevar su propia suscripción activa independientemente de quién la pague, y eso también se comprueba y se bloquea.
Una transacción, durabilidad mixta: los jobs de desmantelamiento se despachan dentro de la transacción con afterCommit(), así que sólo se disparan una vez la transacción se ha confirmado de verdad. El borrado de dispositivos conocidos es duro y permanente, porque un registro de dispositivo no tiene sentido cuando su dueño ya no puede autenticarse. La fila del usuario se borra de forma suave a propósito, no porque el equipo olvidara que existe forceDelete(), sino porque el contacto sigue teniendo valor de negocio después de que la cuenta desaparezca.
«Borrar al usuario» no significa la misma operación en cada tabla que toca. Significa desaparecido permanentemente para las cosas cuya única razón de existir era esa identidad concreta, y conservado para las cosas para las que tu negocio aún tiene un uso legítimo. Una petición de borrado que trata todas las tablas igual o está violando una necesidad real de negocio o está violando la petición real de la persona. No puedes elegir cuál por accidente.