import type { Sequelize } from "sequelize"; import { QueryTypes } from "sequelize"; import { z } from "zod/v4"; import { inspectResourceScopeSchema, resourceGlobalIntegrationClause, resourceScopeOwnerClause, } from "./resource-scope-schema"; /** Pasos DDL reanudables; MariaDB no los revierte como una transacción DML. */ export const resourceScopeMigrationSteps = { scope: "ALTER TABLE resources WAIT 30 ADD COLUMN scope VARCHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'company'", ownerCheck: `ALTER TABLE resources WAIT 30 ADD CONSTRAINT resource_scope_owner CHECK (${resourceScopeOwnerClause})`, integrationCheck: `ALTER TABLE resources WAIT 30 ADD CONSTRAINT resource_global_no_integration CHECK (${resourceGlobalIntegrationClause})`, nullableOwner: "ALTER TABLE resources WAIT 30 MODIFY COLUMN companyId CHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL", } as const; async function assertMigrationPreconditions(database: Sequelize) { const settings = z .array( z.object({ version: z.string(), foreignKeys: z.number().int(), checks: z.number().int() }) ) .parse( await database.query( "SELECT VERSION() AS version,@@foreign_key_checks AS foreignKeys,@@check_constraint_checks AS checks", { type: QueryTypes.SELECT } ) ); if (!settings[0]?.version.startsWith("11.8.")) { throw new Error("Migración Resource scope ensayada exclusivamente con MariaDB 11.8."); } if (settings[0].foreignKeys !== 1 || settings[0].checks !== 1) { throw new Error("Migración Resource scope requiere CHECK y foreign keys activados."); } const owners = await database.query( "SELECT r.id FROM resources r LEFT JOIN companies c ON c.id=r.companyId WHERE r.companyId IS NOT NULL AND c.id IS NULL LIMIT 1", { type: QueryTypes.SELECT } ); if (owners.length) { throw new Error("Migración Resource scope detenida: propietario huérfano."); } await assertResourceForeignKeys(database); } /** Invocación explícita con escrituras detenidas y backup verificado; nunca desde el arranque. */ export async function migrateResourceScope(database: Sequelize) { await assertMigrationPreconditions(database); let schema = await inspectResourceScopeSchema(database); const completed: string[] = []; if (schema.mode === "legacy") { await database.query(resourceScopeMigrationSteps.scope); completed.push("scope"); schema = await inspectResourceScopeSchema(database); } const invalid = await database.query( `SELECT id FROM resources WHERE NOT (${resourceScopeOwnerClause}) OR NOT (${resourceGlobalIntegrationClause}) LIMIT 1`, { type: QueryTypes.SELECT } ); if (invalid.length) { throw new Error( "Migración Resource scope detenida: datos de ámbito o integración inconsistentes." ); } if (!schema.ownerCheck) { await database.query(resourceScopeMigrationSteps.ownerCheck); completed.push("ownerCheck"); } if (!schema.integrationCheck) { await database.query(resourceScopeMigrationSteps.integrationCheck); completed.push("integrationCheck"); } if (schema.owner.nullable === "NO") { await database.query(resourceScopeMigrationSteps.nullableOwner); completed.push("nullableOwner"); } const final = await inspectResourceScopeSchema(database); if (final.mode !== "scoped") { throw new Error("Migración Resource scope incompleta; mantener el servicio detenido."); } await assertMigrationPreconditions(database); return { mode: final.mode, completed }; } async function assertResourceForeignKeys(database: Sequelize) { const keys = z .array( z.object({ tableName: z.string(), parent: z.string(), columnNames: z.string(), targets: z.string(), deleteRule: z.string(), updateRule: z.string(), }) ) .parse( await database.query( "SELECT k.TABLE_NAME AS tableName,k.REFERENCED_TABLE_NAME AS parent,GROUP_CONCAT(k.COLUMN_NAME ORDER BY k.ORDINAL_POSITION) AS columnNames,GROUP_CONCAT(k.REFERENCED_COLUMN_NAME ORDER BY k.ORDINAL_POSITION) AS targets,r.DELETE_RULE AS deleteRule,r.UPDATE_RULE AS updateRule FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_CONSTRAINTS r ON r.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND r.TABLE_NAME=k.TABLE_NAME AND r.CONSTRAINT_NAME=k.CONSTRAINT_NAME WHERE k.CONSTRAINT_SCHEMA=DATABASE() AND k.REFERENCED_TABLE_SCHEMA=DATABASE() GROUP BY k.TABLE_NAME,k.CONSTRAINT_NAME,k.REFERENCED_TABLE_NAME,r.DELETE_RULE,r.UPDATE_RULE", { type: QueryTypes.SELECT } ) ); const tables = await database.query<{ name: string }>( "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", { type: QueryTypes.SELECT } ); const expected = [ ["resources", "companies", "companyId", "id"], ["resources", "resource_definitions", "templateId", "id"], [ "resource_directory_domains", "resources", "resourceId,companyId,templateId", "id,companyId,templateId", ], [ "resource_network_shares", "resources", "resourceId,companyId,templateId", "id,companyId,templateId", ], ["asset_support", "resources", "companyId,resourceId", "companyId,id"], ] as const; for (const [table, parent, columns, targets] of expected) { if ( tables.some((entry) => entry.name === table) && !keys.some( (key) => key.tableName === table && key.parent === parent && key.columnNames === columns && key.targets === targets && ["RESTRICT", "NO ACTION"].includes(key.deleteRule) && ["RESTRICT", "NO ACTION"].includes(key.updateRule) ) ) { throw new Error( `Migración Resource scope detenida: falta FK compatible en ${table} (${columns}).` ); } } const primary = await database.query<{ columnNames: string }>( "SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) AS columnNames FROM information_schema.KEY_COLUMN_USAGE WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='resources' AND CONSTRAINT_NAME='PRIMARY'", { type: QueryTypes.SELECT } ); if (primary[0]?.columnNames !== "id") { throw new Error("Migración Resource scope detenida: se esperaba PK Resource por id."); } const nullable = await database.query( "SELECT k.TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.COLUMNS c ON c.TABLE_SCHEMA=k.TABLE_SCHEMA AND c.TABLE_NAME=k.TABLE_NAME AND c.COLUMN_NAME=k.COLUMN_NAME WHERE k.CONSTRAINT_SCHEMA=DATABASE() AND k.REFERENCED_TABLE_SCHEMA=DATABASE() AND k.REFERENCED_TABLE_NAME='resources' AND c.IS_NULLABLE='YES' AND (SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE p WHERE p.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND p.TABLE_NAME=k.TABLE_NAME AND p.CONSTRAINT_NAME=k.CONSTRAINT_NAME)>1", { type: QueryTypes.SELECT } ); if (nullable.length) { throw new Error( "Migración Resource scope detenida: componente nullable en una FK operativa compuesta." ); } }