BD normalizada

This commit is contained in:
David Arranz 2026-10-10 16:40:51 +02:00
parent ee21c0a80e
commit bf4831a34f
41 changed files with 2112 additions and 360 deletions

View File

@ -11,23 +11,22 @@
"start": "node --env-file=../../.env dist/index.js"
},
"dependencies": {
"@metis/core": "workspace:*",
"express": "4.22.1",
"helmet": "8.1.0",
"zod": "4.3.6",
"@metis/identity": "workspace:*",
"sequelize": "6.37.8",
"mariadb": "3.5.4",
"express-session": "1.19.0",
"connect-session-sequelize": "8.0.6",
"@metis/companies": "workspace:*",
"@metis/assets": "workspace:*",
"@metis/resources": "workspace:*"
"@metis/companies": "workspace:*",
"@metis/core": "workspace:*",
"@metis/identity": "workspace:*",
"@metis/resources": "workspace:*",
"express": "4.22.1",
"express-session": "1.19.0",
"helmet": "8.1.0",
"mariadb": "3.5.4",
"sequelize": "6.37.8",
"zod": "4.3.6"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/express": "4.17.21",
"tsup": "8.5.1",
"@types/express-session": "^1.18.2"
"@types/express-session": "^1.18.2",
"tsup": "8.5.1"
}
}

View File

@ -20,6 +20,8 @@ import { createResourceModule } from "@metis/resources/module";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { AccessKindSchema } from "../../../modules/companies/src/api/domain/access/access-definition";
import { accessValueTable } from "../../../modules/companies/src/api/infrastructure/persistence/access-relational";
import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration";
import { verifyAccess } from "./access.integration";
@ -46,6 +48,7 @@ import { verifyPrinters } from "./printers.integration";
import { verifyProcesses } from "./processes.integration";
import { verifyRecordDeletion } from "./record-deletion.integration";
import { verifyRelationships } from "./relationships.integration";
import { verifyRemainingNormalization } from "./remaining-normalization.integration";
import { verifyRemoteConnections } from "./remote.integration";
import { verifyResourceMigration } from "./resource-migration.integration";
import { verifyResourceNormalization } from "./resource-normalization.integration";
@ -142,6 +145,9 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await database.query("DELETE FROM access_person_references");
await database.query("DELETE FROM access_references");
await database.query("DELETE FROM access_revisions");
for (const kind of AccessKindSchema.options) {
await database.query(`DELETE FROM ${accessValueTable(kind)}`);
}
await database.query("DELETE FROM access_items");
await database.query("DELETE FROM access_workspaces WHERE companyId IS NOT NULL");
await database.query("DELETE FROM users");
@ -992,6 +998,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
customer.id
);
await verifyConfigurationNormalization(database);
await verifyRemainingNormalization(database);
await verifyRecordDeletion((path, body) => operator.request(path, body), database);
const countAfterAccess = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM users",

View File

@ -1,8 +1,11 @@
import { createAssetModule } from "@metis/assets/module";
import { createCompanyModule } from "@metis/companies/module";
import { createIdentityModule, migrateIdentity } from "@metis/identity/module";
import {
SequelizeSessionStore,
createIdentityModule,
migrateIdentity,
} from "@metis/identity/module";
import { createResourceModule } from "@metis/resources/module";
import connectSequelize from "connect-session-sequelize";
import session from "express-session";
import type { Sequelize } from "sequelize";
@ -34,12 +37,7 @@ export async function createIdentityRuntime(
assets.services.dashboard,
resources.services.dashboard
);
const SequelizeStore = connectSequelize(session.Store);
const store = new SequelizeStore({
db: database,
tableName: "http_sessions",
expiration: 8 * 60 * 60 * 1000,
});
const store = new SequelizeSessionStore(database);
const middleware = session({
name: "metis.sid",
secret: environment.SESSION_SECRET,
@ -65,6 +63,7 @@ export async function createIdentityRuntime(
dashboard.register(app);
},
middleware,
stop: () => store.stopExpiringSessions(),
// El almacén relacional no utiliza un temporizador de fondo.
stop: () => undefined,
};
}

View File

