170 lines
7.0 KiB
TypeScript
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."
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|