Synchronizing a project device pushes its quantity onto every linked device row, but manualQuantity is not part of the sync snapshot and was left untouched. A device whose quantity is lower than the row's left the row at manualQuantity > quantity, violating the snapshot invariant. The violation surfaced far from its cause: the invariant is only checked when a full state snapshot is read, and the automatic snapshot runs every 25 revisions. A project could therefore accumulate the broken row silently and then reject every subsequent command, because the failing validation rolls back the whole transaction including the revision bump. Recompute manualQuantity as the synchronized quantity minus the total of the linked external objects, matching the invariant that assertCircuitDeviceRowQuantity enforces elsewhere, and reject a synchronized quantity that falls below that total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
import { and, eq, inArray } from "drizzle-orm";
|
|
import {
|
|
assertProjectDeviceRowSyncProjectCommand,
|
|
createProjectDeviceRowSyncProjectCommand,
|
|
invertProjectDeviceRowSyncOperation,
|
|
projectDeviceSyncRowSnapshotFields,
|
|
type ProjectDeviceSyncRowSnapshot,
|
|
} from "../../domain/models/project-device-row-sync-project-command.model.js";
|
|
import type {
|
|
ExecuteProjectDeviceRowSyncCommandInput,
|
|
ProjectDeviceRowSyncProjectCommandStore,
|
|
} from "../../domain/ports/project-device-row-sync-project-command.store.js";
|
|
import type { AppDatabase } from "../database-context.js";
|
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
|
import { circuitLists } from "../schema/circuit-lists.js";
|
|
import { circuits } from "../schema/circuits.js";
|
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
|
import { projectDevices } from "../schema/project-devices.js";
|
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
|
|
|
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
|
|
|
export class ProjectDeviceRowSyncProjectCommandRepository
|
|
implements ProjectDeviceRowSyncProjectCommandStore
|
|
{
|
|
constructor(private readonly database: AppDatabase) {}
|
|
|
|
execute(input: ExecuteProjectDeviceRowSyncCommandInput) {
|
|
assertProjectDeviceRowSyncProjectCommand(input.command);
|
|
|
|
return executeProjectCommandTransaction(
|
|
this.database,
|
|
input,
|
|
(tx) => this.applyCommand(tx, input)
|
|
);
|
|
}
|
|
|
|
private applyCommand(
|
|
tx: AppDatabase,
|
|
input: ExecuteProjectDeviceRowSyncCommandInput
|
|
) {
|
|
const projectDevice = tx
|
|
.select({ id: projectDevices.id })
|
|
.from(projectDevices)
|
|
.where(
|
|
and(
|
|
eq(
|
|
projectDevices.id,
|
|
input.command.payload.projectDeviceId
|
|
),
|
|
eq(projectDevices.projectId, input.projectId)
|
|
)
|
|
)
|
|
.get();
|
|
if (!projectDevice) {
|
|
throw new Error(
|
|
"Project device does not belong to project."
|
|
);
|
|
}
|
|
|
|
const rowIds = input.command.payload.rows.map(
|
|
(row) => row.rowId
|
|
);
|
|
const persistedRows = tx
|
|
.select({
|
|
row: circuitDeviceRows,
|
|
projectId: circuitLists.projectId,
|
|
})
|
|
.from(circuitDeviceRows)
|
|
.innerJoin(
|
|
circuits,
|
|
eq(circuits.id, circuitDeviceRows.circuitId)
|
|
)
|
|
.innerJoin(
|
|
circuitLists,
|
|
eq(circuitLists.id, circuits.circuitListId)
|
|
)
|
|
.where(inArray(circuitDeviceRows.id, rowIds))
|
|
.all();
|
|
if (
|
|
persistedRows.length !== rowIds.length ||
|
|
persistedRows.some(
|
|
(persisted) => persisted.projectId !== input.projectId
|
|
)
|
|
) {
|
|
throw new Error(
|
|
"One or more sync rows do not belong to project."
|
|
);
|
|
}
|
|
|
|
const persistedById = new Map(
|
|
persistedRows.map((persisted) => [
|
|
persisted.row.id,
|
|
persisted.row,
|
|
])
|
|
);
|
|
for (const assignment of input.command.payload.rows) {
|
|
const persisted = persistedById.get(assignment.rowId);
|
|
if (
|
|
!persisted ||
|
|
!snapshotMatchesRow(assignment.expected, persisted)
|
|
) {
|
|
throw new Error(
|
|
"Project-device row changed before sync execution."
|
|
);
|
|
}
|
|
}
|
|
|
|
const inverse = createProjectDeviceRowSyncProjectCommand(
|
|
projectDevice.id,
|
|
invertProjectDeviceRowSyncOperation(
|
|
input.command.payload.operation
|
|
),
|
|
input.command.payload.rows.map((row) => ({
|
|
rowId: row.rowId,
|
|
expected: row.target,
|
|
target: row.expected,
|
|
}))
|
|
);
|
|
|
|
for (const assignment of input.command.payload.rows) {
|
|
const values: typeof assignment.target & {
|
|
manualQuantity?: number;
|
|
} = { ...assignment.target };
|
|
if (assignment.target.quantity !== assignment.expected.quantity) {
|
|
const externalTotal = tx
|
|
.select({ planningValues: externalModelObjects.planningValues })
|
|
.from(externalModelObjects)
|
|
.where(
|
|
eq(externalModelObjects.circuitDeviceRowId, assignment.rowId)
|
|
)
|
|
.all()
|
|
.reduce(
|
|
(sum, object) => sum + object.planningValues.effectiveQuantity,
|
|
0
|
|
);
|
|
const manualQuantity = assignment.target.quantity - externalTotal;
|
|
if (manualQuantity < 0) {
|
|
throw new Error(
|
|
"Synchronized quantity is below the total quantity of linked external objects."
|
|
);
|
|
}
|
|
values.manualQuantity = manualQuantity;
|
|
}
|
|
const updated = tx
|
|
.update(circuitDeviceRows)
|
|
.set(values)
|
|
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
|
.run();
|
|
if (updated.changes !== 1) {
|
|
throw new Error(
|
|
"Project-device row changed during sync execution."
|
|
);
|
|
}
|
|
}
|
|
const affectedCircuitIds = new Set(
|
|
input.command.payload.rows
|
|
.filter(
|
|
(assignment) =>
|
|
assignment.expected.phaseType !==
|
|
assignment.target.phaseType
|
|
)
|
|
.map(
|
|
(assignment) =>
|
|
persistedById.get(assignment.rowId)!.circuitId
|
|
)
|
|
);
|
|
for (const circuitId of affectedCircuitIds) {
|
|
updateDerivedCircuitVoltage(
|
|
tx,
|
|
input.projectId,
|
|
circuitId
|
|
);
|
|
}
|
|
|
|
return inverse;
|
|
}
|
|
}
|
|
|
|
function snapshotMatchesRow(
|
|
snapshot: ProjectDeviceSyncRowSnapshot,
|
|
row: CircuitDeviceRow
|
|
) {
|
|
return projectDeviceSyncRowSnapshotFields.every(
|
|
(field) => snapshot[field] === row[field]
|
|
);
|
|
}
|