@ -19,6 +19,10 @@ import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { mailSnapshot } from "../../../modules/companies/src/api/infrastructure/persistence/mail-remote-snapshots";
import { snapshotValues } from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-normalization";
import { writeSnapshotRow } from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-writer";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyMail(
@ -304,32 +308,37 @@ export async function verifyMail(
state: "configured",
notes: "Conservar",
};
const raw = JSON.stringify(legacyValues);
await database.query(
"INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,NULL,NULL,:values,:key)",
{
replacements: {
await database.transaction(async (transaction) => {
await writeSnapshotRow(
database,
"mail_configurations",
mailSnapshot,
{
id: legacyId,
companyId,
...legacyValues,
values: raw,
key: [person.id, account.id, first.id].join(":"),
values: legacyValues,
currentKey: [person.id, account.id, first.id].join(":"),
},
}
);
await database.query(
"INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,0,:actorId,:recordedAt,:values)",
{
replacements: {
transaction,
"insert"
);
await writeSnapshotRow(
database,
"mail_configuration_revisions",
mailSnapshot,
{
id: revisionId,
configurationId: legacyId,
position: 0,
actorId,
recordedAt: new Date().toISOString(),
values: raw,
values: legacyValues,
},
}
);
transaction,
"insert"
);
});
const legacy = MailConfigurationsResponseSchema.parse(
await (await request(path)).json()
).configurations.find((item) => item.id === legacyId);
@ -351,14 +360,14 @@ export async function verifyMail(
assert.equal(adopted.history[0]?.id, revisionId);
assert.equal(adopted.history.length, 2);
assert.deepEqual(adopted.history[0]?.values, legacyValues);
const persisted = await database.query<{ values: string }>(
"SELECT `values` FROM mail_configuration_revisions WHERE id=:id",
const persisted = await database.query<Record<string, unknown>>(
"SELECT * FROM mail_configuration_revisions WHERE id=:id",
{ replacements: { id: revisionId }, type: QueryTypes.SELECT }
);
const original = persisted[0]?.values;
const original = persisted[0];
assert.ok(original);
assert.deepEqual(typeof original === "string" ? JSON.parse(original) : original, legacyValues);
assert.deepEqual(snapshotValues(mailSnapshot, original), legacyValues);
await database.query("UPDATE mail_configurations SET currentKey='invalid' WHERE id=:id", {
replacements: { id: mobile.id },
});

View File

@ -35,7 +35,7 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
needId: string;
actorId: string;
}>(
"SELECT p.id AS profileId,p.companyId,i.id AS needId,u.id AS actorId FROM work_profiles p JOIN access_workspaces w ON w.companyId=p.companyId JOIN access_items i ON i.workspaceId=w.id CROSS JOIN users u WHERE JSON_UNQUOTE(JSON_EXTRACT(i.`values`, '$.kind'))='need' LIMIT 1",
"SELECT p.id AS profileId,p.companyId,i.id AS needId,u.id AS actorId FROM work_profiles p JOIN access_workspaces w ON w.companyId=p.companyId JOIN access_items i ON i.workspaceId=w.id CROSS JOIN users u WHERE i.kind='need' LIMIT 1",
{ type: QueryTypes.SELECT }
);
const subject = folderSubjects[0];

View File

@ -0,0 +1,284 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import session from "express-session";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { AccessKindSchema } from "../../../modules/companies/src/api/domain/access/access-definition";
import {
accessValueTable,
normalizeAccess,
readAccessItems,
readAccessRevisions,
} from "../../../modules/companies/src/api/infrastructure/persistence/access-relational";
import {
type ProcessTable,
normalizeProcesses,
readProcessRows,
} from "../../../modules/companies/src/api/infrastructure/persistence/process-relational";
import { supportIdentifier } from "../../../modules/companies/src/api/infrastructure/persistence/support-record-relational";
import { SequelizeSessionStore } from "../../../modules/identity/src/api/infrastructure/persistence/sequelize-session.store";
import {
normalizeSessions,
sessionFromRow,
sessionRow,
} from "../../../modules/identity/src/api/infrastructure/persistence/session-normalization";
const processTables: readonly ProcessTable[] = [
"process_templates",
"process_template_revisions",
"person_processes",
"person_process_revisions",
];
const processChildren = [
"process_template_instructions",
"process_template_revision_instructions",
"person_process_tasks",
"person_process_revision_tasks",
];
async function accessSnapshot(database: Sequelize) {
return database.transaction(async (transaction) => ({
items: (await readAccessItems(database, transaction)).sort((a, b) => a.id.localeCompare(b.id)),
revisions: (await readAccessRevisions(database, transaction)).sort((a, b) =>
a.id.localeCompare(b.id)
),
}));
}
async function processSnapshot(database: Sequelize) {
return database.transaction(async (transaction) => {
const snapshots = new Map<ProcessTable, Record<string, unknown>[]>();
for (const table of processTables) {
snapshots.set(table, await readProcessRows(database, table, transaction));
}
return snapshots;
});
}
async function legacyRows(
database: Sequelize,
table: string,
rows: readonly { id: unknown; values: unknown }[],
transaction?: Transaction
) {
for (const row of rows) {
await database.query(`UPDATE ${supportIdentifier(table)} SET \`values\`=:values WHERE id=:id`, {
replacements: { id: row.id, values: JSON.stringify(row.values) },
...(transaction ? { transaction } : {}),
});
}
}
async function verifyAccessMigration(database: Sequelize) {
const expected = await accessSnapshot(database);
assert.ok(expected.items.some((item) => item.kind === "need"));
assert.ok(expected.revisions.length > expected.items.length);
const query = database.getQueryInterface();
for (const table of ["access_items", "access_revisions"]) {
await query.addColumn(table, "values", { type: DataTypes.JSON, allowNull: true });
}
await database.transaction(async (transaction) => {
await legacyRows(database, "access_items", expected.items, transaction);
await legacyRows(database, "access_revisions", expected.revisions, transaction);
for (const history of [false, true]) {
for (const kind of AccessKindSchema.options) {
await database.query(`DELETE FROM ${supportIdentifier(accessValueTable(kind, history))}`, {
transaction,
});
}
}
});
const first = expected.items[0];
assert.ok(first);
await legacyRows(database, "access_items", [{ id: first.id, values: { kind: first.kind } }]);
await assert.rejects(() => normalizeAccess(database));
assert.ok((await query.describeTable("access_items")).values);
await legacyRows(database, "access_items", [first]);
await normalizeAccess(database);
assert.deepEqual(await accessSnapshot(database), expected);
for (const table of ["access_items", "access_revisions"]) {
assert.equal((await query.describeTable(table)).values, undefined);
}
// Corte entre la transferencia de la raíz y el historial.
await query.addColumn("access_items", "values", { type: DataTypes.JSON, allowNull: true });
await legacyRows(database, "access_items", expected.items);
await normalizeAccess(database);
await normalizeAccess(database);
assert.deepEqual(await accessSnapshot(database), expected);
await assert.rejects(() =>
database.query(
"INSERT INTO access_need_permissions (ownerId,position,permissionId) VALUES (:ownerId,999,:permissionId)",
{ replacements: { ownerId: randomUUID(), permissionId: randomUUID() } }
)
);
}
async function verifyProcessMigration(database: Sequelize) {
const expected = await processSnapshot(database);
assert.ok((expected.get("person_processes")?.length ?? 0) > 0);
const query = database.getQueryInterface();
for (const table of processTables) {
await query.addColumn(table, "values", { type: DataTypes.JSON, allowNull: true });
}
await database.transaction(async (transaction) => {
for (const [table, rows] of expected) {
for (const row of rows) {
await legacyRows(database, table, [{ id: row.id, values: row.values }], transaction);
}
}
for (const table of processChildren) {
await database.query(`DELETE FROM ${supportIdentifier(table)}`, { transaction });
}
});
const first = expected.get("person_processes")?.[0];
assert.ok(first);
await legacyRows(database, "person_processes", [{ id: first.id, values: {} }]);
await assert.rejects(() => normalizeProcesses(database));
assert.ok((await query.describeTable("person_processes")).values);
await legacyRows(database, "person_processes", [{ id: first.id, values: first.values }]);
await normalizeProcesses(database);
assert.deepEqual(await processSnapshot(database), expected);
for (const table of processTables) {
assert.equal((await query.describeTable(table)).values, undefined);
}
await query.addColumn("person_processes", "values", { type: DataTypes.JSON, allowNull: true });
for (const row of expected.get("person_processes") ?? []) {
await legacyRows(database, "person_processes", [{ id: row.id, values: row.values }]);
}
await normalizeProcesses(database);
await normalizeProcesses(database);
assert.deepEqual(await processSnapshot(database), expected);
}
async function sessionSnapshot(database: Sequelize) {
const rows = await database.query<Record<string, unknown>>(
"SELECT * FROM http_sessions ORDER BY sid",
{ type: QueryTypes.SELECT }
);
return rows.map((row) => ({
sid: row.sid,
expires: row.expires,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
values: sessionRow(sessionFromRow(row)),
}));
}
async function verifySessionMigration(database: Sequelize) {
const expected = await sessionSnapshot(database);
assert.ok(expected.length);
const query = database.getQueryInterface();
await query.addColumn("http_sessions", "data", { type: DataTypes.TEXT, allowNull: true });
const original = await database.query<Record<string, unknown>>(
"SELECT * FROM http_sessions ORDER BY sid",
{ type: QueryTypes.SELECT }
);
for (const row of original) {
await database.query("UPDATE http_sessions SET data=:data WHERE sid=:sid", {
replacements: { sid: row.sid, data: JSON.stringify(sessionFromRow(row)) },
});
}
const first = original[0];
assert.ok(first);
await database.query("UPDATE http_sessions SET data=:data WHERE sid=:sid", {
replacements: {
sid: first.sid,
data: JSON.stringify({ unknown: "No omitir silenciosamente" }),
},
});
await assert.rejects(() => normalizeSessions(database));
assert.ok((await query.describeTable("http_sessions")).data);
await database.query("UPDATE http_sessions SET data=:data WHERE sid=:sid", {
replacements: { sid: first.sid, data: JSON.stringify(sessionFromRow(first)) },
});
await normalizeSessions(database);
await normalizeSessions(database);
assert.deepEqual(await sessionSnapshot(database), expected);
assert.equal((await query.describeTable("http_sessions")).data, undefined);
const store = new SequelizeSessionStore(database);
const sid = randomUUID();
const cookie = Object.assign(new session.Cookie(), {
maxAge: 3600000,
httpOnly: true,
secure: false,
sameSite: false,
});
const data = { cookie, userId: undefined, authVersion: undefined, csrfToken: "test-csrf-token" };
await store.set(sid, data);
const restored = await new Promise<session.SessionData>((resolve, reject) => {
store
.get(sid, (error, result) => {
if (error) {
reject(error);
} else if (result) {
resolve(result);
} else {
reject(new Error("Sesión no recuperada."));
}
})
.catch(reject);
});
assert.equal(restored.cookie.originalMaxAge, 3600000);
assert.equal(restored.cookie.secure, false);
assert.equal(restored.cookie.sameSite, false);
assert.equal(restored.csrfToken, data.csrfToken);
await store.touch(sid, { ...data, csrfToken: "No sobrescribir al tocar la cookie" });
const stored = await database.query<{ csrfToken: string }>(
"SELECT csrfToken FROM http_sessions WHERE sid=:sid",
{ replacements: { sid }, type: QueryTypes.SELECT }
);
assert.equal(stored[0]?.csrfToken, data.csrfToken);
await store.destroy(sid);
assert.deepEqual(
await database.query("SELECT sid FROM http_sessions WHERE sid=:sid", {
replacements: { sid },
type: QueryTypes.SELECT,
}),
[]
);
await assert.rejects(() =>
store.set(sid, { ...data, cookie: { ...cookie, originalMaxAge: -1 } })
);
}
/** Recrea el formato legado únicamente en la base de pruebas y verifica recuperación e idempotencia. */
export async function verifyRemainingNormalization(database: Sequelize) {
assert.equal(database.getDatabaseName(), "metis_desk_test");
await verifyAccessMigration(database);
await verifyProcessMigration(database);
await verifySessionMigration(database);
// Las variantes polimórficas también se protegen frente a escritura SQL directa.
await assert.rejects(() =>
database.query(
"UPDATE remote_connections SET targetKind='asset',targetAssetId=NULL,targetResourceId=NULL"
)
);
await assert.rejects(() =>
database.query("UPDATE mail_configurations SET snapshotKind='profile',mailboxId=NULL")
);
const jsonChecks = await database.query(
"SELECT CONSTRAINT_NAME FROM information_schema.CHECK_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND LOWER(CHECK_CLAUSE) LIKE '%json_valid%'",
{ type: QueryTypes.SELECT }
);
assert.deepEqual(jsonChecks, []);
}

View File

@ -4,7 +4,7 @@ Se mantiene Sequelize 6 y MariaDB. El criterio del proyecto es almacenar datos d
## Estado de la normalización
La normalización completa todavía está en curso. La revisión anterior extendió el modelo relacional de activos a recursos, puestos, asistencia y configuraciones de perfiles. La continuación incorpora tickets, intervenciones y plantillas de intervención. No representa el cierre de la auditoría de toda la base.
La auditoría y normalización de la persistencia actual están completadas. Activos, recursos, configuraciones, tickets e intervenciones se completan con cuentas y accesos, correo, accesos remotos, procesos y sesiones HTTP. No se mantienen documentos JSON en la base de desarrollo; los contratos HTTP continúan usando JSON como transporte.
| Ámbito | Persistencia actual |
| --- | --- |
@ -16,12 +16,12 @@ La normalización completa todavía está en curso. La revisión anterior extend
| Perfiles de usuario | Nombre, estado, notas y referencias en columnas actuales e históricas |
| Teletrabajo | Estado, periodo, notas y persona en columnas actuales e históricas |
| Aplicaciones, certificados, impresoras y carpetas del perfil | Valores escalares y referencias en columnas actuales e históricas |
| Cuentas y accesos | Pendiente: catálogo discriminado, asignaciones, permisos, grupos y necesidades |
| Correo | Pendiente: proyecciones de buzones, usos y configuraciones, incluido su formato histórico anterior |
| Accesos remotos | Pendiente |
| Procesos y plantillas de procesos | Pendiente: pasos y revisiones |
| Cuentas y accesos | Subtipos en tablas actuales e históricas; permisos de necesidades en asociaciones ordenadas |
| Correo | Buzones, usos y configuraciones en columnas actuales e históricas; variantes legadas conservadas |
| Accesos remotos | Origen, destinos con claves foráneas independientes, VPN y estado en columnas actuales e históricas |
| Procesos y plantillas de procesos | Columnas escalares y tablas de instrucciones/tareas actuales e históricas |
| Tickets e intervenciones | Columnas actuales e históricas; afectados, pasos, procedimientos congelados y plantillas en tablas relacionadas |
| Sesiones HTTP | Pendiente de evaluar el contrato del almacén de sesiones existente; no se introduce una excepción nueva para JSON |
| Sesiones HTTP | Almacén Sequelize propio: autenticación, CSRF y opciones de cookie en columnas; sin documento serializado |
Las columnas `data` de fotos y logotipos son datos binarios; no son documentos JSON de negocio.
@ -36,7 +36,7 @@ Las tablas existentes `resource_definitions`, `resource_template_fields`, `resou
La ausencia de una característica se representa mediante ausencia de fila. Una cadena vacía, `0` y `false` siguen siendo valores y se conservan. Las fechas se leen como fechas ISO. La especificación congelada no se reemplaza por el estado actual del catálogo.
Los vínculos de integración con buzones y accesos se conservan en columnas con claves foráneas. Eliminar una referencia externa no debe eliminar en cascada el recurso ni sus revisiones. Las proyecciones de compatibilidad de correo/accesos siguen existiendo hasta normalizar sus consumidores.
Los vínculos de integración con buzones y accesos se conservan en columnas con claves foráneas. Eliminar una referencia externa no debe eliminar en cascada el recurso ni sus revisiones. Las proyecciones de compatibilidad de correo/accesos conservan las integraciones y sus identidades; sus lectores y escritores también utilizan columnas y tablas normalizadas.
## Puestos, perfiles y configuraciones
@ -79,9 +79,57 @@ El DDL de MariaDB no es transaccional. Una interrupción puede dejar columnas de
Las pruebas de integración utilizan exclusivamente `metis_desk_test`. Comprueban origen inválido, conservación de UUID/historial/orden/valores, referencias incompatibles, idempotencia, arranque vacío y mantenimiento del comportamiento HTTP. Las fixtures SQL que antes escribían JSON se adaptan a las tablas nuevas.
## Continuación pendiente
## Cuentas y accesos
Antes de dar por terminada la normalización hay que implementar y verificar las filas pendientes del inventario, adaptar sus lectores de dashboard/procesos y proyecciones de compatibilidad, y repetir una auditoría del esquema real y del código de persistencia. No basta con cambiar una columna JSON a LONGTEXT ni con renombrar los documentos serializados.
Se conservan las raíces access_workspaces, access_items y access_revisions, y las proyecciones access_references y access_person_references. La raíz identifica compañía/ámbito y tipo; las revisiones mantienen UUID, secuencia, autor e instante. Cada tipo tiene una tabla de valores y otra de valores históricos:
| Tipo | Actual | Histórica |
| --- | --- | --- |
| Sistema | access_system_values | access_system_revision_values |
| Tipo de recurso | access_resource_type_values | access_resource_type_revision_values |
| Permiso | access_permission_values | access_permission_revision_values |
| Plantilla de grupo | access_template_values | access_template_revision_values |
| Entorno | access_environment_values | access_environment_revision_values |
| Cuenta | access_account_values | access_account_revision_values |
| Titular/usuario de cuenta | access_account_person_values | access_account_person_revision_values |
| Grupo | access_group_values | access_group_revision_values |
| Pertenencia a grupo | access_membership_values | access_membership_revision_values |
| Proyección de recurso | access_resource_values | access_resource_revision_values |
| Permiso concedido | access_grant_values | access_grant_revision_values |
| Necesidad | access_need_values | access_need_revision_values |
| Cobertura | access_coverage_values | access_coverage_revision_values |
ownerId es clave primaria y foránea a la raíz o revisión correspondiente. Las referencias entre elementos y personas tienen claves foráneas. access_need_permissions y access_need_revision_permissions conservan permisos y orden; no permiten duplicar un permiso dentro de la misma necesidad. Las vistas access_current_values y access_historical_values unen columnas de los subtipos para lecturas comunes, sin generar JSON SQL.
El catálogo global y los ámbitos de compañía mantienen sus reglas. Se conservan titularidad histórica, cuentas compartidas, caducidad opcional, grupos, permisos, coberturas, bloqueos de concurrencia y proyecciones de referencias.
## Correo y accesos remotos
mailboxes, mailbox_uses, mail_configurations y sus revisiones almacenan dirección, tipo/contexto, cuenta vinculada, personas autorizadas, periodo, perfil, protocolo, estado y notas en columnas. snapshotKind diferencia configuraciones históricas anteriores (persona/cuenta/activo) de las actuales (perfil/buzón). No se inventa un perfil para transformar el historial anterior. Las restricciones SQL exigen las referencias apropiadas a cada variante.
remote_connections y remote_connection_revisions mantienen persona, tipo de origen, activo corporativo cuando corresponde, sede/red, método, VPN, estado y notas. El destino se separa en targetAssetId y targetResourceId, con claves foráneas reales. Las restricciones SQL impiden destinos incompatibles, un origen personal con activo corporativo y acceso al propio equipo como destino.
Los repositorios, lectores de dashboard/procesos y proyecciones de recursos reconstruyen los mismos contratos anteriores. Se mantienen las configuraciones múltiples por persona, la adopción de configuraciones legadas y todo el historial.
## Procesos
process_templates y process_template_revisions tienen columnas de nombre, tipo, estado y notas. Sus pasos ordenados se guardan en process_template_instructions y process_template_revision_instructions.
person_processes y person_process_revisions tienen persona, plantilla, nombre congelado de plantilla, nombre del proceso, tipo, estado y notas. person_process_tasks y person_process_revision_tasks conservan UUID de tarea, orden, título, estado, notas y referencia de revisión (tipo, UUID, etiqueta y procedencia).
La referencia de una tarea es información congelada para revisar una situación, no una asignación viva. No lleva FK al elemento revisado: debe conservarse aunque ese elemento se retire o elimine. Sí existen claves foráneas entre procesos, personas, plantillas, revisiones y sus tablas dependientes. Las instrucciones originales y tareas históricas no se reemplazan por una plantilla modificada.
## Sesiones
http_sessions conserva sid, expiración y fechas de creación/actualización; añade columnas de usuario, versión de autenticación, token CSRF y opciones de cookie. La columna data desaparece. El almacén propio SequelizeSessionStore sustituye connect-session-sequelize, que se retira de las dependencias.
Se conservan duración original y expiración de la cookie, opciones booleanas, autenticación y protección CSRF. touch renueva la cookie sin sobrescribir autenticación o CSRF de otra petición. Las sesiones vencidas no se recuperan y se purgan al guardar sesiones; la expiración tiene índice. No se necesita temporizador de fondo. La migración rechaza atributos de sesión desconocidos en lugar de descartarlos.
## Auditoría final
La auditoría de information_schema en desarrollo no encuentra tipos JSON, restricciones JSON_VALID ni columnas values, specification, characteristics, procedure u otros documentos serializados. Las columnas instructions de intervenciones son texto de instrucciones de un paso; data en fotos/logotipos es binario.
El código de producción no declara DataTypes.JSON. Los lectores JSON restantes corresponden a migraciones del origen anterior; las escrituras JSON de pruebas reconstruyen ese origen exclusivamente en metis_desk_test. El uso de JSON en HTTP, traducciones, logs y fixtures no es persistencia de documentos de negocio.
## Verificación de la revisión anterior
@ -101,3 +149,12 @@ El typecheck global sigue encontrando el problema anterior de `locale` en `packa
- La prueba de integración reconstruye el origen JSON exclusivamente en `metis_desk_test`, rechaza documentos y referencias incoherentes, conserva las instantáneas y contadores, comprueba restricciones de destinos y repite la migración después de simular un corte entre raíz y revisiones. También se ha ejercitado el arranque desde una base de pruebas vacía.
- Base de desarrollo migrada e inicialización verificada. Auditoría del esquema: desaparecen las columnas `values`/`procedure` de los seis registros principales y existen las diez tablas relacionadas. Copia previa: `/home/david/backups/metis-desk/20261010-133639-tickets-intervenciones.sql`.
- Los chequeos globales mantienen el error previo de `locale` en el calendario de shadcn/ui y errores de lint/formato en otros ficheros. No se modifica el código vendor ni cambios de interfaz realizados simultáneamente.
## Verificación de cuentas, correo, accesos remotos, procesos y sesiones
- Suite completa: 277 pruebas superadas; 3 pruebas de espaciado superadas.
- Typecheck de servidor, identidad, compañías y recursos: correcto. Build: correcto.
- Biome y ESLint sobre todos los ficheros modificados: correctos, sin avisos.
- Integración HTTP/MariaDB desde una base de pruebas vacía: correcta. Se verifica transferencia del origen legado, rechazo de datos inválidos, conservación de valores actuales e históricos, UUID, orden, duración de cookies, CSRF, restricciones SQL e idempotencia, incluido un corte entre raíz e historial.
- Base de desarrollo migrada, inicialización verificada y auditoría de esquema sin columnas JSON ni documentos serializados. Copia previa: /home/david/backups/metis-desk/20261010-140951-normalizacion-restante.sql.
- El typecheck global mantiene el error previo de locale en el calendario vendor; lint/check global mantienen problemas de código y formato ajenos a estos cambios. No se modifica código vendor.

View File

@ -18,6 +18,15 @@ 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,
normalizeAccess,
readAccessItems,
readAccessRevisions,
writeAccessRow,
} from "./access-relational";
class WorkspaceModel extends Model<
InferAttributes<WorkspaceModel>,
InferCreationAttributes<WorkspaceModel>
@ -27,25 +36,6 @@ class WorkspaceModel extends Model<
declare scopeKey: string;
}
class ItemModel extends Model<InferAttributes<ItemModel>, InferCreationAttributes<ItemModel>> {
declare id: string;
declare workspaceId: string;
declare kind: string;
declare values: unknown;
}
class RevisionModel extends Model<
InferAttributes<RevisionModel>,
InferCreationAttributes<RevisionModel>
> {
declare id: string;
declare itemId: string;
declare sequence: number;
declare actorId: string;
declare recordedAt: string;
declare values: unknown;
}
class ReferenceModel extends Model<
InferAttributes<ReferenceModel>,
InferCreationAttributes<ReferenceModel>
@ -93,7 +83,6 @@ export async function migrateAccess(database: Sequelize) {
id: idColumn,
workspaceId: reference("access_workspaces"),
kind: { type: DataTypes.STRING(30), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
});
}
if (!exists("access_revisions")) {
@ -103,7 +92,6 @@ export async function migrateAccess(database: Sequelize) {
sequence: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
});
}
if (!exists("access_references")) {
@ -141,26 +129,7 @@ export async function migrateAccess(database: Sequelize) {
},
{ sequelize: database, tableName: "access_workspaces", timestamps: false }
);
ItemModel.init(
{
id: idColumn,
workspaceId: { type: DataTypes.UUID, allowNull: false },
kind: { type: DataTypes.STRING(30), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
},
{ sequelize: database, tableName: "access_items", timestamps: false }
);
RevisionModel.init(
{
id: idColumn,
itemId: { type: DataTypes.UUID, allowNull: false },
sequence: { type: DataTypes.INTEGER, allowNull: false },
actorId: { type: DataTypes.UUID, allowNull: false },
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
},
{ sequelize: database, tableName: "access_revisions", timestamps: false }
);
await normalizeAccess(database);
ReferenceModel.init(
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
@ -177,7 +146,9 @@ export async function migrateAccess(database: Sequelize) {
{ 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 ItemModel.findAll()) {
for (const item of await database.transaction((transaction) =>
readAccessItems(database, transaction)
)) {
const value = domainValues(item.values).data;
if ("personId" in value) {
@ -219,7 +190,11 @@ function domainValues(value: unknown) {
}
export class AccessPersistenceMapper {
toDomain(root: WorkspaceModel, rows: readonly ItemModel[], revisions: readonly RevisionModel[]) {
toDomain(
root: WorkspaceModel,
rows: readonly AccessItemRow[],
revisions: readonly AccessRevisionRow[]
) {
const items = rows.map((row) => {
const values = domainValues(row.values);
const history = revisions
@ -343,13 +318,12 @@ export class SequelizeAccessRepository implements IAccessRepository {
root = created;
}
const roots = company ? [global, root] : await WorkspaceModel.findAll({ transaction });
const rows = await ItemModel.findAll({
where: { workspaceId: roots.map((item) => item.id) },
transaction,
});
const revisions = rows.length
? await RevisionModel.findAll({ where: { itemId: rows.map((item) => item.id) }, 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),
@ -419,11 +393,20 @@ export class SequelizeAccessRepository implements IAccessRepository {
);
for (const record of records) {
await ItemModel.upsert(record.item, { transaction });
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) {
await RevisionModel.bulkCreate(revisions, { transaction });
for (const revision of revisions) {
await writeAccessRow(this.database, revision, transaction, "insert");
}
}
const personId = Object.entries(record.item.values).find(([key]) => key === "personId")?.[1];

View File

@ -0,0 +1,409 @@
import { isDeepStrictEqual } from "node:util";
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import {
AccessDefinitionSchema,
type AccessKind,
AccessKindSchema,
} from "../../domain/access/access-definition";
import { supportIdentifier, writeSupportScalars } from "./support-record-relational";
const ref = (model = "access_items", allowNull = false): ModelAttributeColumnOptions => ({
type: DataTypes.UUID,
allowNull,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
const named = {
name: { type: DataTypes.STRING(100), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
};
const note = { type: DataTypes.TEXT, allowNull: true };
const scope = { systemId: ref(), environmentId: ref("access_items", true) };
const period = {
startDate: { type: DataTypes.STRING(10), allowNull: false },
endDate: { type: DataTypes.STRING(10), allowNull: true },
};
const configuration = { type: DataTypes.STRING(20), allowNull: false };
export const accessColumns: Record<AccessKind, Record<string, ModelAttributeColumnOptions>> = {
system: named,
resource_type: named,
permission: { ...named, resourceTypeId: ref(), description: note },
template: { ...named, description: note, systemId: ref("access_items", true) },
environment: { ...named, systemId: ref() },
account: {
...scope,
login: { type: DataTypes.STRING(255), allowNull: false },
usage: { type: DataTypes.STRING(16), allowNull: false },
active: named.active,
expiresOn: { type: DataTypes.STRING(10), allowNull: true },
notes: note,
},
account_person: {
accountId: ref(),
personId: ref("company_contacts"),
...period,
configuration: { ...configuration, allowNull: true },
},
group: {
...scope,
...named,
identifier: { type: DataTypes.STRING(255), allowNull: false },
description: note,
templateId: ref("access_items", true),
},
membership: { accountId: ref(), groupId: ref(), ...period, configuration },
resource: {
...named,
resourceTypeId: ref(),
systemId: ref("access_items", true),
environmentId: ref("access_items", true),
reference: { type: DataTypes.STRING(255), allowNull: true },
notes: note,
},
grant: {
resourceId: ref(),
permissionId: ref(),
accountId: ref("access_items", true),
groupId: ref("access_items", true),
...period,
configuration,
},
need: { personId: ref("company_contacts"), resourceId: ref(), ...period, notes: note },
coverage: {
needId: ref(),
grantId: ref(),
accountPersonId: ref(),
membershipId: ref("access_items", true),
...period,
},
};
export function accessValueTable(kind: AccessKind, history = false) {
return `access_${kind}_${history ? "revision_" : ""}values`;
}
const itemSchema = z.object({
id: z.uuid(),
workspaceId: z.uuid(),
kind: AccessKindSchema,
values: AccessDefinitionSchema,
});
const revisionSchema = z.object({
id: z.uuid(),
itemId: z.uuid(),
sequence: z.number().int().nonnegative(),
actorId: z.uuid(),
recordedAt: z.iso.datetime(),
values: AccessDefinitionSchema,
});
export type AccessItemRow = z.infer<typeof itemSchema>;
export type AccessRevisionRow = z.infer<typeof revisionSchema>;
function decoded(row: Record<string, unknown>, permissionIds: string[]) {
if (row.values !== undefined) {
return AccessDefinitionSchema.parse(
typeof row.values === "string" ? JSON.parse(row.values) : row.values
);
}
const kind = AccessKindSchema.parse(row.kind);
const values = Object.fromEntries(
Object.keys(accessColumns[kind]).map((key) => [
key,
key === "active"
? z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(row[key])
: row[key],
])
);
return AccessDefinitionSchema.parse({
kind,
...values,
...(kind === "need" ? { permissionIds } : {}),
});
}
export async function readAccessItems(
database: Sequelize,
transaction: Transaction,
companyId?: string
) {
const rows = await database.query<Record<string, unknown>>(
`SELECT i.id,i.workspaceId,v.* FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId LEFT JOIN access_current_values v ON v.ownerId=i.id AND v.kind=i.kind${companyId ? " WHERE w.companyId=:companyId OR w.companyId IS NULL" : ""}`,
{ replacements: { companyId: companyId ?? null }, transaction, type: QueryTypes.SELECT }
);
const permissions = await database.query<{
ownerId: string;
permissionId: string;
position: number;
}>("SELECT * FROM access_need_permissions ORDER BY ownerId,position", {
transaction,
type: QueryTypes.SELECT,
});
return rows.map((row) =>
itemSchema.parse({ ...row, values: decoded(row, permissionIds(permissions, row.id)) })
);
}
export async function readAccessRevisions(database: Sequelize, transaction: Transaction) {
const rows = await database.query<Record<string, unknown>>(
"SELECT r.id,r.itemId,r.sequence,r.actorId,r.recordedAt,v.* FROM access_revisions r JOIN access_items i ON i.id=r.itemId LEFT JOIN access_historical_values v ON v.ownerId=r.id AND v.kind=i.kind",
{ transaction, type: QueryTypes.SELECT }
);
const permissions = await database.query<{
ownerId: string;
permissionId: string;
position: number;
}>("SELECT * FROM access_need_revision_permissions ORDER BY ownerId,position", {
transaction,
type: QueryTypes.SELECT,
});
return rows.map((row) =>
revisionSchema.parse({ ...row, values: decoded(row, permissionIds(permissions, row.id)) })
);
}
function permissionIds(
rows: readonly { ownerId: string; permissionId: string; position: number }[],
id: unknown
) {
const selected = rows.filter((row) => row.ownerId === id);
if (selected.some((row, position) => row.position !== position)) {
throw new Error("Orden de permisos de necesidad inconsistente.");
}
return selected.map((row) => row.permissionId);
}
export async function writeAccessRow(
database: Sequelize,
row: AccessItemRow | AccessRevisionRow,
transaction: Transaction,
operation: "insert" | "update"
) {
const { values, ...metadata } = row;
const input = AccessDefinitionSchema.parse(values);
const history = "itemId" in row;
await writeSupportScalars(
database,
history ? "access_revisions" : "access_items",
metadata,
transaction,
operation
);
await writeAccessValues(database, row.id, input, history, transaction);
}
async function writeAccessValues(
database: Sequelize,
id: string,
input: z.infer<typeof AccessDefinitionSchema>,
history: boolean,
transaction: Transaction
) {
const { kind, ...values } = input;
const { permissionIds: _permissions, ...columns } = z
.record(z.string(), z.unknown())
.parse(values);
const table = accessValueTable(kind, history);
await database.query(`DELETE FROM ${supportIdentifier(table)} WHERE ownerId=:id`, {
replacements: { id },
transaction,
});
await writeSupportScalars(database, table, { ownerId: id, ...columns }, transaction, "insert");
if (kind === "need") {
const links = history ? "access_need_revision_permissions" : "access_need_permissions";
await database.query(`DELETE FROM ${supportIdentifier(links)} WHERE ownerId=:id`, {
replacements: { id },
transaction,
});
for (const [position, permissionId] of input.permissionIds.entries()) {
await writeSupportScalars(
database,
links,
{ ownerId: id, permissionId, position },
transaction,
"insert"
);
}
}
}
async function prepareAccessValues(database: Sequelize) {
const query = database.getQueryInterface();
const existing = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
);
for (const kind of AccessKindSchema.options) {
for (const history of [false, true]) {
const table = accessValueTable(kind, history);
if (!existing.some((row) => row.name === table)) {
await query.createTable(table, {
ownerId: {
...ref(history ? "access_revisions" : "access_items"),
primaryKey: true,
onDelete: "CASCADE",
},
...accessColumns[kind],
});
}
}
}
await prepareNeedPermissions(database, existing);
await createAccessViews(database);
}
async function prepareNeedPermissions(database: Sequelize, existing: readonly { name: string }[]) {
const query = database.getQueryInterface();
for (const history of [false, true]) {
const table = history ? "access_need_revision_permissions" : "access_need_permissions";
if (!existing.some((row) => row.name === table)) {
await query.createTable(table, {
ownerId: {
...ref(accessValueTable("need", history)),
references: { model: accessValueTable("need", history), key: "ownerId" },
primaryKey: true,
onDelete: "CASCADE",
},
position: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true },
permissionId: ref(),
});
}
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((index) => index.name === `${table}_unique`)) {
await query.addIndex(table, ["ownerId", "permissionId"], {
unique: true,
name: `${table}_unique`,
});
}
}
}
async function createAccessViews(database: Sequelize) {
// Vistas de lectura: columnas SQL, sin reconstruir documentos JSON en la base.
const allColumns = [...new Set(Object.values(accessColumns).flatMap(Object.keys))];
for (const history of [false, true]) {
const selections = AccessKindSchema.options.map(
(kind) =>
`SELECT ownerId,'${kind}' AS kind,${allColumns.map((name) => (name in accessColumns[kind] ? supportIdentifier(name) : `NULL AS ${supportIdentifier(name)}`)).join(",")} FROM ${supportIdentifier(accessValueTable(kind, history))}`
);
await database.query(
`CREATE OR REPLACE VIEW ${history ? "access_historical_values" : "access_current_values"} AS ${selections.join(" UNION ALL ")}`
);
}
}
export async function normalizeAccess(database: Sequelize) {
await prepareAccessValues(database);
const query = database.getQueryInterface();
const original = await query.describeTable("access_items");
const history = await query.describeTable("access_revisions");
if (!(original.values || history.values)) {
return;
}
await database.transaction(async (transaction) => {
const items = original.values
? (
await database.query<Record<string, unknown>>("SELECT * FROM access_items", {
type: QueryTypes.SELECT,
transaction,
})
).map((row) => itemSchema.parse({ ...row, values: decoded(row, []) }))
: await readAccessItems(database, transaction);
const revisions = history.values
? (
await database.query<Record<string, unknown>>("SELECT * FROM access_revisions", {
type: QueryTypes.SELECT,
transaction,
})
).map((row) => revisionSchema.parse({ ...row, values: decoded(row, []) }))
: await readAccessRevisions(database, transaction);
for (const item of items) {
const ordered = revisions
.filter((row) => row.itemId === item.id)
.sort((a, b) => a.sequence - b.sequence);
if (
item.kind !== item.values.kind ||
!ordered.length ||
ordered.some(
(row, index) =>
row.sequence !== index ||
row.values.kind !== item.kind ||
row.recordedAt < (ordered[index - 1]?.recordedAt ?? "")
) ||
!isDeepStrictEqual(ordered.at(-1)?.values, item.values)
) {
throw new Error("Historial de acceso inconsistente durante la normalización.");
}
}
// Insertar valores de todos los propietarios antes de validar referencias entre subtipos.
for (const row of [...items, ...revisions]) {
await writeAccessValues(database, row.id, row.values, "itemId" in row, transaction);
}
if (
!(
isDeepStrictEqual(
(await readAccessItems(database, transaction)).sort(byId),
items.sort(byId)
) &&
isDeepStrictEqual(
(await readAccessRevisions(database, transaction)).sort(byId),
revisions.sort(byId)
)
)
) {
throw new Error("Los accesos normalizados no coinciden con el origen.");
}
});
for (const [table, columns] of [
["access_items", original],
["access_revisions", history],
] as const) {
if (columns.values) {
await database.query(`ALTER TABLE ${supportIdentifier(table)} DROP COLUMN \`values\``);
}
}
}
function byId(left: { id: string }, right: { id: string }) {
return left.id.localeCompare(right.id);
}
export function readCompanyAccessItems(
database: Sequelize,
companyId: string,
transaction: Transaction
) {
return readAccessItems(database, transaction, companyId);
}

View File

@ -145,7 +145,22 @@ export const folderSnapshot = {
booleans: [],
} satisfies SnapshotDefinition<ReturnType<typeof FolderMappingInputSchema.parse>>;
import {
mailSnapshot,
mailboxSnapshot,
mailboxUseSnapshot,
remoteSnapshot,
} from "./mail-remote-snapshots";
export const configurationSnapshots = new Map<string, SnapshotDefinition<unknown>>([
["remote_connection_revisions", remoteSnapshot],
["mailbox_use_revisions", mailboxUseSnapshot],
["mailbox_revisions", mailboxSnapshot],
["mail_configuration_revisions", mailSnapshot],
["mail_configurations", mailSnapshot],
["mailboxes", mailboxSnapshot],
["mailbox_uses", mailboxUseSnapshot],
["remote_connections", remoteSnapshot],
["work_profiles", profileSnapshot],
["work_profile_revisions", profileSnapshot],
["telework_authorizations", teleworkSnapshot],

View File

@ -1,6 +1,10 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { ensureSnapshotConstraint } from "./mail-remote-constraints";
import { mailSnapshot } from "./mail-remote-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateMailConfigurations(database: Sequelize) {
const query = database.getQueryInterface();
const rows = await database.query<{ name: string }>(
@ -19,12 +23,7 @@ export async function migrateMailConfigurations(database: Sequelize) {
await query.createTable("mail_configurations", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: reference("companies"),
personId: { ...reference("company_contacts"), allowNull: true },
accountId: { ...reference("access_items"), allowNull: true },
assetId: { ...reference("assets"), allowNull: true },
profileId: { ...reference("work_profiles"), allowNull: true },
mailboxId: { ...reference("mailboxes"), allowNull: true },
values: { type: DataTypes.JSON, allowNull: false },
...mailSnapshot.columns,
currentKey: { type: DataTypes.STRING(110), allowNull: true, unique: true },
});
}
@ -35,7 +34,7 @@ export async function migrateMailConfigurations(database: Sequelize) {
position: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...mailSnapshot.columns,
});
}
@ -71,4 +70,17 @@ export async function migrateMailConfigurations(database: Sequelize) {
name: "mail_revision_position",
});
}
await normalizeScalarSnapshots(
database,
["mail_configurations", "mail_configuration_revisions"],
mailSnapshot
);
for (const table of ["mail_configurations", "mail_configuration_revisions"]) {
await ensureSnapshotConstraint(
database,
table,
`${table}_snapshot_shape`,
"(snapshotKind='legacy' AND personId IS NOT NULL AND accountId IS NOT NULL AND assetId IS NOT NULL AND profileId IS NULL AND mailboxId IS NULL) OR (snapshotKind='profile' AND profileId IS NOT NULL AND mailboxId IS NOT NULL AND personId IS NULL AND accountId IS NULL AND assetId IS NULL)"
);
}
}

View File

@ -20,6 +20,9 @@ export function persistedJson(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
import { mailSnapshot } from "./mail-remote-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
export type MailRow = {
id: string;
companyId: string;
@ -71,10 +74,10 @@ function mailReferences(values: MailConfigurationValues) {
export class MailConfigurationPersistenceMapper {
toDomain(row: MailRow, revisions: readonly MailRevisionRow[]) {
const values = valuesOf(row.values);
const values = valuesOf(snapshotValues(mailSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, position) => {
const revisionValues = valuesOf(revision.values);
const revisionValues = valuesOf(snapshotValues(mailSnapshot, revision));
if (
revision.position !== position ||
@ -134,7 +137,7 @@ export class MailConfigurationPersistenceMapper {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
...mailReferences(item.values),
values: JSON.stringify(values),
values: values,
},
revisions: item.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
@ -142,7 +145,7 @@ export class MailConfigurationPersistenceMapper {
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()),
values: revision.values.toPrimitive(),
})),
};
}

View File

@ -0,0 +1,23 @@
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { supportIdentifier } from "./support-record-relational";
/** Valida las alternativas de las relaciones polimórficas también en MariaDB. */
export async function ensureSnapshotConstraint(
database: Sequelize,
table: string,
name: string,
expression: string
) {
const existing = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=:table AND CONSTRAINT_NAME=:name",
{ replacements: { table, name }, type: QueryTypes.SELECT }
);
if (!existing.length) {
await database.query(
`ALTER TABLE ${supportIdentifier(table)} ADD CONSTRAINT ${supportIdentifier(name)} CHECK (${expression})`
);
}
}

View File

@ -0,0 +1,122 @@
import { DataTypes } from "sequelize";
import { MailSnapshotSchema } from "../../domain/mail-configuration-values";
import { MailboxInputSchema, MailboxUseInputSchema } from "../../domain/mailbox-values";
import { RemoteConnectionInputSchema } from "../../domain/remote-connection-values";
import type { SnapshotDefinition } from "./scalar-snapshot-normalization";
const reference = (model: string, allowNull = true) => ({
type: DataTypes.UUID,
allowNull,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
const note = { type: DataTypes.TEXT, allowNull: true };
const state = { type: DataTypes.STRING(20), allowNull: false };
export const mailboxSnapshot = {
schema: MailboxInputSchema,
columns: {
address: { type: DataTypes.STRING(254), allowNull: false },
kind: { type: DataTypes.STRING(20), allowNull: false },
context: { type: DataTypes.STRING(255), allowNull: true },
accountId: reference("access_items"),
active: { type: DataTypes.BOOLEAN, allowNull: false },
notes: note,
},
booleans: ["active"],
} satisfies SnapshotDefinition<ReturnType<typeof MailboxInputSchema.parse>>;
export const mailboxUseSnapshot = {
schema: MailboxUseInputSchema,
columns: {
mailboxId: reference("mailboxes", false),
personId: reference("company_contacts", false),
startDate: { type: DataTypes.STRING(10), allowNull: false },
endDate: { type: DataTypes.STRING(10), allowNull: true },
},
} satisfies SnapshotDefinition<ReturnType<typeof MailboxUseInputSchema.parse>>;
export const mailSnapshot = {
schema: MailSnapshotSchema,
columns: {
snapshotKind: { type: DataTypes.STRING(10), allowNull: false },
personId: reference("company_contacts"),
accountId: reference("access_items"),
assetId: reference("assets"),
profileId: reference("work_profiles"),
mailboxId: reference("mailboxes"),
address: { type: DataTypes.STRING(254), allowNull: false },
method: { type: DataTypes.STRING(16), allowNull: false },
state,
notes: note,
},
fromColumns(row: Record<string, unknown>) {
const { snapshotKind, personId, accountId, assetId, profileId, mailboxId, ...common } = row;
if (snapshotKind === "legacy") {
return { ...common, personId, accountId, assetId };
}
if (snapshotKind !== "profile") {
throw new Error("Tipo de instantánea de correo inconsistente.");
}
return { ...common, profileId, mailboxId };
},
toColumns(source: unknown) {
const input = MailSnapshotSchema.parse(source);
return {
personId: null,
accountId: null,
assetId: null,
profileId: null,
mailboxId: null,
snapshotKind: "profileId" in input ? "profile" : "legacy",
...input,
};
},
} satisfies SnapshotDefinition<ReturnType<typeof MailSnapshotSchema.parse>>;
export const remoteSnapshot = {
schema: RemoteConnectionInputSchema,
columns: {
personId: reference("company_contacts", false),
sourceKind: { type: DataTypes.STRING(20), allowNull: false },
sourceAssetId: reference("assets"),
targetKind: { type: DataTypes.STRING(16), allowNull: false },
targetAssetId: reference("assets"),
targetResourceId: reference("resources"),
siteId: reference("company_sites"),
method: { type: DataTypes.STRING(80), allowNull: false },
requiresVpn: { type: DataTypes.BOOLEAN, allowNull: false },
state,
notes: note,
},
booleans: ["requiresVpn"],
fromColumns(row: Record<string, unknown>) {
const { targetAssetId, targetResourceId, ...values } = row;
return {
...values,
targetId:
row.targetKind === "asset"
? targetAssetId
: row.targetKind === "resource"
? targetResourceId
: null,
};
},
toColumns(source: unknown) {
const input = RemoteConnectionInputSchema.parse(source);
const { targetId, ...values } = input;
return {
...values,
targetAssetId: input.targetKind === "asset" ? targetId : null,
targetResourceId: input.targetKind === "resource" ? targetId : null,
};
},
} satisfies SnapshotDefinition<ReturnType<typeof RemoteConnectionInputSchema.parse>>;

View File

@ -1,6 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateMailboxes(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -18,7 +21,6 @@ export async function migrateMailboxes(database: Sequelize) {
const root = {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
values: { type: DataTypes.JSON, allowNull: false },
};
for (const [table, columns] of [
@ -26,18 +28,27 @@ export async function migrateMailboxes(database: Sequelize) {
"mailboxes",
{
...root,
...mailboxSnapshot.columns,
accountId: fk("access_items", true),
addressKey: { type: DataTypes.STRING(300), allowNull: false, unique: true },
},
],
["mailbox_uses", { ...root, mailboxId: fk("mailboxes"), personId: fk("company_contacts") }],
[
"mailbox_uses",
{
...root,
...mailboxUseSnapshot.columns,
mailboxId: fk("mailboxes"),
personId: fk("company_contacts"),
},
],
[
"mailbox_revisions",
{
id: root.id,
itemId: fk("mailboxes"),
position: { type: DataTypes.INTEGER, allowNull: false },
values: root.values,
...mailboxSnapshot.columns,
actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
},
@ -48,7 +59,7 @@ export async function migrateMailboxes(database: Sequelize) {
id: root.id,
itemId: fk("mailbox_uses"),
position: { type: DataTypes.INTEGER, allowNull: false },
values: root.values,
...mailboxUseSnapshot.columns,
actorId: fk("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
},
@ -71,4 +82,10 @@ export async function migrateMailboxes(database: Sequelize) {
});
}
}
await normalizeScalarSnapshots(database, ["mailboxes", "mailbox_revisions"], mailboxSnapshot);
await normalizeScalarSnapshots(
database,
["mailbox_uses", "mailbox_use_revisions"],
mailboxUseSnapshot
);
}

View File

@ -9,6 +9,9 @@ import { MailboxInputSchema, MailboxUseInputSchema } from "../../domain/mailbox-
import { MailboxUseValues } from "../../domain/value-objects/mailbox-use-values.vo";
import { MailboxValues } from "../../domain/value-objects/mailbox-values.vo";
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
export function mailboxIdentity(raw: string) {
const value = UniqueID.create(raw);
@ -86,11 +89,11 @@ function mailboxUseValues(raw: unknown) {
export class MailboxPersistenceMapper {
toDomain(row: MailboxRow, revisions: readonly MailboxRevisionRow[]) {
const values = mailboxValues(row.values);
const values = mailboxValues(snapshotValues(mailboxSnapshot, row));
const history = ordered(revisions).map((revision) =>
MailboxRevision.rehydrate(
{
values: mailboxValues(revision.values),
values: mailboxValues(snapshotValues(mailboxSnapshot, revision)),
actorId: mailboxIdentity(revision.actorId),
recordedAt: revision.recordedAt,
},
@ -114,11 +117,11 @@ export class MailboxPersistenceMapper {
}
toUseDomain(row: MailboxUseRow, revisions: readonly MailboxRevisionRow[]) {
const values = mailboxUseValues(row.values);
const values = mailboxUseValues(snapshotValues(mailboxUseSnapshot, row));
const history = ordered(revisions).map((revision) =>
MailboxUseRevision.rehydrate(
{
values: mailboxUseValues(revision.values),
values: mailboxUseValues(snapshotValues(mailboxUseSnapshot, revision)),
actorId: mailboxIdentity(revision.actorId),
recordedAt: revision.recordedAt,
},
@ -152,12 +155,12 @@ export class MailboxPersistenceMapper {
return {
id: item.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
values: JSON.stringify(item.values.toPrimitive()),
values: item.values.toPrimitive(),
revisions: item.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
itemId: item.id.toPrimitive(),
position,
values: JSON.stringify(revision.values.toPrimitive()),
values: revision.values.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
})),

View File

@ -5,6 +5,12 @@ import { DataTypes, QueryTypes } from "sequelize";
import { ProcessTemplate } from "../../domain/process-template.aggregate";
import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values.vo";
import {
normalizeProcesses,
processColumns,
processTemplateColumns,
writeProcessRow,
} from "./process-relational";
import {
ProcessTemplatePersistenceMapper,
processTemplateNameKey,
@ -26,12 +32,10 @@ export async function migrateProcesses(database: Sequelize) {
const root = {
id: { type: DataTypes.UUID, primaryKey: true },
values: { type: DataTypes.JSON, allowNull: false },
};
const revision = {
id: root.id,
position: { type: DataTypes.INTEGER, allowNull: false },
values: root.values,
actorId: fk("users", true),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
};
@ -42,22 +46,30 @@ export async function migrateProcesses(database: Sequelize) {
"process_templates",
{
...root,
...processTemplateColumns,
nameKey: { type: DataTypes.STRING(160), allowNull: false, unique: true },
builtinKey: { type: DataTypes.STRING(30), allowNull: true, unique: true },
},
],
["process_template_revisions", { ...revision, itemId: fk("process_templates") }],
[
"process_template_revisions",
{ ...revision, ...processTemplateColumns, itemId: fk("process_templates") },
],
[
"person_processes",
{
...root,
...processColumns,
companyId: fk("companies"),
personId: fk("company_contacts"),
templateId: fk("process_templates"),
currentKey: { type: DataTypes.STRING(100), allowNull: true, unique: true },
},
],
["person_process_revisions", { ...revision, itemId: fk("person_processes") }],
[
"person_process_revisions",
{ ...revision, ...processColumns, itemId: fk("person_processes") },
],
] as const) {
if (!tables.some((t) => t.name === table)) {
await query.createTable(table, columns);
@ -77,6 +89,7 @@ export async function migrateProcesses(database: Sequelize) {
}
}
await database.query("INSERT IGNORE INTO process_catalog_lock (id) VALUES (1)");
await normalizeProcesses(database);
const defaults = [
{
key: "onboarding",
@ -153,14 +166,20 @@ export async function migrateProcesses(database: Sequelize) {
}
const record = new ProcessTemplatePersistenceMapper().toPersistence(template.data);
await database.query(
"INSERT INTO process_templates (id,nameKey,builtinKey,`values`) VALUES (:id,:nameKey,:builtinKey,:values)",
{ replacements: { ...record.root, builtinKey: item.key }, transaction }
await writeProcessRow(
database,
"process_templates",
{ ...record.root, builtinKey: item.key },
transaction,
"insert"
);
for (const revision of record.revisions) {
await database.query(
"INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)",
{ replacements: revision, transaction }
await writeProcessRow(
database,
"process_template_revisions",
revision,
transaction,
"insert"
);
}
}
@ -169,7 +188,7 @@ export async function migrateProcesses(database: Sequelize) {
async function normalizeTemplateNameKeys(database: Sequelize, transaction: Transaction) {
const existingNames = await database.query<{ id: string; nameKey: string; name: string }>(
"SELECT id,nameKey,JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.name')) AS name FROM process_templates",
"SELECT id,nameKey,name FROM process_templates",
{ transaction, type: QueryTypes.SELECT }
);

View File

@ -27,6 +27,23 @@ export type PersonProcessRevisionRow = {
values: unknown;
};
const rowSchema = z.object({
id: z.uuid(),
companyId: z.uuid(),
personId: z.uuid(),
templateId: z.uuid(),
currentKey: z.string().nullable(),
values: z.unknown(),
});
const revisionSchema = z.object({
id: z.uuid(),
itemId: z.uuid(),
position: z.number().int().nonnegative(),
actorId: z.uuid().nullable(),
recordedAt: z.iso.datetime(),
values: z.unknown(),
});
function identity(value: string) {
const parsed = UniqueID.create(value);
@ -85,7 +102,9 @@ function validateTransitions(history: readonly PersonProcessRevision[]) {
}
export class PersonProcessPersistenceMapper {
toDomain(row: PersonProcessRow, revisions: readonly PersonProcessRevisionRow[]) {
toDomain(source: unknown, revisionRows: readonly unknown[]) {
const row = rowSchema.parse(source);
const revisions = revisionRows.map((item) => revisionSchema.parse(item));
const values = mapped(row.values);
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((r, index) => {
@ -137,7 +156,7 @@ export class PersonProcessPersistenceMapper {
currentKey: item.values.closed
? null
: `${item.companyId.toPrimitive()}:${values.personId}:${values.kind}`,
values: JSON.stringify(values),
values: values,
},
revisions: item.history.map((r, position) => ({
id: r.id.toPrimitive(),
@ -145,7 +164,7 @@ export class PersonProcessPersistenceMapper {
position,
actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null,
recordedAt: r.recordedAt,
values: JSON.stringify(r.values.toPrimitive()),
values: r.values.toPrimitive(),
})),
};
}

View File

@ -0,0 +1,318 @@
import { isDeepStrictEqual } from "node:util";
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { ProcessTemplateInputSchema, ProcessValuesSchema } from "../../domain/process-values";
import { PersonProcessPersistenceMapper } from "./process-persistence.mapper";
import { ProcessTemplatePersistenceMapper } from "./process-template-persistence.mapper";
import { supportIdentifier, writeSupportScalars } from "./support-record-relational";
const name = { type: DataTypes.STRING(160), allowNull: false };
const note = { type: DataTypes.TEXT, allowNull: true };
const kind = { type: DataTypes.STRING(20), allowNull: false };
const reference = (model: string): ModelAttributeColumnOptions => ({
type: DataTypes.UUID,
allowNull: false,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
export const processTemplateColumns = {
name,
kind,
active: { type: DataTypes.BOOLEAN, allowNull: false },
notes: note,
};
export const processColumns = {
personId: reference("company_contacts"),
templateId: reference("process_templates"),
templateName: name,
kind,
name,
state: { type: DataTypes.STRING(20), allowNull: false },
notes: note,
};
export type ProcessTable =
| "process_templates"
| "process_template_revisions"
| "person_processes"
| "person_process_revisions";
function templateTable(table: ProcessTable) {
return table === "process_templates" || table === "process_template_revisions";
}
function columns(table: ProcessTable): Record<string, ModelAttributeColumnOptions> {
return templateTable(table) ? processTemplateColumns : processColumns;
}
function children(table: ProcessTable) {
switch (table) {
case "process_templates":
return "process_template_instructions";
case "process_template_revisions":
return "process_template_revision_instructions";
case "person_processes":
return "person_process_tasks";
case "person_process_revisions":
return "person_process_revision_tasks";
}
}
function input(table: ProcessTable, value: unknown) {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
return templateTable(table)
? ProcessTemplateInputSchema.parse(parsed)
: ProcessValuesSchema.parse(parsed);
}
function task(row: Record<string, unknown>) {
return {
id: row.taskId,
title: row.title,
status: row.status,
notes: row.notes,
reference:
row.referenceKind === null
? null
: {
kind: row.referenceKind,
id: row.referenceId,
label: row.referenceLabel,
...(row.sourceKind === null ? {} : { sourceKind: row.sourceKind }),
},
};
}
export async function readProcessRows(
database: Sequelize,
table: ProcessTable,
transaction: Transaction,
mode: "compatible" | "normalized" = "compatible"
): Promise<Record<string, unknown>[]> {
const rows = await database.query<Record<string, unknown>>(
`SELECT * FROM ${supportIdentifier(table)} ORDER BY id`,
{ type: QueryTypes.SELECT, transaction }
);
const childRows = await database.query<Record<string, unknown>>(
`SELECT * FROM ${supportIdentifier(children(table))} ORDER BY ownerId,position`,
{ type: QueryTypes.SELECT, transaction }
);
return rows.map((row) => {
const metadata = Object.fromEntries(
Object.entries(row).filter(
([key]) =>
!(key in columns(table)) ||
(table === "person_processes" && (key === "personId" || key === "templateId"))
)
);
if (mode === "compatible" && row.values !== undefined) {
return { ...metadata, values: input(table, row.values) };
}
const values = Object.fromEntries(
Object.keys(columns(table)).map((name) => [
name,
name === "active"
? z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(row[name])
: row[name],
])
);
const selected = childRows.filter((child) => child.ownerId === row.id);
if (selected.some((child, position) => child.position !== position)) {
throw new Error("Orden de tareas de proceso inconsistente.");
}
return {
...metadata,
values: input(table, {
...values,
...(templateTable(table)
? { instructions: selected.map((child) => child.title) }
: { tasks: selected.map(task) }),
}),
};
});
}
export async function writeProcessRow(
database: Sequelize,
table: ProcessTable,
row: Record<string, unknown>,
transaction: Transaction,
operation: "insert" | "update"
) {
const values = input(table, row.values);
const { values: _values, ...metadata } = row;
const {
instructions: _instructions,
tasks: _tasks,
...scalars
} = z.record(z.string(), z.unknown()).parse(values);
await writeSupportScalars(database, table, { ...metadata, ...scalars }, transaction, operation);
const ownerId = z.uuid().parse(row.id);
const childTable = children(table);
await database.query(`DELETE FROM ${supportIdentifier(childTable)} WHERE ownerId=:ownerId`, {
replacements: { ownerId },
transaction,
});
if ("instructions" in values) {
for (const [position, title] of values.instructions.entries()) {
await writeSupportScalars(
database,
childTable,
{ ownerId, position, title },
transaction,
"insert"
);
}
} else {
for (const [position, item] of values.tasks.entries()) {
await writeSupportScalars(
database,
childTable,
{
ownerId,
position,
taskId: item.id,
title: item.title,
status: item.status,
notes: item.notes,
referenceKind: item.reference?.kind ?? null,
referenceId: item.reference?.id ?? null,
referenceLabel: item.reference?.label ?? null,
sourceKind: item.reference?.sourceKind ?? null,
},
transaction,
"insert"
);
}
}
}
async function prepare(database: Sequelize, table: ProcessTable) {
const query = database.getQueryInterface();
const original = await query.describeTable(table);
for (const [name, column] of Object.entries(columns(table))) {
if (!(name in original)) {
await query.addColumn(table, name, { ...column, allowNull: true });
}
}
const childTable = children(table);
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
{ replacements: { table: childTable }, type: QueryTypes.SELECT }
);
if (!tables.length) {
await query.createTable(childTable, {
ownerId: { ...reference(table), onDelete: "CASCADE", primaryKey: true },
position: { type: DataTypes.INTEGER, allowNull: false, primaryKey: templateTable(table) },
title: name,
...(templateTable(table)
? {}
: {
taskId: { type: DataTypes.UUID, allowNull: false, primaryKey: true },
status: { type: DataTypes.STRING(20), allowNull: false },
notes: note,
referenceKind: { type: DataTypes.STRING(20), allowNull: true },
referenceId: { type: DataTypes.UUID, allowNull: true },
referenceLabel: { type: DataTypes.STRING(500), allowNull: true },
sourceKind: { type: DataTypes.STRING(20), allowNull: true },
}),
});
}
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: childTable }, type: QueryTypes.SELECT }
);
if (!indexes.some((row) => row.name === `${childTable}_position`)) {
await query.addIndex(childTable, ["ownerId", "position"], {
unique: true,
name: `${childTable}_position`,
});
}
return original.values !== undefined;
}
async function transfer(database: Sequelize, rootTable: ProcessTable, revisionTable: ProcessTable) {
await database.transaction(async (transaction) => {
const roots = await readProcessRows(database, rootTable, transaction);
const revisions = await readProcessRows(database, revisionTable, transaction);
for (const root of roots) {
const history = revisions.filter((row) => row.itemId === root.id);
if (templateTable(rootTable)) {
new ProcessTemplatePersistenceMapper().toDomain(root, history);
} else {
new PersonProcessPersistenceMapper().toDomain(root, history);
}
}
for (const [table, rows] of [
[rootTable, roots],
[revisionTable, revisions],
] as const) {
for (const row of rows) {
await writeProcessRow(database, table, row, transaction, "update");
}
if (
!isDeepStrictEqual(rows, await readProcessRows(database, table, transaction, "normalized"))
) {
throw new Error("Los procesos normalizados no coinciden con el origen.");
}
}
});
}
export async function normalizeProcesses(database: Sequelize) {
for (const [root, revision] of [
["process_templates", "process_template_revisions"],
["person_processes", "person_process_revisions"],
] as const) {
const rootLegacy = await prepare(database, root);
const revisionLegacy = await prepare(database, revision);
if (rootLegacy || revisionLegacy) {
await transfer(database, root, revision);
for (const [table, legacy] of [
[root, rootLegacy],
[revision, revisionLegacy],
] as const) {
if (legacy) {
await database.query(`ALTER TABLE ${supportIdentifier(table)} DROP COLUMN \`values\``);
}
}
}
await tightenProcessColumns(database, root);
await tightenProcessColumns(database, revision);
}
}
async function tightenProcessColumns(database: Sequelize, table: ProcessTable) {
const query = database.getQueryInterface();
const final = await query.describeTable(table);
for (const [name, column] of Object.entries(columns(table))) {
if (column.allowNull === false && final[name]?.allowNull) {
await query.changeColumn(table, name, column);
}
}
}

View File

@ -45,6 +45,8 @@ function configurationDetail(
type Profile = { id: string; values: WorkProfileInput };
import { readAccessItems } from "./access-relational";
type Row = { id: string; values: unknown };
type Table =
@ -200,10 +202,13 @@ export class ProcessReviewReader {
...this.remote(await rows("remote_connections"), assetNames, resourceNames),
];
const relationships: ProcessReviewSources["relationships"][number][] = [];
const rawAccess = await this.database.query<{ id: string; values: unknown }>(
"SELECT i.id,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId",
const workspaces = await this.database.query<{ id: string }>(
"SELECT id FROM access_workspaces WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const rawAccess = (await readAccessItems(this.database, transaction)).filter((row) =>
workspaces.some((workspace) => workspace.id === row.workspaceId)
);
const access = rawAccess.map((r) => ({
id: profileIdentity(r.id).toPrimitive(),
values: AccessDefinitionSchema.parse(persistedJson(r.values)),
@ -211,7 +216,7 @@ export class ProcessReviewReader {
relationships.push(...accessReviewRelationships(access));
const mailboxes = await this.database.query<{ id: string; address: string }>(
"SELECT id,JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.address')) AS address FROM mailboxes WHERE companyId=:companyId",
"SELECT id,address FROM mailboxes WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);

View File

@ -22,6 +22,16 @@ export type ProcessTemplateRevisionRow = {
values: unknown;
};
const rowSchema = z.object({ id: z.uuid(), nameKey: z.string(), values: z.unknown() });
const revisionSchema = z.object({
id: z.uuid(),
itemId: z.uuid(),
position: z.number().int().nonnegative(),
actorId: z.uuid().nullable(),
recordedAt: z.iso.datetime(),
values: z.unknown(),
});
function identity(value: string) {
const parsed = UniqueID.create(value);
@ -43,7 +53,9 @@ function mapped(raw: unknown) {
}
export class ProcessTemplatePersistenceMapper {
toDomain(row: ProcessTemplateRow, revisions: readonly ProcessTemplateRevisionRow[]) {
toDomain(source: unknown, revisionRows: readonly unknown[]) {
const row = rowSchema.parse(source);
const revisions = revisionRows.map((item) => revisionSchema.parse(item));
const values = mapped(row.values);
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((r, index) => {
@ -84,7 +96,7 @@ export class ProcessTemplatePersistenceMapper {
root: {
id: item.id.toPrimitive(),
nameKey: processTemplateNameKey(values.name),
values: JSON.stringify(values),
values: values,
},
revisions: item.history.map((r, position) => ({
id: r.id.toPrimitive(),
@ -92,7 +104,7 @@ export class ProcessTemplatePersistenceMapper {
position,
actorId: r.actorId.getOrUndefined()?.toPrimitive() ?? null,
recordedAt: r.recordedAt,
values: JSON.stringify(r.values.toPrimitive()),
values: r.values.toPrimitive(),
})),
};
}

View File

@ -1,6 +1,10 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { ensureSnapshotConstraint } from "./mail-remote-constraints";
import { remoteSnapshot } from "./mail-remote-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateRemoteConnections(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -19,12 +23,7 @@ export async function migrateRemoteConnections(database: Sequelize) {
await query.createTable("remote_connections", {
id: { type: DataTypes.UUID, primaryKey: true },
companyId: reference("companies"),
personId: reference("company_contacts"),
sourceAssetId: reference("assets", true),
targetAssetId: reference("assets", true),
targetResourceId: reference("resources", true),
siteId: reference("company_sites", true),
values: { type: DataTypes.JSON, allowNull: false },
...remoteSnapshot.columns,
currentKey: { type: DataTypes.STRING(64), allowNull: true, unique: true },
});
}
@ -35,7 +34,7 @@ export async function migrateRemoteConnections(database: Sequelize) {
position: { type: DataTypes.INTEGER, allowNull: false },
actorId: reference("users"),
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
...remoteSnapshot.columns,
});
}
const indexes = await database.query<{ name: string }>(
@ -49,4 +48,17 @@ export async function migrateRemoteConnections(database: Sequelize) {
name: "remote_revision_position",
});
}
await normalizeScalarSnapshots(
database,
["remote_connections", "remote_connection_revisions"],
remoteSnapshot
);
for (const table of ["remote_connections", "remote_connection_revisions"]) {
await ensureSnapshotConstraint(
database,
table,
`${table}_snapshot_shape`,
"((sourceKind='company_asset' AND sourceAssetId IS NOT NULL) OR (sourceKind IN ('personal_computer','personal_mobile') AND sourceAssetId IS NULL)) AND ((targetKind='network' AND targetAssetId IS NULL AND targetResourceId IS NULL) OR (targetKind='asset' AND targetAssetId IS NOT NULL AND targetResourceId IS NULL AND siteId IS NULL AND (sourceAssetId IS NULL OR sourceAssetId<>targetAssetId)) OR (targetKind='resource' AND targetResourceId IS NOT NULL AND targetAssetId IS NULL AND siteId IS NULL))"
);
}
}

View File

@ -10,6 +10,8 @@ import {
} from "../../domain/remote-connection-values";
import { RemoteConnectionValues } from "../../domain/value-objects/remote-connection-values.vo";
import { remoteSnapshot } from "./mail-remote-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
export type RemoteRow = {
@ -47,10 +49,10 @@ function mapped(raw: unknown) {
export class RemoteConnectionPersistenceMapper {
toDomain(row: RemoteRow, revisions: readonly RemoteRevisionRow[]) {
const values = mapped(row.values);
const values = mapped(snapshotValues(remoteSnapshot, row));
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, index) => {
const value = mapped(revision.values);
const value = mapped(snapshotValues(remoteSnapshot, revision));
const previous = ordered[index - 1];
if (
@ -123,7 +125,7 @@ export class RemoteConnectionPersistenceMapper {
targetAssetId: values.targetKind === "asset" ? values.targetId : null,
targetResourceId: values.targetKind === "resource" ? values.targetId : null,
siteId: values.siteId,
values: JSON.stringify(values),
values: values,
...remoteKeys(item.values),
},
revisions: item.history.map((revision, position) => ({
@ -132,7 +134,7 @@ export class RemoteConnectionPersistenceMapper {
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(revision.values.toPrimitive()),
values: revision.values.toPrimitive(),
})),
};
}

View File

@ -10,6 +10,8 @@ export type SnapshotDefinition<T> = {
schema: z.ZodType<T>;
columns: Columns;
booleans?: readonly string[];
fromColumns?: (row: Record<string, unknown>) => unknown;
toColumns?: (input: T) => object;
};
function identifier(value: string) {
@ -41,7 +43,7 @@ export function snapshotValues<T>(definition: SnapshotDefinition<T>, row: object
})
);
return definition.schema.parse(input);
return definition.schema.parse(definition.fromColumns ? definition.fromColumns(input) : input);
}
export async function normalizeScalarSnapshots<T>(
@ -100,7 +102,7 @@ async function transferSnapshotRow<T>(
const expected = snapshotValues(definition, row);
const values = z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.parse(expected);
.parse(definition.toColumns ? definition.toColumns(expected) : expected);
for (const [name, value] of Object.entries(values)) {
if (name.endsWith("Id") && original.has(name) && !isDeepStrictEqual(row[name], value)) {

View File

@ -0,0 +1,26 @@
import type { Sequelize, Transaction } from "sequelize";
import { z } from "zod/v4";
import { type SnapshotDefinition, snapshotValues } from "./scalar-snapshot-normalization";
import { writeSupportScalars } from "./support-record-relational";
export async function writeSnapshotRow<T>(
database: Sequelize,
table: string,
definition: SnapshotDefinition<T>,
row: object,
transaction: Transaction,
operation: "insert" | "update"
) {
const { values: _values, ...metadata } = z.record(z.string(), z.unknown()).parse(row);
const input = snapshotValues(definition, row);
const columns = definition.toColumns ? definition.toColumns(input) : input;
await writeSupportScalars(
database,
table,
{ ...metadata, ...z.record(z.string(), z.unknown()).parse(columns) },
transaction,
operation
);
}

View File

@ -13,11 +13,11 @@ import type {
const OPEN_TICKET = "t.state IN ('open','in_progress','waiting')";
const PENDING_INTERVENTION = "i.state IN ('pending','in_progress')";
const PROFILE_REVIEW = `p.active=true AND (person.active=0 OR a.status='retired' OR (p.accountId IS NOT NULL AND (
account.id IS NULL OR JSON_EXTRACT(account.\`values\`,'$.active')<>true OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.usage'))='technical'
OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.expiresOn')) < :today
OR NOT EXISTS (SELECT 1 FROM access_items s WHERE s.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.systemId')) AND s.kind='system' AND JSON_EXTRACT(s.\`values\`,'$.active')=true)
OR (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.environmentId')),'null') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM access_items e WHERE e.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.environmentId')) AND e.kind='environment' AND JSON_EXTRACT(e.\`values\`,'$.active')=true))
OR NOT EXISTS (SELECT 1 FROM access_items link WHERE link.kind='account_person' AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.personId'))=p.personId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.accountId'))=p.accountId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.startDate'))<=:today AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate'))>:today) AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.configuration')) IN ('pending','configured'))
account.ownerId IS NULL OR account.active<>true OR account.usage='technical'
OR account.expiresOn < :today
OR NOT EXISTS (SELECT 1 FROM access_current_values s WHERE s.ownerId=account.systemId AND s.kind='system' AND s.active=true)
OR (NULLIF(account.environmentId,'null') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM access_current_values e WHERE e.ownerId=account.environmentId AND e.kind='environment' AND e.active=true))
OR NOT EXISTS (SELECT 1 FROM access_current_values link WHERE link.kind='account_person' AND link.personId=p.personId AND link.accountId=p.accountId AND link.startDate<=:today AND (NULLIF(link.endDate,'null') IS NULL OR link.endDate>:today) AND link.configuration IN ('pending','configured'))
)))`;
export class SequelizeDashboardRepository implements IDashboardRepository {
@ -102,7 +102,7 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
private async issues(query: DashboardDetailsQuery) {
if (query.issue === "profile") {
const from =
"FROM work_profiles p JOIN companies c ON c.id=p.companyId JOIN company_contacts person ON person.id=p.personId JOIN assets a ON a.id=p.assetId LEFT JOIN access_items account ON account.id=p.accountId";
"FROM work_profiles p JOIN companies c ON c.id=p.companyId JOIN company_contacts person ON person.id=p.personId JOIN assets a ON a.id=p.assetId LEFT JOIN access_current_values account ON account.ownerId=p.accountId";
const where = `WHERE (:companyId IS NULL OR p.companyId=:companyId) AND ${PROFILE_REVIEW}`;
const count = await this.select<{ total: number }>(
`SELECT COUNT(*) AS total ${from} ${where}`,
@ -138,9 +138,9 @@ export class SequelizeDashboardRepository implements IDashboardRepository {
`EXISTS (SELECT 1 FROM ${table} cfg JOIN work_profiles w ON w.id=cfg.profileId WHERE cfg.companyId=p.companyId AND w.personId=p.id AND cfg.state NOT IN ('removed','never_configured'))`
)
.join(" OR ");
const where = `WHERE p.active=0 AND (:companyId IS NULL OR p.companyId=:companyId) AND (p.id IN (:ids) OR EXISTS (SELECT 1 FROM work_profiles w WHERE w.personId=p.id AND w.active=true) OR EXISTS (SELECT 1 FROM access_items link JOIN access_workspaces ws ON ws.id=link.workspaceId WHERE ws.companyId=p.companyId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.personId'))=p.id AND ((link.kind='need' AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate'))>:today)) OR (link.kind='account_person' AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.configuration')) IN ('pending','configured','removal_pending'))))
OR EXISTS (SELECT 1 FROM remote_connections r WHERE r.companyId=p.companyId AND r.personId=p.id AND JSON_UNQUOTE(JSON_EXTRACT(r.\`values\`,'$.state')) NOT IN ('removed','never_configured'))
OR EXISTS (SELECT 1 FROM mail_configurations m LEFT JOIN work_profiles w ON w.id=m.profileId WHERE m.companyId=p.companyId AND (m.personId=p.id OR w.personId=p.id) AND JSON_UNQUOTE(JSON_EXTRACT(m.\`values\`,'$.state')) NOT IN ('removed','never_configured'))
const where = `WHERE p.active=0 AND (:companyId IS NULL OR p.companyId=:companyId) AND (p.id IN (:ids) OR EXISTS (SELECT 1 FROM work_profiles w WHERE w.personId=p.id AND w.active=true) OR EXISTS (SELECT 1 FROM access_current_values link JOIN access_items li ON li.id=link.ownerId JOIN access_workspaces ws ON ws.id=li.workspaceId WHERE ws.companyId=p.companyId AND link.personId=p.id AND ((link.kind='need' AND (NULLIF(link.endDate,'null') IS NULL OR link.endDate>:today)) OR (link.kind='account_person' AND link.configuration IN ('pending','configured','removal_pending'))))
OR EXISTS (SELECT 1 FROM remote_connections r WHERE r.companyId=p.companyId AND r.personId=p.id AND r.state NOT IN ('removed','never_configured'))
OR EXISTS (SELECT 1 FROM mail_configurations m LEFT JOIN work_profiles w ON w.id=m.profileId WHERE m.companyId=p.companyId AND (m.personId=p.id OR w.personId=p.id) AND m.state NOT IN ('removed','never_configured'))
OR ${profileConfigurations}
OR EXISTS (SELECT 1 FROM telework_authorizations ta WHERE ta.companyId=p.companyId AND ta.personId=p.id AND ta.active=true AND (ta.expiresOn IS NULL OR ta.expiresOn>=:today)))`;
const extra = { ids: ids.length ? ids : [""] };

View File

@ -9,18 +9,14 @@ import type {
} from "../../application/folder-mapping.repository";
import { folderReferences } from "../../application/folder-mapping-policy";
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
import { AccessValues } from "../../domain/access/access-values.vo";
import { FolderMappingError } from "../../domain/folder-mapping-values";
import { readCompanyAccessItems } from "./access-relational";
import type { FolderRevisionRow, FolderRow } from "./folder-mapping-persistence.mapper";
import { FolderMappingPersistenceMapper } from "./folder-mapping-persistence.mapper";
import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper";
import {
WorkProfilePersistenceMapper,
persistedJson,
profileIdentity,
} from "./work-profile-persistence.mapper";
import { WorkProfilePersistenceMapper, profileIdentity } from "./work-profile-persistence.mapper";
export class SequelizeFolderMappingRepository implements IFolderMappingRepository {
private readonly mapper = new FolderMappingPersistenceMapper();
@ -61,10 +57,7 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
"SELECT r.* FROM work_profile_revisions r JOIN work_profiles p ON p.id=r.profileId WHERE p.companyId=:companyId",
{ ...options, type: QueryTypes.SELECT }
);
const items = await this.database.query<{ id: string; values: unknown }>(
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
{ ...options, type: QueryTypes.SELECT }
);
const items = await readCompanyAccessItems(this.database, company.toPrimitive(), transaction);
const people = await this.database.query<{ id: string; active: boolean }>(
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
{ ...options, type: QueryTypes.SELECT }
@ -87,9 +80,7 @@ export class SequelizeFolderMappingRepository implements IFolderMappingRepositor
)
),
items: items.map((item) => {
const values = AccessValues.create(
AccessDefinitionSchema.parse(persistedJson(item.values))
);
const values = AccessValues.create(item.values);
if (!values.isSuccess) {
throw new Error("Recurso de carpeta persistido inconsistente.");

View File

@ -92,6 +92,9 @@ function validateReferences(context: MailContext, accounts: MailboxContext["item
}
}
import { mailSnapshot } from "./mail-remote-snapshots";
import { writeSnapshotRow } from "./scalar-snapshot-writer";
export class SequelizeMailConfigurationRepository implements IMailConfigurationRepository {
private readonly mapper = new MailConfigurationPersistenceMapper();
@ -228,18 +231,26 @@ export class SequelizeMailConfigurationRepository implements IMailConfigurationR
if (previous !== candidate.data) {
const record = this.mapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO mail_configurations (id,companyId,personId,accountId,assetId,profileId,mailboxId,`values`,currentKey) VALUES (:id,:companyId,:personId,:accountId,:assetId,:profileId,:mailboxId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey),personId=VALUES(personId),accountId=VALUES(accountId),assetId=VALUES(assetId),profileId=VALUES(profileId),mailboxId=VALUES(mailboxId)",
{ replacements: record.root, transaction }
await writeSnapshotRow(
this.database,
"mail_configurations",
mailSnapshot,
record.root,
transaction,
previous ? "update" : "insert"
);
const stored = new Set(
previous?.history.map((revision) => revision.id.toPrimitive()) ?? []
);
for (const revision of record.revisions.filter((item) => !stored.has(item.id))) {
await this.database.query(
"INSERT INTO mail_configuration_revisions (id,configurationId,position,actorId,recordedAt,`values`) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:values)",
{ replacements: revision, transaction }
await writeSnapshotRow(
this.database,
"mail_configuration_revisions",
mailSnapshot,
revision,
transaction,
"insert"
);
}
}

View File

@ -9,8 +9,11 @@ import { Mailbox } from "../../domain/mailbox.aggregate";
import type { MailboxUse } from "../../domain/mailbox-use.aggregate";
import { MailboxError } from "../../domain/mailbox-values";
import { readCompanyAccessItems } from "./access-relational";
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
import type { MailboxRevisionRow, MailboxRow, MailboxUseRow } from "./mailbox-persistence.mapper";
import { MailboxPersistenceMapper, mailboxIdentity } from "./mailbox-persistence.mapper";
import { writeSnapshotRow } from "./scalar-snapshot-writer";
export class SequelizeMailboxRepository implements IMailboxRepository {
private readonly mapper = new MailboxPersistenceMapper();
@ -51,10 +54,7 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
{ replacements, transaction, type: QueryTypes.SELECT }
);
const items = await this.database.query<{ id: string; values: unknown }>(
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
{ replacements, transaction, type: QueryTypes.SELECT }
);
const items = await readCompanyAccessItems(this.database, companyId.toPrimitive(), transaction);
const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users",
{ transaction, type: QueryTypes.SELECT }
@ -122,10 +122,25 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
for (const revision of row.revisions.filter((revision) => !existing.has(revision.id))) {
const table = item instanceof Mailbox ? "mailbox_revisions" : "mailbox_use_revisions";
await this.database.query(
`INSERT INTO ${table} (id,itemId,position,actorId,recordedAt,\`values\`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)`,
{ replacements: revision, transaction }
);
if (item instanceof Mailbox) {
await writeSnapshotRow(
this.database,
table,
mailboxSnapshot,
revision,
transaction,
"insert"
);
} else {
await writeSnapshotRow(
this.database,
table,
mailboxUseSnapshot,
revision,
transaction,
"insert"
);
}
}
}
@ -133,6 +148,43 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
private async persistItem(
item: Mailbox | MailboxUse,
previous: Mailbox | MailboxUse | undefined,
transaction: Transaction
) {
const operation = previous ? "update" : "insert";
const row = this.mapper.toPersistence(item);
if (item instanceof Mailbox) {
const values = item.values.toPrimitive();
await writeSnapshotRow(
this.database,
"mailboxes",
mailboxSnapshot,
{
id: row.id,
companyId: row.companyId,
values,
addressKey: `${row.companyId}:${values.address}`,
},
transaction,
operation
);
} else {
await writeSnapshotRow(
this.database,
"mailbox_uses",
mailboxUseSnapshot,
{ id: row.id, companyId: row.companyId, values: item.values.toPrimitive() },
transaction,
operation
);
}
await this.saveRevisions(item, previous, transaction);
}
save(companyId: UniqueID, action: Parameters<IMailboxRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
@ -152,42 +204,7 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
: loaded.data.uses.find((old) => old.id.equals(item.id));
if (previous !== item) {
const row = this.mapper.toPersistence(item);
if (item instanceof Mailbox) {
const values = item.values.toPrimitive();
await this.database.query(
"INSERT INTO mailboxes (id,companyId,accountId,addressKey,`values`) VALUES (:id,:companyId,:accountId,:addressKey,:values) ON DUPLICATE KEY UPDATE accountId=VALUES(accountId),addressKey=VALUES(addressKey),`values`=VALUES(`values`)",
{
replacements: {
id: row.id,
companyId: row.companyId,
values: row.values,
accountId: values.accountId,
addressKey: `${row.companyId}:${values.address}`,
},
transaction,
}
);
} else {
const values = item.values.toPrimitive();
await this.database.query(
"INSERT INTO mailbox_uses (id,companyId,mailboxId,personId,`values`) VALUES (:id,:companyId,:mailboxId,:personId,:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)",
{
replacements: {
id: row.id,
companyId: row.companyId,
values: row.values,
mailboxId: values.mailboxId,
personId: values.personId,
},
transaction,
}
);
}
await this.saveRevisions(item, previous, transaction);
await this.persistItem(item, previous, transaction);
}
return Result.ok({

View File

@ -12,13 +12,9 @@ import type {
} from "../../application/process.repository";
import { ProcessError } from "../../domain/process-values";
import type { PersonProcessRevisionRow, PersonProcessRow } from "./process-persistence.mapper";
import { PersonProcessPersistenceMapper } from "./process-persistence.mapper";
import { readProcessRows, writeProcessRow } from "./process-relational";
import { ProcessReviewReader } from "./process-review-reader";
import type {
ProcessTemplateRevisionRow,
ProcessTemplateRow,
} from "./process-template-persistence.mapper";
import { ProcessTemplatePersistenceMapper } from "./process-template-persistence.mapper";
export class SequelizeProcessRepository implements IProcessRepository {
@ -32,13 +28,11 @@ export class SequelizeProcessRepository implements IProcessRepository {
) {}
private async loadCatalog(transaction: Transaction): Promise<ProcessCatalogContext> {
const templates = await this.database.query<ProcessTemplateRow>(
"SELECT * FROM process_templates",
{ transaction, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<ProcessTemplateRevisionRow>(
"SELECT * FROM process_template_revisions",
{ transaction, type: QueryTypes.SELECT }
const templates = await readProcessRows(this.database, "process_templates", transaction);
const revisions = await readProcessRows(
this.database,
"process_template_revisions",
transaction
);
const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users",
@ -71,14 +65,10 @@ export class SequelizeProcessRepository implements IProcessRepository {
return Result.fail(new ProcessError("COMPANY_NOT_FOUND"));
}
const catalog = await this.loadCatalog(transaction);
const rows = await this.database.query<PersonProcessRow>(
"SELECT * FROM person_processes WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const revisions = await this.database.query<PersonProcessRevisionRow>(
"SELECT r.* FROM person_process_revisions r JOIN person_processes p ON p.id=r.itemId WHERE p.companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
const rows = (await readProcessRows(this.database, "person_processes", transaction)).filter(
(row) => row.companyId === companyId
);
const revisions = await readProcessRows(this.database, "person_process_revisions", transaction);
const persons = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM company_contacts WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
@ -147,16 +137,22 @@ export class SequelizeProcessRepository implements IProcessRepository {
if (previous !== candidate.data) {
const record = this.templateMapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO process_templates (id,nameKey,`values`) VALUES (:id,:nameKey,:values) ON DUPLICATE KEY UPDATE nameKey=VALUES(nameKey),`values`=VALUES(`values`)",
{ replacements: record.root, transaction }
await writeProcessRow(
this.database,
"process_templates",
record.root,
transaction,
previous ? "update" : "insert"
);
const known = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const r of record.revisions.filter((r) => !known.has(r.id))) {
await this.database.query(
"INSERT INTO process_template_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)",
{ replacements: r, transaction }
await writeProcessRow(
this.database,
"process_template_revisions",
r,
transaction,
"insert"
);
}
}
@ -176,7 +172,9 @@ export class SequelizeProcessRepository implements IProcessRepository {
}
);
if (!rows.length) return Result.fail(new ProcessError("PROCESS_NOT_FOUND"));
if (!rows.length) {
return Result.fail(new ProcessError("PROCESS_NOT_FOUND"));
}
await this.database.query("DELETE FROM person_process_revisions WHERE itemId=:id", {
replacements: { id: process.toPrimitive() },
transaction,
@ -210,16 +208,22 @@ export class SequelizeProcessRepository implements IProcessRepository {
if (previous !== candidate.data) {
const record = this.processMapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO person_processes (id,companyId,personId,templateId,currentKey,`values`) VALUES (:id,:companyId,:personId,:templateId,:currentKey,:values) ON DUPLICATE KEY UPDATE currentKey=VALUES(currentKey),`values`=VALUES(`values`)",
{ replacements: record.root, transaction }
await writeProcessRow(
this.database,
"person_processes",
record.root,
transaction,
previous ? "update" : "insert"
);
const known = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const r of record.revisions.filter((r) => !known.has(r.id))) {
await this.database.query(
"INSERT INTO person_process_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values)",
{ replacements: r, transaction }
await writeProcessRow(
this.database,
"person_process_revisions",
r,
transaction,
"insert"
);
}
}

View File

@ -12,8 +12,10 @@ import { remoteReferences } from "../../application/remote-connection-policy";
import type { IWorkProfileInventoryService } from "../../application/work-profile.repository";
import { RemoteConnectionError } from "../../domain/remote-connection-values";
import { remoteSnapshot } from "./mail-remote-snapshots";
import type { RemoteRevisionRow, RemoteRow } from "./remote-connection-persistence.mapper";
import { RemoteConnectionPersistenceMapper } from "./remote-connection-persistence.mapper";
import { writeSnapshotRow } from "./scalar-snapshot-writer";
import type { TeleworkRevisionRow, TeleworkRow } from "./telework-authorization-persistence.mapper";
import {
TeleworkAuthorizationPersistenceMapper,
@ -125,16 +127,24 @@ export class SequelizeRemoteConnectionRepository implements IRemoteConnectionRep
if (previous !== candidate.data) {
const record = this.mapper.toPersistence(candidate.data);
await this.database.query(
"INSERT INTO remote_connections (id,companyId,personId,sourceAssetId,targetAssetId,targetResourceId,siteId,`values`,currentKey) VALUES (:id,:companyId,:personId,:sourceAssetId,:targetAssetId,:targetResourceId,:siteId,:values,:currentKey) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`),currentKey=VALUES(currentKey)",
{ replacements: record.root, transaction }
await writeSnapshotRow(
this.database,
"remote_connections",
remoteSnapshot,
record.root,
transaction,
previous ? "update" : "insert"
);
const stored = new Set(previous?.history.map((r) => r.id.toPrimitive()));
for (const revision of record.revisions.filter((r) => !stored.has(r.id))) {
await this.database.query(
"INSERT INTO remote_connection_revisions (id,connectionId,position,actorId,recordedAt,`values`) VALUES (:id,:connectionId,:position,:actorId,:recordedAt,:values)",
{ replacements: revision, transaction }
await writeSnapshotRow(
this.database,
"remote_connection_revisions",
remoteSnapshot,
revision,
transaction,
"insert"
);
}
}

View File

@ -8,15 +8,11 @@ import type {
IWorkProfileRepository,
WorkProfileContext,
} from "../../application/work-profile.repository";
import { AccessDefinitionSchema } from "../../domain/access/access-definition";
import { WorkProfileError } from "../../domain/work-profile-values";
import { readCompanyAccessItems } from "./access-relational";
import type { ProfileRevisionRow, ProfileRow } from "./work-profile-persistence.mapper";
import {
WorkProfilePersistenceMapper,
persistedJson,
profileIdentity,
} from "./work-profile-persistence.mapper";
import { WorkProfilePersistenceMapper, profileIdentity } from "./work-profile-persistence.mapper";
export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
private readonly mapper = new WorkProfilePersistenceMapper();
@ -53,10 +49,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const items = await this.database.query<{ id: string; values: unknown }>(
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE w.companyId=:companyId OR w.companyId IS NULL",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const items = await readCompanyAccessItems(this.database, companyId, transaction);
const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id,name FROM users",
{ transaction, type: QueryTypes.SELECT }
@ -74,7 +67,7 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
})),
items: items.map((item) => ({
id: profileIdentity(item.id),
values: AccessDefinitionSchema.parse(persistedJson(item.values)),
values: item.values,
})),
assets: await this.inventory.list(company),
actors: new Map(actors.map((actor) => [actor.id, actor.name])),

View File

@ -1,7 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
/** Migración aditiva; nunca altera ni borra tablas existentes. */
import { normalizeSessions, sessionColumns } from "./session-normalization";
/** Migraciones explícitas de identidad y normalización de sesiones. */
export async function migrateIdentity(database: Sequelize): Promise<void> {
const query = database.getQueryInterface();
const tableRows = await database.query<{ name: string }>(
@ -50,9 +52,10 @@ export async function migrateIdentity(database: Sequelize): Promise<void> {
await query.createTable("http_sessions", {
sid: { type: DataTypes.STRING(36), primaryKey: true },
expires: { type: DataTypes.DATE, allowNull: true },
data: { type: DataTypes.TEXT, allowNull: true },
...sessionColumns,
createdAt: { type: DataTypes.DATE, allowNull: false },
updatedAt: { type: DataTypes.DATE, allowNull: false },
});
}
await normalizeSessions(database);
}

View File

@ -0,0 +1,82 @@
import session from "express-session";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { restoreSession, sessionRow, writeSession } from "./session-normalization";
type Callback = (error?: Error | null) => void;
function failure(error: unknown) {
return error instanceof Error ? error : new Error("Fallo del almacén de sesiones.");
}
/** Almacén propio: persistencia relacional del contrato de sesión usado por Metis Desk. */
export class SequelizeSessionStore extends session.Store {
constructor(private readonly database: Sequelize) {
super();
}
async get(
sid: string,
callback: (error: Error | null, data?: session.SessionData | null) => void
) {
let result: session.SessionData | null = null;
let error: Error | null = null;
try {
const rows = await this.database.query<Record<string, unknown>>(
"SELECT * FROM http_sessions WHERE sid=:sid AND expires>NOW()",
{ replacements: { sid }, type: QueryTypes.SELECT }
);
result = rows[0] ? restoreSession(rows[0]) : null;
} catch (caught) {
error = failure(caught);
}
callback(error, result);
}
set(sid: string, data: session.SessionData, callback?: Callback) {
return this.complete(async () => {
await writeSession(this.database, sid, data);
await this.database.query("DELETE FROM http_sessions WHERE expires<=NOW()");
}, callback);
}
destroy(sid: string, callback?: Callback) {
return this.complete(async () => {
await this.database.query("DELETE FROM http_sessions WHERE sid=:sid", {
replacements: { sid },
});
}, callback);
}
touch(sid: string, data: session.SessionData, callback?: Callback) {
return this.complete(async () => {
const cookie = sessionRow(data);
const expires = cookie.cookieExpires
? new Date(cookie.cookieExpires)
: new Date(Date.now() + 8 * 60 * 60 * 1000);
// Tocar la sesión renueva la cookie; no sobrescribe la autenticación de otra petición.
await this.database.query(
"UPDATE http_sessions SET expires=:expires,cookieExpires=:cookieExpires,updatedAt=NOW() WHERE sid=:sid",
{ replacements: { sid, expires, cookieExpires: cookie.cookieExpires } }
);
}, callback);
}
private async complete(operation: () => Promise<void>, callback?: Callback) {
let error: Error | null = null;
try {
await operation();
} catch (caught) {
if (!callback) {
throw caught;
}
error = failure(caught);
}
callback?.(error);
}
}

View File

@ -0,0 +1,190 @@
import { isDeepStrictEqual } from "node:util";
import session from "express-session";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
const optionalBoolean = { type: DataTypes.BOOLEAN, allowNull: true };
export const sessionColumns = {
userId: {
type: DataTypes.UUID,
allowNull: true,
references: { model: "users", key: "id" },
onDelete: "SET NULL",
},
authVersion: { type: DataTypes.INTEGER, allowNull: true },
csrfToken: { type: DataTypes.STRING(128), allowNull: true },
cookieExpires: { type: DataTypes.STRING(30), allowNull: true },
originalMaxAge: { type: DataTypes.BIGINT, allowNull: true },
cookiePath: { type: DataTypes.STRING(255), allowNull: true },
cookieDomain: { type: DataTypes.STRING(255), allowNull: true },
cookieSecure: { type: DataTypes.STRING(10), allowNull: true },
cookieHttpOnly: optionalBoolean,
cookieSameSite: { type: DataTypes.STRING(10), allowNull: true },
cookiePartitioned: optionalBoolean,
cookiePriority: { type: DataTypes.STRING(10), allowNull: true },
};
const cookieSchema = z.object({
originalMaxAge: z.number().int().nonnegative().nullable(),
expires: z.preprocess(
(value) => (value instanceof Date ? value.toISOString() : value),
z.iso.datetime().nullish()
),
path: z.string().max(255).optional(),
domain: z.string().max(255).optional(),
secure: z.union([z.boolean(), z.literal("auto")]).optional(),
httpOnly: z.boolean().optional(),
sameSite: z.union([z.boolean(), z.enum(["lax", "strict", "none"])]).optional(),
partitioned: z.boolean().optional(),
priority: z.enum(["low", "medium", "high"]).optional(),
});
const sessionSchema = z.strictObject({
cookie: cookieSchema,
userId: z.uuid().optional(),
authVersion: z.number().int().nonnegative().optional(),
csrfToken: z.string().max(128).optional(),
});
export function sessionValues(source: unknown) {
return sessionSchema.parse(source);
}
export function sessionRow(source: unknown) {
const data = sessionValues(source);
const cookie = data.cookie;
return {
userId: data.userId ?? null,
authVersion: data.authVersion ?? null,
csrfToken: data.csrfToken ?? null,
cookieExpires: cookie.expires ?? null,
originalMaxAge: cookie.originalMaxAge,
cookiePath: cookie.path ?? null,
cookieDomain: cookie.domain ?? null,
cookieSecure: cookie.secure === undefined ? null : String(cookie.secure),
cookieHttpOnly: cookie.httpOnly ?? null,
cookieSameSite: cookie.sameSite === undefined ? null : String(cookie.sameSite),
cookiePartitioned: cookie.partitioned ?? null,
cookiePriority: cookie.priority ?? null,
};
}
function flag(value: unknown) {
return z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(value);
}
function literal(value: unknown) {
return value === "true" ? true : value === "false" ? false : value;
}
export function sessionFromRow(row: Record<string, unknown>) {
return sessionValues({
cookie: {
originalMaxAge:
row.originalMaxAge === null
? null
: z.coerce.number().int().nonnegative().parse(row.originalMaxAge),
expires: row.cookieExpires,
...(row.cookiePath === null ? {} : { path: row.cookiePath }),
...(row.cookieDomain === null ? {} : { domain: row.cookieDomain }),
...(row.cookieSecure === null ? {} : { secure: literal(row.cookieSecure) }),
...(row.cookieHttpOnly === null ? {} : { httpOnly: flag(row.cookieHttpOnly) }),
...(row.cookieSameSite === null ? {} : { sameSite: literal(row.cookieSameSite) }),
...(row.cookiePartitioned === null ? {} : { partitioned: flag(row.cookiePartitioned) }),
...(row.cookiePriority === null ? {} : { priority: row.cookiePriority }),
},
...(row.userId === null ? {} : { userId: row.userId }),
...(row.authVersion === null ? {} : { authVersion: row.authVersion }),
...(row.csrfToken === null ? {} : { csrfToken: row.csrfToken }),
});
}
export function restoreSession(row: Record<string, unknown>): session.SessionData {
const data = sessionFromRow(row);
const cookie = Object.assign(new session.Cookie(), {
...data.cookie,
expires: data.cookie.expires ? new Date(data.cookie.expires) : null,
});
cookie.originalMaxAge = data.cookie.originalMaxAge;
return {
cookie,
userId: data.userId,
authVersion: data.authVersion,
csrfToken: data.csrfToken ?? "",
};
}
export async function writeSession(
database: Sequelize,
sid: string,
source: unknown,
transaction?: Transaction
) {
z.string().min(1).max(36).parse(sid);
const row = sessionRow(source);
const expires = row.cookieExpires
? new Date(row.cookieExpires)
: new Date(Date.now() + 8 * 60 * 60 * 1000);
const names = Object.keys(row);
await database.query(
`INSERT INTO http_sessions (sid,expires,createdAt,updatedAt,${names.map((name) => `\`${name}\``).join(",")}) VALUES (:sid,:expires,NOW(),NOW(),${names.map((name) => `:${name}`).join(",")}) ON DUPLICATE KEY UPDATE expires=VALUES(expires),updatedAt=NOW(),${names.map((name) => `\`${name}\`=VALUES(\`${name}\`)`).join(",")}`,
{ replacements: { sid, expires, ...row }, ...(transaction ? { transaction } : {}) }
);
}
export async function normalizeSessions(database: Sequelize) {
const query = database.getQueryInterface();
const original = await query.describeTable("http_sessions");
for (const [name, column] of Object.entries(sessionColumns)) {
if (!(name in original)) {
await query.addColumn("http_sessions", name, column);
}
}
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='http_sessions'",
{ type: QueryTypes.SELECT }
);
if (!indexes.some((index) => index.name === "http_sessions_expires")) {
await query.addIndex("http_sessions", ["expires"], { name: "http_sessions_expires" });
}
if (!original.data) {
return;
}
await database.transaction(async (transaction) => {
const rows = await database.query<Record<string, unknown>>("SELECT * FROM http_sessions", {
transaction,
type: QueryTypes.SELECT,
});
for (const row of rows) {
const source = typeof row.data === "string" ? JSON.parse(row.data) : row.data;
const expected = sessionRow(source);
const names = Object.keys(expected);
await database.query(
`UPDATE http_sessions SET ${names.map((name) => `\`${name}\`=:${name}`).join(",")} WHERE sid=:sid`,
{ replacements: { sid: row.sid, ...expected }, transaction }
);
const actual = await database.query<Record<string, unknown>>(
"SELECT * FROM http_sessions WHERE sid=:sid",
{ replacements: { sid: row.sid }, transaction, type: QueryTypes.SELECT }
);
if (!(actual[0] && isDeepStrictEqual(expected, sessionRow(sessionFromRow(actual[0]))))) {
throw new Error("La sesión normalizada no coincide con el origen.");
}
}
});
await database.query("ALTER TABLE http_sessions DROP COLUMN data");
}

View File

@ -7,12 +7,13 @@ import { UserInputMapper } from "./application/user-input.mapper";
import { registerIdentityRoutes } from "./infrastructure/http/identity.routes";
import { requireAuthenticated } from "./infrastructure/http/require-authenticated";
import { migrateIdentity } from "./infrastructure/persistence/migrate-identity";
import { SequelizeSessionStore } from "./infrastructure/persistence/sequelize-session.store";
import { SequelizeUserRepository } from "./infrastructure/persistence/sequelize-user.repository";
import { initializeIdentityModels } from "./infrastructure/persistence/user.model";
import { UserPersistenceMapper } from "./infrastructure/persistence/user-persistence.mapper";
import { ScryptPasswordHasher } from "./infrastructure/security/scrypt-password-hasher";
export { migrateIdentity };
export { SequelizeSessionStore, migrateIdentity };
export async function createIdentityModule(database: Sequelize, origin: string) {
initializeIdentityModels(database);

View File

@ -22,6 +22,10 @@ import { ResourceCatalogPersistenceMapper } from "./resource-catalog-persistence
import { readDefinitions } from "./resource-catalog-relational";
import { readInventory, writeInventory } from "./resource-inventory-relational";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
import {
readResourceProjectionHistory,
readResourceProjections,
} from "./resource-projection-reader";
const mailboxSchema = z.strictObject({
address: z.email().max(254),
@ -44,14 +48,6 @@ const accessSchema = z.strictObject({
type SourceRow = { id: string; companyId: string; values: unknown };
type RevisionRow = {
id: string;
actorId: string;
recordedAt: string;
values: unknown;
position: number;
};
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
@ -178,12 +174,7 @@ async function importResource(
return;
}
const table = kind === "mailbox" ? "mailbox_revisions" : "access_revisions";
const column = kind === "mailbox" ? "position" : "sequence";
const rows = await database.query<RevisionRow>(
`SELECT id,actorId,recordedAt,\`values\`,${column} AS position FROM ${table} WHERE itemId=:id ORDER BY ${column}`,
{ replacements: { id: row.id }, type: QueryTypes.SELECT, transaction }
);
const rows = await readResourceProjectionHistory(database, kind, row.id, transaction);
const specification = spec(catalog, kind);
const values = ResourceValues.create(map(catalog, kind, row.values), specification);
@ -257,14 +248,13 @@ export async function migrateLegacyResources(database: Sequelize) {
);
for (const company of companies) {
const mailboxRows = await database.query<SourceRow>(
"SELECT id,companyId,`values` FROM mailboxes WHERE companyId=:companyId",
{ replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction }
);
const accessRows = await database.query<SourceRow>(
"SELECT i.id,w.companyId,i.`values` FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE i.kind='resource' AND w.companyId=:companyId",
{ replacements: { companyId: company.id }, type: QueryTypes.SELECT, transaction }
const mailboxRows = await readResourceProjections(
database,
"mailbox",
company.id,
transaction
);
const accessRows = await readResourceProjections(database, "access", company.id, transaction);
for (const row of mailboxRows) {
await importResource(database, catalog, "mailbox", row, transaction);

View File

@ -1,10 +1,13 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import type { Resource } from "../../domain/resource.aggregate";
import type { ResourceInput } from "../../domain/resource-values";
import { writeResourceRow } from "./resource-relational-sql";
/** Proyecciones para los contratos de accesos y perfiles: la ficha editable pertenece a resources. */
export async function projectResource(
database: Sequelize,
@ -23,34 +26,32 @@ export async function projectResource(
if (integration.kind === "mailbox") {
const values = mailboxProjection(current);
await database.query(
"INSERT INTO mailboxes (id,companyId,accountId,addressKey,`values`) VALUES (:id,:companyId,:accountId,:addressKey,:values) ON DUPLICATE KEY UPDATE accountId=VALUES(accountId),addressKey=VALUES(addressKey),`values`=VALUES(`values`)",
{
replacements: {
id,
companyId,
accountId: values.accountId,
addressKey: `${companyId}:${values.address}`,
values: JSON.stringify(values),
},
transaction,
}
const columns = scalarProjection(values);
await writeResourceRow(
database,
"mailboxes",
{ id, companyId, addressKey: `${companyId}:${values.address}`, ...columns },
transaction
);
for (const [position, revision] of resource.history.entries()) {
await database.query(
"INSERT INTO mailbox_revisions (id,itemId,position,actorId,recordedAt,`values`) VALUES (:id,:itemId,:position,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id",
{
replacements: {
id: revision.id.toPrimitive(),
const revisionId = revision.id.toPrimitive();
if (!(await projectionExists(database, "mailbox_revisions", revisionId, transaction))) {
await writeResourceRow(
database,
"mailbox_revisions",
{
id: revisionId,
itemId: id,
position,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(mailboxProjection(revision.values.toPrimitive())),
...scalarProjection(mailboxProjection(revision.values.toPrimitive())),
},
transaction,
}
);
transaction
);
}
}
return;
@ -70,25 +71,47 @@ export async function projectResource(
}
const values = accessProjection(current);
await database.query(
"INSERT INTO access_items (id,workspaceId,kind,`values`) VALUES (:id,:workspaceId,'resource',:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)",
{ replacements: { id, workspaceId, values: JSON.stringify(values) }, transaction }
await writeResourceRow(
database,
"access_items",
{ id, workspaceId, kind: "resource" },
transaction
);
const { kind: _kind, ...columns } = scalarProjection(values);
await writeResourceRow(
database,
"access_resource_values",
{ ownerId: id, ...columns },
transaction
);
for (const [sequence, revision] of resource.history.entries()) {
await database.query(
"INSERT INTO access_revisions (id,itemId,sequence,actorId,recordedAt,`values`) VALUES (:id,:itemId,:sequence,:actorId,:recordedAt,:values) ON DUPLICATE KEY UPDATE id=id",
{
replacements: {
id: revision.id.toPrimitive(),
const revisionId = revision.id.toPrimitive();
if (!(await projectionExists(database, "access_revisions", revisionId, transaction))) {
await writeResourceRow(
database,
"access_revisions",
{
id: revisionId,
itemId: id,
sequence,
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: JSON.stringify(accessProjection(revision.values.toPrimitive())),
},
transaction,
}
);
transaction
);
const { kind: _revisionKind, ...frozen } = scalarProjection(
accessProjection(revision.values.toPrimitive())
);
await writeResourceRow(
database,
"access_resource_revision_values",
{ ownerId: revisionId, ...frozen },
transaction
);
}
}
// El contexto es fijo; estas referencias preservan las FK utilizadas por permisos y necesidades.
for (const targetId of [
@ -145,3 +168,24 @@ function accessProjection(input: ResourceInput) {
notes: input.notes || null,
};
}
function scalarProjection(values: unknown) {
return z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.parse(values);
}
async function projectionExists(
database: Sequelize,
table: "mailbox_revisions" | "access_revisions",
id: string,
transaction: Transaction
) {
const rows = await database.query(`SELECT id FROM ${table} WHERE id=:id`, {
replacements: { id },
transaction,
type: QueryTypes.SELECT,
});
return rows.length > 0;
}

View File

@ -0,0 +1,75 @@
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
const bool = z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean);
const mailbox = z.object({
address: z.email(),
kind: z.enum(["corporate", "external"]),
context: z.string().nullable(),
accountId: z.uuid().nullable(),
active: bool,
notes: z.string().nullable(),
});
const access = z
.object({
name: z.string(),
active: bool,
resourceTypeId: z.uuid(),
systemId: z.uuid().nullable(),
environmentId: z.uuid().nullable(),
reference: z.string().nullable(),
notes: z.string().nullable(),
})
.transform((values) => ({ kind: "resource", ...values }));
const root = z.object({ id: z.uuid(), companyId: z.uuid() });
const revision = z.object({
id: z.uuid(),
actorId: z.uuid(),
recordedAt: z.iso.datetime(),
position: z.number().int().nonnegative(),
});
export async function readResourceProjections(
database: Sequelize,
kind: "mailbox" | "access",
companyId: string,
transaction: Transaction
) {
const sql =
kind === "mailbox"
? "SELECT * FROM mailboxes WHERE companyId=:companyId"
: "SELECT i.id,w.companyId,v.* FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId JOIN access_resource_values v ON v.ownerId=i.id WHERE w.companyId=:companyId";
const rows = await database.query<Record<string, unknown>>(sql, {
replacements: { companyId },
transaction,
type: QueryTypes.SELECT,
});
return rows.map((row) => ({
...root.parse(row),
values: kind === "mailbox" ? mailbox.parse(row) : access.parse(row),
}));
}
export async function readResourceProjectionHistory(
database: Sequelize,
kind: "mailbox" | "access",
id: string,
transaction: Transaction
) {
const sql =
kind === "mailbox"
? "SELECT * FROM mailbox_revisions WHERE itemId=:id ORDER BY position"
: "SELECT r.id,r.actorId,r.recordedAt,r.sequence AS position,v.* FROM access_revisions r JOIN access_resource_revision_values v ON v.ownerId=r.id WHERE r.itemId=:id ORDER BY r.sequence";
const rows = await database.query<Record<string, unknown>>(sql, {
replacements: { id },
transaction,
type: QueryTypes.SELECT,
});
return rows.map((row) => ({
...revision.parse(row),
values: kind === "mailbox" ? mailbox.parse(row) : access.parse(row),
}));
}

View File

@ -101,20 +101,20 @@ export class SequelizeResourceRepository implements IResourceRepository {
"SELECT id, name FROM users",
{ type: QueryTypes.SELECT, transaction }
);
const rawReferences = await this.database.query<{ id: string; values: unknown }>(
"SELECT i.id,i.values FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId WHERE (w.companyId=:companyId OR w.companyId IS NULL) AND i.kind IN ('account','system','environment','resource_type')",
const rawReferences = await this.database.query<Record<string, unknown>>(
"SELECT i.id,v.kind,v.name,v.identifier,v.active,v.systemId FROM access_items i JOIN access_workspaces w ON w.id=i.workspaceId JOIN access_current_values v ON v.ownerId=i.id AND v.kind=i.kind WHERE (w.companyId=:companyId OR w.companyId IS NULL) AND i.kind IN ('account','system','environment','resource_type')",
{ replacements: { companyId: companyId.toPrimitive() }, transaction, type: QueryTypes.SELECT }
);
const references = rawReferences.map((row) => {
const data = z
.object({
kind: z.enum(["account", "system", "environment", "resource_type"]),
name: z.string().optional(),
identifier: z.string().optional(),
active: z.boolean(),
systemId: z.uuid().optional(),
name: z.string().nullish(),
identifier: z.string().nullish(),
active: z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean),
systemId: z.uuid().nullish(),
})
.parse(typeof row.values === "string" ? JSON.parse(row.values) : row.values);
.parse(row);
return ResourceReferenceSchema.parse({
id: row.id,

View File

@ -50,9 +50,6 @@ importers:
'@metis/resources':
specifier: workspace:*
version: link:../../modules/resources
connect-session-sequelize:
specifier: 8.0.6
version: 8.0.6(sequelize@6.37.8(mariadb@3.5.4))
express:
specifier: 4.22.1
version: 4.22.1
@ -2224,12 +2221,6 @@ packages:
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
connect-session-sequelize@8.0.6:
resolution: {integrity: sha512-SAcXvRHKA3T1z5udDYK1dy783gh5CX7ok5dZNvuJEBd52FOi8ZfZjTMA8lwS357NNB2TmWDaV3bIBbypYco/CA==}
engines: {node: '>= 22'}
peerDependencies:
sequelize: '>= 6.37.7'
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
@ -5591,13 +5582,6 @@ snapshots:
confbox@0.1.8: {}
connect-session-sequelize@8.0.6(sequelize@6.37.8(mariadb@3.5.4)):
dependencies:
debug: 4.4.3
sequelize: 6.37.8(mariadb@3.5.4)
transitivePeerDependencies:
- supports-color
consola@3.4.2: {}
content-disposition@0.5.4: