metis-desk/modules/assets/src/api/infrastructure/persistence/workstation-relational.ts

293 lines
8.8 KiB
TypeScript
Raw Normal View History

2026-10-10 11:32:40 +00:00
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 type { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
type Values = ReturnType<typeof WorkstationInputSchema.parse>;
export type WorkstationRow = {
id: string;
companyId: string;
siteId: string | null;
personId: string | null;
values: Values;
};
export type WorkstationRevisionRow = {
id: string;
workstationId: string;
actorId: string;
recordedAt: string;
position: number;
siteId: string | null;
personId: string | null;
values: Values;
};
type StoredRow = Record<string, unknown> & {
id: string;
name: string;
active: boolean;
siteId: string | null;
personId: string | null;
};
type Member = { ownerId: string; assetId: string; kind: string; position: number };
const tables = [
{ root: "workstations", members: "workstation_members", owner: "workstationId" },
{ root: "workstation_revisions", members: "workstation_revision_members", owner: "revisionId" },
] as const;
function reconstruct(row: StoredRow, members: readonly Member[]): Values {
const ordered = members
.filter((member) => member.ownerId === row.id)
.sort((a, b) => a.position - b.position);
if (ordered.some((member) => !["exclusive", "shared"].includes(member.kind))) {
throw new Error("Tipo de asignación de activo inconsistente.");
}
return WorkstationInputSchema.parse({
name: row.name,
active: Boolean(row.active),
siteId: row.siteId,
personId: row.personId,
assetIds: ordered
.filter((member) => member.kind === "exclusive")
.map((member) => member.assetId),
sharedAssetIds: ordered
.filter((member) => member.kind === "shared")
.map((member) => member.assetId),
});
}
export async function readWorkstations(
database: Sequelize,
companyId: string,
transaction: Transaction
) {
const roots = await readAssetRows<StoredRow & Omit<WorkstationRow, "values">>(
database,
"workstations",
"companyId",
[companyId],
transaction
);
const revisions = await readAssetRows<StoredRow & Omit<WorkstationRevisionRow, "values">>(
database,
"workstation_revisions",
"workstationId",
roots.map((row) => row.id),
transaction
);
const rootMembers = await database.query<Member>(
"SELECT workstationId AS ownerId,assetId,kind,position FROM workstation_members WHERE workstationId IN (:ids)",
{
replacements: {
ids: roots.map((row) => row.id).concat(["00000000-0000-0000-0000-000000000000"]),
},
type: QueryTypes.SELECT,
transaction,
}
);
const revisionMembers = await database.query<Member>(
"SELECT revisionId AS ownerId,assetId,kind,position FROM workstation_revision_members WHERE revisionId IN (:ids)",
{
replacements: {
ids: revisions.map((row) => row.id).concat(["00000000-0000-0000-0000-000000000000"]),
},
type: QueryTypes.SELECT,
transaction,
}
);
return {
rows: roots.map((row) => ({
id: row.id,
companyId: row.companyId,
siteId: row.siteId,
personId: row.personId,
values: reconstruct(row, rootMembers),
})),
revisions: revisions.map((row) => ({
id: row.id,
workstationId: row.workstationId,
actorId: row.actorId,
recordedAt: row.recordedAt,
position: row.position,
siteId: row.siteId,
personId: row.personId,
values: reconstruct(row, revisionMembers),
})),
};
}
async function writeMembers(
database: Sequelize,
table: string,
owner: string,
id: string,
values: Values,
transaction: Transaction
) {
await database.query(`DELETE FROM ${table} WHERE ${owner}=:id`, {
replacements: { id },
transaction,
});
for (const [kind, ids] of [
["exclusive", values.assetIds],
["shared", values.sharedAssetIds],
] as const) {
for (const [position, assetId] of ids.entries()) {
await writeAssetRow(database, table, { [owner]: id, assetId, kind, position }, transaction);
}
}
}
async function writeSnapshot(
database: Sequelize,
definition: (typeof tables)[number],
row: Record<string, string | number | boolean | null>,
values: Values,
transaction: Transaction
) {
await writeAssetRow(
database,
definition.root,
{ ...row, name: values.name, active: values.active },
transaction
);
await writeMembers(
database,
definition.members,
definition.owner,
String(row.id),
values,
transaction
);
}
export async function writeWorkstation(
database: Sequelize,
data: ReturnType<WorkstationPersistenceMapper["toPersistence"]>,
existing: ReadonlySet<string>,
transaction: Transaction
) {
const { values, ...root } = data.workstation;
await writeSnapshot(database, tables[0], root, values, transaction);
for (const { values, ...revision } of data.revisions) {
if (!existing.has(revision.id)) {
await writeSnapshot(database, tables[1], revision, values, transaction);
}
}
}
export async function normalizeWorkstations(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]: {
type: DataTypes.UUID,
primaryKey: true,
references: { model: definition.root, key: "id" },
onDelete: "CASCADE",
},
assetId: {
type: DataTypes.UUID,
primaryKey: true,
references: { model: "assets", key: "id" },
onDelete: "RESTRICT",
},
kind: { type: DataTypes.STRING(10), allowNull: false },
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.");
}
}
});
}