metis-desk/modules/resources/src/api/infrastructure/persistence/resource-persistence.mapper.ts
2026-10-25 01:19:17 +02:00

189 lines
6.1 KiB
TypeScript

import { UniqueID } from "@repo/rdx-ddd";
import { z } from "zod/v4";
import { Resource } from "../../domain/resource.aggregate";
import { ResourceCatalogError } from "../../domain/resource-definition";
import { ResourceRevision } from "../../domain/resource-revision.entity";
import type { ResourceOwnership } from "../../domain/resource-scope";
import { ResourceOwnershipSchema } from "../../domain/resource-scope";
import { ResourceInputSchema, ResourceSpecificationSchema } from "../../domain/resource-values";
import {
ResourceSpecification,
ResourceValues,
} from "../../domain/value-objects/resource-values.vo";
import type { InventoryRow, RevisionRow } from "./resource-inventory-relational";
export function inventoryIdentity(value: string) {
const parsed = UniqueID.create(value);
if (!parsed.isSuccess) {
throw new Error("Identidad de recurso persistida inconsistente.");
}
return parsed.data;
}
export type ScopedInventoryRow = Omit<InventoryRow, "companyId"> & ResourceOwnership;
/** Único fallback legacy, reservado al origen de persistencia conocido. */
export function readResourceOwnership(row: { companyId: unknown }, mode: "legacy" | "scoped") {
const scope: unknown =
mode === "legacy" && !Object.hasOwn(row, "scope") ? "company" : Reflect.get(row, "scope");
const parsed = ResourceOwnershipSchema.safeParse({ scope, companyId: row.companyId });
if (!parsed.success) {
throw new ResourceCatalogError("INVALID_RESOURCE_SCOPE");
}
return parsed.data;
}
export class ResourcePersistenceMapper {
/** Entrada de la tabla legacy conocida: solo aquí se admite la ausencia de scope. */
toDomain(row: InventoryRow, revisions: readonly RevisionRow[]) {
return this.read(row, revisions, readResourceOwnership(row, "legacy"));
}
toScopedDomain(row: ScopedInventoryRow | InventoryRow, revisions: readonly RevisionRow[]) {
return this.read(row, revisions, readResourceOwnership(row, "scoped"));
}
private read(
row: Omit<InventoryRow, "companyId">,
revisions: readonly RevisionRow[],
ownership: ResourceOwnership
) {
const owner =
ownership.scope === "company"
? { scope: ownership.scope, companyId: inventoryIdentity(ownership.companyId) }
: { scope: ownership.scope, companyId: null };
const specification = ResourceSpecification.create(
ResourceSpecificationSchema.parse(row.specification)
);
if (!specification.isSuccess) {
throw new Error("Especificación de recurso persistida inconsistente.");
}
const values = ResourceValues.reconstruct(
ResourceInputSchema.parse(row.values),
specification.data
);
if (!values.isSuccess) {
throw new Error("Valores de recurso persistidos inconsistentes.");
}
const current = values.data.toPrimitive();
if (row.templateId !== current.templateId) {
throw new Error("Referencia de recurso persistida inconsistente.");
}
const ordered = [...revisions].sort((a, b) => a.position - b.position);
const history = ordered.map((revision, position) => {
const revisionSpecification = ResourceSpecification.create(
ResourceSpecificationSchema.parse(revision.specification ?? row.specification)
);
if (!revisionSpecification.isSuccess) {
throw new Error("Especificación histórica inconsistente.");
}
const previous = ResourceValues.reconstruct(
ResourceInputSchema.parse(revision.values),
revisionSpecification.data
);
if (
!previous.isSuccess ||
revision.position !== position ||
(position > 0 && revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "")) ||
!z.iso.datetime().safeParse(revision.recordedAt).success
) {
throw new Error("Historial de recurso persistido inconsistente.");
}
if (revision.resourceId !== row.id) {
throw new Error("Revisión fuera del recurso.");
}
return ResourceRevision.rehydrate(
{
values: previous.data,
specification: revisionSpecification.data,
actorId: inventoryIdentity(revision.actorId),
recordedAt: revision.recordedAt,
},
inventoryIdentity(revision.id)
);
});
if (!(history.length && history.at(-1)?.values.equals(values.data))) {
throw new Error("Última revisión de recurso inconsistente.");
}
return Resource.rehydrate(
{
...owner,
specification: specification.data,
values: values.data,
history,
},
inventoryIdentity(row.id)
);
}
toScopedPersistence(resource: Resource) {
const saved = this.toPersistence(resource);
return { ...saved, resource: { ...saved.resource, scope: "company" as const } };
}
toPersistence(resource: Resource) {
const values = resource.values.toPrimitive();
return {
resource: {
id: resource.id.toPrimitive(),
companyId: resource.requireCompanyId().toPrimitive(),
templateId: values.templateId,
specification: resource.specification.toPrimitive(),
values,
},
revisions: this.revisions(resource),
};
}
toGlobalPersistence(resource: Resource) {
if (
resource.scope !== "global" ||
resource.companyId !== null ||
resource.values.toPrimitive().integration
) {
throw new ResourceCatalogError("RESOURCE_SCOPE_UNSUPPORTED");
}
return {
resource: {
id: resource.id.toPrimitive(),
scope: "global" as const,
companyId: null,
templateId: resource.values.toPrimitive().templateId,
specification: resource.specification.toPrimitive(),
values: resource.values.toPrimitive(),
},
revisions: this.revisions(resource),
};
}
private revisions(resource: Resource) {
return resource.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
resourceId: resource.id.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
values: revision.values.toPrimitive(),
specification: revision.specification.toPrimitive(),
position,
}));
}
}