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
|
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
|
||||||
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
|
einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und
|
||||||
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
|
bestehende Fachoperationen sind aber noch nicht allgemein an diese Grenze
|
||||||
angeschlossen. Für Circuit-Feldänderungen existiert ein erster interner
|
angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren interne
|
||||||
Command-Store, der Fachänderung, automatisch erzeugtes inverses Kommando und
|
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
|
||||||
Revision gemeinsam committen beziehungsweise zurückrollen kann. Er ist noch
|
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
|
nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo ist daher
|
||||||
noch nicht verfügbar.
|
noch nicht verfügbar.
|
||||||
|
|
||||||
|
|||||||
@@ -153,14 +153,19 @@ Completed foundation:
|
|||||||
ownership and equipment-identifier uniqueness inside its transaction
|
ownership and equipment-identifier uniqueness inside its transaction
|
||||||
- circuit updates derive their inverse from the persisted pre-command row and
|
- circuit updates derive their inverse from the persisted pre-command row and
|
||||||
commit the domain update, inverse command, change set and revision together
|
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
|
- 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:
|
Remaining constraints before implementing history:
|
||||||
|
|
||||||
- extend the concrete project-scoped command union and executors beyond the
|
- extend the concrete project-scoped command union and executors beyond the
|
||||||
internal `circuit.update` slice; the generic versioned command envelope is
|
internal Circuit and CircuitDeviceRow update slices; the generic versioned
|
||||||
complete
|
command envelope is complete
|
||||||
- route domain writes and revision recording through one shared project
|
- route domain writes and revision recording through one shared project
|
||||||
transaction boundary; the current revision repository only commits history
|
transaction boundary; the current revision repository only commits history
|
||||||
records and the counter together
|
records and the counter together
|
||||||
|
|||||||
@@ -376,6 +376,9 @@ Implemented foundation:
|
|||||||
persisted pre-command state
|
persisted pre-command state
|
||||||
- circuit update, inverse command, change set and revision share one SQLite
|
- circuit update, inverse command, change set and revision share one SQLite
|
||||||
transaction with stale-command and late-failure rollback coverage
|
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
|
- revision metadata, change-set payloads and the project counter are committed
|
||||||
in one SQLite transaction
|
in one SQLite transaction
|
||||||
- a stale expected revision produces no history writes
|
- a stale expected revision produces no history writes
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"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": "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/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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"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;
|
overriddenFields?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CircuitDeviceRowPatchInput = Partial<CircuitDeviceRowUpdateInput> & {
|
export interface CircuitDeviceRowPatchInput {
|
||||||
|
linkedProjectDeviceId?: string | null;
|
||||||
sortOrder?: number;
|
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 {
|
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
|
||||||
circuitId: string;
|
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,
|
UpdateCircuitInput,
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||||
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
|
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
|
||||||
import {
|
|
||||||
parseOverriddenFields,
|
|
||||||
serializeOverriddenFields,
|
|
||||||
} from "./project-device-sync.service.js";
|
|
||||||
|
|
||||||
export class CircuitWriteService {
|
export class CircuitWriteService {
|
||||||
private readonly circuitRepository: CircuitRepository;
|
private readonly circuitRepository: CircuitRepository;
|
||||||
@@ -256,21 +252,10 @@ export class CircuitWriteService {
|
|||||||
throw new Error("Invalid device row id.");
|
throw new Error("Invalid device row id.");
|
||||||
}
|
}
|
||||||
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
||||||
let overriddenFields = input.overriddenFields;
|
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
||||||
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
current,
|
||||||
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
input
|
||||||
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);
|
|
||||||
}
|
|
||||||
await this.deviceRowRepository.updateFields(rowId, {
|
await this.deviceRowRepository.updateFields(rowId, {
|
||||||
...input,
|
...input,
|
||||||
...(overriddenFields !== undefined ? { overriddenFields } : {}),
|
...(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,
|
projectDeviceSyncFields,
|
||||||
type ProjectDeviceSyncField,
|
type ProjectDeviceSyncField,
|
||||||
} from "../../shared/constants/project-device-sync-fields.js";
|
} 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 ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
|
||||||
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
|
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
|
||||||
@@ -25,29 +34,6 @@ export interface ProjectDeviceSyncRestoreRow {
|
|||||||
overriddenFields: ProjectDeviceSyncField[];
|
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) {
|
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
||||||
return projectDevice[field];
|
return projectDevice[field];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { db } from "../../db/client.js";
|
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";
|
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
|
||||||
|
|
||||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
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,
|
assertCircuitUpdateProjectCommand,
|
||||||
createCircuitUpdateProjectCommand,
|
createCircuitUpdateProjectCommand,
|
||||||
} from "../src/domain/models/circuit-project-command.model.js";
|
} 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", () => {
|
describe("serialized project commands", () => {
|
||||||
it("round-trips a versioned command envelope", () => {
|
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