metis-desk/modules/resources/src/api/infrastructure/persistence/resource-scope-migration.ts
2026-10-25 01:19:17 +02:00

170 lines
7.0 KiB
TypeScript

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."
);
}
}