Add atomic device row updates

This commit is contained in:
2026-07-23 21:38:20 +02:00
parent 296cb0f1c4
commit 2d0aa11d9c
14 changed files with 918 additions and 53 deletions
+5 -3
View File
@@ -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
View File
@@ -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;
}
+5 -20
View File
@@ -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();
}
});
});
+44
View File
@@ -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/
);
});
});