import type { Sequelize, Transaction } from "sequelize"; import { DataTypes, QueryTypes } from "sequelize"; import { z } from "zod/v4"; import type { ResourceSpecificationData } from "../../domain/resource-values"; import { ResourceSpecificationSchema } from "../../domain/resource-values"; import { readResourceRows, writeResourceRow } from "./resource-relational-sql"; const bool = z.union([z.boolean(), z.literal(0), z.literal(1)]).transform(Boolean); const identity = z.object({ revisionId: z.uuid(), resourceId: z.uuid() }); const headerSchema = identity.extend({ templateId: z.uuid(), templateName: z.string(), sectionsDefined: bool, }); const fieldSchema = identity.extend({ fieldId: z.uuid(), name: z.string(), fieldType: z.enum(["text", "multiline", "number", "date", "boolean", "choice"]), unit: z.string(), required: bool, sectionId: z.uuid().nullable(), position: z.number().int(), }); const sectionSchema = identity.extend({ id: z.uuid(), name: z.string(), description: z.string(), position: z.number().int(), }); const choiceSchema = identity.extend({ fieldId: z.uuid(), position: z.number().int(), value: z.string(), }); /** Se ejecuta antes de que una estructura viva pueda cambiar; las revisiones anteriores reciben su instantánea original. */ export async function migrateResourceRevisionSpecifications(database: Sequelize) { const query = database.getQueryInterface(); const tables = await database.query<{ name: string }>( "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", { type: QueryTypes.SELECT } ); const revision = { type: DataTypes.UUID, primaryKey: true, references: { model: "resource_revisions", key: "id" }, onDelete: "CASCADE", }; const parent = { type: DataTypes.UUID, allowNull: false, references: { model: "resources", key: "id" }, onDelete: "RESTRICT", }; const definitions = [ [ "resource_revision_specifications", { revisionId: revision, resourceId: parent, templateId: { type: DataTypes.UUID, allowNull: false }, templateName: { type: DataTypes.STRING(100), allowNull: false }, sectionsDefined: { type: DataTypes.BOOLEAN, allowNull: false }, }, ], [ "resource_revision_specification_sections", { revisionId: revision, resourceId: parent, id: { type: DataTypes.UUID, primaryKey: true }, name: { type: DataTypes.STRING(100), allowNull: false }, description: { type: DataTypes.TEXT, allowNull: false }, position: { type: DataTypes.INTEGER, allowNull: false }, }, ], [ "resource_revision_specification_fields", { revisionId: revision, resourceId: parent, fieldId: { type: DataTypes.UUID, primaryKey: true, references: { model: "resource_definitions", key: "id" }, onDelete: "RESTRICT", }, name: { type: DataTypes.STRING(100), allowNull: false }, 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: { type: DataTypes.INTEGER, allowNull: false }, }, ], [ "resource_revision_specification_choices", { revisionId: revision, resourceId: parent, fieldId: { type: DataTypes.UUID, primaryKey: true }, position: { type: DataTypes.INTEGER, primaryKey: true }, value: { type: DataTypes.STRING(100), allowNull: false }, }, ], ] as const; for (const [table, columns] of definitions) { if (!tables.some((item) => item.name === table)) { await query.createTable(table, columns); } } await database.transaction(async (transaction) => { const missing = await database.query<{ id: string }>( "SELECT r.id FROM resource_revisions r LEFT JOIN resource_revision_specifications s ON s.revisionId=r.id WHERE s.revisionId IS NULL", { type: QueryTypes.SELECT, transaction } ); for (let offset = 0; offset < missing.length; offset += 500) { const ids = missing.slice(offset, offset + 500).map((item) => item.id); const options = { replacements: { ids }, transaction }; await database.query( "INSERT INTO resource_revision_specifications (revisionId,resourceId,templateId,templateName,sectionsDefined) SELECT r.id,a.id,a.templateId,a.templateName,a.sectionsDefined FROM resource_revisions r JOIN resources a ON a.id=r.resourceId WHERE r.id IN (:ids)", options ); for (const [suffix, columns] of [ ["sections", "id,name,description,position"], ["fields", "fieldId,name,fieldType,unit,required,sectionId,position"], ["choices", "fieldId,position,value"], ] as const) { const source = suffix === "choices" ? "resource_specification_field_choices" : `resource_specification_${suffix}`; const selected = columns .split(",") .map((column) => `s.\`${column}\``) .join(","); await database.query( `INSERT INTO resource_revision_specification_${suffix} (revisionId,resourceId,${columns .split(",") .map((column) => `\`${column}\``) .join( "," )}) SELECT r.id,r.resourceId,${selected} FROM resource_revisions r JOIN ${source} s ON s.resourceId=r.resourceId WHERE r.id IN (:ids)`, options ); } } }); } export async function readResourceRevisionSpecifications( database: Sequelize, ids: readonly string[], transaction: Transaction, revisionIds?: readonly string[] ) { const headers = z .array(headerSchema) .parse( await readResourceRows( database, "resource_revision_specifications", revisionIds ? "revisionId" : "resourceId", revisionIds ?? ids, transaction ) ); const fields = z .array(fieldSchema) .parse( await readResourceRows( database, "resource_revision_specification_fields", revisionIds ? "revisionId" : "resourceId", revisionIds ?? ids, transaction ) ); const sections = z .array(sectionSchema) .parse( await readResourceRows( database, "resource_revision_specification_sections", revisionIds ? "revisionId" : "resourceId", revisionIds ?? ids, transaction ) ); const choices = z .array(choiceSchema) .parse( await readResourceRows( database, "resource_revision_specification_choices", revisionIds ? "revisionId" : "resourceId", revisionIds ?? ids, transaction ) ); return new Map( headers.map((header) => [ header.revisionId, ResourceSpecificationSchema.parse({ templateId: header.templateId, templateName: header.templateName, ...(header.sectionsDefined ? { sections: sections .filter((item) => item.revisionId === header.revisionId) .sort((a, b) => a.position - b.position) .map(({ id, name, description }) => ({ id, name, description })), } : {}), fields: fields .filter((item) => item.revisionId === header.revisionId) .sort((a, b) => a.position - b.position) .map((field) => ({ fieldId: field.fieldId, name: field.name, type: field.fieldType, unit: field.unit, required: field.required, ...(field.sectionId ? { sectionId: field.sectionId } : {}), choices: choices .filter( (item) => item.revisionId === header.revisionId && item.fieldId === field.fieldId ) .sort((a, b) => a.position - b.position) .map((item) => item.value), })), }), ]) ); } export async function writeResourceRevisionSpecification( database: Sequelize, resourceId: string, revisionId: string, specification: ResourceSpecificationData, transaction: Transaction ) { const identity = { resourceId, revisionId }; await writeResourceRow( database, "resource_revision_specifications", { ...identity, templateId: specification.templateId, templateName: specification.templateName, sectionsDefined: specification.sections !== undefined, }, transaction ); for (const [position, section] of (specification.sections ?? []).entries()) { await writeResourceRow( database, "resource_revision_specification_sections", { ...identity, ...section, position }, transaction ); } for (const [position, field] of specification.fields.entries()) { await writeResourceRow( database, "resource_revision_specification_fields", { ...identity, fieldId: field.fieldId, name: field.name, fieldType: field.type, unit: field.unit, required: field.required, sectionId: field.sectionId ?? null, position, }, transaction ); for (const [choicePosition, value] of field.choices.entries()) { await writeResourceRow( database, "resource_revision_specification_choices", { ...identity, fieldId: field.fieldId, position: choicePosition, value }, transaction ); } } }