limpieza de codigo

This commit is contained in:
David Arranz 2026-10-10 17:57:05 +02:00
parent bf4831a34f
commit a0b88f1364
90 changed files with 599 additions and 3169 deletions

View File

@ -1,225 +0,0 @@
import assert from "node:assert/strict";
import {
AssetResponseSchema,
InventoryResponseSchema,
SaveDefinitionResponseSchema,
} from "@metis/assets/common";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { readDefinitions } from "../../../modules/assets/src/api/infrastructure/persistence/asset-catalog-relational";
import { readInventory } from "../../../modules/assets/src/api/infrastructure/persistence/asset-inventory-relational";
import {
normalizeAssetCatalog,
normalizeAssetInventory,
} from "../../../modules/assets/src/api/infrastructure/persistence/asset-normalization-migration";
import { AssetPersistenceMapper } from "../../../modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper";
import { normalizeAssetRelationships } from "../../../modules/assets/src/api/infrastructure/persistence/asset-relationship-normalization";
export async function verifyAssetNormalization(
database: Sequelize,
request: (path: string, body?: unknown) => Promise<Response>,
companyId: string
) {
const fieldIds: string[] = [];
for (const [type, value] of [
["text", "Texto ñ"],
["number", 0],
["boolean", false],
["date", "2026-10-10"],
["choice", "A"],
] as const) {
const response = await request(`/asset-catalog/${companyId}`, {
values: {
kind: "field",
name: `Migración ${type}`,
description: "",
unit: "",
type,
choices: type === "choice" ? ["A", "B"] : [],
},
});
assert.equal(response.status, 201);
fieldIds.push(SaveDefinitionResponseSchema.parse(await response.json()).savedId);
assert.ok(value !== undefined);
}
const templateResponse = await request(`/asset-catalog/${companyId}`, {
values: {
kind: "template",
name: "Migración tipada",
description: "",
fields: fieldIds.map((fieldId) => ({ fieldId, required: false })),
},
});
const templateId = SaveDefinitionResponseSchema.parse(await templateResponse.json()).savedId;
const assetResponse = await request(`/assets/${companyId}`, {
values: {
name: "Migración valores",
templateId,
siteId: null,
status: "available",
notes: "",
characteristics: Object.fromEntries(
fieldIds.map((id, index) => [id, ["Texto ñ", 0, false, "2026-10-10", "A"][index]])
),
},
});
assert.equal(assetResponse.status, 201);
const created = AssetResponseSchema.parse(await assetResponse.json()).asset;
const catalogRoots = await database.query<{ id: string }>("SELECT id FROM asset_catalogs", {
type: QueryTypes.SELECT,
});
const companies = await database.query<{ companyId: string }>(
"SELECT DISTINCT companyId FROM assets",
{ type: QueryTypes.SELECT }
);
const before = await database.transaction(async (transaction) => ({
definitions: (
await Promise.all(catalogRoots.map((root) => readDefinitions(database, root.id, transaction)))
).flat(),
inventories: await Promise.all(
companies.map(async (company) => ({
companyId: company.companyId,
...(await readInventory(database, company.companyId, transaction)),
}))
),
}));
const relationships = await database.query<{
id: string;
sourceId: string;
targetId: string;
typeId: string;
name: string;
inverseName: string;
symmetric: boolean;
notes: string;
}>(
"SELECT id,sourceId,targetId,typeId,name,inverseName,symmetric,notes FROM asset_relationships ORDER BY id",
{ type: QueryTypes.SELECT }
);
// Recrear el origen anterior solo en la base dedicada de pruebas, conservando sus UUID.
assert.equal(database.getDatabaseName(), "metis_desk_test");
for (const table of ["asset_definitions", "assets", "asset_revisions", "asset_relationships"]) {
await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`);
}
await database.query("ALTER TABLE assets ADD COLUMN specification JSON NULL");
const store = async (table: string, id: string, values: unknown) =>
database.query(`UPDATE ${table} SET \`values\`=:values WHERE id=:id`, {
replacements: { id, values: JSON.stringify(values) },
});
for (const definition of before.definitions) {
await store("asset_definitions", definition.id, definition.values);
}
for (const inventory of before.inventories) {
for (const row of inventory.rows) {
await store("assets", row.id, row.values);
await database.query("UPDATE assets SET specification=:value WHERE id=:id", {
replacements: { id: row.id, value: JSON.stringify(row.specification) },
});
}
for (const row of inventory.revisions) {
await store("asset_revisions", row.id, row.values);
}
}
for (const { id, ...values } of relationships) {
await store("asset_relationships", id, { ...values, symmetric: Boolean(values.symmetric) });
}
const first = before.definitions[0];
assert.ok(first);
await store("asset_definitions", first.id, { kind: "invalid" });
await assert.rejects(normalizeAssetCatalog(database));
assert.ok((await database.getQueryInterface().describeTable("asset_definitions")).values);
await store("asset_definitions", first.id, first.values);
await normalizeAssetCatalog(database);
await database.query("DELETE FROM asset_characteristics");
await database.query("DELETE FROM asset_revision_characteristics");
await database.query("UPDATE assets SET name='Origen pendiente'");
await database.query("UPDATE asset_revisions SET name='Origen pendiente'");
await normalizeAssetInventory(database);
await normalizeAssetRelationships(database);
// Un segundo arranque no modifica ni duplica el catálogo, los valores ni el historial.
await normalizeAssetCatalog(database);
await normalizeAssetInventory(database);
await normalizeAssetRelationships(database);
await database.transaction(async (transaction) => {
for (const root of catalogRoots) {
const actual = await readDefinitions(database, root.id, transaction);
assert.deepEqual(
actual.sort((a, b) => a.id.localeCompare(b.id)),
before.definitions
.filter((item) => item.catalogId === root.id)
.sort((a, b) => a.id.localeCompare(b.id))
);
}
const mapper = new AssetPersistenceMapper();
for (const expected of before.inventories) {
const actual = await readInventory(database, expected.companyId, transaction);
const project = (inventory: typeof actual) =>
inventory.rows
.map((row) =>
mapper.toPersistence(
mapper.toDomain(
row,
inventory.revisions.filter((item) => item.assetId === row.id)
)
)
)
.sort((a, b) => a.asset.id.localeCompare(b.asset.id));
assert.deepEqual(project(actual), project(expected));
}
});
// Recuperación del corte entre DDL: las revisiones ya se normalizaron pero
// aún quedan las columnas de origen del activo.
await database.query(
"ALTER TABLE assets ADD COLUMN `values` JSON NULL, ADD COLUMN specification JSON NULL"
);
for (const inventory of before.inventories) {
for (const row of inventory.rows) {
await store("assets", row.id, row.values);
await database.query("UPDATE assets SET specification=:value WHERE id=:id", {
replacements: { id: row.id, value: JSON.stringify(row.specification) },
});
}
}
await normalizeAssetInventory(database);
const after = await request(`/assets/${companyId}`);
assert.equal(after.status, 200);
assert.deepEqual(
InventoryResponseSchema.parse(await after.json()).assets.find((item) => item.id === created.id),
created
);
const currentRelationships = await database.query<(typeof relationships)[number]>(
"SELECT id,sourceId,targetId,typeId,name,inverseName,symmetric,notes FROM asset_relationships ORDER BY id",
{ type: QueryTypes.SELECT }
);
assert.deepEqual(currentRelationships, relationships);
for (const table of ["asset_definitions", "assets", "asset_revisions", "asset_relationships"]) {
const columns = await database.getQueryInterface().describeTable(table);
assert.equal(columns.values, undefined);
assert.equal(columns.specification, undefined);
}
const booleanField = fieldIds[2];
assert.ok(booleanField);
await assert.rejects(
database.query(
"UPDATE asset_characteristics SET textValue='incompatible' WHERE assetId=:assetId AND fieldId=:fieldId",
{ replacements: { assetId: created.id, fieldId: booleanField } }
)
);
}

View File

@ -11,10 +11,7 @@ import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import {
importLegacyBrands,
migrateBrands,
} from "../../../modules/assets/src/api/infrastructure/persistence/brand-migration";
import { migrateBrands } from "../../../modules/assets/src/api/infrastructure/persistence/brand-migration";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
@ -118,7 +115,6 @@ export async function verifyBrands(
assert.deepEqual(BrandResponseSchema.parse(await response.json()).brand, unused);
await error(url, {}, 404, "BRAND_NOT_FOUND");
await migrateBrands(database);
await importLegacyBrands(database);
assert.equal(
(await list()).some((brand) => brand.id === unused.id),
false
@ -217,42 +213,6 @@ export async function verifyBrands(
);
assert.ok(snapshots.every((row) => row.brandId === created.id));
const legacyName = `Antigua ${randomUUID()}`;
const legacyValues = {
...values,
brandId: undefined,
characteristics: { [field.id]: legacyName },
};
const legacy = await asset(companyId, legacyValues);
const before = await database.query(
"SELECT * FROM asset_revisions WHERE assetId=:id ORDER BY position",
{ replacements: { id: legacy.id }, type: QueryTypes.SELECT }
);
await database.query("UPDATE brand_catalog_lock SET legacyImported=0 WHERE id=1");
await importLegacyBrands(database);
const imported = (await list()).find((brand) => brand.name === legacyName);
assert.ok(imported);
assert.equal(imported.builtin, false);
await save({ name: `${legacyName} editada`, active: false }, imported.id);
await importLegacyBrands(database);
assert.equal(
(await list()).some((brand) => brand.name === legacyName),
false
);
assert.deepEqual(
await database.query("SELECT * FROM asset_revisions WHERE assetId=:id ORDER BY position", {
replacements: { id: legacy.id },
type: QueryTypes.SELECT,
}),
before
);
const linked = await asset(companyId, { ...legacy.values, brandId: builtin.id }, legacy.id);
assert.equal(linked.history.length, 2);
assert.equal(linked.history[0]?.values.characteristics[field.id], legacyName);
assert.equal(linked.history[0]?.values.brandId, undefined);
await save({ name: builtin.name, active: false }, builtin.id);
const changed = await save({ name: builtin.name, active: false, logoSvg }, builtin.id);

View File

@ -1,54 +0,0 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { configurationSnapshots } from "../../../modules/companies/src/api/infrastructure/persistence/configuration-snapshots";
import {
normalizeScalarSnapshots,
snapshotValues,
} from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-normalization";
export async function verifyConfigurationNormalization(database: Sequelize) {
assert.equal(database.getDatabaseName(), "metis_desk_test");
for (const [table, definition] of configurationSnapshots) {
const rows = await database.query<Record<string, unknown>>(
`SELECT * FROM ${table} ORDER BY id`,
{ type: QueryTypes.SELECT }
);
const expected = rows.map((row) => ({ id: row.id, values: snapshotValues(definition, row) }));
await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`);
const store = (id: unknown, values: unknown) =>
database.query(`UPDATE ${table} SET \`values\`=:values WHERE id=:id`, {
replacements: { id, values: JSON.stringify(values) },
});
for (const row of expected) {
await store(row.id, row.values);
}
if (table === "work_profiles" && expected[0]) {
const input = definition.schema.parse(expected[0].values);
const foreign = { ...Object(input), personId: randomUUID() };
await store(expected[0].id, foreign);
await assert.rejects(normalizeScalarSnapshots(database, [table], definition));
assert.ok((await database.getQueryInterface().describeTable(table)).values);
await store(expected[0].id, input);
}
await normalizeScalarSnapshots(database, [table], definition);
await normalizeScalarSnapshots(database, [table], definition);
const actual = await database.query<Record<string, unknown>>(
`SELECT * FROM ${table} ORDER BY id`,
{ type: QueryTypes.SELECT }
);
assert.deepEqual(
actual.map((row) => ({ id: row.id, values: snapshotValues(definition, row) })),
expected
);
assert.equal((await database.getQueryInterface().describeTable(table)).values, undefined);
}
}

View File

@ -27,13 +27,11 @@ import { migrateInterventionTemplates } from "../../../modules/companies/src/api
import { verifyAccess } from "./access.integration";
import { createApp } from "./app";
import { verifyApplications } from "./applications.integration";
import { verifyAssetNormalization } from "./asset-normalization.integration";
import { verifyAssets } from "./assets.integration";
import { verifyBrands } from "./brands.integration";
import { verifyCertificates } from "./certificates.integration";
import { verifyCompanyLogos } from "./company-logos.integration";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
import { verifyConfigurationNormalization } from "./configuration-normalization.integration";
import {
verifyDashboard,
verifyDashboardBoundaries,
@ -44,17 +42,15 @@ import { verifyInterventionTemplates } from "./intervention-templates.integratio
import { verifyInterventions } from "./interventions.integration";
import { verifyInventory } from "./inventory.integration";
import { verifyMail } from "./mail.integration";
import { verifyPersistence } from "./persistence.integration";
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";
import { verifyResources } from "./resources.integration";
import { verifySupport } from "./support.integration";
import { verifySupportNormalization } from "./support-normalization.integration";
import { verifyTelework } from "./telework.integration";
import { verifyTickets } from "./tickets.integration";
import { verifyWorkProfiles } from "./work-profiles.integration";
@ -943,7 +939,6 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
customer.id,
secondCompany.id
);
await verifySupportNormalization(database);
await verifyBrands(
(...args) => operator.request(...args),
customer.id,
@ -991,14 +986,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
migratedServer.close((error) => (error ? reject(error) : resolve()))
);
}
await verifyResourceNormalization(database);
await verifyAssetNormalization(
database,
(path, body) => operator.request(path, body),
customer.id
);
await verifyConfigurationNormalization(database);
await verifyRemainingNormalization(database);
await verifyPersistence(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

@ -20,7 +20,7 @@ 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 { snapshotValues } from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-reader";
import { writeSnapshotRow } from "../../../modules/companies/src/api/infrastructure/persistence/scalar-snapshot-writer";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;

View File

@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import session from "express-session";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import {
initializeAssetCatalogSchema,
initializeAssetInventorySchema,
} from "../../../modules/assets/src/api/infrastructure/persistence/asset-relational-schema";
import { initializeWorkstationMembers } from "../../../modules/assets/src/api/infrastructure/persistence/workstation-relational";
import { initializeAccessValues } from "../../../modules/companies/src/api/infrastructure/persistence/access-relational";
import { initializeProcessTasks } from "../../../modules/companies/src/api/infrastructure/persistence/process-relational";
import { supportIdentifier } from "../../../modules/companies/src/api/infrastructure/persistence/support-record-relational";
import { initializeSupportChildren } from "../../../modules/companies/src/api/infrastructure/persistence/support-record-schema";
import { SequelizeSessionStore } from "../../../modules/identity/src/api/infrastructure/persistence/sequelize-session.store";
import { initializeSessionIndexes } from "../../../modules/identity/src/api/infrastructure/persistence/session-persistence";
import {
initializeResourceCatalogSchema,
initializeResourceInventorySchema,
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-relational-schema";
async function snapshot(database: Sequelize) {
const keys = await database.query<{ tableName: string; columnName: string }>(
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND CONSTRAINT_NAME='PRIMARY' ORDER BY TABLE_NAME,ORDINAL_POSITION",
{ type: QueryTypes.SELECT }
);
const tables = [...new Set(keys.map((key) => key.tableName))];
const result = new Map<string, Record<string, unknown>[]>();
for (const table of tables) {
const ordering = keys
.filter((key) => key.tableName === table)
.map((key) => supportIdentifier(key.columnName));
result.set(
table,
await database.query<Record<string, unknown>>(
`SELECT * FROM ${supportIdentifier(table)} ORDER BY ${ordering.join(",")}`,
{ type: QueryTypes.SELECT }
)
);
}
return result;
}
async function initialize(database: Sequelize) {
await initializeAssetCatalogSchema(database);
await initializeAssetInventorySchema(database);
await initializeResourceCatalogSchema(database);
await initializeResourceInventorySchema(database);
await initializeWorkstationMembers(database);
await initializeAccessValues(database);
await initializeProcessTasks(database);
await initializeSupportChildren(
database,
"intervention_templates",
"intervention_template_revisions"
);
await initializeSupportChildren(database, "tickets", "ticket_revisions");
await initializeSupportChildren(database, "interventions", "intervention_revisions");
await initializeSessionIndexes(database);
}
async function verifySessionStore(database: Sequelize) {
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 } })
);
}
/** Verifica el esquema actual sin fabricar documentos ni columnas de otros formatos. */
export async function verifyPersistence(database: Sequelize) {
assert.equal(database.getDatabaseName(), "metis_desk_test");
const before = await snapshot(database);
await initialize(database);
await initialize(database);
assert.deepEqual(await snapshot(database), before);
const checks = 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(checks, []);
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")
);
await verifySessionStore(database);
}

View File

@ -1,284 +0,0 @@
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

@ -1,164 +0,0 @@
import assert from "node:assert/strict";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { readDefinitions } from "../../../modules/resources/src/api/infrastructure/persistence/resource-catalog-relational";
import { readInventory } from "../../../modules/resources/src/api/infrastructure/persistence/resource-inventory-relational";
import {
enforceResourceConstraints,
normalizeResourceCatalog,
normalizeResourceInventory,
} from "../../../modules/resources/src/api/infrastructure/persistence/resource-normalization-migration";
import { ResourcePersistenceMapper } from "../../../modules/resources/src/api/infrastructure/persistence/resource-persistence.mapper";
export async function verifyResourceNormalization(database: Sequelize) {
const catalogRoots = await database.query<{ id: string }>("SELECT id FROM resource_catalogs", {
type: QueryTypes.SELECT,
});
const companies = await database.query<{ companyId: string }>(
"SELECT DISTINCT companyId FROM resources",
{ type: QueryTypes.SELECT }
);
const before = await database.transaction(async (transaction) => ({
definitions: (
await Promise.all(catalogRoots.map((root) => readDefinitions(database, root.id, transaction)))
).flat(),
inventories: await Promise.all(
companies.map(async (company) => ({
companyId: company.companyId,
...(await readInventory(database, company.companyId, transaction)),
}))
),
}));
// Recrear el origen anterior solo en la base dedicada de pruebas, conservando sus UUID.
assert.equal(database.getDatabaseName(), "metis_desk_test");
const [accountForeignKey] = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_revisions' AND COLUMN_NAME='accountId' AND REFERENCED_TABLE_NAME='access_items'",
{ type: QueryTypes.SELECT }
);
assert.ok(accountForeignKey);
assert.match(accountForeignKey.name, /^[a-zA-Z0-9_]+$/);
await database.query(
`ALTER TABLE resource_revisions DROP FOREIGN KEY \`${accountForeignKey.name}\`, ADD CONSTRAINT resource_revision_account_legacy FOREIGN KEY (accountId) REFERENCES access_items(id) ON DELETE CASCADE`
);
await enforceResourceConstraints(database);
await enforceResourceConstraints(database);
const rules = await database.query<{ rule: string }>(
"SELECT c.DELETE_RULE AS rule FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND c.CONSTRAINT_NAME=k.CONSTRAINT_NAME AND c.TABLE_NAME=k.TABLE_NAME WHERE k.TABLE_SCHEMA=DATABASE() AND k.TABLE_NAME='resource_revisions' AND k.COLUMN_NAME='accountId' AND k.REFERENCED_TABLE_NAME='access_items'",
{ type: QueryTypes.SELECT }
);
assert.deepEqual(
rules.map((row) => row.rule),
["RESTRICT"]
);
for (const table of ["resource_definitions", "resources", "resource_revisions"]) {
await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`);
}
await database.query("ALTER TABLE resources ADD COLUMN specification JSON NULL");
const store = async (table: string, id: string, values: unknown) =>
database.query(`UPDATE ${table} SET \`values\`=:values WHERE id=:id`, {
replacements: { id, values: JSON.stringify(values) },
});
for (const definition of before.definitions) {
await store("resource_definitions", definition.id, definition.values);
}
for (const inventory of before.inventories) {
for (const row of inventory.rows) {
await store("resources", row.id, row.values);
await database.query("UPDATE resources SET specification=:value WHERE id=:id", {
replacements: { id: row.id, value: JSON.stringify(row.specification) },
});
}
for (const row of inventory.revisions) {
await store("resource_revisions", row.id, row.values);
}
}
const first = before.definitions[0];
assert.ok(first);
await store("resource_definitions", first.id, { kind: "invalid" });
await assert.rejects(normalizeResourceCatalog(database));
assert.ok((await database.getQueryInterface().describeTable("resource_definitions")).values);
await store("resource_definitions", first.id, first.values);
await normalizeResourceCatalog(database);
await database.query("DELETE FROM resource_characteristics");
await database.query("DELETE FROM resource_revision_characteristics");
await database.query("UPDATE resources SET name='Origen pendiente'");
await database.query("UPDATE resource_revisions SET name='Origen pendiente'");
await normalizeResourceInventory(database);
// Un segundo arranque no modifica ni duplica el catálogo, los valores ni el historial.
await normalizeResourceCatalog(database);
await normalizeResourceInventory(database);
await database.transaction(async (transaction) => {
for (const root of catalogRoots) {
const actual = await readDefinitions(database, root.id, transaction);
assert.deepEqual(
actual.sort((a, b) => a.id.localeCompare(b.id)),
before.definitions
.filter((item) => item.catalogId === root.id)
.sort((a, b) => a.id.localeCompare(b.id))
);
}
const mapper = new ResourcePersistenceMapper();
for (const expected of before.inventories) {
const actual = await readInventory(database, expected.companyId, transaction);
const project = (inventory: typeof actual) =>
inventory.rows
.map((row) =>
mapper.toPersistence(
mapper.toDomain(
row,
inventory.revisions.filter((item) => item.resourceId === row.id)
)
)
)
.sort((a, b) => a.resource.id.localeCompare(b.resource.id));
assert.deepEqual(project(actual), project(expected));
}
});
// Recuperación del corte entre DDL: las revisiones ya se normalizaron pero
// aún quedan las columnas de origen del activo.
await database.query(
"ALTER TABLE resources ADD COLUMN `values` JSON NULL, ADD COLUMN specification JSON NULL"
);
for (const inventory of before.inventories) {
for (const row of inventory.rows) {
await store("resources", row.id, row.values);
await database.query("UPDATE resources SET specification=:value WHERE id=:id", {
replacements: { id: row.id, value: JSON.stringify(row.specification) },
});
}
}
await normalizeResourceInventory(database);
await database.transaction(async (transaction) => {
for (const expected of before.inventories) {
const actual = await readInventory(database, expected.companyId, transaction);
assert.deepEqual(
actual.rows.sort((a, b) => a.id.localeCompare(b.id)),
expected.rows.sort((a, b) => a.id.localeCompare(b.id))
);
assert.deepEqual(
actual.revisions.sort((a, b) => a.id.localeCompare(b.id)),
expected.revisions.sort((a, b) => a.id.localeCompare(b.id))
);
}
});
for (const table of ["resource_definitions", "resources", "resource_revisions"]) {
const columns = await database.getQueryInterface().describeTable(table);
assert.equal(columns.values, undefined);
assert.equal(columns.specification, undefined);
}
}

