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

124 lines
4.3 KiB
TypeScript

import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
const ColumnSchema = z.object({
name: z.string(),
type: z.string(),
nullable: z.enum(["YES", "NO"]),
defaultValue: z.string().nullable(),
charset: z.string().nullable(),
collation: z.string().nullable(),
});
const CheckSchema = z.object({ name: z.string(), clause: z.string() });
export const resourceScopeOwnerClause =
"scope = 'company' and char_length(scope) = 7 and companyId is not null or scope = 'global' and char_length(scope) = 6 and companyId is null";
export const resourceGlobalIntegrationClause =
"scope = 'company' or integrationKind is null and addressFieldId is null and contextFieldId is null and mailboxKind is null and accountId is null and referenceFieldId is null and resourceTypeId is null and systemId is null and environmentId is null";
function normalizedClause(clause: string) {
return clause
.split(/('(?:''|[^'])*')/)
.map((part, index) =>
index % 2 === 1 ? part : part.toLowerCase().replaceAll("`", "").replace(/\s+/g, "")
)
.join("");
}
/** Solo inspección: un esquema parcial nunca se convierte silenciosamente en legacy. */
export async function inspectResourceScopeSchema(database: Sequelize) {
const columns = z
.array(ColumnSchema)
.parse(
await database.query(
"SELECT COLUMN_NAME AS name,COLUMN_TYPE AS type,IS_NULLABLE AS nullable,COLUMN_DEFAULT AS defaultValue,CHARACTER_SET_NAME AS charset,COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resources' ORDER BY ORDINAL_POSITION",
{ type: QueryTypes.SELECT }
)
);
const checks = z
.array(CheckSchema)
.parse(
await database.query(
"SELECT CONSTRAINT_NAME AS name,CHECK_CLAUSE AS clause FROM information_schema.CHECK_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='resources'",
{ type: QueryTypes.SELECT }
)
);
const owner = columns.find((column) => column.name === "companyId");
const scope = columns.find((column) => column.name === "scope");
if (
!owner ||
owner.type !== "char(36)" ||
owner.charset !== "utf8mb4" ||
owner.collation !== "utf8mb4_bin"
) {
throw new Error("Esquema Resource incompatible: se esperaba companyId CHAR(36) utf8mb4_bin.");
}
for (const [name, clause] of [
["resource_scope_owner", resourceScopeOwnerClause],
["resource_global_no_integration", resourceGlobalIntegrationClause],
] as const) {
const check = checks.find((entry) => entry.name === name);
if (check && normalizedClause(check.clause) !== normalizedClause(clause)) {
throw new Error(`Esquema Resource incompatible: definición inesperada de ${name}.`);
}
}
const ownerCheck = checks.some((check) => check.name === "resource_scope_owner");
const integrationCheck = checks.some((check) => check.name === "resource_global_no_integration");
if (!scope) {
if (owner.nullable !== "NO" || ownerCheck || integrationCheck) {
throw new Error(
"Esquema Resource incompatible: propietario nullable o checks scoped sin discriminador."
);
}
return { mode: "legacy" as const, owner, scope, ownerCheck, integrationCheck };
}
if (
scope.type !== "varchar(7)" ||
scope.nullable !== "NO" ||
scope.charset !== "ascii" ||
scope.collation !== "ascii_bin" ||
!["company", "'company'"].includes(scope.defaultValue ?? "")
) {
throw new Error(
"Esquema Resource incompatible: scope debe ser VARCHAR(7) ascii_bin NOT NULL DEFAULT company."
);
}
return {
mode:
owner.nullable === "YES" && ownerCheck && integrationCheck
? ("scoped" as const)
: ("partial" as const),
owner,
scope,
ownerCheck,
integrationCheck,
};
}
/** Estado fijado por instancia de lector; la expansión requiere reiniciar el proceso. */
export class ResourceScopeSchemaReader {
private state?: ReturnType<typeof inspectResourceScopeSchema>;
constructor(private readonly database: Sequelize) {}
async read() {
this.state ??= inspectResourceScopeSchema(this.database);
const schema = await this.state;
if (schema.mode === "partial") {
throw new Error(
"Esquema Resource parcial: detener servicio y reanudar la migración de scope."
);
}
return schema.mode;
}
}