Add atomic device row updates
This commit is contained in:
@@ -69,9 +69,11 @@ und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
|
||||
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
|
||||
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
|
||||
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
|
||||
angeschlossen. Für Circuit-Feldänderungen existiert ein erster interner
|
||||
Command-Store, der Fachänderung, automatisch erzeugtes inverses Kommando und
|
||||
Revision gemeinsam committen beziehungsweise zurückrollen kann. Er ist noch
|
||||
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
|
||||
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
|
||||
Revision gemeinsam committen beziehungsweise zurückrollen können.
|
||||
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
||||
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Die Stores sind noch
|
||||
nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo ist daher
|
||||
noch nicht verfügbar.
|
||||
|
||||
|
||||
@@ -153,14 +153,19 @@ Completed foundation:
|
||||
ownership and equipment-identifier uniqueness inside its transaction
|
||||
- circuit updates derive their inverse from the persisted pre-command row and
|
||||
commit the domain update, inverse command, change set and revision together
|
||||
- an internal `circuit-device-row.update` command applies the same boundary to
|
||||
device fields, validates project-device and room ownership and records
|
||||
automatically derived local override metadata in its normalized forward
|
||||
command
|
||||
- integration tests apply the generated inverse as a new `undo` revision and
|
||||
verify rollback for stale revisions and late history failures
|
||||
verify rollback for stale revisions and late history failures on both update
|
||||
command types
|
||||
|
||||
Remaining constraints before implementing history:
|
||||
|
||||
- extend the concrete project-scoped command union and executors beyond the
|
||||
internal `circuit.update` slice; the generic versioned command envelope is
|
||||
complete
|
||||
internal Circuit and CircuitDeviceRow update slices; the generic versioned
|
||||
command envelope is complete
|
||||
- route domain writes and revision recording through one shared project
|
||||
transaction boundary; the current revision repository only commits history
|
||||
records and the counter together
|
||||
|
||||
@@ -376,6 +376,9 @@ Implemented foundation:
|
||||
persisted pre-command state
|
||||
- circuit update, inverse command, change set and revision share one SQLite
|
||||
transaction with stale-command and late-failure rollback coverage
|
||||
- an internal typed `circuit-device-row.update` command uses the same boundary,
|
||||
records derived ProjectDevice override metadata and rejects cross-project
|
||||
rows, device links and rooms
|
||||
- revision metadata, change-set payloads and the project counter are committed
|
||||
in one SQLite transaction
|
||||
- a stale expected revision produces no history writes
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@
|
||||
"build:api": "tsc -p tsconfig.json",
|
||||
"build:web": "next build",
|
||||
"start": "node dist/server/index.js",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/database-backup.test.ts",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/database-backup.test.ts",
|
||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/database-backup.test.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:backup": "tsx scripts/db-backup.ts",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitDeviceRowUpdateProjectCommand,
|
||||
createCircuitDeviceRowUpdateProjectCommand,
|
||||
type CircuitDeviceRowUpdateField,
|
||||
type CircuitDeviceRowUpdatePatch,
|
||||
type CircuitDeviceRowUpdateValues,
|
||||
} from "../../domain/models/circuit-device-row-project-command.model.js";
|
||||
import type {
|
||||
CircuitDeviceRowProjectCommandStore,
|
||||
ExecuteCircuitDeviceRowUpdateCommandInput,
|
||||
} from "../../domain/ports/circuit-device-row-project-command.store.js";
|
||||
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.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 { projectDevices } from "../schema/project-devices.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import {
|
||||
toCircuitDeviceRowPatchValues,
|
||||
type CircuitDeviceRowPatchInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
|
||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||
|
||||
export class CircuitDeviceRowProjectCommandRepository
|
||||
implements CircuitDeviceRowProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
|
||||
assertCircuitDeviceRowUpdateProjectCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Invalid device row id.");
|
||||
}
|
||||
|
||||
const circuit = tx
|
||||
.select({ circuitListId: circuits.circuitListId })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, current.circuitId))
|
||||
.get();
|
||||
const owningList = circuit
|
||||
? tx
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitLists.id, circuit.circuitListId),
|
||||
eq(circuitLists.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.get()
|
||||
: null;
|
||||
if (!owningList) {
|
||||
throw new Error("Circuit device row does not belong to project.");
|
||||
}
|
||||
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as CircuitDeviceRowPatchInput;
|
||||
this.assertLinkedProjectDevice(tx, input.projectId, patch);
|
||||
this.assertRoom(tx, input.projectId, patch);
|
||||
|
||||
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
||||
current,
|
||||
patch
|
||||
);
|
||||
if (
|
||||
overriddenFields !== undefined &&
|
||||
patch.overriddenFields === undefined
|
||||
) {
|
||||
patch.overriddenFields = overriddenFields;
|
||||
}
|
||||
|
||||
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
|
||||
current.id,
|
||||
patch as CircuitDeviceRowUpdatePatch
|
||||
);
|
||||
const inversePatch = Object.fromEntries(
|
||||
appliedForward.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getCircuitDeviceRowFieldValue(current, change.field),
|
||||
])
|
||||
) as CircuitDeviceRowUpdatePatch;
|
||||
const inverse = createCircuitDeviceRowUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
|
||||
const update = tx
|
||||
.update(circuitDeviceRows)
|
||||
.set(toCircuitDeviceRowPatchValues(patch))
|
||||
.where(eq(circuitDeviceRows.id, current.id))
|
||||
.run();
|
||||
if (update.changes !== 1) {
|
||||
throw new Error("Circuit device row changed before command execution.");
|
||||
}
|
||||
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: appliedForward,
|
||||
inverse,
|
||||
});
|
||||
|
||||
return { revision, inverse };
|
||||
});
|
||||
}
|
||||
|
||||
private assertLinkedProjectDevice(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
patch: CircuitDeviceRowPatchInput
|
||||
) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(patch, "linkedProjectDeviceId") ||
|
||||
patch.linkedProjectDeviceId === null ||
|
||||
patch.linkedProjectDeviceId === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const device = database
|
||||
.select({ id: projectDevices.id })
|
||||
.from(projectDevices)
|
||||
.where(
|
||||
and(
|
||||
eq(projectDevices.id, patch.linkedProjectDeviceId),
|
||||
eq(projectDevices.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!device) {
|
||||
throw new Error("Invalid linked project device id.");
|
||||
}
|
||||
}
|
||||
|
||||
private assertRoom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
patch: CircuitDeviceRowPatchInput
|
||||
) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(patch, "roomId") ||
|
||||
patch.roomId === null ||
|
||||
patch.roomId === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const room = database
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId)))
|
||||
.get();
|
||||
if (!room) {
|
||||
throw new Error("Invalid room id.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCircuitDeviceRowFieldValue<
|
||||
TField extends CircuitDeviceRowUpdateField,
|
||||
>(
|
||||
row: CircuitDeviceRow,
|
||||
field: TField
|
||||
): CircuitDeviceRowUpdateValues[TField] {
|
||||
return row[field] as unknown as CircuitDeviceRowUpdateValues[TField];
|
||||
}
|
||||
@@ -20,9 +20,26 @@ export interface CircuitDeviceRowUpdateInput {
|
||||
overriddenFields?: string;
|
||||
}
|
||||
|
||||
export type CircuitDeviceRowPatchInput = Partial<CircuitDeviceRowUpdateInput> & {
|
||||
export interface CircuitDeviceRowPatchInput {
|
||||
linkedProjectDeviceId?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
phaseType?: string | null;
|
||||
connectionKind?: string | null;
|
||||
costGroup?: string | null;
|
||||
category?: string | null;
|
||||
level?: string | null;
|
||||
roomId?: string | null;
|
||||
roomNumberSnapshot?: string | null;
|
||||
roomNameSnapshot?: string | null;
|
||||
quantity?: number;
|
||||
powerPerUnit?: number;
|
||||
simultaneityFactor?: number;
|
||||
cosPhi?: number | null;
|
||||
remark?: string | null;
|
||||
overriddenFields?: string | null;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
|
||||
circuitId: string;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitDeviceRowUpdateCommandType =
|
||||
"circuit-device-row.update" as const;
|
||||
export const circuitDeviceRowUpdateCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitDeviceRowUpdateValues {
|
||||
linkedProjectDeviceId: string | null;
|
||||
sortOrder: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
phaseType: string | null;
|
||||
connectionKind: string | null;
|
||||
costGroup: string | null;
|
||||
category: string | null;
|
||||
level: string | null;
|
||||
roomId: string | null;
|
||||
roomNumberSnapshot: string | null;
|
||||
roomNameSnapshot: string | null;
|
||||
quantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi: number | null;
|
||||
remark: string | null;
|
||||
overriddenFields: string | null;
|
||||
}
|
||||
|
||||
export type CircuitDeviceRowUpdateField = keyof CircuitDeviceRowUpdateValues;
|
||||
export type CircuitDeviceRowUpdatePatch =
|
||||
Partial<CircuitDeviceRowUpdateValues>;
|
||||
|
||||
export interface CircuitDeviceRowUpdateFieldChange<
|
||||
TField extends CircuitDeviceRowUpdateField = CircuitDeviceRowUpdateField,
|
||||
> {
|
||||
field: TField;
|
||||
value: CircuitDeviceRowUpdateValues[TField];
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowUpdateCommandPayload {
|
||||
rowId: string;
|
||||
changes: CircuitDeviceRowUpdateFieldChange[];
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowUpdateProjectCommand
|
||||
extends SerializedProjectCommand<CircuitDeviceRowUpdateCommandPayload> {
|
||||
schemaVersion: typeof circuitDeviceRowUpdateCommandSchemaVersion;
|
||||
type: typeof circuitDeviceRowUpdateCommandType;
|
||||
}
|
||||
|
||||
export function createCircuitDeviceRowUpdateProjectCommand(
|
||||
rowId: string,
|
||||
patch: CircuitDeviceRowUpdatePatch
|
||||
): CircuitDeviceRowUpdateProjectCommand {
|
||||
const changes = Object.entries(patch).map(([field, value]) => ({
|
||||
field: field as CircuitDeviceRowUpdateField,
|
||||
value,
|
||||
})) as CircuitDeviceRowUpdateFieldChange[];
|
||||
const command: CircuitDeviceRowUpdateProjectCommand = {
|
||||
schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion,
|
||||
type: circuitDeviceRowUpdateCommandType,
|
||||
payload: { rowId, changes },
|
||||
};
|
||||
assertCircuitDeviceRowUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitDeviceRowUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitDeviceRowUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitDeviceRowUpdateCommandSchemaVersion ||
|
||||
command.type !== circuitDeviceRowUpdateCommandType
|
||||
) {
|
||||
throw new Error("Unsupported circuit device-row update command.");
|
||||
}
|
||||
if (!isPlainObject(command.payload)) {
|
||||
throw new Error("Circuit device-row update payload must be an object.");
|
||||
}
|
||||
const rowId = command.payload.rowId;
|
||||
const changes = command.payload.changes;
|
||||
if (typeof rowId !== "string" || !rowId.trim()) {
|
||||
throw new Error("Circuit device-row update command requires a row id.");
|
||||
}
|
||||
if (!Array.isArray(changes) || changes.length === 0) {
|
||||
throw new Error(
|
||||
"Circuit device-row update command requires at least one change."
|
||||
);
|
||||
}
|
||||
|
||||
const seenFields = new Set<string>();
|
||||
for (const change of changes) {
|
||||
if (!isPlainObject(change) || typeof change.field !== "string") {
|
||||
throw new Error(
|
||||
"Circuit device-row update command contains an invalid change."
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isCircuitDeviceRowUpdateField(change.field) ||
|
||||
seenFields.has(change.field)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit device-row update command contains an invalid or duplicate field."
|
||||
);
|
||||
}
|
||||
assertCircuitDeviceRowUpdateFieldValue(change.field, change.value);
|
||||
seenFields.add(change.field);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCircuitDeviceRowUpdateFieldValue(
|
||||
field: CircuitDeviceRowUpdateField,
|
||||
value: unknown
|
||||
) {
|
||||
if (field === "name" || field === "displayName") {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${field} must be a non-empty string.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (field === "sortOrder") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error("sortOrder must be a finite number.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
field === "quantity" ||
|
||||
field === "powerPerUnit" ||
|
||||
field === "simultaneityFactor"
|
||||
) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative finite number.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (field === "cosPhi") {
|
||||
if (
|
||||
value !== null &&
|
||||
(typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
||||
) {
|
||||
throw new Error("cosPhi must be a positive finite number or null.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value !== null && typeof value !== "string") {
|
||||
throw new Error(`${field} must be a string or null.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isCircuitDeviceRowUpdateField(
|
||||
value: string
|
||||
): value is CircuitDeviceRowUpdateField {
|
||||
return [
|
||||
"linkedProjectDeviceId",
|
||||
"sortOrder",
|
||||
"name",
|
||||
"displayName",
|
||||
"phaseType",
|
||||
"connectionKind",
|
||||
"costGroup",
|
||||
"category",
|
||||
"level",
|
||||
"roomId",
|
||||
"roomNumberSnapshot",
|
||||
"roomNameSnapshot",
|
||||
"quantity",
|
||||
"powerPerUnit",
|
||||
"simultaneityFactor",
|
||||
"cosPhi",
|
||||
"remark",
|
||||
"overriddenFields",
|
||||
].includes(value);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CircuitDeviceRowUpdateProjectCommand } from "../models/circuit-device-row-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitDeviceRowUpdateCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
command: CircuitDeviceRowUpdateProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExecutedCircuitDeviceRowUpdateCommand {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitDeviceRowUpdateProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowProjectCommandStore {
|
||||
executeUpdate(
|
||||
input: ExecuteCircuitDeviceRowUpdateCommandInput
|
||||
): ExecutedCircuitDeviceRowUpdateCommand;
|
||||
}
|
||||
@@ -17,11 +17,7 @@ import type {
|
||||
UpdateCircuitInput,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
|
||||
import {
|
||||
parseOverriddenFields,
|
||||
serializeOverriddenFields,
|
||||
} from "./project-device-sync.service.js";
|
||||
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
|
||||
|
||||
export class CircuitWriteService {
|
||||
private readonly circuitRepository: CircuitRepository;
|
||||
@@ -256,21 +252,10 @@ export class CircuitWriteService {
|
||||
throw new Error("Invalid device row id.");
|
||||
}
|
||||
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
||||
let overriddenFields = input.overriddenFields;
|
||||
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
||||
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
||||
const inputValues = input as Record<string, unknown>;
|
||||
const currentValues = current as unknown as Record<string, unknown>;
|
||||
for (const field of projectDeviceSyncFields) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(input, field) &&
|
||||
(inputValues[field] ?? null) !== (currentValues[field] ?? null)
|
||||
) {
|
||||
overrides.add(field);
|
||||
}
|
||||
}
|
||||
overriddenFields = serializeOverriddenFields(overrides);
|
||||
}
|
||||
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
||||
current,
|
||||
input
|
||||
);
|
||||
await this.deviceRowRepository.updateFields(rowId, {
|
||||
...input,
|
||||
...(overriddenFields !== undefined ? { overriddenFields } : {}),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
projectDeviceSyncFields,
|
||||
type ProjectDeviceSyncField,
|
||||
} from "../../shared/constants/project-device-sync-fields.js";
|
||||
|
||||
export function parseOverriddenFields(
|
||||
value: string | null | undefined
|
||||
): ProjectDeviceSyncField[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((field): field is ProjectDeviceSyncField =>
|
||||
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeOverriddenFields(
|
||||
fields: Iterable<ProjectDeviceSyncField>
|
||||
): string | undefined {
|
||||
const fieldSet = new Set(fields);
|
||||
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
|
||||
return unique.length > 0 ? JSON.stringify(unique) : undefined;
|
||||
}
|
||||
|
||||
export function deriveOverriddenFieldsForLocalEdit(
|
||||
current: {
|
||||
linkedProjectDeviceId: string | null | undefined;
|
||||
overriddenFields: string | null | undefined;
|
||||
} & Partial<Record<ProjectDeviceSyncField, unknown>>,
|
||||
patch: Partial<Record<ProjectDeviceSyncField | "overriddenFields", unknown>>
|
||||
): string | null | undefined {
|
||||
if (
|
||||
!current.linkedProjectDeviceId ||
|
||||
Object.prototype.hasOwnProperty.call(patch, "overriddenFields")
|
||||
) {
|
||||
return patch.overriddenFields as string | null | undefined;
|
||||
}
|
||||
|
||||
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
||||
for (const field of projectDeviceSyncFields) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(patch, field) &&
|
||||
(patch[field] ?? null) !== (current[field] ?? null)
|
||||
) {
|
||||
overrides.add(field);
|
||||
}
|
||||
}
|
||||
return serializeOverriddenFields(overrides);
|
||||
}
|
||||
@@ -5,6 +5,15 @@ import {
|
||||
projectDeviceSyncFields,
|
||||
type ProjectDeviceSyncField,
|
||||
} from "../../shared/constants/project-device-sync-fields.js";
|
||||
import {
|
||||
parseOverriddenFields,
|
||||
serializeOverriddenFields,
|
||||
} from "./project-device-overrides.js";
|
||||
|
||||
export {
|
||||
parseOverriddenFields,
|
||||
serializeOverriddenFields,
|
||||
} from "./project-device-overrides.js";
|
||||
|
||||
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
|
||||
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
|
||||
@@ -25,29 +34,6 @@ export interface ProjectDeviceSyncRestoreRow {
|
||||
overriddenFields: ProjectDeviceSyncField[];
|
||||
}
|
||||
|
||||
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((field): field is ProjectDeviceSyncField =>
|
||||
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeOverriddenFields(fields: Iterable<ProjectDeviceSyncField>): string | undefined {
|
||||
const fieldSet = new Set(fields);
|
||||
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
|
||||
return unique.length > 0 ? JSON.stringify(unique) : undefined;
|
||||
}
|
||||
|
||||
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
||||
return projectDevice[field];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { db } from "../../db/client.js";
|
||||
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
|
||||
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
|
||||
|
||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||
export const circuitDeviceRowProjectCommandStore =
|
||||
new CircuitDeviceRowProjectCommandRepository(db);
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
|
||||
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { rooms } from "../src/db/schema/rooms.js";
|
||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
|
||||
import { deserializeProjectCommand } from "../src/domain/models/project-command.model.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values([
|
||||
{ id: "project-1", name: "Test project" },
|
||||
{ id: "project-2", name: "Other project" },
|
||||
])
|
||||
.run();
|
||||
|
||||
const boards = new DistributionBoardRepository(context.db);
|
||||
const board = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-1",
|
||||
"UV-01"
|
||||
);
|
||||
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
|
||||
"project-2",
|
||||
"UV-02"
|
||||
);
|
||||
const section = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, board.id))
|
||||
.get();
|
||||
const foreignSection = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, foreignBoard.id))
|
||||
.get();
|
||||
assert.ok(section);
|
||||
assert.ok(foreignSection);
|
||||
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values([
|
||||
{
|
||||
id: "circuit-1",
|
||||
circuitListId: board.id,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1F1",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "circuit-2",
|
||||
circuitListId: foreignBoard.id,
|
||||
sectionId: foreignSection.id,
|
||||
equipmentIdentifier: "-1F1",
|
||||
sortOrder: 10,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
context.db
|
||||
.insert(projectDevices)
|
||||
.values([
|
||||
{
|
||||
id: "project-device-1",
|
||||
projectId: "project-1",
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte",
|
||||
quantity: 1,
|
||||
powerPerUnit: 0.1,
|
||||
simultaneityFactor: 1,
|
||||
},
|
||||
{
|
||||
id: "project-device-2",
|
||||
projectId: "project-2",
|
||||
name: "Fremdgerät",
|
||||
displayName: "Fremdgerät",
|
||||
quantity: 1,
|
||||
powerPerUnit: 0.2,
|
||||
simultaneityFactor: 1,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
context.db
|
||||
.insert(rooms)
|
||||
.values([
|
||||
{
|
||||
id: "room-1",
|
||||
projectId: "project-1",
|
||||
floorId: null,
|
||||
roomNumber: "001",
|
||||
roomName: "Büro",
|
||||
},
|
||||
{
|
||||
id: "room-2",
|
||||
projectId: "project-2",
|
||||
floorId: null,
|
||||
roomNumber: "999",
|
||||
roomName: "Fremdraum",
|
||||
},
|
||||
])
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitDeviceRows)
|
||||
.values([
|
||||
{
|
||||
id: "row-1",
|
||||
circuitId: "circuit-1",
|
||||
linkedProjectDeviceId: "project-device-1",
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte lokal",
|
||||
quantity: 1,
|
||||
powerPerUnit: 0.1,
|
||||
simultaneityFactor: 1,
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
circuitId: "circuit-2",
|
||||
sortOrder: 10,
|
||||
name: "Fremdzeile",
|
||||
displayName: "Fremdzeile",
|
||||
quantity: 1,
|
||||
powerPerUnit: 0.2,
|
||||
simultaneityFactor: 1,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
function getRow(context: DatabaseContext) {
|
||||
const row = context.db
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||
.get();
|
||||
assert.ok(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("circuit device-row project-command repository", () => {
|
||||
it("commits the row update, tracked override, inverse and revision together", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||
const executed = store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
description: "Gerätezeile bearbeiten",
|
||||
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
quantity: 2,
|
||||
roomId: "room-1",
|
||||
remark: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const row = getRow(context);
|
||||
assert.equal(row.quantity, 2);
|
||||
assert.equal(row.roomId, "room-1");
|
||||
assert.equal(row.remark, null);
|
||||
assert.equal(row.overriddenFields, "[\"quantity\"]");
|
||||
assert.equal(executed.revision.revisionNumber, 1);
|
||||
assert.deepEqual(executed.inverse.payload, {
|
||||
rowId: "row-1",
|
||||
changes: [
|
||||
{ field: "quantity", value: 1 },
|
||||
{ field: "roomId", value: null },
|
||||
{ field: "remark", value: null },
|
||||
{ field: "overriddenFields", value: null },
|
||||
],
|
||||
});
|
||||
|
||||
const changeSet = context.db.select().from(projectChangeSets).get();
|
||||
assert.ok(changeSet);
|
||||
const storedForward = deserializeProjectCommand(
|
||||
changeSet.forwardPayloadJson
|
||||
);
|
||||
assert.deepEqual(storedForward, {
|
||||
schemaVersion: 1,
|
||||
type: "circuit-device-row.update",
|
||||
payload: {
|
||||
rowId: "row-1",
|
||||
changes: [
|
||||
{ field: "quantity", value: 2 },
|
||||
{ field: "roomId", value: "room-1" },
|
||||
{ field: "remark", value: null },
|
||||
{ field: "overriddenFields", value: "[\"quantity\"]" },
|
||||
],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the generated inverse and restores override metadata", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||
const forward = store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
quantity: 3,
|
||||
}),
|
||||
});
|
||||
const undone = store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
command: forward.inverse,
|
||||
});
|
||||
|
||||
const row = getRow(context);
|
||||
assert.equal(row.quantity, 1);
|
||||
assert.equal(row.overriddenFields, null);
|
||||
assert.equal(undone.revision.revisionNumber, 2);
|
||||
assert.deepEqual(
|
||||
context.db
|
||||
.select({ source: projectRevisions.source })
|
||||
.from(projectRevisions)
|
||||
.all()
|
||||
.map((revision) => revision.source),
|
||||
["user", "undo"]
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back the row and override update for a stale revision", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||
store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
quantity: 2,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
quantity: 4,
|
||||
}),
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof ProjectRevisionConflictError &&
|
||||
error.actualRevision === 1
|
||||
);
|
||||
|
||||
const row = getRow(context);
|
||||
assert.equal(row.quantity, 2);
|
||||
assert.equal(row.overriddenFields, "[\"quantity\"]");
|
||||
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back the row update when late history persistence fails", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_device_row_command_change_set
|
||||
BEFORE INSERT ON project_change_sets
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced device-row history failure');
|
||||
END;
|
||||
`);
|
||||
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
quantity: 5,
|
||||
}),
|
||||
}),
|
||||
/forced device-row history failure/
|
||||
);
|
||||
|
||||
const row = getRow(context);
|
||||
assert.equal(row.quantity, 1);
|
||||
assert.equal(row.overriddenFields, null);
|
||||
assert.equal(
|
||||
context.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, "project-1"))
|
||||
.get()?.currentRevision,
|
||||
0
|
||||
);
|
||||
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects cross-project rows, devices and rooms without history", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitDeviceRowUpdateProjectCommand("row-2", {
|
||||
quantity: 2,
|
||||
}),
|
||||
}),
|
||||
/does not belong to project/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
linkedProjectDeviceId: "project-device-2",
|
||||
}),
|
||||
}),
|
||||
/Invalid linked project device/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
store.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
roomId: "room-2",
|
||||
}),
|
||||
}),
|
||||
/Invalid room/
|
||||
);
|
||||
|
||||
assert.equal(getRow(context).linkedProjectDeviceId, "project-device-1");
|
||||
assert.equal(getRow(context).roomId, null);
|
||||
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
assertCircuitUpdateProjectCommand,
|
||||
createCircuitUpdateProjectCommand,
|
||||
} from "../src/domain/models/circuit-project-command.model.js";
|
||||
import {
|
||||
assertCircuitDeviceRowUpdateProjectCommand,
|
||||
createCircuitDeviceRowUpdateProjectCommand,
|
||||
} from "../src/domain/models/circuit-device-row-project-command.model.js";
|
||||
|
||||
describe("serialized project commands", () => {
|
||||
it("round-trips a versioned command envelope", () => {
|
||||
@@ -122,3 +126,43 @@ describe("circuit update project commands", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("circuit device-row update project commands", () => {
|
||||
it("creates typed nullable device-row changes", () => {
|
||||
const command = createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
roomId: null,
|
||||
quantity: 2,
|
||||
cosPhi: 0.9,
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
assertCircuitDeviceRowUpdateProjectCommand(command)
|
||||
);
|
||||
assert.deepEqual(command.payload.changes, [
|
||||
{ field: "roomId", value: null },
|
||||
{ field: "quantity", value: 2 },
|
||||
{ field: "cosPhi", value: 0.9 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects invalid device-row values", () => {
|
||||
assert.throws(
|
||||
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {}),
|
||||
/at least one change/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
displayName: "",
|
||||
}),
|
||||
/non-empty string/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||
quantity: -1,
|
||||
}),
|
||||
/non-negative/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user