View File

@ -1,169 +0,0 @@
import assert from "node:assert/strict";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { migrateInterventions } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-migration";
import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration";
import {
type SupportTable,
readSupportRows,
supportChildren,
supportIdentifier,
} from "../../../modules/companies/src/api/infrastructure/persistence/support-record-relational";
import { migrateTickets } from "../../../modules/companies/src/api/infrastructure/persistence/ticket-migration";
const tables: readonly SupportTable[] = [
"intervention_templates",
"intervention_template_revisions",
"tickets",
"ticket_revisions",
"interventions",
"intervention_revisions",
];
async function snapshots(database: Sequelize) {
return database.transaction(async (transaction) => {
const result = new Map<SupportTable, Record<string, unknown>[]>();
for (const table of tables) {
const rows = await readSupportRows(database, table, transaction);
rows.sort((left, right) => String(left.id).localeCompare(String(right.id)));
result.set(table, rows);
}
return result;
});
}
async function migrate(database: Sequelize) {
await migrateInterventionTemplates(database);
await migrateTickets(database);
await migrateInterventions(database);
}
async function clearChildren(database: Sequelize, table: SupportTable, transaction: Transaction) {
const children = supportChildren(table);
for (const child of [children.targets, children.steps]) {
if (child) {
await database.query(`DELETE FROM ${supportIdentifier(child)}`, { transaction });
}
}
}
async function restoreLegacy(
database: Sequelize,
expected: ReadonlyMap<SupportTable, readonly Record<string, unknown>[]>
) {
await database.transaction(async (transaction) => {
for (const [table, rows] of expected) {
for (const row of rows) {
await database.query(
`UPDATE ${supportIdentifier(table)} SET \`values\`=:values${table === "interventions" ? ",`procedure`=:procedure" : ""} WHERE id=:id`,
{
replacements: {
id: row.id,
values: JSON.stringify(row.values),
procedure: JSON.stringify(row.procedure ?? null),
},
transaction,
}
);
}
await clearChildren(database, table, transaction);
}
await database.query("DELETE FROM intervention_procedures", { transaction });
});
}
// Las columnas JSON solo se recrean en la base dedicada para probar el formato de origen.
export async function verifySupportNormalization(database: Sequelize) {
assert.equal(database.getDatabaseName(), "metis_desk_test");
const expected = await snapshots(database);
const counters = await database.query(
"SELECT nextNumber FROM ticket_counter UNION ALL SELECT nextNumber FROM intervention_counter",
{ type: QueryTypes.SELECT }
);
assert.ok((expected.get("tickets")?.length ?? 0) > 0);
assert.ok((expected.get("interventions")?.length ?? 0) > 0);
assert.ok(expected.get("interventions")?.some((row) => row.procedure !== null));
const query = database.getQueryInterface();
for (const table of tables) {
await query.addColumn(table, "values", { type: DataTypes.JSON, allowNull: true });
}
await query.addColumn("interventions", "procedure", { type: DataTypes.JSON, allowNull: true });
await restoreLegacy(database, expected);
const first = expected.get("tickets")?.[0];
assert.ok(first);
const valid = JSON.stringify(first.values);
await database.query("UPDATE tickets SET `values`=:values WHERE id=:id", {
replacements: { id: first.id, values: JSON.stringify({ subject: "Origen inválido" }) },
});
await assert.rejects(() => migrateTickets(database));
assert.ok((await query.describeTable("tickets")).values);
assert.deepEqual(
await database.query("SELECT ownerId FROM ticket_targets", { type: QueryTypes.SELECT }),
[]
);
await database.query("UPDATE tickets SET `values`=:values WHERE id=:id", {
replacements: { id: first.id, values: valid },
});
// Una referencia escalar incompatible tampoco se corrige silenciosamente.
await database.query("UPDATE tickets SET requesterId=NULL WHERE id=:id", {
replacements: { id: first.id },
});
const input = first.values;
assert.ok(input && typeof input === "object" && "requesterId" in input);
if (input.requesterId !== null) {
await assert.rejects(() => migrateTickets(database), /inconsistente/);
}
await database.query("UPDATE tickets SET requesterId=:requesterId WHERE id=:id", {
replacements: { id: first.id, requesterId: input.requesterId },
});
await migrate(database);
assert.deepEqual(await snapshots(database), expected);
for (const table of tables) {
const columns = await query.describeTable(table);
assert.equal(columns.values, undefined);
assert.equal(columns.procedure, undefined);
}
// Simula un corte de DDL: raíz legada, revisiones ya normalizadas.
await query.addColumn("tickets", "values", { type: DataTypes.JSON, allowNull: true });
for (const row of expected.get("tickets") ?? []) {
await database.query("UPDATE tickets SET `values`=:values WHERE id=:id", {
replacements: { id: row.id, values: JSON.stringify(row.values) },
});
}
await migrate(database);
assert.deepEqual(await snapshots(database), expected);
await migrate(database);
assert.deepEqual(await snapshots(database), expected);
assert.deepEqual(
await database.query(
"SELECT nextNumber FROM ticket_counter UNION ALL SELECT nextNumber FROM intervention_counter",
{ type: QueryTypes.SELECT }
),
counters
);
// Solo puede existir un destino por fila y los destinos deben existir.
await assert.rejects(() =>
database.query(
"INSERT INTO ticket_targets (ownerId,position,personId,workstationId,assetId,resourceId) VALUES (:id,999,NULL,NULL,NULL,NULL)",
{ replacements: { id: first.id } }
)
);
await assert.rejects(() =>
database.query(
"INSERT INTO ticket_targets (ownerId,position,personId,workstationId,assetId,resourceId) VALUES (:id,999,'00000000-0000-0000-0000-000000000001',NULL,NULL,NULL)",
{ replacements: { id: first.id } }
)
);
}

View File

