import { UniqueID } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; import type { CreationOptional, InferAttributes, InferCreationAttributes, Sequelize, Transaction, } from "sequelize"; import { DataTypes, Model, QueryTypes } from "sequelize"; import { z } from "zod/v4"; import { LocalIdentityOwnerSchema } from "../../../common/dto/shared/access.dto"; import type { AccessSnapshot, IAccessRepository } from "../../application/access.repository"; import type { ICompanyRepository } from "../../application/company.repository"; import type { DirectoryContextSnapshot } from "../../application/directory-context-plan"; import { AccessDefinitionSchema } from "../../domain/access/access-definition"; import { AccessError } from "../../domain/access/access-error"; import { AccessItem, AccessRevision } from "../../domain/access/access-item.entity"; import { AccessValues } from "../../domain/access/access-values.vo"; import { AccessWorkspace } from "../../domain/access/access-workspace.aggregate"; import type { Person } from "../../domain/person.entity"; import { type AccessItemRow, type AccessRevisionRow, initializeAccessValues, readAccessItems, readAccessRevisions, writeAccessRow, } from "./access-relational"; import { migrateDirectoryContexts } from "./directory-context-migration"; import { migrateLocalIdentityEnvironments } from "./local-identity-migration"; class WorkspaceModel extends Model< InferAttributes, InferCreationAttributes > { declare id: string; declare companyId: string | null; declare scopeKey: string; } class ReferenceModel extends Model< InferAttributes, InferCreationAttributes > { declare id: CreationOptional; declare itemId: string; declare targetId: string; } class PersonReferenceModel extends Model< InferAttributes, InferCreationAttributes > { declare itemId: string; declare personId: string; } const idColumn = { type: DataTypes.UUID, primaryKey: true }; const reference = (table: string) => ({ type: DataTypes.UUID, allowNull: false, references: { model: table, key: "id" }, onDelete: "RESTRICT", }); export async function migrateAccess(database: Sequelize) { const query = database.getQueryInterface(); const rows = await database.query<{ name: string }>( "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", { type: QueryTypes.SELECT } ); const exists = (name: string) => rows.some((row) => row.name === name); if (!exists("access_workspaces")) { await query.createTable("access_workspaces", { id: idColumn, companyId: { ...reference("companies"), allowNull: true }, scopeKey: { type: DataTypes.STRING(40), allowNull: false, unique: true }, }); } if (!exists("access_items")) { await query.createTable("access_items", { id: idColumn, workspaceId: reference("access_workspaces"), kind: { type: DataTypes.STRING(30), allowNull: false }, }); } if (!exists("access_revisions")) { await query.createTable("access_revisions", { id: idColumn, itemId: reference("access_items"), sequence: { type: DataTypes.INTEGER, allowNull: false }, actorId: reference("users"), recordedAt: { type: DataTypes.STRING(24), allowNull: false }, }); } if (!exists("access_references")) { await query.createTable("access_references", { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, itemId: reference("access_items"), targetId: reference("access_items"), }); } if (!exists("access_person_references")) { await query.createTable("access_person_references", { itemId: { ...reference("access_items"), primaryKey: true }, personId: { ...reference("company_contacts"), primaryKey: true }, }); } // Índices fuera de las ramas de creación: permiten completar un arranque interrumpido. for (const [table, fields, name] of [ ["access_revisions", ["itemId", "sequence"], "access_revision_sequence_unique"], ["access_references", ["itemId", "targetId"], "access_reference_unique"], ] as const) { const indexes = await database.query<{ name: string }>( "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table", { replacements: { table }, type: QueryTypes.SELECT } ); if (!indexes.some((item) => item.name === name)) { await query.addIndex(table, [...fields], { unique: true, name }); } } WorkspaceModel.init( { id: idColumn, companyId: { type: DataTypes.UUID, allowNull: true }, scopeKey: { type: DataTypes.STRING(40), allowNull: false }, }, { sequelize: database, tableName: "access_workspaces", timestamps: false } ); await initializeAccessValues(database); await migrateDirectoryContexts(database); await migrateLocalIdentityEnvironments(database); ReferenceModel.init( { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, itemId: { type: DataTypes.UUID, allowNull: false }, targetId: { type: DataTypes.UUID, allowNull: false }, }, { sequelize: database, tableName: "access_references", timestamps: false } ); PersonReferenceModel.init( { itemId: { type: DataTypes.UUID, primaryKey: true }, personId: { type: DataTypes.UUID, primaryKey: true }, }, { sequelize: database, tableName: "access_person_references", timestamps: false } ); // Completar referencias de registros ya existentes sin reescribir sus valores ni su historial. for (const item of await database.transaction((transaction) => readAccessItems(database, transaction) )) { const value = domainValues(item.values).data; if ("personId" in value) { await PersonReferenceModel.findOrCreate({ where: { itemId: item.id, personId: value.personId }, defaults: { itemId: item.id, personId: value.personId }, }); } } await WorkspaceModel.findOrCreate({ where: { scopeKey: "global" }, defaults: { id: UniqueID.generateNewID().toPrimitive(), companyId: null, scopeKey: "global" }, }); } function validId(value: string) { const id = UniqueID.create(value); if (!id.isSuccess) { throw new Error("Identidad de acceso persistida inconsistente."); } return id.data; } function domainValues(value: unknown) { const parsed = AccessDefinitionSchema.safeParse(value); if (!parsed.success) { throw new Error("Valores de acceso persistidos inconsistentes."); } const values = AccessValues.create(parsed.data); if (!values.isSuccess) { throw new Error("Valores de acceso persistidos inconsistentes."); } return values.data; } export class AccessPersistenceMapper { toDomain( root: WorkspaceModel, rows: readonly AccessItemRow[], revisions: readonly AccessRevisionRow[] ) { const items = rows.map((row) => { const values = domainValues(row.values); const history = revisions .filter((revision) => revision.itemId === row.id) .sort((a, b) => a.sequence - b.sequence) .map((revision, index) => { if ( revision.sequence !== index || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(revision.recordedAt) ) { throw new Error("Historial de acceso persistido inconsistente."); } return AccessRevision.rehydrate( domainValues(revision.values), { actorId: validId(revision.actorId), recordedAt: revision.recordedAt }, validId(revision.id) ); }); if (!history.at(-1)?.values.equals(values) || row.kind !== values.data.kind) { throw new Error("Historial de acceso persistido inconsistente."); } return AccessItem.rehydrate( { companyId: root.companyId ? Maybe.some(validId(root.companyId)) : Maybe.none(), values, history, }, validId(row.id) ); }); return AccessWorkspace.rehydrate( root.companyId ? Maybe.some(validId(root.companyId)) : Maybe.none(), items, validId(root.id) ); } toPersistence(workspace: AccessWorkspace) { return workspace.items.map((item) => ({ item: { id: item.id.toPrimitive(), workspaceId: workspace.id.toPrimitive(), kind: item.values.data.kind, values: item.values.data, }, revisions: item.history.map((revision, sequence) => ({ id: revision.id.toPrimitive(), itemId: item.id.toPrimitive(), sequence, actorId: revision.actorId.toPrimitive(), recordedAt: revision.recordedAt, values: revision.values.data, })), references: item.values .references() .filter((id) => { const person = item.values.reference("personId").getOrUndefined(); return !person?.equals(id); }) .map((id) => ({ itemId: item.id.toPrimitive(), targetId: id.toPrimitive() })), })); } } export class SequelizeAccessRepository implements IAccessRepository { constructor( private readonly database: Sequelize, private readonly companies: ICompanyRepository, private readonly mapper: AccessPersistenceMapper ) {} private async load( companyId: Maybe, transaction: Transaction, write: boolean ): Promise> { // Orden de bloqueo único: catálogo global, compañía y espacio de accesos. Serializa altas y cambios de catálogos. const global = await WorkspaceModel.findOne({ where: { scopeKey: "global" }, transaction, ...(write ? { lock: transaction.LOCK.UPDATE } : {}), }); if (!global) { throw new Error("Catálogo no inicializado."); } let root = global; let people: readonly Person[] = []; const company = companyId.getOrUndefined(); if (company) { const exists = await this.database.query<{ id: string }>( `SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`, { replacements: { id: company.toPrimitive() }, transaction, type: QueryTypes.SELECT } ); if (!exists.length) { return Result.fail(new AccessError("COMPANY_NOT_FOUND")); } const aggregate = (await this.companies.findById(company)).getOrUndefined(); if (!aggregate) { return Result.fail(new AccessError("COMPANY_NOT_FOUND")); } people = aggregate.people; const [created] = await WorkspaceModel.findOrCreate({ where: { scopeKey: company.toPrimitive() }, defaults: { id: UniqueID.generateNewID().toPrimitive(), scopeKey: company.toPrimitive(), companyId: company.toPrimitive(), }, transaction, }); root = created; } const roots = company ? [global, root] : await WorkspaceModel.findAll({ transaction }); const rows = (await readAccessItems(this.database, transaction)).filter((row) => roots.some((root) => root.id === row.workspaceId) ); const revisions = (await readAccessRevisions(this.database, transaction)).filter((revision) => rows.some((row) => row.id === revision.itemId) ); const workspace = this.mapper.toDomain( root, rows.filter((row) => row.workspaceId === root.id), revisions ); const catalog = roots .filter((item) => item.id !== root.id) .flatMap( (item) => this.mapper.toDomain( item, rows.filter((row) => row.workspaceId === item.id), revisions ).items ); const actorRows = await this.database.query<{ id: string; name: string }>( "SELECT id,name FROM users", { transaction, type: QueryTypes.SELECT } ); const actors = new Map(actorRows.map((row) => [row.id, row.name])); const localEnvironments = company ? z.array(LocalIdentityOwnerSchema).parse( await this.database.query( "SELECT l.environmentId,l.assetId,a.name AS assetName,l.endedAt FROM access_local_identity_environments l JOIN assets a ON a.id=l.assetId AND a.companyId=l.companyId WHERE l.companyId=:companyId", { replacements: { companyId: company.toPrimitive() }, transaction, type: QueryTypes.SELECT, } ) ) : []; const directories = company ? await this.database.query<{ environmentId: string }>( "SELECT environmentId FROM access_directory_environments WHERE workspaceId=:workspaceId", { replacements: { workspaceId: root.id }, transaction, type: QueryTypes.SELECT } ) : []; return Result.ok({ localEnvironments, directoryEnvironmentIds: directories.map((row) => row.environmentId), actors, workspace, context: { catalog, people, today: new Date().toISOString().slice(0, 10) }, }); } get(companyId: Maybe) { return this.database.transaction((transaction) => this.load(companyId, transaction, false)); } update(companyId: Maybe, action: Parameters[1]) { return this.database.transaction((transaction) => this.updateInTransaction(companyId, action, transaction) ); } async updateInTransaction( companyId: Maybe, action: ( snapshot: DirectoryContextSnapshot ) => ReturnType[1]>, transaction: Transaction ) { const loaded = await this.load(companyId, transaction, true); if (!loaded.isSuccess) { return loaded; } const replacements = { workspaceId: loaded.data.workspace.id.toPrimitive() }; const systems = await this.database.query<{ systemId: string }>( "SELECT systemId FROM access_directory_systems WHERE workspaceId=:workspaceId", { replacements, transaction, type: QueryTypes.SELECT } ); const environments = await this.database.query<{ environmentId: string }>( "SELECT environmentId FROM access_directory_environments WHERE workspaceId=:workspaceId", { replacements, transaction, type: QueryTypes.SELECT } ); const changed = action({ ...loaded.data, directorySystems: systems.map((row) => row.systemId), directoryEnvironments: environments.map((row) => row.environmentId), }); if (!changed.isSuccess) { return changed; } // Un contexto documental no se desactiva por una edición lateral del sistema o del entorno. if ( changed.data.items.some((item) => { const linked = systems.some((row) => row.systemId === item.id.toPrimitive()) || environments.some((row) => row.environmentId === item.id.toPrimitive()); return linked && "active" in item.values.data && !item.values.data.active; }) ) { return Result.fail(new AccessError("IMMUTABLE_FIELDS")); } if (changed.data !== loaded.data.workspace) { await this.persistChanges(changed.data, loaded.data.workspace, transaction); } return Result.ok({ localEnvironments: loaded.data.localEnvironments, directoryEnvironmentIds: loaded.data.directoryEnvironmentIds ?? [], workspace: changed.data, context: loaded.data.context, actors: loaded.data.actors, }); } private async persistChanges( workspace: AccessWorkspace, previousWorkspace: AccessWorkspace, transaction: Transaction ) { const records = this.mapper.toPersistence(workspace); const previous = new Set( previousWorkspace.items.flatMap((item) => item.history.map((revision) => revision.id.toPrimitive()) ) ); for (const record of records) { await writeAccessRow( this.database, record.item, transaction, previousWorkspace.items.some((item) => item.id.toPrimitive() === record.item.id) ? "update" : "insert" ); const revisions = record.revisions.filter((revision) => !previous.has(revision.id)); if (revisions.length) { for (const revision of revisions) { await writeAccessRow(this.database, revision, transaction, "insert"); } } const personId = Object.entries(record.item.values).find(([key]) => key === "personId")?.[1]; if (typeof personId === "string") { await PersonReferenceModel.findOrCreate({ where: { itemId: record.item.id, personId }, defaults: { itemId: record.item.id, personId }, transaction, }); } for (const reference of record.references) { await ReferenceModel.findOrCreate({ where: reference, defaults: reference, transaction, }); } } } }