@ -3,10 +3,9 @@ import assert from "node:assert/strict";
import { InventoryResponseSchema, WorkstationResponseSchema } from "@metis/assets/common";
import { CompanyResponseSchema } from "@metis/companies/common";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { normalizeWorkstations } from "../../../modules/assets/src/api/infrastructure/persistence/workstation-relational";
import { initializeWorkstationMembers } from "../../../modules/assets/src/api/infrastructure/persistence/workstation-relational";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
@ -167,40 +166,17 @@ export async function verifyWorkstations(
.length,
0
);
// Migrar instantáneas antiguas sin activos compartidos conserva identidades e historial.
const beforeMigration = InventoryResponseSchema.parse(
// Repetir la preparación del esquema conserva composición e historial.
const beforeInitialization = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
);
for (const table of ["workstations", "workstation_revisions"]) {
await database.query(`ALTER TABLE ${table} ADD COLUMN \`values\` JSON NULL`);
}
for (const station of beforeMigration.workstations) {
const { sharedAssetIds: _shared, ...legacyValues } = station.values;
await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
replacements: { id: station.id, values: JSON.stringify(legacyValues) },
});
for (const revision of station.history) {
const { sharedAssetIds: _revisionShared, ...legacyRevision } = revision.values;
await database.query("UPDATE workstation_revisions SET `values`=:values WHERE id=:id", {
replacements: { id: revision.id, values: JSON.stringify(legacyRevision) },
});
}
}
await normalizeWorkstations(database);
await normalizeWorkstations(database);
await initializeWorkstationMembers(database);
await initializeWorkstationMembers(database);
assert.deepEqual(
InventoryResponseSchema.parse(await (await request(`/assets/${companyId}`)).json()),
beforeMigration
beforeInitialization
);
const jsonColumns = await database.query<{ name: string }>(
"SELECT COLUMN_NAME AS name FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('workstations','workstation_revisions') AND COLUMN_NAME='values'",
{ type: QueryTypes.SELECT }
);
assert.equal(jsonColumns.length, 0);
await error(
{ ...input, personId: null, assetIds: [], sharedAssetIds: [first.id] },
"ASSET_IN_WORKSTATION"

View File

@ -47,15 +47,12 @@ IBrandRepository define el puerto. BrandPersistenceMapper reconstruye y serializ
AssetValues incorpora brandId opcional como Maybe<UniqueID>. Activos y revisiones tienen clave foránea nullable con borrado restringido. El servidor comprueba disponibilidad y presencia del campo de serie en la especificación del activo, y escribe el nombre canónico de la marca como característica. Cada revisión conserva su UUID de marca y una instantánea de su nombre: renombrar el catálogo no reescribe el historial. Al guardar de nuevo el activo se captura el nombre actual.
## Migración compatible
## Inicialización del catálogo
migrateBrands crea tablas y semillas de manera idempotente, sin reactivar marcas desactivadas ni sustituir sus UUID. migrateInventory añade las referencias nullable explícitamente.
migrateBrands crea tablas y semillas de manera idempotente, sin reactivar marcas desactivadas ni sustituir sus UUID. migrateInventory declara las referencias opcionales al crear las tablas.
La migración añade logoSvg y brand_catalog_lock.logosImported. Incorpora una sola vez los 23 SVG de fabricantes conocidos donde no haya un logotipo; Clónico queda sin imagen porque no es una marca. No sobrescribe logotipos existentes. El marcador transaccional evita reponer imágenes eliminadas o sustituidas después. Las altas iniciales de marcas de serie reciben su SVG local. Las fuentes se registran en [Fuentes de logotipos de marcas](fuentes-de-logotipos-de-marcas.md).
importLegacyBrands importa una única vez los nombres válidos que encuentre en el campo de serie de los activos existentes, como marcas personalizadas salvo coincidencia con una de serie. No modifica los valores de activos, no crea revisiones y no toca el historial. Los textos anteriores permanecen visibles como Valor anterior y pueden conservarse o sustituirse por una selección explícita; no se asocian automáticamente por coincidencia de nombre. Valores antiguos que no cumplan el límite del catálogo permanecen en su ficha.
Un marcador transaccional evita que reinicios posteriores vuelvan a crear nombres de marcas personalizadas ya renombradas. Las semillas mantienen los nombres originales protegidos. El catálogo de campos/plantillas de recursos continúa separado.
## HTTP
@ -84,4 +81,4 @@ La eliminación se prueba en marcas personalizadas activas e inactivas, con entr
- pnpm build: correcto, 2 tareas; se mantiene el aviso previo del tamaño del paquete web.
- pnpm lint / pnpm check: ejecutados; conservan los 25/72 errores anteriores, fuera de los archivos modificados. Los 35 archivos de código/contratos modificados o nuevos pasan la comprobación dirigida sin avisos.
- Navegador: catálogo y marcas importadas visibles, búsqueda y limpieza, filtro de estado y vacío, edición protegida, desplegable del activo, alta relacionada y cancelación conservando selección/borrador. Verificados español/inglés y anchura móvil sin desbordamiento horizontal de página.
- La revisión visual no guardó activos ni marcas de prueba en la base de desarrollo. Las migraciones incorporan las semillas y nombres previos; las mutaciones de prueba se ejecutan sobre la base dedicada.
- La revisión visual no guardó activos ni marcas de prueba en la base de desarrollo. La inicialización incorpora las semillas; las mutaciones de prueba se ejecutan sobre la base dedicada.

View File

@ -122,11 +122,11 @@ AccessValues es un valor compuesto inmutable, con UUID, Name y CalendarDate reco
MariaDB almacena:
- access_workspaces: identidad del agregado y ámbito de compañía.
- access_items: entidades con definición tipada serializada en JSON.
- access_items: identidad, ámbito y tipo de cada elemento. Los atributos se guardan en tablas de subtipos actuales e históricas, y los permisos de necesidades en asociaciones ordenadas.
- access_revisions: versiones, secuencia, actor y timestamp.
- access_references y access_person_references: referencias relacionales para impedir huérfanos y conservar las dependencias.
Los JSON se validan y reconstruyen al cargar; no son bolsas de campos libres. No se aceptan claves desconocidas. La última revisión debe corresponder con el estado actual. Las revisiones se añaden, no se sobrescriben.
Los objetos de dominio se reconstruyen desde columnas y relaciones y se validan al cargar. No se aceptan claves desconocidas. La última revisión debe corresponder con el estado actual. Las revisiones se añaden, no se sobrescriben.
Las migraciones son explícitas e idempotentes. Los cambios se realizan dentro de transacciones, bloqueando primero el catálogo global y después la compañía. Esta serialización evita altas concurrentes duplicadas y colisiones entre catálogos globales y locales. Las pertenencias y reglas entre ámbitos se validan en el dominio.

View File

@ -38,7 +38,7 @@ La inspección del repositorio muestra:
| Concurrencia | `FOR UPDATE`, bloqueos del catálogo y orden fijo de adquisición | Deben reproducirse dentro de la misma transacción/conexión. |
| Históricos | Revisiones de activos y asignaciones, entre otros | Deben mantenerse identidad, orden e inmutabilidad del histórico. |
| Sesiones | `connect-session-sequelize` 8.0.6 sobre `http_sessions` | Eliminar Sequelize completamente requiere sustituir o adaptar el store de Express. |
| Normalización | Activos tienen persistencia relacional; todavía aparecen JSON/JSON_EXTRACT en otros ámbitos | Cambiar ORM no completa la normalización. Son proyectos distintos. |
| Normalización | La persistencia de negocio utiliza columnas y tablas relacionadas | La elección de ORM debe conservar el diseño relacional y sus restricciones. |
Fuentes locales principales: `apps/server/src/infrastructure/http/identity-runtime.ts`,
`modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts`,
@ -270,8 +270,8 @@ Esta es una priorización por encaje, no una afirmación de falta de mantenimien
7. **Sesiones:** inicialmente puede seguir el store Sequelize. Para retirar la dependencia
completamente habría que reemplazarlo preservando expiración, cookies y validación de
sesiones, o asumir un cierre de sesiones controlado. No usar MemoryStore como reemplazo.
8. **Normalización:** continuar la eliminación de JSON como trabajo separado, autorizado y
con migraciones propias. Ninguna herramienta garantiza una BD normalizada por usarla.
8. **Modelo relacional:** preservar el diseño de columnas, tablas dependientes y restricciones.
Ninguna herramienta garantiza una BD normalizada por usarla.
9. **Rendimiento:** medir número de consultas, tiempo y filas leídas con datos equivalentes;
no afirmar que un ORM es más rápido solo por su API.

View File

@ -105,4 +105,4 @@ Las pruebas de intervenciones cubren actuaciones sencillas, requisitos de cierre
## Persistencia normalizada
Los valores actuales y las revisiones se almacenan en columnas tradicionales. Los afectados y pasos se guardan en relaciones ordenadas con claves foráneas; los procedimientos de intervención conservan una copia inmutable de la plantilla y su revisión. No se utilizan columnas JSON para este ámbito. Véase [Normalización de la persistencia](normalizacion-de-la-persistencia.md#tickets-intervenciones-y-plantillas) para el inventario de tablas y las garantías de transferencia.
Los valores actuales y las revisiones se almacenan en columnas tradicionales. Los afectados y pasos se guardan en relaciones ordenadas con claves foráneas; los procedimientos de intervención conservan una copia inmutable de la plantilla y su revisión. No se utilizan columnas JSON para este ámbito. Véase [Persistencia relacional](normalizacion-de-la-persistencia.md#tickets-intervenciones-y-plantillas) para el inventario de tablas y las restricciones de integridad.

View File

@ -1,4 +1,4 @@
# Normalización de activos
# Persistencia relacional de activos
## Objetivo
@ -22,10 +22,6 @@ Una instantánea de la plantilla se conserva mediante `asset_specification_secti
`asset_revisions` conserva las propiedades estables de cada revisión. Sus características se guardan en `asset_revision_characteristics`, vinculadas a la instantánea del activo.
## Migración
La migración crea las tablas normalizadas, lee una única vez los datos JSON existentes, valida su estructura con los schemas de dominio y crea las filas correspondientes dentro de una transacción. Solo elimina las columnas JSON después de una transferencia íntegra. Cualquier dato que no cumpla el contrato detiene el arranque sin descartar el origen.
## Relaciones entre activos
`asset_relationships` guarda sus extremos, tipo, nombre e inverso capturados, simetría, notas y auditoría en columnas. Se conservan sus UUID, clave de relación activa, autor y fechas de creación y retirada. Los nombres capturados no cambian cuando se renombra el tipo en el catálogo.
@ -36,48 +32,14 @@ Las FK compuestas impiden asignar una sección de otra plantilla, usar un campo
`sectionsDefined` y `brandDefined` conservan la distinción del contrato entre una propiedad ausente, una lista vacía y una marca nula en datos históricos. Una marca no nula siempre implica `brandDefined=1`.
El catálogo global, los particulares de compañía, disponibilidad, secciones, orden y campos obligatorios mantienen su comportamiento. Dominio, DTO y HTTP continúan utilizando objetos `values` y `specification`; estos objetos se ensamblan desde las tablas normalizadas y no se almacenan como JSON. Se adaptan también las lecturas de dashboard y asistencia técnica.
El catálogo global, los particulares de compañía, disponibilidad, secciones, orden y campos obligatorios mantienen su comportamiento. Dominio, DTO y HTTP continúan utilizando objetos `values` y `specification`; estos objetos se ensamblan desde las tablas normalizadas y no se almacenan como JSON. Los lectores de dashboard y asistencia técnica utilizan las mismas columnas.
## Ejecución y recuperación
## Inicialización
Las migraciones se ejecutan antes de exponer las rutas durante `createAssetModule`. Primero crean las estructuras necesarias y transfieren datos dentro de transacciones. La lectura desde el destino se compara con el origen antes de retirar sus columnas. Se valida el historial completo, además de los valores actuales. Un dato inválido detiene la transferencia y conserva las columnas originales.
`createAssetModule` prepara explícitamente tablas, índices, restricciones y catálogos antes de exponer las rutas. Las inicializaciones son idempotentes y no modifican los valores ni el historial de los activos.
MariaDB confirma las operaciones DDL de forma independiente: la migración no promete rollback del esquema completo. Cada paso comprueba el estado existente y permite repetir un arranque, incluido el corte entre la retirada del JSON de las revisiones y la del activo. No se deben mantener escritores de versiones antiguas funcionando durante una actualización del esquema.
La creación del esquema usa columnas con su nulabilidad definitiva y tablas dependientes para opciones, secciones, especificaciones y valores. Los repositorios reconstruyen y validan el agregado completo, incluidas sus revisiones.
Antes de aplicar cambios a la BD de desarrollo puede ejecutarse `bash scripts/backup-assets-normalization.sh`. La copia SQL completa se guarda fuera del repositorio en `/home/david/.local/state/metis-desk/backups`, con permisos restringidos. Contiene datos privados y no debe versionarse.
Para una copia de seguridad de desarrollo puede utilizarse `bash scripts/backup-database.sh`. El SQL se guarda fuera del repositorio en `/home/david/.local/state/metis-desk/backups`, con permisos restringidos.
## Alcance
Esta entrega normaliza catálogo de activos, inventario, especificaciones, revisiones y relaciones entre activos. No cambia la persistencia de recursos, puestos de trabajo, asistencia técnica, tickets ni los otros módulos que todavía tienen JSON. Su normalización requiere migraciones específicas posteriores; no se introduce JSON nuevo para guardar valores en el diseño de activos.
## Verificación
`apps/server/src/asset-normalization.integration.ts`, incluido en `pnpm --filter @metis/server test`, reconstruye el formato anterior únicamente en `metis_desk_test` y comprueba transferencia, dato inválido sin descarte del origen, idempotencia, recuperación de DDL interrumpido y restricciones de valores. Cubre los cinco tipos, cero, falso, campos omitidos, secciones congeladas, marcas, identidades y revisiones existentes. La integración HTTP habitual verifica catálogo global/local, creación y edición, relaciones, puestos, asistencia y dashboards.
## Esquema de relaciones
```mermaid
erDiagram
asset_catalogs ||--o{ asset_definitions : contiene
asset_definitions ||--o{ asset_field_choices : opciones
asset_definitions ||--o{ asset_template_sections : secciones
asset_definitions ||--o{ asset_template_fields : plantilla_y_campo
asset_template_sections o|--o{ asset_template_fields : agrupa
asset_definitions ||--o{ asset_availability : disponibilidad_por_compania
asset_definitions ||--o{ assets : plantilla
asset_brands o|--o{ assets : marca
assets ||--o{ asset_specification_sections : captura
assets ||--|{ asset_specification_fields : captura
asset_specification_sections o|--o{ asset_specification_fields : agrupa
asset_specification_fields ||--o{ asset_specification_field_choices : captura_opciones
asset_specification_fields ||--o{ asset_characteristics : valor_actual
assets ||--|{ asset_revisions : historial
asset_revisions ||--o{ asset_revision_characteristics : valores_historicos
asset_specification_fields ||--o{ asset_revision_characteristics : interpreta
assets ||--o{ asset_relationships : origen_o_destino
asset_definitions ||--o{ asset_relationships : tipo
```
La tabla `asset_template_fields` referencia `asset_definitions` dos veces: plantilla y campo. `asset_relationships` referencia `assets` dos veces: origen y destino. Las cardinalidades de valor por campo se interpretan dentro de cada activo o revisión; los campos opcionales pueden no tener fila.
En desarrollo se comprobó la lectura de las tres compañías y la conservación de 52 definiciones, 5 activos, 6 revisiones y 1 relación. La copia previa de esta ejecución se guardó en `/home/david/.local/state/metis-desk/backups/pre-normalizacion-activos-20261010-113023.sql`.
El resto del modelo se describe en [Persistencia relacional](normalizacion-de-la-persistencia.md).

View File

@ -1,15 +1,15 @@
# Normalización de la persistencia
# Persistencia relacional
Se mantiene Sequelize 6 y MariaDB. El criterio del proyecto es almacenar datos de negocio en columnas y relaciones, sin documentos JSON. Los objetos `values` del dominio y el JSON del contrato HTTP no implican columnas JSON.
## Estado de la normalización
## Modelo de almacenamiento
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.
El modelo utiliza columnas y relaciones para activos, recursos, configuraciones, cuentas y accesos, correo, procesos, tickets, intervenciones y sesiones HTTP. Los contratos HTTP utilizan JSON como transporte; el almacenamiento es relacional.
| Ámbito | Persistencia actual |
| --- | --- |
| Compañías, personas, departamentos, sedes, usuarios y marcas | Columnas y relaciones existentes |
| Catálogo e inventario de activos | Definiciones, campos tipados, especificaciones y revisiones normalizados previamente |
| Compañías, personas, departamentos, sedes, usuarios y marcas | Columnas y relaciones |
| Catálogo e inventario de activos | Definiciones, campos tipados, especificaciones y revisiones en columnas y tablas |
| Catálogo e inventario de recursos | Definiciones, opciones, secciones, especificaciones congeladas, valores tipados e integración en columnas/tablas |
| Puestos de trabajo | Nombre, estado y referencias en columnas; activos exclusivos/compartidos por puesto y revisión en tablas |
| Asistencia técnica de activos | Destino, VPN, preferencia, estado y notas en columnas actuales e históricas |
@ -17,7 +17,7 @@ La auditoría y normalización de la persistencia actual están completadas. Act
| 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 | 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 |
| Correo | Buzones, usos y configuraciones en columnas actuales e históricas |
| 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 |
@ -27,7 +27,7 @@ Las columnas `data` de fotos y logotipos son datos binarios; no son documentos J
## Recursos
Las tablas existentes `resource_definitions`, `resource_template_fields`, `resources` y `resource_revisions` mantienen sus identidades. Se añaden:
Las tablas principales son `resource_definitions`, `resource_template_fields`, `resources` y `resource_revisions`. Sus tablas dependientes son:
- `resource_field_choices`: opciones ordenadas de cada campo.
- `resource_template_sections`: secciones ordenadas, identificadas por `(templateId, id)`; copiar una plantilla puede conservar los identificadores internos de sus secciones.
@ -42,13 +42,13 @@ Los vínculos de integración con buzones y accesos se conservan en columnas con
`workstation_members` conserva la composición actual del puesto, con tipo `exclusive` o `shared` y posición. `workstation_revision_members` conserva la composición de cada revisión. `workstation_assets` sigue siendo la proyección técnica que protege referencias históricas y reserva activos exclusivos de puestos vigentes; no es otra asignación de negocio.
Los perfiles y configuraciones mantienen sus reglas de unicidad, transiciones y cierre. El cambio de persistencia no modifica rutas, formularios, DTO, contadores, permisos, identidad ni comportamiento funcional. Las referencias de las revisiones se conservan mediante claves foráneas.
Los perfiles y configuraciones mantienen sus reglas de unicidad, transiciones y cierre. Las referencias de las revisiones se conservan mediante claves foráneas.
## Tickets, intervenciones y plantillas
Los seis registros principales (`tickets`, `ticket_revisions`, `interventions`, `intervention_revisions`, `intervention_templates`, `intervention_template_revisions`) ya no almacenan `values` en JSON. Asunto, descripción, estados, prioridad, canal, modalidad, fechas, minutos, diagnóstico, trabajo realizado, resolución, nombre y estado de la plantilla tienen columnas tradicionales según la entidad. `plannedDate` es una fecha SQL; los instantes ISO conservan su representación exacta en columnas de texto para no perder precisión del historial.
Los registros tickets, ticket_revisions, interventions, intervention_revisions, intervention_templates e intervention_template_revisions almacenan sus atributos en columnas. Asunto, descripción, estados, prioridad, canal, modalidad, fechas, minutos, diagnóstico, trabajo realizado, resolución, nombre y estado de la plantilla tienen columnas tradicionales según la entidad. `plannedDate` es una fecha SQL; los instantes ISO conservan su representación exacta en columnas de texto para no perder precisión del historial.
Se añaden diez tablas:
Las diez tablas dependientes son:
| Información | Actual | Histórica |
| --- | --- | --- |
@ -65,19 +65,13 @@ El procedimiento conserva plantilla, revisión concreta, nombre, descripción, e
Los repositorios reconstruyen los mismos Value Objects y agregados. Se mantienen las rutas, DTO, UUID, autores, fechas, contadores, reintentos idempotentes, revisión esperada, conflictos concurrentes y reglas para cerrar o reabrir. Los lectores del dashboard consultan las columnas normalizadas tanto del estado actual como de las revisiones.
## Migraciones
## Inicialización del esquema
Las migraciones se ejecutan explícitamente al inicializar los módulos; no se utiliza `sync({ alter: true })`.
Los módulos preparan explícitamente sus tablas, índices y restricciones antes de exponer las rutas. Las columnas obligatorias se declaran al crear cada tabla; las tablas dependientes se crean de manera idempotente. No se utiliza sync({ alter: true }).
1. Se añaden columnas y tablas de destino sin borrar el origen.
2. Se valida el documento anterior mediante el esquema de dominio, incluidas las referencias ya persistidas.
3. La transferencia de valores se realiza en una transacción.
4. Se reconstruye el destino y se compara con el origen antes de retirar las columnas antiguas.
5. Se eliminan las columnas JSON transferidas y se refuerzan las restricciones.
La lectura reconstruye los Value Objects y agregados desde columnas y relaciones, valida sus referencias e historial y rechaza persistencia inconsistente. Los cambios se guardan mediante transacciones. Repetir la inicialización no añade revisiones ni modifica valores de negocio.
El DDL de MariaDB no es transaccional. Una interrupción puede dejar columnas de preparación, pero el origen se conserva hasta verificar la transferencia. Las migraciones de tickets, intervenciones y plantillas permiten reconstruir conjuntamente registros legados y normalizados si el DDL se interrumpe entre columnas. La migración de recursos admite también el corte en el que las revisiones ya se transfirieron y el recurso aún conserva sus columnas de origen. Repetir las migraciones no añade revisiones ni duplica datos.
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.
Las pruebas de integración utilizan exclusivamente metis_desk_test y comprueban creación del esquema vacío, operaciones HTTP, restricciones, historial, concurrencia e idempotencia.
## Cuentas y accesos
@ -105,11 +99,11 @@ El catálogo global y los ámbitos de compañía mantienen sus reglas. Se conser
## 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.
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 identifica el alcance de cada configuración de correo: persona/cuenta/activo o perfil/buzón. 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.
Los repositorios, lectores de dashboard/procesos y proyecciones de recursos reconstruyen los contratos de dominio. Una persona puede tener múltiples configuraciones y cada cambio conserva el historial.
## Procesos
@ -121,40 +115,12 @@ La referencia de una tarea es información congelada para revisar una situación
## 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.
http_sessions almacena sid, expiración, fechas de creación/actualización, usuario, versión de autenticación, token CSRF y opciones de cookie. SequelizeSessionStore implementa el contrato de express-session mediante columnas tradicionales.
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.
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. El almacén rechaza atributos de sesión desconocidos en lugar de descartarlos.
## Auditoría final
## Comprobaciones de persistencia
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
- Suite completa: 277 pruebas superadas; espaciado: 3 pruebas superadas.
- Typecheck de servidor, activos, compañías y recursos: correcto.
- Build: correcto; mantiene el aviso de tamaño de bundles del frontend.
- Biome y ESLint sobre los ficheros modificados en esta revisión: correctos.
- Arranque y migraciones sobre una base de pruebas vacía: correctos.
- Inicialización de desarrollo: comprobada después de guardar una copia SQL externa al repositorio en `/home/david/backups/metis-desk/`.
El typecheck global sigue encontrando el problema anterior de `locale` en `packages/shadcn-ui/src/components/calendar.tsx`. El lint/check global conserva errores de otros ficheros (principalmente código de fotos y formato pendiente). No se modifica código vendor ni cambios de interfaz ajenos a esta normalización.
## Verificación de tickets e intervenciones
- Suite completa: 277 pruebas superadas, incluida la integración HTTP/MariaDB y la transferencia del formato legado; 3 pruebas de espaciado superadas.
- Typecheck de servidor y compañías, build, Biome y ESLint de todos los ficheros modificados: correctos.
- 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.
La persistencia no declara DataTypes.JSON ni interpreta documentos serializados. El uso de JSON en HTTP, traducciones y logs es independiente del almacenamiento de negocio.

View File

@ -24,7 +24,7 @@ No se eliminan puestos ni revisiones. Guardar valores normalizados equivalentes
Workstation es un agregado; WorkstationRevision es una entidad interna. WorkstationValues es un Value Object inmutable con nombres Name, referencias UniqueID y opcionales Maybe. Infrastructure reconstruye y valida los valores, la última revisión, la auditoría y el ámbito.
Las tablas workstations y workstation_revisions mantienen raíces y revisiones con claves foráneas a compañía, sede, persona y actor. workstation_assets es una proyección técnica de las referencias utilizadas en todo el historial y de sus reservas vigentes; no representa otra entidad de negocio. Conserva claves foráneas a activos históricos de ambas listas; solo la composición exclusiva genera la reserva vigente. Las instantáneas anteriores se interpretan con una lista compartida vacía, sin reescribir datos ni UUID. Conserva claves compuestas para la pertenencia a compañía y una clave única nullable para la reserva. Las migraciones son idempotentes.
Las tablas workstations y workstation_revisions mantienen raíces y revisiones con claves foráneas a compañía, sede, persona y actor. workstation_assets es una proyección técnica de las referencias utilizadas en todo el historial y de sus reservas vigentes; no representa otra entidad de negocio. Conserva claves foráneas a activos históricos de ambas listas; solo la composición exclusiva genera la reserva vigente. Conserva claves compuestas para la pertenencia a compañía y una clave única nullable para la reserva. Las migraciones son idempotentes.
El repositorio del inventario carga los puestos y serializa sus escrituras con el bloqueo de catálogo y compañía ya utilizado por activos y relaciones. Conserva revisiones existentes y solo inserta las nuevas.

View File

@ -67,7 +67,7 @@ working tree posterior a 6bb9845; no se ha creado otro commit automáticamente.
- Imports de valores y tipos mezclados: se separaron 237 declaraciones mixtas en import type
y los imports de runtime correspondientes. Una comparación de tokens y bindings verificó
que 193 ficheros de esta última fase solo cambian imports, no lógica ni imports de runtime.
- Cast de JSON persistido: se usa unknown antes de la validación del dashboard.
- Lectura de persistencia: se usa unknown antes de validar las filas y reconstruir los objetos del dashboard.
- Promesa de envío en AccessForm: se utiliza la mutación con gestión explícita del resultado
y del error, sin descartar una promesa rechazada de mutateAsync.
- Código sin consumidores y variables no utilizadas: retirada tras comprobar referencias.

View File

@ -23,9 +23,9 @@ El editor se integra en los modales existentes de plantillas, con ayuda inmediat
Ante controles inválidos se abre la sección antes de que el navegador enfoque el control; ante errores del formulario o servidor se abren las secciones para facilitar la revisión. La obligación de rellenar un campo continúa siendo independiente de su sección.
## Datos existentes y persistencia
## Persistencia
Las secciones y asociaciones se persisten dentro del JSON validado de definición y especificación, usando las tablas y transacciones actuales; no hay cambio de estructura SQL. TemplateSection es una entidad interna con UniqueID y Name. El contrato admite definiciones y especificaciones anteriores sin sections/sectionId.
Las secciones se guardan en asset_template_sections y resource_template_sections. Las asociaciones asset_template_fields y resource_template_fields incluyen la sección opcional de cada campo. Las instantáneas utilizan asset_specification_sections y resource_specification_sections, junto con sus tablas de campos. Las claves foráneas garantizan que cada campo se vincule a una sección de su propia plantilla o especificación. TemplateSection es una entidad interna con UniqueID y Name. Una plantilla o especificación puede no tener secciones.
Las especificaciones de activos y recursos capturan el orden, nombre, descripción e identidad de las secciones y la pertenencia de cada campo al crear el registro. Editar una plantilla posteriormente no modifica estas instantáneas, valores ni historial. Los registros anteriores sin secciones permanecen planos incluso al editarlos. No se realiza una migración silenciosa de sus especificaciones.
@ -33,4 +33,4 @@ La inicialización amplía una sola vez la presentación de las plantillas de se
## Verificación
Pruebas de contratos, nombres/UUID duplicados, referencias a secciones ajenas, límites/anidamiento, igualdad semántica, ámbito global y local, persistencia JSON, ampliación idempotente, conservación de instantáneas y reglas obligatorias. Pruebas compartidas para eliminar secciones, copiar/remapear identidades y presentación agrupada o plana. Comprobación de interacción en navegador para el editor y el formulario de características.
Pruebas de contratos, nombres/UUID duplicados, referencias a secciones ajenas, límites/anidamiento, igualdad semántica, ámbito global y local, persistencia relacional, ampliación idempotente, conservación de instantáneas y reglas obligatorias. Pruebas compartidas para eliminar secciones, copiar/remapear identidades y presentación agrupada o plana. Comprobación de interacción en navegador para el editor y el formulario de características.

View File

@ -53,4 +53,4 @@ Desde el listado se inicia una intervención para el ticket con asunto y afectad
## Persistencia normalizada
Los valores actuales y las revisiones se almacenan en columnas tradicionales. Los afectados y pasos se guardan en relaciones ordenadas con claves foráneas; los procedimientos de intervención conservan una copia inmutable de la plantilla y su revisión. No se utilizan columnas JSON para este ámbito. Véase [Normalización de la persistencia](normalizacion-de-la-persistencia.md#tickets-intervenciones-y-plantillas) para el inventario de tablas y las garantías de transferencia.
Los valores actuales y las revisiones se almacenan en columnas tradicionales. Los afectados y pasos se guardan en relaciones ordenadas con claves foráneas; los procedimientos de intervención conservan una copia inmutable de la plantilla y su revisión. No se utilizan columnas JSON para este ámbito. Véase [Persistencia relacional](normalizacion-de-la-persistencia.md#tickets-intervenciones-y-plantillas) para el inventario de tablas y las restricciones de integridad.

View File

@ -1,5 +1,4 @@
import type { Sequelize, Transaction } from "sequelize";
import { z } from "zod/v4";
import type { AssetDefinitionSpec } from "../../domain/asset-definition";
import { AssetDefinitionSchema } from "../../domain/asset-definition";
@ -214,7 +213,3 @@ export async function readBindings(
return rows.map((row) => ({ ...row, required: Boolean(row.required) }));
}
export const LegacyJsonSchema = z
.unknown()
.transform((value) => (typeof value === "string" ? JSON.parse(value) : value));

View File

@ -1,7 +1,7 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
import { definitionColumns, normalizeAssetCatalog } from "./asset-normalization-migration";
import { definitionColumns, initializeAssetCatalogSchema } from "./asset-relational-schema";
export class CatalogModel extends Model<
InferAttributes<CatalogModel>,
@ -69,6 +69,7 @@ export async function migrateAssetCatalog(database: Sequelize) {
};
const binding = {
id,
sectionId: { type: DataTypes.UUID, allowNull: true },
templateId: fk("asset_definitions"),
fieldId: fk("asset_definitions"),
required: { type: DataTypes.BOOLEAN, allowNull: false },
@ -99,7 +100,7 @@ export async function migrateAssetCatalog(database: Sequelize) {
await query.addIndex(table, [...fields], { unique: true, name });
}
}
await normalizeAssetCatalog(database);
await initializeAssetCatalogSchema(database);
CatalogModel.init(catalog, {
sequelize: database,
tableName: "asset_catalogs",

View File

@ -1,342 +0,0 @@
import { isDeepStrictEqual } from "node:util";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { AssetDefinitionSchema } from "../../domain/asset-definition";
import { AssetInputSchema, AssetSpecificationSchema } from "../../domain/asset-values";
import type { BindingRow, DefinitionRow } from "./asset-catalog-relational";
import { LegacyJsonSchema, readDefinitions, writeDefinition } from "./asset-catalog-relational";
import type { InventoryRow, RevisionRow } from "./asset-inventory-relational";
import { readInventory, writeInventory } from "./asset-inventory-relational";
import { AssetPersistenceMapper } from "./asset-persistence.mapper";
import { removeAssetColumn } from "./asset-relational-sql";
const fk = (model: string, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: model === "asset_definitions" ? "RESTRICT" : "CASCADE",
});
const title = { type: DataTypes.STRING(100), allowNull: false };
const position = { type: DataTypes.INTEGER, allowNull: false };
export const definitionColumns = {
kind: { type: DataTypes.STRING(12), allowNull: true },
name: { ...title, allowNull: true },
description: { type: DataTypes.TEXT, allowNull: true },
fieldType: { type: DataTypes.STRING(12), allowNull: true },
unit: { type: DataTypes.STRING(30), allowNull: true },
inverseName: { ...title, allowNull: true },
symmetric: { type: DataTypes.BOOLEAN, allowNull: true },
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
export const inventoryColumns = {
name: { ...title, allowNull: true },
status: { type: DataTypes.STRING(15), allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
brandDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
export const specificationColumns = {
templateName: { ...title, allowNull: true },
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
type Columns = Parameters<ReturnType<Sequelize["getQueryInterface"]>["createTable"]>[1];
async function create(database: Sequelize, name: string, columns: Columns) {
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:name",
{ replacements: { name }, type: QueryTypes.SELECT }
);
if (!tables.length) {
await database.getQueryInterface().createTable(name, columns);
}
}
async function add(database: Sequelize, table: string, columns: Columns) {
const previous = await database.getQueryInterface().describeTable(table);
for (const [column, definition] of Object.entries(columns)) {
if (!(column in previous)) {
await database.getQueryInterface().addColumn(table, column, definition);
}
}
}
function legacy(value: unknown) {
return LegacyJsonSchema.parse(value);
}
export async function normalizeAssetCatalog(database: Sequelize) {
await add(database, "asset_definitions", definitionColumns);
await create(database, "asset_field_choices", {
fieldId: { ...fk("asset_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
await create(database, "asset_template_sections", {
id: { type: DataTypes.UUID, primaryKey: true },
templateId: fk("asset_definitions"),
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
await add(database, "asset_template_fields", { sectionId: fk("asset_template_sections", true) });
const columns = await database.getQueryInterface().describeTable("asset_definitions");
if (!columns.values) {
return;
}
// Las columnas originales se conservan hasta terminar la transferencia validada.
await database.query("ALTER TABLE asset_definitions MODIFY COLUMN `values` LONGTEXT NULL");
await database.transaction(async (transaction) => {
const rows = await database.query<DefinitionRow>("SELECT * FROM asset_definitions", {
type: QueryTypes.SELECT,
transaction,
});
const bindings = await database.query<BindingRow>("SELECT * FROM asset_template_fields", {
type: QueryTypes.SELECT,
transaction,
});
for (const row of rows) {
const values = AssetDefinitionSchema.parse(legacy(row.values));
validateLegacyBindings(row.id, values, bindings);
await writeDefinition(database, { ...row, values }, transaction);
if (values.kind === "template") {
for (const field of values.fields) {
await database.query(
"UPDATE asset_template_fields SET sectionId=:sectionId WHERE templateId=:id AND fieldId=:fieldId",
{
replacements: {
sectionId: field.sectionId ?? null,
id: row.id,
fieldId: field.fieldId,
},
transaction,
}
);
}
}
}
await verifyCatalogTransfer(database, rows, transaction);
});
await removeAssetColumn(database, "asset_definitions", "values");
}
export async function normalizeAssetInventory(database: Sequelize) {
await add(database, "assets", { ...inventoryColumns, ...specificationColumns });
await add(database, "asset_revisions", {
...inventoryColumns,
templateId: { ...fk("asset_definitions"), allowNull: true },
});
await create(database, "asset_specification_sections", {
assetId: { ...fk("assets"), primaryKey: true },
id: { type: DataTypes.UUID, primaryKey: true },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
await create(database, "asset_specification_fields", {
assetId: { ...fk("assets"), primaryKey: true },
fieldId: { ...fk("asset_definitions"), primaryKey: true },
name: title,
fieldType: { type: DataTypes.STRING(12), allowNull: false },
unit: { type: DataTypes.STRING(30), allowNull: false },
required: { type: DataTypes.BOOLEAN, allowNull: false },
sectionId: { type: DataTypes.UUID, allowNull: true },
position,
});
await create(database, "asset_specification_field_choices", {
assetId: { ...fk("assets"), primaryKey: true },
fieldId: { ...fk("asset_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
const typedValues = {
fieldId: { ...fk("asset_definitions"), primaryKey: true },
valueType: { type: DataTypes.STRING(12), allowNull: false },
textValue: { type: DataTypes.TEXT, allowNull: true },
numberValue: { type: DataTypes.DOUBLE, allowNull: true },
booleanValue: { type: DataTypes.BOOLEAN, allowNull: true },
dateValue: { type: DataTypes.DATEONLY, allowNull: true },
};
await create(database, "asset_characteristics", {
assetId: { ...fk("assets"), primaryKey: true },
...typedValues,
});
await create(database, "asset_revision_characteristics", {
revisionId: { ...fk("asset_revisions"), primaryKey: true },
assetId: fk("assets"),
...typedValues,
});
// Una referencia no nula implica que la propiedad ya está definida, incluso al
// recuperar una instalación que se interrumpió antes de añadir el indicador.
for (const table of ["assets", "asset_revisions"]) {
await database.query(
`UPDATE ${table} SET brandDefined=1 WHERE brandId IS NOT NULL AND brandDefined=0`
);
}
const columns = await database.getQueryInterface().describeTable("assets");
if (!columns.values) {
await removeAssetColumn(database, "assets", "specification");
return;
}
await database.query(
"ALTER TABLE assets MODIFY COLUMN `values` LONGTEXT NULL, MODIFY COLUMN specification LONGTEXT NULL"
);
const revisionColumns = await database.getQueryInterface().describeTable("asset_revisions");
if (revisionColumns.values) {
await database.query("ALTER TABLE asset_revisions MODIFY COLUMN `values` LONGTEXT NULL");
}
await database.transaction(async (transaction) => {
const assets = await database.query<InventoryRow>("SELECT * FROM assets", {
type: QueryTypes.SELECT,
transaction,
});
const revisions = await database.query<RevisionRow>("SELECT * FROM asset_revisions", {
type: QueryTypes.SELECT,
transaction,
});
const mapper = new AssetPersistenceMapper();
if (!revisionColumns.values) {
await restoreTransferredRevisions(database, assets, revisions, transaction);
}
const expectedAssets = [];
for (const row of assets) {
const asset = mapper.toDomain(
{
...row,
values: AssetInputSchema.parse(legacy(row.values)),
specification: AssetSpecificationSchema.parse(legacy(row.specification)),
},
revisions
.filter((revision) => revision.assetId === row.id)
.map((revision) => ({
...revision,
values: AssetInputSchema.parse(legacy(revision.values)),
}))
);
const expected = mapper.toPersistence(asset);
await writeInventory(database, expected, new Set(), transaction);
expectedAssets.push(expected);
}
await verifyInventoryTransfer(database, expectedAssets, transaction);
});
await removeAssetColumn(database, "asset_revisions", "values");
await removeAssetColumn(database, "assets", "values");
await removeAssetColumn(database, "assets", "specification");
}
function validateLegacyBindings(
id: string,
values: ReturnType<typeof AssetDefinitionSchema.parse>,
bindings: BindingRow[]
) {
if (values.kind !== "template") {
return;
}
const stored = bindings
.filter((binding) => binding.templateId === id)
.sort((a, b) => a.position - b.position);
if (
stored.length !== values.fields.length ||
stored.some(
(binding, index) =>
binding.fieldId !== values.fields[index]?.fieldId ||
Boolean(binding.required) !== values.fields[index]?.required
)
) {
throw new Error("Referencias de plantilla incompatibles con la normalización.");
}
}
async function verifyCatalogTransfer(
database: Sequelize,
rows: DefinitionRow[],
transaction: Transaction
) {
for (const catalogId of new Set(rows.map((row) => row.catalogId))) {
const actual = await readDefinitions(database, catalogId, transaction);
for (const row of rows.filter((item) => item.catalogId === catalogId)) {
const transferred = actual.find((item) => item.id === row.id);
if (
!(
transferred &&
isDeepStrictEqual(AssetDefinitionSchema.parse(legacy(row.values)), transferred.values)
)
) {
throw new Error("La verificación del catálogo normalizado no coincide con el origen.");
}
}
}
}
async function restoreTransferredRevisions(
database: Sequelize,
assets: InventoryRow[],
revisions: RevisionRow[],
transaction: Transaction
) {
for (const companyId of new Set(assets.map((row) => row.companyId))) {
const previous = await readInventory(database, companyId, transaction);
for (const revision of revisions) {
const transferred = previous.revisions.find((item) => item.id === revision.id);
if (transferred) {
revision.values = transferred.values;
}
}
}
}
async function verifyInventoryTransfer(
database: Sequelize,
expectedAssets: ReturnType<AssetPersistenceMapper["toPersistence"]>[],
transaction: Transaction
) {
const mapper = new AssetPersistenceMapper();
for (const companyId of new Set(expectedAssets.map((item) => item.asset.companyId))) {
const transferred = await readInventory(database, companyId, transaction);
for (const expected of expectedAssets.filter((item) => item.asset.companyId === companyId)) {
const row = transferred.rows.find((item) => item.id === expected.asset.id);
if (
!(
row &&
isDeepStrictEqual(
expected,
mapper.toPersistence(
mapper.toDomain(
row,
transferred.revisions.filter((item) => item.assetId === row.id)
)
)
)
)
) {
throw new Error("La verificación del inventario normalizado no coincide con el origen.");
}
}
}
}

View File

@ -3,9 +3,8 @@ import { QueryTypes } from "sequelize";
import { sqlIdentifier } from "./asset-relational-sql";
/** Restricciones posteriores al traslado: nunca se aceptan valores parcialmente migrados. */
/** Restricciones de integridad del modelo relacional de activos. */
export async function enforceAssetRelationalConstraints(database: Sequelize) {
await requireAssetColumns(database);
const constraints = await database.query<{ tableName: string; name: string }>(
"SELECT TABLE_NAME AS tableName,CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE()",
{ type: QueryTypes.SELECT }
@ -68,42 +67,3 @@ export async function enforceAssetRelationalConstraints(database: Sequelize) {
);
}
}
async function requireAssetColumns(database: Sequelize) {
for (const [table, columns] of [
[
"asset_definitions",
"MODIFY kind VARCHAR(12) NOT NULL, MODIFY name VARCHAR(100) NOT NULL, MODIFY description TEXT NOT NULL",
],
[
"assets",
"MODIFY name VARCHAR(100) NOT NULL, MODIFY status VARCHAR(15) NOT NULL, MODIFY notes TEXT NOT NULL, MODIFY templateName VARCHAR(100) NOT NULL",
],
[
"asset_revisions",
"MODIFY name VARCHAR(100) NOT NULL, MODIFY status VARCHAR(15) NOT NULL, MODIFY notes TEXT NOT NULL, MODIFY templateId CHAR(36) BINARY NOT NULL",
],
]) {
if (!(table && columns)) {
throw new Error("Contrato de normalización incompleto.");
}
const description = await database.getQueryInterface().describeTable(table);
const required =
table === "asset_definitions"
? ["kind", "name", "description"]
: table === "assets"
? ["name", "status", "notes", "templateName"]
: ["name", "status", "notes", "templateId"];
if (required.some((column) => description[column]?.allowNull)) {
await database.query(`ALTER TABLE ${sqlIdentifier(table)} ${columns}`);
}
}
for (const table of ["asset_characteristics", "asset_revision_characteristics"]) {
const columns = await database.getQueryInterface().describeTable(table);
if (columns.dateValue?.type !== "DATE") {
await database.query(`ALTER TABLE ${sqlIdentifier(table)} MODIFY dateValue DATE NULL`);
}
}
}

View File

@ -0,0 +1,107 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
const fk = (model: string, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: model === "asset_definitions" ? "RESTRICT" : "CASCADE",
});
const title = { type: DataTypes.STRING(100), allowNull: false };
const position = { type: DataTypes.INTEGER, allowNull: false };
export const definitionColumns = {
kind: { type: DataTypes.STRING(12), allowNull: false },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
fieldType: { type: DataTypes.STRING(12), allowNull: true },
unit: { type: DataTypes.STRING(30), allowNull: true },
inverseName: { ...title, allowNull: true },
symmetric: { type: DataTypes.BOOLEAN, allowNull: true },
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
export const inventoryColumns = {
name: title,
status: { type: DataTypes.STRING(15), allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: false },
brandDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
export const specificationColumns = {
templateName: title,
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
type Columns = Parameters<ReturnType<Sequelize["getQueryInterface"]>["createTable"]>[1];
async function create(database: Sequelize, name: string, columns: Columns) {
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:name",
{ replacements: { name }, type: QueryTypes.SELECT }
);
if (!tables.length) {
await database.getQueryInterface().createTable(name, columns);
}
}
export async function initializeAssetCatalogSchema(database: Sequelize) {
await create(database, "asset_field_choices", {
fieldId: { ...fk("asset_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
await create(database, "asset_template_sections", {
id: { type: DataTypes.UUID, primaryKey: true },
templateId: fk("asset_definitions"),
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
}
export async function initializeAssetInventorySchema(database: Sequelize) {
await create(database, "asset_specification_sections", {
assetId: { ...fk("assets"), primaryKey: true },
id: { type: DataTypes.UUID, primaryKey: true },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
await create(database, "asset_specification_fields", {
assetId: { ...fk("assets"), primaryKey: true },
fieldId: { ...fk("asset_definitions"), primaryKey: true },
name: title,
fieldType: { type: DataTypes.STRING(12), allowNull: false },
unit: { type: DataTypes.STRING(30), allowNull: false },
required: { type: DataTypes.BOOLEAN, allowNull: false },
sectionId: { type: DataTypes.UUID, allowNull: true },
position,
});
await create(database, "asset_specification_field_choices", {
assetId: { ...fk("assets"), primaryKey: true },
fieldId: { ...fk("asset_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
const typedValues = {
fieldId: { ...fk("asset_definitions"), primaryKey: true },
valueType: { type: DataTypes.STRING(12), allowNull: false },
textValue: { type: DataTypes.TEXT, allowNull: true },
numberValue: { type: DataTypes.DOUBLE, allowNull: true },
booleanValue: { type: DataTypes.BOOLEAN, allowNull: true },
dateValue: { type: DataTypes.DATEONLY, allowNull: true },
};
await create(database, "asset_characteristics", {
assetId: { ...fk("assets"), primaryKey: true },
...typedValues,
});
await create(database, "asset_revision_characteristics", {
revisionId: { ...fk("asset_revisions"), primaryKey: true },
assetId: fk("assets"),
...typedValues,
});
}

View File

@ -36,23 +36,3 @@ export async function readAssetRows<T extends object>(
{ replacements: { ids }, type: QueryTypes.SELECT, transaction }
);
}
export async function removeAssetColumn(database: Sequelize, table: string, column: string) {
const columns = await database.getQueryInterface().describeTable(table);
if (!(column in columns)) {
return;
}
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME=:column AND REFERENCED_TABLE_NAME IS NOT NULL",
{ replacements: { table, column }, type: QueryTypes.SELECT }
);
for (const { name } of constraints) {
await database.query(
`ALTER TABLE ${sqlIdentifier(table)} DROP FOREIGN KEY ${sqlIdentifier(name)}`
);
}
// Evita el fallo de QueryInterface.removeColumn con Sequelize 6 y mariadb 3.
await database.query(`ALTER TABLE ${sqlIdentifier(table)} DROP COLUMN ${sqlIdentifier(column)}`);
}

View File

@ -1,69 +0,0 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { RelationshipValuesSchema } from "../../domain/value-objects/asset-relationship-values.vo";
import { LegacyJsonSchema } from "./asset-catalog-relational";
import { removeAssetColumn } from "./asset-relational-sql";
export const relationshipColumns = {
name: { type: DataTypes.STRING(100), allowNull: false },
inverseName: { type: DataTypes.STRING(100), allowNull: false },
symmetric: { type: DataTypes.BOOLEAN, allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: false },
};
export async function normalizeAssetRelationships(database: Sequelize) {
const query = database.getQueryInterface();
const columns = await query.describeTable("asset_relationships");
if (!columns.values) {
return;
}
for (const [column, definition] of Object.entries(relationshipColumns)) {
if (!(column in columns)) {
await query.addColumn("asset_relationships", column, { ...definition, allowNull: true });
}
}
await database.transaction(async (transaction) => {
const rows = await database.query<{
id: string;
sourceId: string;
targetId: string;
typeId: string;
values: unknown;
}>("SELECT id,sourceId,targetId,typeId,`values` FROM asset_relationships", {
type: QueryTypes.SELECT,
transaction,
});
for (const row of rows) {
const values = RelationshipValuesSchema.parse(LegacyJsonSchema.parse(row.values));
if (
values.sourceId !== row.sourceId ||
values.targetId !== row.targetId ||
values.typeId !== row.typeId
) {
throw new Error("Referencias de relación incompatibles con la normalización.");
}
await database.query(
"UPDATE asset_relationships SET name=:name,inverseName=:inverseName,symmetric=:symmetric,notes=:notes WHERE id=:id",
{
replacements: {
id: row.id,
name: values.name,
inverseName: values.inverseName,
symmetric: values.symmetric,
notes: values.notes,
},
transaction,
}
);
}
});
for (const [column, definition] of Object.entries(relationshipColumns)) {
await query.changeColumn("asset_relationships", column, definition);
}
await removeAssetColumn(database, "asset_relationships", "values");
}

View File

@ -0,0 +1,8 @@
import { DataTypes } from "sequelize";
export const relationshipColumns = {
name: { type: DataTypes.STRING(100), allowNull: false },
inverseName: { type: DataTypes.STRING(100), allowNull: false },
symmetric: { type: DataTypes.BOOLEAN, allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: false },
};

View File

@ -1,10 +1,7 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
import {
normalizeAssetRelationships,
relationshipColumns,
} from "./asset-relationship-normalization";
import { relationshipColumns } from "./asset-relationship-schema";
export class RelationshipModel extends Model<
InferAttributes<RelationshipModel>,
@ -84,7 +81,6 @@ export async function migrateRelationships(database: Sequelize) {
);
}
}
await normalizeAssetRelationships(database);
RelationshipModel.init(columns, {
sequelize: database,
tableName: "asset_relationships",

View File

@ -0,0 +1,9 @@
import { DataTypes } from "sequelize";
export const supportColumns = {
destination: { type: DataTypes.STRING(300), allowNull: false },
requiresVpn: { type: DataTypes.BOOLEAN, allowNull: false },
preferred: { type: DataTypes.BOOLEAN, allowNull: false },
state: { type: DataTypes.STRING(12), allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: false },
};

View File

@ -1,9 +1,7 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { inventoryColumns, specificationColumns } from "./asset-normalization-migration";
import { writeWorkstation } from "./workstation-relational";
import { inventoryColumns, specificationColumns } from "./asset-relational-schema";
const id = { type: DataTypes.UUID, primaryKey: true };
@ -47,168 +45,4 @@ export async function migrateInventory(database: Sequelize) {
if (!tables.some((table) => table.name === "asset_revisions")) {
await query.createTable("asset_revisions", revision);
}
const revisionColumns = await query.describeTable("asset_revisions");
if (!("siteId" in revisionColumns)) {
await query.addColumn("asset_revisions", "siteId", fk("company_sites", true));
if (revisionColumns.values) {
await database.query(
"UPDATE asset_revisions SET siteId=NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`values`,'$.siteId')),'null')"
);
}
}
for (const table of ["assets", "asset_revisions"]) {
const columns = await query.describeTable(table);
if (!("brandId" in columns)) {
await query.addColumn(table, "brandId", fk("asset_brands", true));
}
}
}
type LegacyAssignment = {
id: string;
companyId: string;
siteId: string | null;
personId: string;
personName: string;
siteName: string | null;
actorId: string | null;
};
type ActiveWorkstationAsset = { assetId: string; personId: string | null };
/**
* Convierte la asignación histórica directa activo-persona en puestos activos antes de
* retirar el campo. Si ya existe un puesto activo para el mismo activo y persona, la
* relación ya está representada y no se duplica.
*/
export async function migrateLegacyAssetAssignments(database: Sequelize) {
const query = database.getQueryInterface();
const assetColumns = await query.describeTable("assets");
if (!("personId" in assetColumns)) {
return;
}
const legacyAssignments = await database.query<LegacyAssignment>(
"SELECT a.id,a.companyId,a.siteId,a.personId,p.name AS personName,s.name AS siteName,(SELECT r.actorId FROM asset_revisions r WHERE r.assetId=a.id ORDER BY r.position DESC LIMIT 1) AS actorId FROM assets a JOIN company_contacts p ON p.id=a.personId AND p.companyId=a.companyId LEFT JOIN company_sites s ON s.id=a.siteId AND s.companyId=a.companyId WHERE a.personId IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(a.`values`,'$.status')) <> 'retired'",
{ type: QueryTypes.SELECT }
);
const activeWorkstationAssets = await database.query<ActiveWorkstationAsset>(
"SELECT wa.assetId,w.personId FROM workstation_assets wa JOIN workstations w ON w.id=wa.workstationId WHERE wa.activeKey IS NOT NULL",
{ type: QueryTypes.SELECT }
);
const activeByAsset = new Map(activeWorkstationAssets.map((item) => [item.assetId, item]));
const groups = new Map<string, LegacyAssignment[]>();
for (const assignment of legacyAssignments) {
const existing = activeByAsset.get(assignment.id);
if (existing) {
if (existing.personId !== assignment.personId) {
throw new Error(
`No se puede migrar el activo ${assignment.id}: ya pertenece a un puesto de otra persona.`
);
}
continue;
}
const key = [assignment.companyId, assignment.personId, assignment.siteId ?? ""].join(":");
const entries = groups.get(key) ?? [];
entries.push(assignment);
groups.set(key, entries);
}
await database.transaction(async (transaction) => {
for (const entries of groups.values()) {
const first = entries[0];
if (!(first?.actorId && first.personName)) {
throw new Error("No se puede reconstruir el autor de la asignación histórica del activo.");
}
const workstationId = UniqueID.generateNewID().toPrimitive();
const revisionId = UniqueID.generateNewID().toPrimitive();
const values = {
name: `Puesto migrado: ${first.personName}${first.siteName ? ` · ${first.siteName}` : ""}`.slice(
0,
100
),
active: true,
siteId: first.siteId,
personId: first.personId,
assetIds: entries.map((entry) => entry.id),
sharedAssetIds: [],
};
const recordedAt = new Date().toISOString();
await writeWorkstation(
database,
{
workstation: {
id: workstationId,
companyId: first.companyId,
siteId: first.siteId,
personId: first.personId,
values,
},
revisions: [
{
id: revisionId,
workstationId,
actorId: first.actorId,
siteId: first.siteId,
personId: first.personId,
values,
recordedAt,
position: 0,
},
],
references: [],
},
new Set(),
transaction
);
for (const entry of entries) {
await database.query(
"INSERT INTO workstation_assets (workstationId,companyId,assetId,activeKey) VALUES (:workstationId,:companyId,:assetId,:activeKey)",
{
replacements: {
workstationId,
companyId: first.companyId,
assetId: entry.id,
activeKey: `${first.companyId}:${entry.id}`,
},
transaction,
}
);
}
}
await database.query("UPDATE assets SET `values`=JSON_REMOVE(`values`,'$.personId')", {
transaction,
});
await database.query("UPDATE asset_revisions SET `values`=JSON_REMOVE(`values`,'$.personId')", {
transaction,
});
});
await removeLegacyPersonColumn(database, "asset_revisions");
await removeLegacyPersonColumn(database, "assets");
}
async function removeLegacyPersonColumn(database: Sequelize, table: "assets" | "asset_revisions") {
const columns = await database.getQueryInterface().describeTable(table);
if (!("personId" in columns)) {
return;
}
const constraints = await database.query<{ constraintName: string }>(
"SELECT CONSTRAINT_NAME AS constraintName FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME='personId' AND REFERENCED_TABLE_NAME IS NOT NULL",
{ replacements: { table }, type: QueryTypes.SELECT }
);
for (const { constraintName } of constraints) {
await database.query(
`ALTER TABLE \`${table}\` DROP FOREIGN KEY \`${constraintName.replaceAll("`", "``")}\``
);
}
// QueryInterface.removeColumn is incompatible with Sequelize 6 and mariadb 3 here.
await database.query(`ALTER TABLE \`${table}\` DROP COLUMN personId`);
}

View File

@ -3,7 +3,6 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { mapBrandInput } from "../../application/brand-input.mapper";
import { BUILTIN_BRANDS } from "../../domain/brand.aggregate";
import { BrandLogoSchema } from "../../domain/value-objects/brand-logo.vo";
@ -44,7 +43,6 @@ export async function migrateBrands(database: Sequelize) {
if (!tables.some((table) => table.name === "brand_catalog_lock")) {
await query.createTable("brand_catalog_lock", {
id: { type: DataTypes.INTEGER, primaryKey: true },
legacyImported: { type: DataTypes.BOOLEAN, allowNull: false },
logosImported: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
});
}
@ -57,7 +55,7 @@ export async function migrateBrands(database: Sequelize) {
defaultValue: false,
});
}
await database.query("INSERT IGNORE INTO brand_catalog_lock (id,legacyImported) VALUES (1,0)");
await database.query("INSERT IGNORE INTO brand_catalog_lock (id) VALUES (1)");
await database.transaction(async (transaction) => {
const locks = await database.query<{ logosImported: number }>(
"SELECT logosImported FROM brand_catalog_lock WHERE id=1 FOR UPDATE",
@ -94,80 +92,3 @@ export async function migrateBrands(database: Sequelize) {
}
});
}
// Importación única: nunca reescribe activos ni revisiones ni vuelve a crear nombres renombrados.
export async function importLegacyBrands(database: Sequelize) {
await database.transaction(async (transaction) => {
const locks = await database.query<{ legacyImported: number }>(
"SELECT legacyImported FROM brand_catalog_lock WHERE id=1 FOR UPDATE",
{ transaction, type: QueryTypes.SELECT }
);
if (locks[0]?.legacyImported) {
return;
}
const definitions = await database.query<{ id: string }>(
"SELECT id FROM asset_definitions WHERE stockKey='field:brand'",
{ transaction, type: QueryTypes.SELECT }
);
const fieldId = definitions[0]?.id;
if (fieldId) {
const columns = await queryColumns(database);
const rows = columns.values
? await database.query<{ values: unknown }>("SELECT `values` FROM assets", {
transaction,
type: QueryTypes.SELECT,
})
: (
await database.query<{ textValue: string }>(
"SELECT textValue FROM asset_characteristics WHERE fieldId=:fieldId AND textValue IS NOT NULL",
{ replacements: { fieldId }, transaction, type: QueryTypes.SELECT }
)
).map((row) => ({ values: { characteristics: { [fieldId]: row.textValue } } }));
for (const row of rows) {
const name = legacyBrandName(row.values, fieldId);
if (name === undefined) {
continue;
}
await database.query(
"INSERT INTO asset_brands (id,name,active,builtin) SELECT :id,:name,1,0 WHERE NOT EXISTS (SELECT 1 FROM asset_brands WHERE name=:name)",
{
replacements: {
id: UniqueID.generateNewID().toPrimitive(),
name,
},
transaction,
}
);
}
}
await database.query("UPDATE brand_catalog_lock SET legacyImported=1 WHERE id=1", {
transaction,
});
});
}
const LegacyValues = z.object({ characteristics: z.record(z.string(), z.unknown()) });
function legacyBrandName(raw: unknown, fieldId: string): string | undefined {
const parsed = LegacyValues.safeParse(typeof raw === "string" ? JSON.parse(raw) : raw);
if (!parsed.success) {
return undefined;
}
const name = parsed.data.characteristics[fieldId];
if (typeof name !== "string") {
return undefined;
}
const mapped = mapBrandInput({ name, active: true });
return mapped.isSuccess ? mapped.data.name.toPrimitive() : undefined;
}
function queryColumns(database: Sequelize) {
return database.getQueryInterface().describeTable("assets");
}

View File

@ -2,7 +2,7 @@ import { UniqueID } from "@repo/rdx-ddd";
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { normalizeSupport, supportColumns } from "./support-normalization-migration";
import { supportColumns } from "./asset-support-schema";
export async function migrateSupport(database: Sequelize) {
const query = database.getQueryInterface();
@ -59,7 +59,6 @@ export async function migrateSupport(database: Sequelize) {
await query.createTable(table, columns);
}
}
await normalizeSupport(database);
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='asset_support'",
{ type: QueryTypes.SELECT }

View File

@ -1,90 +0,0 @@
import { isDeepStrictEqual } from "node:util";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { SupportValuesSchema } from "../../domain/support-values";
import { removeAssetColumn } from "./asset-relational-sql";
export const supportColumns = {
destination: { type: DataTypes.STRING(300), allowNull: true },
requiresVpn: { type: DataTypes.BOOLEAN, allowNull: true },
preferred: { type: DataTypes.BOOLEAN, allowNull: true },
state: { type: DataTypes.STRING(12), allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
};
export async function normalizeSupport(database: Sequelize) {
for (const table of ["asset_support", "asset_support_revisions"]) {
const columns = await database.getQueryInterface().describeTable(table);
for (const [name, definition] of Object.entries(supportColumns)) {
if (!(name in columns)) {
await database.getQueryInterface().addColumn(table, name, definition);
}
}
if (columns.values) {
await database.transaction(async (transaction) => {
const rows = await database.query<{ id: string; toolId: string; values: unknown }>(
`SELECT id,toolId,\`values\` FROM ${table}`,
{ type: QueryTypes.SELECT, transaction }
);
for (const row of rows) {
await transferSupportRow(database, table, row, transaction);
}
});
await removeAssetColumn(database, table, "values");
}
const finalColumns = await database.getQueryInterface().describeTable(table);
for (const [name, definition] of Object.entries(supportColumns)) {
if (!finalColumns[name]?.allowNull) {
continue;
}
await database
.getQueryInterface()
.changeColumn(table, name, { ...definition, allowNull: false });
}
}
}
async function transferSupportRow(
database: Sequelize,
table: string,
row: { id: string; toolId: string; values: unknown },
transaction: Transaction
) {
const values = SupportValuesSchema.parse(
typeof row.values === "string" ? JSON.parse(row.values) : row.values
);
if (values.toolId !== row.toolId) {
throw new Error("Referencia de asistencia inconsistente.");
}
await database.query(
`UPDATE ${table} SET destination=:destination,requiresVpn=:requiresVpn,preferred=:preferred,state=:state,notes=:notes WHERE id=:id`,
{ replacements: { id: row.id, ...values }, transaction }
);
const [actual] = await database.query<Record<string, unknown>>(
`SELECT toolId,destination,requiresVpn,preferred,state,notes FROM ${table} WHERE id=:id`,
{ replacements: { id: row.id }, transaction, type: QueryTypes.SELECT }
);
if (
!(
actual &&
isDeepStrictEqual(
values,
SupportValuesSchema.parse({
...actual,
requiresVpn: Boolean(actual.requiresVpn),
preferred: Boolean(actual.preferred),
})
)
)
) {
throw new Error("La asistencia normalizada no coincide con el origen.");
}
}

View File

@ -10,14 +10,7 @@ import type { WorkstationAssetModel } from "./workstation.model";
import type { WorkstationRevisionRow, WorkstationRow } from "./workstation-relational";
function valuesOf(raw: unknown, siteId: string | null, personId: string | null) {
// Las instantáneas anteriores no tenían recursos compartidos. Se interpretan como una lista vacía sin reescribir el historial.
const legacy = WorkstationInputSchema.extend({
sharedAssetIds: z.array(z.uuid()).max(200).optional(),
}).parse(raw);
const values = WorkstationValues.create({
...legacy,
sharedAssetIds: legacy.sharedAssetIds ?? [],
});
const values = WorkstationValues.create(WorkstationInputSchema.parse(raw));
if (
!values.isSuccess ||

View File

@ -1,12 +1,9 @@
import { isDeepStrictEqual } from "node:util";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
import { WorkstationInputSchema } from "../../domain/workstation-values";
import { readAssetRows, removeAssetColumn, writeAssetRow } from "./asset-relational-sql";
import { readAssetRows, writeAssetRow } from "./asset-relational-sql";
import type { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
type Values = ReturnType<typeof WorkstationInputSchema.parse>;
@ -190,21 +187,11 @@ export async function writeWorkstation(
}
}
export async function normalizeWorkstations(database: Sequelize) {
export async function initializeWorkstationMembers(database: Sequelize) {
const query = database.getQueryInterface();
const existing = await query.showAllTables();
for (const definition of tables) {
const columns = await query.describeTable(definition.root);
for (const [name, column] of Object.entries({
name: { type: DataTypes.STRING(100), allowNull: true },
active: { type: DataTypes.BOOLEAN, allowNull: true },
})) {
if (!(name in columns)) {
await query.addColumn(definition.root, name, column);
}
}
if (!existing.includes(definition.members)) {
await query.createTable(definition.members, {
[definition.owner]: {
@ -223,70 +210,5 @@ export async function normalizeWorkstations(database: Sequelize) {
position: { type: DataTypes.INTEGER, allowNull: false },
});
}
if (columns.values) {
await transferWorkstations(database, definition);
await removeAssetColumn(database, definition.root, "values");
}
const finalColumns = await query.describeTable(definition.root);
if (finalColumns.name?.allowNull || finalColumns.active?.allowNull) {
await query.changeColumn(definition.root, "name", {
type: DataTypes.STRING(100),
allowNull: false,
});
await query.changeColumn(definition.root, "active", {
type: DataTypes.BOOLEAN,
allowNull: false,
});
}
}
}
async function transferWorkstations(database: Sequelize, definition: (typeof tables)[number]) {
await database.query(`ALTER TABLE ${definition.root} MODIFY COLUMN \`values\` LONGTEXT NULL`);
await database.transaction(async (transaction) => {
const rows = await database.query<StoredRow>(`SELECT * FROM ${definition.root}`, {
type: QueryTypes.SELECT,
transaction,
});
for (const row of rows) {
const raw: unknown = typeof row.values === "string" ? JSON.parse(row.values) : row.values;
const legacy = WorkstationInputSchema.extend({
sharedAssetIds: z.array(z.uuid()).max(200).optional(),
}).parse(raw);
const values = WorkstationInputSchema.parse({
...legacy,
sharedAssetIds: legacy.sharedAssetIds ?? [],
});
if (values.siteId !== row.siteId || values.personId !== row.personId) {
throw new Error("Referencias de puesto inconsistentes.");
}
await database.query(`UPDATE ${definition.root} SET name=:name,active=:active WHERE id=:id`, {
replacements: { id: row.id, name: values.name, active: values.active },
transaction,
});
await writeMembers(
database,
definition.members,
definition.owner,
row.id,
values,
transaction
);
const members = await database.query<Member>(
`SELECT ${definition.owner} AS ownerId,assetId,kind,position FROM ${definition.members} WHERE ${definition.owner}=:id`,
{ replacements: { id: row.id }, transaction, type: QueryTypes.SELECT }
);
const [actual] = await database.query<StoredRow>(
`SELECT * FROM ${definition.root} WHERE id=:id`,
{ replacements: { id: row.id }, transaction, type: QueryTypes.SELECT }
);
if (!(actual && isDeepStrictEqual(values, reconstruct(actual, members)))) {
throw new Error("La normalización del puesto no coincide con el origen.");
}
}
});
}

View File

@ -1,7 +1,7 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
import { normalizeWorkstations } from "./workstation-relational";
import { initializeWorkstationMembers } from "./workstation-relational";
/** Proyección técnica de referencias históricas y reserva vigente; no representa otra entidad de negocio. */
export class WorkstationAssetModel extends Model<
@ -94,5 +94,5 @@ export async function migrateWorkstations(database: Sequelize) {
tableName: "workstation_assets",
timestamps: false,
});
await normalizeWorkstations(database);
await initializeWorkstationMembers(database);
}

View File

@ -17,20 +17,17 @@ import { registerRelationshipRoutes } from "./infrastructure/http/asset-relation
import { registerBrandRoutes } from "./infrastructure/http/brand.routes";
import { registerSupportRoutes } from "./infrastructure/http/support.routes";
import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
import {
migrateInventory,
migrateLegacyAssetAssignments,
} from "./infrastructure/persistence/asset.model";
import { migrateInventory } from "./infrastructure/persistence/asset.model";
import {
CatalogModel,
migrateAssetCatalog,
} from "./infrastructure/persistence/asset-catalog.model";
import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper";
import { AssetDashboardReader } from "./infrastructure/persistence/asset-dashboard-reader";
import { normalizeAssetInventory } from "./infrastructure/persistence/asset-normalization-migration";
import { enforceAssetRelationalConstraints } from "./infrastructure/persistence/asset-relational-constraints";
import { initializeAssetInventorySchema } from "./infrastructure/persistence/asset-relational-schema";
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
import { importLegacyBrands, migrateBrands } from "./infrastructure/persistence/brand-migration";
import { migrateBrands } from "./infrastructure/persistence/brand-migration";
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
import { SequelizeBrandRepository } from "./infrastructure/persistence/sequelize-brand.repository";
@ -61,9 +58,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
await migrateRelationships(database);
await migrateWorkstations(database);
await migrateLegacyAssetAssignments(database);
await normalizeAssetInventory(database);
await importLegacyBrands(database);
await initializeAssetInventorySchema(database);
await enforceAssetRelationalConstraints(database);
await migrateSupport(database);
const support = new SupportUseCases(new SequelizeSupportRepository(database));

View File

@ -152,7 +152,7 @@ test("plantillas de intervención: mapper conserva pasos, revisiones y ámbito",
mapper.toDomain(
{
...persisted.root,
values: JSON.stringify({ ...original.values.toPrimitive(), active: false }),
values: { ...original.values.toPrimitive(), active: false },
},
persisted.revisions
)

View File

@ -21,7 +21,7 @@ import type { Person } from "../../domain/person.entity";
import {
type AccessItemRow,
type AccessRevisionRow,
normalizeAccess,
initializeAccessValues,
readAccessItems,
readAccessRevisions,
writeAccessRow,
@ -129,7 +129,7 @@ export async function migrateAccess(database: Sequelize) {
},
{ sequelize: database, tableName: "access_workspaces", timestamps: false }
);
await normalizeAccess(database);
await initializeAccessValues(database);
ReferenceModel.init(
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },

View File

@ -1,5 +1,3 @@
import { isDeepStrictEqual } from "node:util";
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { z } from "zod/v4";
@ -109,11 +107,6 @@ 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) => [
@ -321,83 +314,8 @@ async function createAccessViews(database: Sequelize) {
}
}
export async function normalizeAccess(database: Sequelize) {
export async function initializeAccessValues(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(

View File

@ -2,7 +2,6 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { applicationSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateApplicationConfigurations(database: Sequelize) {
const q = database.getQueryInterface();
@ -47,9 +46,4 @@ export async function migrateApplicationConfigurations(database: Sequelize) {
name: "application_configuration_position",
});
}
await normalizeScalarSnapshots(
database,
["application_configurations", "application_configuration_revisions"],
applicationSnapshot
);
}

View File

@ -9,8 +9,8 @@ import {
import { ApplicationConfigurationValues } from "../../domain/value-objects/application-configuration-values.vo";
import { applicationSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
import { snapshotValues } from "./scalar-snapshot-reader";
import { profileIdentity } from "./work-profile-persistence.mapper";
export type ApplicationRow = {
id: string;
@ -32,7 +32,7 @@ export type ApplicationRevisionRow = {
function mapped(raw: unknown) {
const parsed = ApplicationConfigurationValues.create(
ApplicationConfigurationInputSchema.parse(persistedJson(raw))
ApplicationConfigurationInputSchema.parse(raw)
);
if (!parsed.isSuccess) {

View File

@ -2,7 +2,6 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { certificateSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateCertificateConfigurations(database: Sequelize) {
const q = database.getQueryInterface();
@ -47,9 +46,4 @@ export async function migrateCertificateConfigurations(database: Sequelize) {
name: "certificate_configuration_position",
});
}
await normalizeScalarSnapshots(
database,
["certificate_configurations", "certificate_configuration_revisions"],
certificateSnapshot
);
}

View File

@ -9,8 +9,8 @@ import {
import { CertificateConfigurationValues } from "../../domain/value-objects/certificate-configuration-values.vo";
import { certificateSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
import { snapshotValues } from "./scalar-snapshot-reader";
import { profileIdentity } from "./work-profile-persistence.mapper";
export type CertificateRow = {
id: string;
@ -32,7 +32,7 @@ export type CertificateRevisionRow = {
function mapped(raw: unknown) {
const parsed = CertificateConfigurationValues.create(
CertificateConfigurationInputSchema.parse(persistedJson(raw))
CertificateConfigurationInputSchema.parse(raw)
);
if (!parsed.isSuccess) {

View File

@ -7,7 +7,7 @@ import { PrinterConfigurationInputSchema } from "../../domain/printer-configurat
import { TeleworkAuthorizationInputSchema } from "../../domain/telework-authorization-values";
import { WorkProfileInputSchema } from "../../domain/work-profile-values";
import type { SnapshotDefinition } from "./scalar-snapshot-normalization";
import type { SnapshotDefinition } from "./scalar-snapshot-reader";
export const profileSnapshot = {
schema: WorkProfileInputSchema,

View File

@ -2,7 +2,6 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { folderSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateFolderMappings(database: Sequelize) {
const q = database.getQueryInterface();
@ -48,9 +47,4 @@ export async function migrateFolderMappings(database: Sequelize) {
name: "folder_mapping_position",
});
}
await normalizeScalarSnapshots(
database,
["folder_mappings", "folder_mapping_revisions"],
folderSnapshot
);
}

View File

@ -9,8 +9,8 @@ import {
import { FolderMappingValues } from "../../domain/value-objects/folder-mapping-values.vo";
import { folderSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
import { snapshotValues } from "./scalar-snapshot-reader";
import { profileIdentity } from "./work-profile-persistence.mapper";
export type FolderRow = {
id: string;
@ -32,7 +32,7 @@ export type FolderRevisionRow = {
};
function mapped(raw: unknown) {
const r = FolderMappingValues.create(FolderMappingInputSchema.parse(persistedJson(raw)));
const r = FolderMappingValues.create(FolderMappingInputSchema.parse(raw));
if (!r.isSuccess) {
throw new Error("Valores de carpeta persistidos inconsistentes.");

View File

@ -1,8 +1,8 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { normalizeSupportRecords } from "./support-record-normalization";
import { interventionColumns } from "./support-record-relational";
import { initializeSupportChildren } from "./support-record-schema";
export async function migrateInterventions(database: Sequelize) {
const query = database.getQueryInterface();
@ -70,5 +70,5 @@ export async function migrateInterventions(database: Sequelize) {
unique: true,
});
}
await normalizeSupportRecords(database, "interventions", "intervention_revisions");
await initializeSupportChildren(database, "interventions", "intervention_revisions");
}

View File

@ -40,12 +40,8 @@ export function interventionIdentity(value: string) {
return id.data;
}
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
function values(value: unknown) {
const result = InterventionValues.create(InterventionInputSchema.parse(json(value)));
const result = InterventionValues.create(InterventionInputSchema.parse(value));
if (!result.isSuccess) {
throw new Error("Valores de intervention inconsistentes.");
@ -58,11 +54,11 @@ export class InterventionPersistenceMapper {
toDomain(root: Record<string, unknown>, rows: readonly Record<string, unknown>[]) {
const current = Root.parse({
...root,
values: json(root.values),
procedure: json(root.procedure),
values: root.values,
procedure: root.procedure,
});
const history = rows
.map((row) => Revision.parse({ ...row, values: json(row.values) }))
.map((row) => Revision.parse({ ...row, values: row.values }))
.sort((a, b) => a.position - b.position);
const reference = Name.create(current.reference, 40);

View File

@ -4,8 +4,8 @@ import { DataTypes, QueryTypes } from "sequelize";
import { interventionTemplateSeeds } from "../../domain/intervention-template-seed";
import { InterventionTemplatePersistenceMapper } from "./intervention-template-persistence.mapper";
import { normalizeSupportRecords } from "./support-record-normalization";
import { interventionTemplateColumns, writeSupportRow } from "./support-record-relational";
import { initializeSupportChildren } from "./support-record-schema";
export async function migrateInterventionTemplates(database: Sequelize) {
const query = database.getQueryInterface();
@ -67,7 +67,7 @@ export async function migrateInterventionTemplates(database: Sequelize) {
unique: true,
});
}
await normalizeSupportRecords(
await initializeSupportChildren(
database,
"intervention_templates",
"intervention_template_revisions"

View File

@ -7,20 +7,16 @@ import { InterventionTemplateRevision } from "../../domain/intervention-template
import { InterventionTemplateInputSchema } from "../../domain/intervention-template-values";
import { InterventionTemplateValues } from "../../domain/value-objects/intervention-template-values.vo";
const JsonValues = z.preprocess(
(value) => (typeof value === "string" ? JSON.parse(value) : value),
InterventionTemplateInputSchema
);
const Root = z.object({
id: z.uuid(),
companyId: z.uuid().nullable(),
requestKey: z.uuid(),
values: JsonValues,
values: InterventionTemplateInputSchema,
});
const Revision = z.object({
id: z.uuid(),
templateId: z.uuid(),
values: JsonValues,
values: InterventionTemplateInputSchema,
actorId: z.uuid().nullable(),
recordedAt: z.iso.datetime(),
position: z.number().int().nonnegative(),

View File

@ -3,7 +3,6 @@ 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();
@ -70,11 +69,6 @@ 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,

View File

@ -16,12 +16,8 @@ export function mailIdentity(value: string) {
return id.data;
}
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";
import { snapshotValues } from "./scalar-snapshot-reader";
export type MailRow = {
id: string;
@ -45,7 +41,7 @@ export type MailRevisionRow = {
};
function valuesOf(raw: unknown) {
const values = MailConfigurationValues.create(MailSnapshotSchema.parse(persistedJson(raw)));
const values = MailConfigurationValues.create(MailSnapshotSchema.parse(raw));
if (!values.isSuccess) {
throw new Error("Valores de correo persistidos inconsistentes.");

View File

@ -4,7 +4,7 @@ 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";
import type { SnapshotDefinition } from "./scalar-snapshot-reader";
const reference = (model: string, allowNull = true) => ({
type: DataTypes.UUID,

View File

@ -2,7 +2,6 @@ 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();
@ -82,10 +81,4 @@ export async function migrateMailboxes(database: Sequelize) {
});
}
}
await normalizeScalarSnapshots(database, ["mailboxes", "mailbox_revisions"], mailboxSnapshot);
await normalizeScalarSnapshots(
database,
["mailbox_uses", "mailbox_use_revisions"],
mailboxUseSnapshot
);
}

View File

@ -10,7 +10,7 @@ import { MailboxUseValues } from "../../domain/value-objects/mailbox-use-values.
import { MailboxValues } from "../../domain/value-objects/mailbox-values.vo";
import { mailboxSnapshot, mailboxUseSnapshot } from "./mail-remote-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { snapshotValues } from "./scalar-snapshot-reader";
export function mailboxIdentity(raw: string) {
const value = UniqueID.create(raw);
@ -22,10 +22,6 @@ export function mailboxIdentity(raw: string) {
return value.data;
}
function json(raw: unknown): unknown {
return typeof raw === "string" ? JSON.parse(raw) : raw;
}
export type MailboxRow = {
id: string;
companyId: string;
@ -68,7 +64,7 @@ function ordered(rows: readonly MailboxRevisionRow[]) {
}
function mailboxValues(raw: unknown) {
const result = MailboxValues.create(MailboxInputSchema.parse(json(raw)));
const result = MailboxValues.create(MailboxInputSchema.parse(raw));
if (!result.isSuccess) {
throw new Error("Buzón persistido inválido.");
@ -78,7 +74,7 @@ function mailboxValues(raw: unknown) {
}
function mailboxUseValues(raw: unknown) {
const result = MailboxUseValues.create(MailboxUseInputSchema.parse(json(raw)));
const result = MailboxUseValues.create(MailboxUseInputSchema.parse(raw));
if (!result.isSuccess) {
throw new Error("Uso de buzón persistido inválido.");

View File

@ -2,7 +2,6 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { printerSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migratePrinterConfigurations(database: Sequelize) {
const q = database.getQueryInterface();
@ -48,9 +47,4 @@ export async function migratePrinterConfigurations(database: Sequelize) {
name: "printer_configuration_position",
});
}
await normalizeScalarSnapshots(
database,
["printer_configurations", "printer_configuration_revisions"],
printerSnapshot
);
}

View File

@ -9,8 +9,8 @@ import {
import { PrinterConfigurationValues } from "../../domain/value-objects/printer-configuration-values.vo";
import { printerSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
import { snapshotValues } from "./scalar-snapshot-reader";
import { profileIdentity } from "./work-profile-persistence.mapper";
export type PrinterRow = {
id: string;
@ -32,9 +32,7 @@ export type PrinterRevisionRow = {
};
function mapped(raw: unknown) {
const parsed = PrinterConfigurationValues.create(
PrinterConfigurationInputSchema.parse(persistedJson(raw))
);
const parsed = PrinterConfigurationValues.create(PrinterConfigurationInputSchema.parse(raw));
if (!parsed.isSuccess) {
throw new Error("Valores de impresora persistidos inconsistentes.");

View File

@ -6,7 +6,7 @@ import { ProcessTemplate } from "../../domain/process-template.aggregate";
import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values.vo";
import {
normalizeProcesses,
initializeProcessTasks,
processColumns,
processTemplateColumns,
writeProcessRow,
@ -89,7 +89,7 @@ export async function migrateProcesses(database: Sequelize) {
}
}
await database.query("INSERT IGNORE INTO process_catalog_lock (id) VALUES (1)");
await normalizeProcesses(database);
await initializeProcessTasks(database);
const defaults = [
{
key: "onboarding",

View File

@ -7,8 +7,6 @@ import { PersonProcessRevision } from "../../domain/process-revision.entity";
import { ProcessValuesSchema } from "../../domain/process-values";
import { ProcessValues } from "../../domain/value-objects/process-values.vo";
import { persistedJson } from "./work-profile-persistence.mapper";
export type PersonProcessRow = {
id: string;
companyId: string;
@ -55,7 +53,7 @@ function identity(value: string) {
}
function mapped(raw: unknown) {
const values = ProcessValues.create(ProcessValuesSchema.parse(persistedJson(raw)));
const values = ProcessValues.create(ProcessValuesSchema.parse(raw));
if (!values.isSuccess) {
throw new Error("Valores de proceso inconsistentes.");

View File

@ -1,13 +1,9 @@
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 };
@ -66,7 +62,7 @@ function children(table: ProcessTable) {
}
function input(table: ProcessTable, value: unknown) {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const parsed = value;
return templateTable(table)
? ProcessTemplateInputSchema.parse(parsed)
@ -94,8 +90,7 @@ function task(row: Record<string, unknown>) {
export async function readProcessRows(
database: Sequelize,
table: ProcessTable,
transaction: Transaction,
mode: "compatible" | "normalized" = "compatible"
transaction: Transaction
): Promise<Record<string, unknown>[]> {
const rows = await database.query<Record<string, unknown>>(
`SELECT * FROM ${supportIdentifier(table)} ORDER BY id`,
@ -115,9 +110,6 @@ export async function readProcessRows(
)
);
if (mode === "compatible" && row.values !== undefined) {
return { ...metadata, values: input(table, row.values) };
}
const values = Object.fromEntries(
Object.keys(columns(table)).map((name) => [
name,
@ -206,13 +198,6 @@ export async function writeProcessRow(
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",
@ -248,71 +233,15 @@ async function prepare(database: Sequelize, table: ProcessTable) {
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"],
export async function initializeProcessTasks(database: Sequelize) {
for (const table 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);
}
await prepare(database, table);
}
}

View File

@ -19,8 +19,8 @@ import type { WorkProfileInput } from "../../domain/work-profile-values";
import { WorkProfileInputSchema } from "../../domain/work-profile-values";
import { configurationSnapshots } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { persistedJson, profileIdentity } from "./work-profile-persistence.mapper";
import { snapshotValues } from "./scalar-snapshot-reader";
import { profileIdentity } from "./work-profile-persistence.mapper";
function configurationDetail(
value: {
@ -91,7 +91,7 @@ export class ProcessReviewReader {
const configurations: ProcessReviewSources["configurations"][number][] = [];
for (const r of data) {
const v = MailSnapshotSchema.parse(persistedJson(r.values));
const v = MailSnapshotSchema.parse(r.values);
const personId =
"profileId" in v ? profileOf(profiles, v.profileId).values.personId : v.personId;
@ -115,7 +115,7 @@ export class ProcessReviewReader {
const configurations: ProcessReviewSources["configurations"][number][] = [];
for (const r of data) {
const v = RemoteConnectionInputSchema.parse(persistedJson(r.values));
const v = RemoteConnectionInputSchema.parse(r.values);
configurations.push({
id: profileIdentity(r.id).toPrimitive(),
@ -157,7 +157,7 @@ export class ProcessReviewReader {
for (const [table, kind, schema] of tables) {
for (const r of await this.rows(table, companyId, transaction)) {
const v = schema.parse(persistedJson(r.values));
const v = schema.parse(r.values);
const p = profileOf(profiles, v.profileId);
const detail = configurationDetail(v, assetNames, resourceNames);
@ -188,7 +188,7 @@ export class ProcessReviewReader {
const profiles = (await rows("work_profiles")).map((r) => ({
id: profileIdentity(r.id).toPrimitive(),
values: WorkProfileInputSchema.parse(persistedJson(r.values)),
values: WorkProfileInputSchema.parse(r.values),
}));
const configurations = [
...this.mail(await rows("mail_configurations"), profiles),
@ -211,7 +211,7 @@ export class ProcessReviewReader {
);
const access = rawAccess.map((r) => ({
id: profileIdentity(r.id).toPrimitive(),
values: AccessDefinitionSchema.parse(persistedJson(r.values)),
values: AccessDefinitionSchema.parse(r.values),
}));
relationships.push(...accessReviewRelationships(access));
@ -221,7 +221,7 @@ export class ProcessReviewReader {
);
for (const r of await rows("mailbox_uses")) {
const v = MailboxUseInputSchema.parse(persistedJson(r.values));
const v = MailboxUseInputSchema.parse(r.values);
relationships.push({
id: profileIdentity(r.id).toPrimitive(),
@ -234,7 +234,7 @@ export class ProcessReviewReader {
}
const authorizations = (await rows("telework_authorizations")).map((r) => ({
id: profileIdentity(r.id).toPrimitive(),
...TeleworkAuthorizationInputSchema.parse(persistedJson(r.values)),
...TeleworkAuthorizationInputSchema.parse(r.values),
}));
const sources = {
assets: assets.map((a) => ({ ...a, id: a.id.toPrimitive() })),

View File

@ -9,8 +9,6 @@ import { ProcessTemplateRevision } from "../../domain/process-template-revision.
import { ProcessTemplateInputSchema } from "../../domain/process-values";
import { ProcessTemplateValues } from "../../domain/value-objects/process-template-values.vo";
import { persistedJson } from "./work-profile-persistence.mapper";
export type ProcessTemplateRow = { id: string; nameKey: string; values: unknown };
export type ProcessTemplateRevisionRow = {
@ -43,7 +41,7 @@ function identity(value: string) {
}
function mapped(raw: unknown) {
const values = ProcessTemplateValues.create(ProcessTemplateInputSchema.parse(persistedJson(raw)));
const values = ProcessTemplateValues.create(ProcessTemplateInputSchema.parse(raw));
if (!values.isSuccess) {
throw new Error("Valores de proceso inconsistentes.");

View File

@ -3,7 +3,6 @@ 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();
@ -48,11 +47,6 @@ 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,

View File

@ -11,8 +11,8 @@ import {
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";
import { snapshotValues } from "./scalar-snapshot-reader";
import { profileIdentity } from "./work-profile-persistence.mapper";
export type RemoteRow = {
id: string;
@ -36,9 +36,7 @@ export type RemoteRevisionRow = {
};
function mapped(raw: unknown) {
const parsed = RemoteConnectionValues.create(
RemoteConnectionInputSchema.parse(persistedJson(raw))
);
const parsed = RemoteConnectionValues.create(RemoteConnectionInputSchema.parse(raw));
if (!parsed.isSuccess) {
throw new Error("Valores de acceso remoto persistidos inconsistentes.");

View File

@ -1,133 +0,0 @@
import { isDeepStrictEqual } from "node:util";
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
type Columns = Record<string, ModelAttributeColumnOptions>;
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) {
return `\`${value.replaceAll("`", "``")}\``;
}
export function snapshotValues<T>(definition: SnapshotDefinition<T>, row: object): T {
const source = z.record(z.string(), z.unknown()).parse(row);
// Compatible con instantáneas de pruebas y la transferencia del formato anterior.
if (source.values !== undefined) {
return definition.schema.parse(
typeof source.values === "string" ? JSON.parse(source.values) : source.values
);
}
const input = Object.fromEntries(
Object.keys(definition.columns).map((name) => {
const value = source[name];
return [
name,
definition.booleans?.includes(name)
? z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(value)
: value,
];
})
);
return definition.schema.parse(definition.fromColumns ? definition.fromColumns(input) : input);
}
export async function normalizeScalarSnapshots<T>(
database: Sequelize,
tables: readonly string[],
definition: SnapshotDefinition<T>
) {
const query = database.getQueryInterface();
for (const table of tables) {
const original = await query.describeTable(table);
for (const [name, column] of Object.entries(definition.columns)) {
if (!(name in original)) {
await query.addColumn(table, name, { ...column, allowNull: true });
}
}
if (original.values) {
await database.transaction(async (transaction) => {
const rows = await database.query<Record<string, unknown>>(
`SELECT * FROM ${identifier(table)}`,
{ type: QueryTypes.SELECT, transaction }
);
for (const row of rows) {
await transferSnapshotRow(
database,
table,
definition,
row,
new Set(Object.keys(original)),
transaction
);
}
});
await database.query(`ALTER TABLE ${identifier(table)} DROP COLUMN \`values\``);
}
const final = await query.describeTable(table);
for (const [name, column] of Object.entries(definition.columns)) {
if (column.allowNull === false && final[name]?.allowNull) {
await query.changeColumn(table, name, column);
}
}
}
}
async function transferSnapshotRow<T>(
database: Sequelize,
table: string,
definition: SnapshotDefinition<T>,
row: Record<string, unknown>,
original: ReadonlySet<string>,
transaction: Transaction
) {
const expected = snapshotValues(definition, row);
const values = z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.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)) {
throw new Error("Referencias de la instantánea inconsistentes.");
}
}
const assignments = Object.keys(values)
.map((name) => `${identifier(name)}=:${name}`)
.join(",");
await database.query(`UPDATE ${identifier(table)} SET ${assignments} WHERE id=:id`, {
replacements: { ...values, id: row.id },
transaction,
});
const [actual] = await database.query<Record<string, unknown>>(
`SELECT * FROM ${identifier(table)} WHERE id=:id`,
{ replacements: { id: row.id }, transaction, type: QueryTypes.SELECT }
);
if (!actual) {
throw new Error("Instantánea desaparecida durante la normalización.");
}
const { values: _legacy, ...normalized } = actual;
if (!isDeepStrictEqual(expected, snapshotValues(definition, normalized))) {
throw new Error("La instantánea normalizada no coincide con el origen.");
}
}

View File

@ -0,0 +1,37 @@
import type { ModelAttributeColumnOptions } from "sequelize";
import { z } from "zod/v4";
type Columns = Record<string, ModelAttributeColumnOptions>;
export type SnapshotDefinition<T> = {
schema: z.ZodType<T>;
columns: Columns;
booleans?: readonly string[];
fromColumns?: (row: Record<string, unknown>) => unknown;
toColumns?: (input: T) => object;
};
export function snapshotValues<T>(definition: SnapshotDefinition<T>, row: object): T {
const source = z.record(z.string(), z.unknown()).parse(row);
if (source.values !== undefined) {
return definition.schema.parse(source.values);
}
const input = Object.fromEntries(
Object.keys(definition.columns).map((name) => {
const value = source[name];
return [
name,
definition.booleans?.includes(name)
? z
.union([z.boolean(), z.literal(0), z.literal(1)])
.transform(Boolean)
.parse(value)
: value,
];
})
);
return definition.schema.parse(definition.fromColumns ? definition.fromColumns(input) : input);
}

View File

@ -1,7 +1,7 @@
import type { Sequelize, Transaction } from "sequelize";
import { z } from "zod/v4";
import { type SnapshotDefinition, snapshotValues } from "./scalar-snapshot-normalization";
import { type SnapshotDefinition, snapshotValues } from "./scalar-snapshot-reader";
import { writeSupportScalars } from "./support-record-relational";
export async function writeSnapshotRow<T>(

View File

@ -78,9 +78,7 @@ export class SequelizeMailboxRepository implements IMailboxRepository {
})),
items: items.map((item) => ({
id: mailboxIdentity(item.id),
values: AccessDefinitionSchema.parse(
typeof item.values === "string" ? JSON.parse(item.values) : item.values
),
values: AccessDefinitionSchema.parse(item.values),
})),
actors: new Map(actors.map((actor) => [actor.id, actor.name])),
today: new Date().toISOString().slice(0, 10),

View File

@ -82,7 +82,7 @@ export function supportChildren(table: SupportTable) {
}
export function supportValues(table: SupportTable, value: unknown) {
const input = typeof value === "string" ? JSON.parse(value) : value;
const input = value;
switch (table) {
case "tickets":
@ -176,8 +176,7 @@ export async function readSupportRows(
database: Sequelize,
table: SupportTable,
transaction: Transaction,
source?: readonly Record<string, unknown>[],
mode: "compatible" | "normalized" = "compatible"
source?: readonly Record<string, unknown>[]
): Promise<Record<string, unknown>[]> {
const rows =
source ??
@ -204,10 +203,7 @@ export async function readSupportRows(
return rows.map((row) => {
const columns = supportColumns(table);
const values =
mode === "compatible" && row.values !== undefined
? supportValues(table, row.values)
: normalizedValues(table, row, targets, steps);
const values = normalizedValues(table, row, targets, steps);
const metadata = Object.fromEntries(
Object.entries(row).filter(
([name]) =>
@ -222,7 +218,7 @@ export async function readSupportRows(
return { ...metadata, values };
}
return { ...metadata, values, procedure: readProcedure(row, procedures, procedureSteps, mode) };
return { ...metadata, values, procedure: readProcedure(row, procedures, procedureSteps) };
});
}
@ -260,14 +256,8 @@ function normalizedValues(
function readProcedure(
row: Record<string, unknown>,
procedures: readonly Record<string, unknown>[],
steps: readonly Record<string, unknown>[],
mode: "compatible" | "normalized"
steps: readonly Record<string, unknown>[]
) {
if (mode === "compatible" && row.procedure !== undefined) {
return InterventionProcedureSchema.nullable().parse(
typeof row.procedure === "string" ? JSON.parse(row.procedure) : row.procedure
);
}
const saved = procedures.find((item) => item.interventionId === row.id);
if (!saved) {
@ -313,8 +303,7 @@ export async function writeSupportRow(
table: SupportTable,
source: Record<string, unknown>,
transaction: Transaction,
operation: "insert" | "update",
procedurePolicy: "preserve" | "replace" = "preserve"
operation: "insert" | "update"
) {
const id = z.uuid().parse(source.id);
const values = supportValues(table, source.values);
@ -351,10 +340,8 @@ export async function writeSupportRow(
transaction
);
}
if (table === "interventions" && (operation === "insert" || procedurePolicy === "replace")) {
const procedure = InterventionProcedureSchema.nullable().parse(
typeof source.procedure === "string" ? JSON.parse(source.procedure) : source.procedure
);
if (table === "interventions" && operation === "insert") {
const procedure = InterventionProcedureSchema.nullable().parse(source.procedure);
await database.query("DELETE FROM intervention_procedures WHERE interventionId=:id", {
replacements: { id },

View File

@ -1,19 +1,7 @@
import { isDeepStrictEqual } from "node:util";
import type { ModelAttributeColumnOptions, Sequelize, Transaction } from "sequelize";
import type { ModelAttributeColumnOptions, Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { InterventionPersistenceMapper } from "./intervention-persistence.mapper";
import { InterventionTemplatePersistenceMapper } from "./intervention-template-persistence.mapper";
import {
type SupportTable,
readSupportRows,
supportChildren,
supportColumns,
supportIdentifier,
writeSupportRow,
} from "./support-record-relational";
import { TicketPersistenceMapper } from "./ticket-persistence.mapper";
import { type SupportTable, supportChildren, supportIdentifier } from "./support-record-relational";
const owned = (table: string, key = "id"): ModelAttributeColumnOptions => ({
type: DataTypes.UUID,
@ -160,169 +148,14 @@ async function createProcedures(database: Sequelize) {
}
}
function validateHistory(
rootTable: SupportTable,
roots: readonly Record<string, unknown>[],
revisions: readonly Record<string, unknown>[]
) {
for (const root of roots) {
switch (rootTable) {
case "tickets":
new TicketPersistenceMapper().toDomain(
root,
revisions.filter((row) => row.ticketId === root.id)
);
break;
case "interventions":
new InterventionPersistenceMapper().toDomain(
root,
revisions.filter((row) => row.interventionId === root.id)
);
break;
case "intervention_templates":
new InterventionTemplatePersistenceMapper().toDomain(
root,
revisions.filter((row) => row.templateId === root.id)
);
break;
default:
throw new Error("La normalización requiere una raíz de soporte.");
}
}
const key =
rootTable === "tickets"
? "ticketId"
: rootTable === "interventions"
? "interventionId"
: "templateId";
if (revisions.some((row) => !roots.some((root) => root.id === row[key]))) {
throw new Error("Revisión de soporte huérfana.");
}
}
async function prepareTable(database: Sequelize, table: SupportTable) {
const query = database.getQueryInterface();
const original = await query.describeTable(table);
for (const [name, column] of Object.entries(supportColumns(table))) {
if (!(name in original)) {
await query.addColumn(table, name, { ...column, allowNull: true });
}
}
await createChildren(database, table);
return new Set(Object.keys(original));
}
async function tightenTable(database: Sequelize, table: SupportTable) {
const query = database.getQueryInterface();
const final = await query.describeTable(table);
for (const [name, column] of Object.entries(supportColumns(table))) {
if (
(column.allowNull === false && final[name]?.allowNull) ||
(name === "plannedDate" && final[name]?.type !== "DATE")
) {
await query.changeColumn(table, name, column);
}
}
}
async function transferRow(
database: Sequelize,
table: SupportTable,
row: Record<string, unknown>,
transaction: Transaction
) {
const raw = await database.query<Record<string, unknown>>(
`SELECT * FROM ${supportIdentifier(table)} WHERE id=:id`,
{ replacements: { id: row.id }, type: QueryTypes.SELECT, transaction }
);
const original = raw[0];
const expected = row.values;
if (!original || typeof expected !== "object" || !expected) {
throw new Error("Registro de soporte desaparecido.");
}
for (const name of ["requesterId", "assigneeId", "ticketId"]) {
if (name in expected && name in original && original[name] !== Reflect.get(expected, name)) {
throw new Error("Referencia de soporte inconsistente.");
}
}
await writeSupportRow(database, table, row, transaction, "update", "replace");
}
async function transferTable(
database: Sequelize,
table: SupportTable,
rows: readonly Record<string, unknown>[],
transaction: Transaction
) {
for (const row of rows) {
await transferRow(database, table, row, transaction);
}
const normalized = await readSupportRows(database, table, transaction, undefined, "normalized");
if (
rows.length !== normalized.length ||
rows.some(
(row) =>
!isDeepStrictEqual(
row,
normalized.find((item) => item.id === row.id)
)
)
) {
throw new Error("Los registros normalizados no coinciden con el origen.");
}
}
async function retireLegacyColumns(
database: Sequelize,
legacy: ReadonlyMap<SupportTable, ReadonlySet<string>>
) {
// MariaDB confirma DDL por separado: se puede reanudar aunque una columna ya se haya retirado.
for (const [table, columns] of legacy) {
for (const name of ["values", "procedure"]) {
if (columns.has(name)) {
await database.query(
`ALTER TABLE ${supportIdentifier(table)} DROP COLUMN ${supportIdentifier(name)}`
);
}
}
}
}
export async function normalizeSupportRecords(
export async function initializeSupportChildren(
database: Sequelize,
rootTable: SupportTable,
revisionTable: SupportTable
) {
const legacy = new Map<SupportTable, ReadonlySet<string>>();
for (const table of [rootTable, revisionTable]) {
legacy.set(table, await prepareTable(database, table));
}
await createChildren(database, rootTable);
await createChildren(database, revisionTable);
if (rootTable === "interventions") {
await createProcedures(database);
}
const transfer = [...legacy.values()].some(
(columns) => columns.has("values") || columns.has("procedure")
);
if (transfer) {
await database.transaction(async (transaction) => {
const roots = await readSupportRows(database, rootTable, transaction);
const revisions = await readSupportRows(database, revisionTable, transaction);
validateHistory(rootTable, roots, revisions);
await transferTable(database, rootTable, roots, transaction);
await transferTable(database, revisionTable, revisions, transaction);
});
await retireLegacyColumns(database, legacy);
}
for (const table of [rootTable, revisionTable]) {
await tightenTable(database, table);
}
}

View File

@ -2,7 +2,6 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { teleworkSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateTeleworkAuthorizations(database: Sequelize) {
const query = database.getQueryInterface();
@ -46,9 +45,4 @@ export async function migrateTeleworkAuthorizations(database: Sequelize) {
name: "telework_revision_position",
});
}
await normalizeScalarSnapshots(
database,
["telework_authorizations", "telework_authorization_revisions"],
teleworkSnapshot
);
}

View File

@ -7,7 +7,7 @@ import { TeleworkAuthorizationInputSchema } from "../../domain/telework-authoriz
import { TeleworkAuthorizationValues } from "../../domain/value-objects/telework-authorization-values.vo";
import { teleworkSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { snapshotValues } from "./scalar-snapshot-reader";
export function teleworkIdentity(value: string) {
const id = UniqueID.create(value);
@ -31,8 +31,7 @@ export type TeleworkRevisionRow = {
};
function valuesOf(raw: unknown) {
const input = typeof raw === "string" ? JSON.parse(raw) : raw;
const values = TeleworkAuthorizationValues.create(TeleworkAuthorizationInputSchema.parse(input));
const values = TeleworkAuthorizationValues.create(TeleworkAuthorizationInputSchema.parse(raw));
if (!values.isSuccess) {
throw new Error("Valores de teletrabajo persistidos inconsistentes.");

View File

@ -1,8 +1,8 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { normalizeSupportRecords } from "./support-record-normalization";
import { ticketColumns } from "./support-record-relational";
import { initializeSupportChildren } from "./support-record-schema";
export async function migrateTickets(database: Sequelize) {
const query = database.getQueryInterface();
@ -70,5 +70,5 @@ export async function migrateTickets(database: Sequelize) {
unique: true,
});
}
await normalizeSupportRecords(database, "tickets", "ticket_revisions");
await initializeSupportChildren(database, "tickets", "ticket_revisions");
}

View File

@ -36,12 +36,8 @@ export function ticketIdentity(value: string) {
return id.data;
}
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
function values(value: unknown) {
const result = TicketValues.create(TicketInputSchema.parse(json(value)));
const result = TicketValues.create(TicketInputSchema.parse(value));
if (!result.isSuccess) {
throw new Error("Valores de ticket inconsistentes.");
@ -52,9 +48,9 @@ function values(value: unknown) {
export class TicketPersistenceMapper {
toDomain(root: Record<string, unknown>, rows: readonly Record<string, unknown>[]) {
const current = Root.parse({ ...root, values: json(root.values) });
const current = Root.parse({ ...root, values: root.values });
const history = rows
.map((row) => Revision.parse({ ...row, values: json(row.values) }))
.map((row) => Revision.parse({ ...row, values: row.values }))
.sort((a, b) => a.position - b.position);
const reference = Name.create(current.reference, 40);

View File

@ -2,7 +2,6 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { profileSnapshot } from "./configuration-snapshots";
import { normalizeScalarSnapshots } from "./scalar-snapshot-normalization";
export async function migrateWorkProfiles(database: Sequelize) {
const query = database.getQueryInterface();
@ -47,9 +46,4 @@ export async function migrateWorkProfiles(database: Sequelize) {
name: "profile_revision_position",
});
}
await normalizeScalarSnapshots(
database,
["work_profiles", "work_profile_revisions"],
profileSnapshot
);
}

View File

@ -7,7 +7,7 @@ import { WorkProfileRevision } from "../../domain/work-profile-revision.entity";
import { WorkProfileInputSchema } from "../../domain/work-profile-values";
import { profileSnapshot } from "./configuration-snapshots";
import { snapshotValues } from "./scalar-snapshot-normalization";
import { snapshotValues } from "./scalar-snapshot-reader";
export function profileIdentity(value: string) {
const id = UniqueID.create(value);
@ -19,10 +19,6 @@ export function profileIdentity(value: string) {
return id.data;
}
export function persistedJson(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
export type ProfileRow = {
id: string;
companyId: string;
@ -44,7 +40,7 @@ export type ProfileRevisionRow = {
};
function valuesOf(raw: unknown) {
const values = WorkProfileValues.create(WorkProfileInputSchema.parse(persistedJson(raw)));
const values = WorkProfileValues.create(WorkProfileInputSchema.parse(raw));
if (!values.isSuccess) {
throw new Error("Valores de perfil persistidos inconsistentes.");

View File

@ -1,9 +1,9 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { normalizeSessions, sessionColumns } from "./session-normalization";
import { initializeSessionIndexes, sessionColumns } from "./session-persistence";
/** Migraciones explícitas de identidad y normalización de sesiones. */
/** Inicialización explícita del esquema de identidad y sesiones. */
export async function migrateIdentity(database: Sequelize): Promise<void> {
const query = database.getQueryInterface();
const tableRows = await database.query<{ name: string }>(
@ -57,5 +57,5 @@ export async function migrateIdentity(database: Sequelize): Promise<void> {
updatedAt: { type: DataTypes.DATE, allowNull: false },
});
}
await normalizeSessions(database);
await initializeSessionIndexes(database);
}

View File

@ -2,7 +2,7 @@ import session from "express-session";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { restoreSession, sessionRow, writeSession } from "./session-normalization";
import { restoreSession, sessionRow, writeSession } from "./session-persistence";
type Callback = (error?: Error | null) => void;

View File

@ -1,5 +1,3 @@
import { isDeepStrictEqual } from "node:util";
import session from "express-session";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
@ -141,15 +139,8 @@ export async function writeSession(
);
}
export async function normalizeSessions(database: Sequelize) {
export async function initializeSessionIndexes(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 }
@ -158,33 +149,4 @@ export async function normalizeSessions(database: Sequelize) {
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

@ -48,10 +48,6 @@ const accessSchema = z.strictObject({
type SourceRow = { id: string; companyId: string; values: unknown };
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
function identity(value: string) {
const id = UniqueID.create(value);
@ -113,7 +109,7 @@ function map(catalog: ResourceCatalog, kind: "mailbox" | "access", value: unknow
const templateId = definition(catalog, `template:${kind}`).id.toPrimitive();
if (kind === "mailbox") {
const old = mailboxSchema.parse(json(value));
const old = mailboxSchema.parse(value);
const addressFieldId = definition(catalog, "field:email").id.toPrimitive();
const contextFieldId = definition(catalog, "field:context").id.toPrimitive();
@ -135,7 +131,7 @@ function map(catalog: ResourceCatalog, kind: "mailbox" | "access", value: unknow
},
};
}
const old = accessSchema.parse(json(value));
const old = accessSchema.parse(value);
const referenceFieldId = definition(catalog, "field:reference").id.toPrimitive();
return {
@ -166,7 +162,7 @@ async function importResource(
);
if (existing) {
const parsed = ResourceInputSchema.parse(json(existing.values));
const parsed = ResourceInputSchema.parse(existing.values);
if (!parsed || parsed.integration?.kind !== kind || existing.companyId !== row.companyId) {
throw new Error("Colisión de identidad al migrar recursos.");

View File

@ -1,5 +1,4 @@
import type { Sequelize, Transaction } from "sequelize";
import { z } from "zod/v4";
import type { ResourceDefinitionSpec } from "../../domain/resource-definition";
import { ResourceDefinitionSchema } from "../../domain/resource-definition";
@ -208,7 +207,3 @@ export async function readBindings(
return rows.map((row) => ({ ...row, required: Boolean(row.required) }));
}
export const LegacyJsonSchema = z
.unknown()
.transform((value) => (typeof value === "string" ? JSON.parse(value) : value));

View File

@ -1,7 +1,7 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
import { definitionColumns, normalizeResourceCatalog } from "./resource-normalization-migration";
import { definitionColumns, initializeResourceCatalogSchema } from "./resource-relational-schema";
export class ResourceCatalogModel extends Model<
InferAttributes<ResourceCatalogModel>,
@ -105,7 +105,7 @@ export async function migrateResourceCatalog(database: Sequelize) {
await query.addIndex(table, [...fields], { unique: true, name });
}
}
await normalizeResourceCatalog(database);
await initializeResourceCatalogSchema(database);
ResourceCatalogModel.init(catalog, {
sequelize: database,
tableName: "resource_catalogs",

View File

@ -1,428 +0,0 @@
import { isDeepStrictEqual } from "node:util";
import type { Sequelize, Transaction } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import { ResourceDefinitionSchema } from "../../domain/resource-definition";
import { ResourceInputSchema, ResourceSpecificationSchema } from "../../domain/resource-values";
import type { BindingRow, DefinitionRow } from "./resource-catalog-relational";
import { LegacyJsonSchema, readDefinitions, writeDefinition } from "./resource-catalog-relational";
import type { InventoryRow, RevisionRow } from "./resource-inventory-relational";
import { readInventory, writeInventory } from "./resource-inventory-relational";
import { ResourcePersistenceMapper } from "./resource-persistence.mapper";
import { removeResourceColumn, sqlIdentifier } from "./resource-relational-sql";
const fk = (model: string, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: ["resource_definitions", "access_items"].includes(model) ? "RESTRICT" : "CASCADE",
});
const title = { type: DataTypes.STRING(100), allowNull: false };
const position = { type: DataTypes.INTEGER, allowNull: false };
export const definitionColumns = {
kind: { type: DataTypes.STRING(12), allowNull: true },
name: { ...title, allowNull: true },
description: { type: DataTypes.TEXT, allowNull: true },
fieldType: { type: DataTypes.STRING(12), allowNull: true },
unit: { type: DataTypes.STRING(30), allowNull: true },
usage: { type: DataTypes.STRING(15), allowNull: true },
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
export const inventoryColumns = {
name: { ...title, allowNull: true },
active: { type: DataTypes.BOOLEAN, allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
integrationKind: { type: DataTypes.STRING(10), allowNull: true },
addressFieldId: fk("resource_definitions", true),
contextFieldId: fk("resource_definitions", true),
mailboxKind: { type: DataTypes.STRING(10), allowNull: true },
accountId: fk("access_items", true),
referenceFieldId: fk("resource_definitions", true),
resourceTypeId: fk("access_items", true),
systemId: fk("access_items", true),
environmentId: fk("access_items", true),
};
export const specificationColumns = {
templateName: { ...title, allowNull: true },
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
type Columns = Parameters<ReturnType<Sequelize["getQueryInterface"]>["createTable"]>[1];
async function create(database: Sequelize, name: string, columns: Columns) {
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:name",
{ replacements: { name }, type: QueryTypes.SELECT }
);
if (!tables.length) {
await database.getQueryInterface().createTable(name, columns);
}
}
async function add(database: Sequelize, table: string, columns: Columns) {
const previous = await database.getQueryInterface().describeTable(table);
for (const [column, definition] of Object.entries(columns)) {
if (!(column in previous)) {
await database.getQueryInterface().addColumn(table, column, definition);
}
}
}
function legacy(value: unknown) {
return LegacyJsonSchema.parse(value);
}
export async function normalizeResourceCatalog(database: Sequelize) {
await add(database, "resource_definitions", definitionColumns);
await create(database, "resource_field_choices", {
fieldId: { ...fk("resource_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
await create(database, "resource_template_sections", {
id: { type: DataTypes.UUID, primaryKey: true },
templateId: { ...fk("resource_definitions"), primaryKey: true },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
await add(database, "resource_template_fields", {
sectionId: { type: DataTypes.UUID, allowNull: true },
});
await ensureSectionIdentity(database);
const columns = await database.getQueryInterface().describeTable("resource_definitions");
if (!columns.values) {
await constrainSections(database);
return;
}
// Las columnas originales se conservan hasta terminar la transferencia validada.
await database.query("ALTER TABLE resource_definitions MODIFY COLUMN `values` LONGTEXT NULL");
await database.transaction(async (transaction) => {
const rows = await database.query<DefinitionRow>("SELECT * FROM resource_definitions", {
type: QueryTypes.SELECT,
transaction,
});
const bindings = await database.query<BindingRow>("SELECT * FROM resource_template_fields", {
type: QueryTypes.SELECT,
transaction,
});
for (const row of rows) {
const values = ResourceDefinitionSchema.parse(legacy(row.values));
validateLegacyBindings(row.id, values, bindings);
await writeDefinition(database, { ...row, values }, transaction);
if (values.kind === "template") {
for (const field of values.fields) {
await database.query(
"UPDATE resource_template_fields SET sectionId=:sectionId WHERE templateId=:id AND fieldId=:fieldId",
{
replacements: {
sectionId: field.sectionId ?? null,
id: row.id,
fieldId: field.fieldId,
},
transaction,
}
);
}
}
}
await verifyCatalogTransfer(database, rows, transaction);
});
await removeResourceColumn(database, "resource_definitions", "values");
await constrainSections(database);
}
export async function normalizeResourceInventory(database: Sequelize) {
await add(database, "resources", { ...inventoryColumns, ...specificationColumns });
await add(database, "resource_revisions", {
...inventoryColumns,
templateId: { ...fk("resource_definitions"), allowNull: true },
});
await create(database, "resource_specification_sections", {
resourceId: { ...fk("resources"), primaryKey: true },
id: { type: DataTypes.UUID, primaryKey: true },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
await create(database, "resource_specification_fields", {
resourceId: { ...fk("resources"), primaryKey: true },
fieldId: { ...fk("resource_definitions"), primaryKey: true },
name: title,
fieldType: { type: DataTypes.STRING(12), allowNull: false },
unit: { type: DataTypes.STRING(30), allowNull: false },
required: { type: DataTypes.BOOLEAN, allowNull: false },
sectionId: { type: DataTypes.UUID, allowNull: true },
position,
});
await create(database, "resource_specification_field_choices", {
resourceId: { ...fk("resources"), primaryKey: true },
fieldId: { ...fk("resource_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
const typedValues = {
fieldId: { ...fk("resource_definitions"), primaryKey: true },
valueType: { type: DataTypes.STRING(12), allowNull: false },
textValue: { type: DataTypes.TEXT, allowNull: true },
numberValue: { type: DataTypes.DOUBLE, allowNull: true },
booleanValue: { type: DataTypes.BOOLEAN, allowNull: true },
dateValue: { type: DataTypes.DATEONLY, allowNull: true },
};
await create(database, "resource_characteristics", {
resourceId: { ...fk("resources"), primaryKey: true },
...typedValues,
});
await create(database, "resource_revision_characteristics", {
revisionId: { ...fk("resource_revisions"), primaryKey: true },
resourceId: fk("resources"),
...typedValues,
});
const columns = await database.getQueryInterface().describeTable("resources");
if (!columns.values) {
await removeResourceColumn(database, "resources", "specification");
return;
}
await database.query(
"ALTER TABLE resources MODIFY COLUMN `values` LONGTEXT NULL, MODIFY COLUMN specification LONGTEXT NULL"
);
const revisionColumns = await database.getQueryInterface().describeTable("resource_revisions");
if (revisionColumns.values) {
await database.query("ALTER TABLE resource_revisions MODIFY COLUMN `values` LONGTEXT NULL");
}
await database.transaction(async (transaction) => {
const resources = await database.query<InventoryRow>("SELECT * FROM resources", {
type: QueryTypes.SELECT,
transaction,
});
const revisions = await database.query<RevisionRow>("SELECT * FROM resource_revisions", {
type: QueryTypes.SELECT,
transaction,
});
const mapper = new ResourcePersistenceMapper();
if (!revisionColumns.values) {
await restoreTransferredRevisions(database, resources, revisions, transaction);
}
const expectedResources = [];
for (const row of resources) {
const resource = mapper.toDomain(
{
...row,
values: ResourceInputSchema.parse(legacy(row.values)),
specification: ResourceSpecificationSchema.parse(legacy(row.specification)),
},
revisions
.filter((revision) => revision.resourceId === row.id)
.map((revision) => ({
...revision,
values: ResourceInputSchema.parse(legacy(revision.values)),
}))
);
const expected = mapper.toPersistence(resource);
await writeInventory(database, expected, new Set(), transaction);
expectedResources.push(expected);
}
await verifyInventoryTransfer(database, expectedResources, transaction);
});
await removeResourceColumn(database, "resource_revisions", "values");
await removeResourceColumn(database, "resources", "values");
await removeResourceColumn(database, "resources", "specification");
}
function validateLegacyBindings(
id: string,
values: ReturnType<typeof ResourceDefinitionSchema.parse>,
bindings: BindingRow[]
) {
if (values.kind !== "template") {
return;
}
const stored = bindings
.filter((binding) => binding.templateId === id)
.sort((a, b) => a.position - b.position);
if (
stored.length !== values.fields.length ||
stored.some(
(binding, index) =>
binding.fieldId !== values.fields[index]?.fieldId ||
Boolean(binding.required) !== values.fields[index]?.required
)
) {
throw new Error("Referencias de plantilla incompatibles con la normalización.");
}
}
async function verifyCatalogTransfer(
database: Sequelize,
rows: DefinitionRow[],
transaction: Transaction
) {
for (const catalogId of new Set(rows.map((row) => row.catalogId))) {
const actual = await readDefinitions(database, catalogId, transaction);
for (const row of rows.filter((item) => item.catalogId === catalogId)) {
const transferred = actual.find((item) => item.id === row.id);
if (
!(
transferred &&
isDeepStrictEqual(ResourceDefinitionSchema.parse(legacy(row.values)), transferred.values)
)
) {
throw new Error("La verificación del catálogo normalizado no coincide con el origen.");
}
}
}
}
async function restoreTransferredRevisions(
database: Sequelize,
resources: InventoryRow[],
revisions: RevisionRow[],
transaction: Transaction
) {
for (const companyId of new Set(resources.map((row) => row.companyId))) {
const previous = await readInventory(database, companyId, transaction);
for (const revision of revisions) {
const transferred = previous.revisions.find((item) => item.id === revision.id);
if (transferred) {
revision.values = transferred.values;
}
}
}
}
async function verifyInventoryTransfer(
database: Sequelize,
expectedResources: ReturnType<ResourcePersistenceMapper["toPersistence"]>[],
transaction: Transaction
) {
const mapper = new ResourcePersistenceMapper();
for (const companyId of new Set(expectedResources.map((item) => item.resource.companyId))) {
const transferred = await readInventory(database, companyId, transaction);
for (const expected of expectedResources.filter(
(item) => item.resource.companyId === companyId
)) {
const row = transferred.rows.find((item) => item.id === expected.resource.id);
if (
!(
row &&
isDeepStrictEqual(
expected,
mapper.toPersistence(
mapper.toDomain(
row,
transferred.revisions.filter((item) => item.resourceId === row.id)
)
)
)
)
) {
throw new Error("La verificación del inventario normalizado no coincide con el origen.");
}
}
}
}
async function ensureSectionIdentity(database: Sequelize) {
const primary = await database.query<{ columnName: string }>(
"SELECT COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_template_sections' AND CONSTRAINT_NAME='PRIMARY' ORDER BY ORDINAL_POSITION",
{ type: QueryTypes.SELECT }
);
if (primary.length === 2 && primary[0]?.columnName === "templateId") {
return;
}
// Las copias antiguas comparten IDs de sección; la identidad incluye su plantilla.
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_template_fields' AND COLUMN_NAME='sectionId' AND REFERENCED_TABLE_NAME IS NOT NULL",
{ type: QueryTypes.SELECT }
);
for (const { name } of constraints) {
await database.getQueryInterface().removeConstraint("resource_template_fields", name);
}
await database.query(
"ALTER TABLE resource_template_sections DROP PRIMARY KEY, ADD PRIMARY KEY (templateId,id)"
);
}
async function constrainSections(database: Sequelize) {
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_template_fields' AND CONSTRAINT_NAME='resource_template_field_section'",
{ type: QueryTypes.SELECT }
);
if (!constraints.length) {
await database.query(
"ALTER TABLE resource_template_fields ADD CONSTRAINT resource_template_field_section FOREIGN KEY (templateId,sectionId) REFERENCES resource_template_sections(templateId,id)"
);
}
}
export async function enforceResourceConstraints(database: Sequelize) {
const required = [
["resource_definitions", ["kind", "name", "description"]],
["resources", ["name", "active", "notes", "templateName"]],
["resource_revisions", ["name", "active", "notes", "templateId"]],
] as const;
const definitions = {
...definitionColumns,
...inventoryColumns,
...specificationColumns,
templateId: fk("resource_definitions"),
};
for (const [table, names] of required) {
const columns = await database.getQueryInterface().describeTable(table);
for (const name of names) {
if (columns[name]?.allowNull) {
await database
.getQueryInterface()
.changeColumn(table, name, { ...definitions[name], allowNull: false });
}
}
}
// Las referencias externas nunca eliminan en cascada recursos ni sus revisiones.
const external = await database.query<{
tableName: string;
constraintName: string;
columnName: string;
targetTable: string;
}>(
"SELECT k.TABLE_NAME AS tableName,k.CONSTRAINT_NAME AS constraintName,k.COLUMN_NAME AS columnName,k.REFERENCED_TABLE_NAME AS targetTable FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_CONSTRAINTS c ON c.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND c.TABLE_NAME=k.TABLE_NAME AND c.CONSTRAINT_NAME=k.CONSTRAINT_NAME WHERE k.TABLE_SCHEMA=DATABASE() AND k.TABLE_NAME IN ('resources','resource_revisions') AND k.REFERENCED_TABLE_NAME IN ('access_items','resource_definitions') AND c.DELETE_RULE='CASCADE'",
{ type: QueryTypes.SELECT }
);
for (const row of external) {
await database.query(
`ALTER TABLE ${sqlIdentifier(row.tableName)} DROP FOREIGN KEY ${sqlIdentifier(row.constraintName)}, ADD CONSTRAINT ${sqlIdentifier(`${row.tableName}_${row.columnName}_restrict_fk`)} FOREIGN KEY (${sqlIdentifier(row.columnName)}) REFERENCES ${sqlIdentifier(row.targetTable)} (id) ON DELETE RESTRICT`
);
}
}

View File

@ -0,0 +1,132 @@
import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
const fk = (model: string, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: ["resource_definitions", "access_items"].includes(model) ? "RESTRICT" : "CASCADE",
});
const title = { type: DataTypes.STRING(100), allowNull: false };
const position = { type: DataTypes.INTEGER, allowNull: false };
export const definitionColumns = {
kind: { type: DataTypes.STRING(12), allowNull: false },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
fieldType: { type: DataTypes.STRING(12), allowNull: true },
unit: { type: DataTypes.STRING(30), allowNull: true },
usage: { type: DataTypes.STRING(15), allowNull: true },
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
export const inventoryColumns = {
name: title,
active: { type: DataTypes.BOOLEAN, allowNull: false },
notes: { type: DataTypes.TEXT, allowNull: false },
integrationKind: { type: DataTypes.STRING(10), allowNull: true },
addressFieldId: fk("resource_definitions", true),
contextFieldId: fk("resource_definitions", true),
mailboxKind: { type: DataTypes.STRING(10), allowNull: true },
accountId: fk("access_items", true),
referenceFieldId: fk("resource_definitions", true),
resourceTypeId: fk("access_items", true),
systemId: fk("access_items", true),
environmentId: fk("access_items", true),
};
export const specificationColumns = {
templateName: title,
sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
};
type Columns = Parameters<ReturnType<Sequelize["getQueryInterface"]>["createTable"]>[1];
async function create(database: Sequelize, name: string, columns: Columns) {
const tables = await database.query<{ name: string }>(
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:name",
{ replacements: { name }, type: QueryTypes.SELECT }
);
if (!tables.length) {
await database.getQueryInterface().createTable(name, columns);
}
}
export async function initializeResourceCatalogSchema(database: Sequelize) {
await create(database, "resource_field_choices", {
fieldId: { ...fk("resource_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
await create(database, "resource_template_sections", {
id: { type: DataTypes.UUID, primaryKey: true },
templateId: { ...fk("resource_definitions"), primaryKey: true },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
await constrainSections(database);
}
export async function initializeResourceInventorySchema(database: Sequelize) {
await create(database, "resource_specification_sections", {
resourceId: { ...fk("resources"), primaryKey: true },
id: { type: DataTypes.UUID, primaryKey: true },
name: title,
description: { type: DataTypes.TEXT, allowNull: false },
position,
});
await create(database, "resource_specification_fields", {
resourceId: { ...fk("resources"), primaryKey: true },
fieldId: { ...fk("resource_definitions"), primaryKey: true },
name: title,
fieldType: { type: DataTypes.STRING(12), allowNull: false },
unit: { type: DataTypes.STRING(30), allowNull: false },
required: { type: DataTypes.BOOLEAN, allowNull: false },
sectionId: { type: DataTypes.UUID, allowNull: true },
position,
});
await create(database, "resource_specification_field_choices", {
resourceId: { ...fk("resources"), primaryKey: true },
fieldId: { ...fk("resource_definitions"), primaryKey: true },
position: { ...position, primaryKey: true },
value: title,
});
const typedValues = {
fieldId: { ...fk("resource_definitions"), primaryKey: true },
valueType: { type: DataTypes.STRING(12), allowNull: false },
textValue: { type: DataTypes.TEXT, allowNull: true },
numberValue: { type: DataTypes.DOUBLE, allowNull: true },
booleanValue: { type: DataTypes.BOOLEAN, allowNull: true },
dateValue: { type: DataTypes.DATEONLY, allowNull: true },
};
await create(database, "resource_characteristics", {
resourceId: { ...fk("resources"), primaryKey: true },
...typedValues,
});
await create(database, "resource_revision_characteristics", {
revisionId: { ...fk("resource_revisions"), primaryKey: true },
resourceId: fk("resources"),
...typedValues,
});
}
async function constrainSections(database: Sequelize) {
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='resource_template_fields' AND CONSTRAINT_NAME='resource_template_field_section'",
{ type: QueryTypes.SELECT }
);
if (!constraints.length) {
await database.query(
"ALTER TABLE resource_template_fields ADD CONSTRAINT resource_template_field_section FOREIGN KEY (templateId,sectionId) REFERENCES resource_template_sections(templateId,id)"
);
}
}
export async function enforceResourceConstraints(database: Sequelize) {
await constrainSections(database);
}

View File

@ -36,23 +36,3 @@ export async function readResourceRows<T extends object>(
{ replacements: { ids }, type: QueryTypes.SELECT, transaction }
);
}
export async function removeResourceColumn(database: Sequelize, table: string, column: string) {
const columns = await database.getQueryInterface().describeTable(table);
if (!(column in columns)) {
return;
}
const constraints = await database.query<{ name: string }>(
"SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME=:column AND REFERENCED_TABLE_NAME IS NOT NULL",
{ replacements: { table, column }, type: QueryTypes.SELECT }
);
for (const { name } of constraints) {
await database.query(
`ALTER TABLE ${sqlIdentifier(table)} DROP FOREIGN KEY ${sqlIdentifier(name)}`
);
}
// Evita el fallo de QueryInterface.removeColumn con Sequelize 6 y mariadb 3.
await database.query(`ALTER TABLE ${sqlIdentifier(table)} DROP COLUMN ${sqlIdentifier(column)}`);
}

View File

@ -2,10 +2,10 @@ import type { Sequelize } from "sequelize";
import { DataTypes, QueryTypes } from "sequelize";
import {
initializeResourceInventorySchema,
inventoryColumns,
normalizeResourceInventory,
specificationColumns,
} from "./resource-normalization-migration";
} from "./resource-relational-schema";
const id = { type: DataTypes.UUID, primaryKey: true };
@ -56,5 +56,5 @@ export async function migrateInventory(database: Sequelize) {
name: "resource_revision_position_unique",
});
}
await normalizeResourceInventory(database);
await initializeResourceInventorySchema(database);
}

View File

@ -17,7 +17,7 @@ import {
} from "./infrastructure/persistence/resource-catalog.model";
import { ResourceCatalogPersistenceMapper } from "./infrastructure/persistence/resource-catalog-persistence.mapper";
import { ResourceDashboardReader } from "./infrastructure/persistence/resource-dashboard-reader";
import { enforceResourceConstraints } from "./infrastructure/persistence/resource-normalization-migration";
import { enforceResourceConstraints } from "./infrastructure/persistence/resource-relational-schema";
import { SequelizeResourceRepository } from "./infrastructure/persistence/sequelize-resource.repository";
import { SequelizeResourceCatalogRepository } from "./infrastructure/persistence/sequelize-resource-catalog.repository";

View File

@ -4,7 +4,7 @@ cd /home/david/dev/metis-desk
backup_dir=/home/david/.local/state/metis-desk/backups
mkdir -p "$backup_dir"
chmod 700 "$backup_dir"
backup_file="$backup_dir/pre-normalizacion-activos-$(date +%Y%m%d-%H%M%S).sql"
backup_file="$backup_dir/metis-desk-$(date +%Y%m%d-%H%M%S).sql"
umask 077
docker compose exec -T database sh -c 'MYSQL_PWD="$MARIADB_PASSWORD" mariadb-dump --user="$MARIADB_USER" --single-transaction --hex-blob "$MARIADB_DATABASE"' > "$backup_file"
printf '%s\n' "$backup_file"