From ac16cca77a37ce2038cfa1c790a54a882e72b517 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 24 Jul 2026 09:09:59 +0200 Subject: [PATCH] Add project device structure history --- AGENTS.md | 3 + docs/circuit-list-editor-api.md | 24 +- docs/current-architecture.md | 8 + ...history-and-external-model-architecture.md | 8 +- docs/spec/07-implementation-phases-todo.md | 2 + package.json | 4 +- ...ce-structure-project-command.repository.ts | 339 ++++++++++++ .../project-device-project-command.model.ts | 32 +- ...-device-structure-project-command.model.ts | 171 ++++++ ...-device-structure-project-command.store.ts | 26 + .../services/project-command.service.ts | 22 + .../composition/project-command-stores.ts | 4 + tests/project-command.model.test.ts | 87 +++ tests/project-command.service.test.ts | 55 ++ ...ructure-project-command.repository.test.ts | 503 ++++++++++++++++++ 15 files changed, 1262 insertions(+), 26 deletions(-) create mode 100644 src/db/repositories/project-device-structure-project-command.repository.ts create mode 100644 src/domain/models/project-device-structure-project-command.model.ts create mode 100644 src/domain/ports/project-device-structure-project-command.store.ts create mode 100644 tests/project-device-structure-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index 98f509a..9afafe5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,6 +236,9 @@ by sorting or moving. Project-device synchronization, disconnect and reconnect are persisted as one atomic multi-row command with complete expected/target row snapshots, including link and override metadata. Canonical ProjectDevice field updates are also persisted and never synchronize linked rows implicitly. +ProjectDevice insertion/deletion preserves stable device ids. Deletion captures +complete disconnected snapshots of linked rows so undo can restore only rows +that have remained unchanged. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 410b6d3..bf8f146 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -28,14 +28,15 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit-device-row.insert`, `circuit-device-row.delete`, `circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`, `circuit.reorder-section`, `circuit.renumber-section` and -`project-device.update` and `project-device.sync-rows`, all with schema version -`1`. Other command types are rejected. Insert commands contain the complete -entity or circuit block with stable ids; delete commands include the expected -parent identity. Circuit snapshots contain zero, one or multiple complete -device rows. Move commands contain each row's expected and target circuit plus -its exact expected and target sort order. This makes deletion and moves -undoable without generating replacement identities or renumbering circuits. -The editor does not consume these endpoints yet. +`project-device.update`, `project-device.insert`, `project-device.delete` and +`project-device.sync-rows`, all with schema version `1`. Other command types +are rejected. Insert commands contain the complete entity or circuit block +with stable ids; delete commands include the expected parent identity. Circuit +snapshots contain zero, one or multiple complete device rows. Move commands +contain each row's expected and target circuit plus its exact expected and +target sort order. This makes deletion and moves undoable without generating +replacement identities or renumbering circuits. The editor does not consume +these endpoints yet. `circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move-with-new-circuit` atomically creates exactly one @@ -68,6 +69,13 @@ and derives its inverse from the persisted device. It validates project ownership and never writes linked `CircuitDeviceRow` values; synchronization remains a separate explicit command. +`project-device.insert` stores the complete canonical device with a stable id. +User inserts cannot attach existing rows. `project-device.delete` captures the +complete device and every currently linked row before deletion. Its inverse +recreates the same device and reconnects only rows whose complete disconnected +snapshot still matches; device, links, revision and history transition are +atomic. + Example: ```json diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 2db23b4..fed6f16 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -117,6 +117,14 @@ gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch synchronisiert. Der bestehende ProjectDevice-`PUT`-Endpunkt verwendet diesen Command bis zum UI-Cutover noch nicht. +`project-device.insert` und `project-device.delete` versionieren außerdem den +Lebenszyklus eines Projektgeräts mit stabiler UUID. Beim Löschen speichert das +inverse Insert den vollständigen Gerätestand sowie vollständige, nach dem +Löschen erwartete Snapshots aller zuvor verknüpften Gerätezeilen. Undo setzt +die Links nur zurück, wenn diese Zeilen weiterhin zum Projekt gehören, +unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revision +und Historienstapel teilen dieselbe Transaktion. Die bestehenden Create-, +Import- und Delete-Endpunkte sind noch nicht umgestellt. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index dcd92b5..8b678ec 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -197,13 +197,17 @@ Completed foundation: protect local values, links and override metadata against silent stale writes - `project-device.update` persists canonical source-device field changes independently and never synchronizes linked rows implicitly +- `project-device.insert` and `project-device.delete` preserve stable device + ids; delete undo restores links only from complete unchanged disconnected-row + snapshots in the same transaction Remaining constraints before completing persistent history: - extend the concrete project-scoped command union and executors beyond the Circuit field/insert/delete and CircuitDeviceRow - field/insert/delete/move, section-reorder/renumber, ProjectDevice field-update - and row-sync slices; the generic versioned command envelope is complete + field/insert/delete/move, section-reorder/renumber and ProjectDevice + field/insert/delete/row-sync slices; the generic versioned command envelope + is complete - route the remaining structural and destructive domain writes through the shared command, revision and stack transaction boundary - replace the session-local frontend command stack only after server-side diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index b91b7ca..724b015 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -411,6 +411,8 @@ Implemented foundation: local values, links and override metadata without silent overwrites - canonical ProjectDevice field updates persist independently with exact inverses and never overwrite linked CircuitDeviceRow values implicitly +- ProjectDevice insert/delete commands preserve stable device ids and restore + previously linked rows only from complete unchanged disconnected snapshots - revision metadata, change-set payloads and the project counter are committed in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index f6fd7fa..d08f005 100644 --- a/package.json +++ b/package.json @@ -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/project-device-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.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/project-device-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.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/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.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/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/project-device-structure-project-command.repository.ts b/src/db/repositories/project-device-structure-project-command.repository.ts new file mode 100644 index 0000000..94ecf2f --- /dev/null +++ b/src/db/repositories/project-device-structure-project-command.repository.ts @@ -0,0 +1,339 @@ +import { + and, + asc, + eq, + inArray, + isNull, +} from "drizzle-orm"; +import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js"; +import { + assertProjectDeviceDeleteProjectCommand, + assertProjectDeviceInsertProjectCommand, + createProjectDeviceDeleteProjectCommand, + createProjectDeviceInsertProjectCommand, + projectDeviceDeleteCommandType, + projectDeviceInsertCommandType, + type ProjectDeviceSnapshot, + type ProjectDeviceStructureProjectCommand, +} from "../../domain/models/project-device-structure-project-command.model.js"; +import type { + ExecuteProjectDeviceStructureCommandInput, + ProjectDeviceStructureProjectCommandStore, +} from "../../domain/ports/project-device-structure-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuits } from "../schema/circuits.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { projects } from "../schema/projects.js"; +import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js"; +import { applyProjectHistoryTransition } from "./project-history.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +const circuitDeviceRowSnapshotFields = [ + "id", + "circuitId", + "linkedProjectDeviceId", + "legacyConsumerId", + "sortOrder", + "name", + "displayName", + "phaseType", + "connectionKind", + "costGroup", + "category", + "level", + "roomId", + "roomNumberSnapshot", + "roomNameSnapshot", + "quantity", + "powerPerUnit", + "simultaneityFactor", + "cosPhi", + "remark", + "overriddenFields", +] as const satisfies readonly (keyof CircuitDeviceRowSnapshot)[]; + +export class ProjectDeviceStructureProjectCommandRepository + implements ProjectDeviceStructureProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteProjectDeviceStructureCommandInput) { + return this.database.transaction((tx) => { + const inverse = this.applyCommand( + tx, + input.projectId, + input.source, + input.command + ); + const revision = appendProjectRevision(tx, { + projectId: input.projectId, + expectedRevision: input.expectedRevision, + source: input.source, + description: input.description, + actorId: input.actorId, + forward: input.command, + inverse, + }); + applyProjectHistoryTransition(tx, { + projectId: input.projectId, + source: input.source, + recordedChangeSetId: revision.changeSetId, + targetChangeSetId: input.historyTargetChangeSetId, + }); + return { revision, inverse }; + }); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + source: ExecuteProjectDeviceStructureCommandInput["source"], + command: ProjectDeviceStructureProjectCommand + ): ProjectDeviceStructureProjectCommand { + if (command.type === projectDeviceInsertCommandType) { + assertProjectDeviceInsertProjectCommand(command); + return this.insert( + database, + projectId, + source, + command.payload.projectDevice, + command.payload.linkedRows + ); + } + if (command.type === projectDeviceDeleteCommandType) { + assertProjectDeviceDeleteProjectCommand(command); + return this.delete( + database, + projectId, + command.payload.projectDeviceId, + command.payload.expectedProjectId + ); + } + throw new Error("Unsupported project-device structure command."); + } + + private insert( + database: AppDatabase, + projectId: string, + source: ExecuteProjectDeviceStructureCommandInput["source"], + snapshot: ProjectDeviceSnapshot, + linkedRows: CircuitDeviceRowSnapshot[] + ) { + if (snapshot.projectId !== projectId) { + throw new Error( + "Project-device snapshot does not belong to project." + ); + } + if (source === "user" && linkedRows.length > 0) { + throw new Error( + "User project-device inserts cannot reconnect existing rows." + ); + } + const project = database + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) { + throw new Error("Project does not exist."); + } + const existing = database + .select({ id: projectDevices.id }) + .from(projectDevices) + .where(eq(projectDevices.id, snapshot.id)) + .get(); + if (existing) { + throw new Error("Project-device id already exists."); + } + + this.assertRestorableRows(database, projectId, linkedRows); + database.insert(projectDevices).values(snapshot).run(); + for (const row of linkedRows) { + const updated = database + .update(circuitDeviceRows) + .set({ linkedProjectDeviceId: snapshot.id }) + .where( + and( + eq(circuitDeviceRows.id, row.id), + isNull(circuitDeviceRows.linkedProjectDeviceId) + ) + ) + .run(); + if (updated.changes !== 1) { + throw new Error( + "A restored project-device row changed during insertion." + ); + } + } + return createProjectDeviceDeleteProjectCommand( + snapshot.id, + projectId + ); + } + + private delete( + database: AppDatabase, + projectId: string, + projectDeviceId: string, + expectedProjectId: string + ) { + if (expectedProjectId !== projectId) { + throw new Error( + "Project-device delete project does not match command project." + ); + } + const projectDevice = database + .select() + .from(projectDevices) + .where( + and( + eq(projectDevices.id, projectDeviceId), + eq(projectDevices.projectId, projectId) + ) + ) + .get(); + if (!projectDevice) { + throw new Error( + "Project device does not belong to project." + ); + } + const linkedRows = database + .select({ + row: circuitDeviceRows, + projectId: circuitLists.projectId, + }) + .from(circuitDeviceRows) + .innerJoin( + circuits, + eq(circuits.id, circuitDeviceRows.circuitId) + ) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where( + eq( + circuitDeviceRows.linkedProjectDeviceId, + projectDevice.id + ) + ) + .orderBy( + asc(circuitDeviceRows.sortOrder), + asc(circuitDeviceRows.id) + ) + .all(); + if ( + linkedRows.some((linked) => linked.projectId !== projectId) + ) { + throw new Error( + "Project device has linked rows outside its project." + ); + } + const inverse = createProjectDeviceInsertProjectCommand( + toProjectDeviceSnapshot(projectDevice), + linkedRows.map(({ row }) => ({ + ...toCircuitDeviceRowSnapshot(row), + linkedProjectDeviceId: null, + })) + ); + + const deleted = database + .delete(projectDevices) + .where( + and( + eq(projectDevices.id, projectDevice.id), + eq(projectDevices.projectId, projectId) + ) + ) + .run(); + if (deleted.changes !== 1) { + throw new Error("Project device could not be deleted."); + } + return inverse; + } + + private assertRestorableRows( + database: AppDatabase, + projectId: string, + snapshots: CircuitDeviceRowSnapshot[] + ) { + if (snapshots.length === 0) { + return; + } + const rowIds = snapshots.map((row) => row.id); + const persistedRows = database + .select({ + row: circuitDeviceRows, + projectId: circuitLists.projectId, + }) + .from(circuitDeviceRows) + .innerJoin( + circuits, + eq(circuits.id, circuitDeviceRows.circuitId) + ) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where(inArray(circuitDeviceRows.id, rowIds)) + .all(); + if ( + persistedRows.length !== snapshots.length || + persistedRows.some( + (persisted) => persisted.projectId !== projectId + ) + ) { + throw new Error( + "One or more restored rows do not belong to project." + ); + } + const persistedById = new Map( + persistedRows.map(({ row }) => [ + row.id, + toCircuitDeviceRowSnapshot(row), + ]) + ); + for (const snapshot of snapshots) { + const persisted = persistedById.get(snapshot.id); + if ( + !persisted || + !circuitDeviceRowSnapshotFields.every( + (field) => persisted[field] === snapshot[field] + ) + ) { + throw new Error( + "A restored project-device row changed before insertion." + ); + } + } + } +} + +function toProjectDeviceSnapshot( + projectDevice: typeof projectDevices.$inferSelect +): ProjectDeviceSnapshot { + if ( + projectDevice.phaseType !== "single_phase" && + projectDevice.phaseType !== "three_phase" + ) { + throw new Error("Persisted project-device phase type is invalid."); + } + return { + id: projectDevice.id, + projectId: projectDevice.projectId, + name: projectDevice.name, + displayName: projectDevice.displayName, + phaseType: projectDevice.phaseType, + connectionKind: projectDevice.connectionKind, + costGroup: projectDevice.costGroup, + category: projectDevice.category, + quantity: projectDevice.quantity, + powerPerUnit: projectDevice.powerPerUnit, + simultaneityFactor: projectDevice.simultaneityFactor, + cosPhi: projectDevice.cosPhi, + remark: projectDevice.remark, + voltageV: projectDevice.voltageV, + }; +} diff --git a/src/domain/models/project-device-project-command.model.ts b/src/domain/models/project-device-project-command.model.ts index 117e2e5..4e51e00 100644 --- a/src/domain/models/project-device-project-command.model.ts +++ b/src/domain/models/project-device-project-command.model.ts @@ -24,6 +24,21 @@ export type ProjectDeviceUpdateField = export type ProjectDeviceUpdatePatch = Partial; +export const projectDeviceUpdateFields = [ + "name", + "displayName", + "phaseType", + "connectionKind", + "costGroup", + "category", + "quantity", + "powerPerUnit", + "simultaneityFactor", + "cosPhi", + "remark", + "voltageV", +] as const satisfies readonly ProjectDeviceUpdateField[]; + export interface ProjectDeviceUpdateFieldChange< TField extends ProjectDeviceUpdateField = ProjectDeviceUpdateField, > { @@ -173,20 +188,9 @@ function assertProjectDeviceUpdateFieldValue( function isProjectDeviceUpdateField( value: string ): value is ProjectDeviceUpdateField { - return [ - "name", - "displayName", - "phaseType", - "connectionKind", - "costGroup", - "category", - "quantity", - "powerPerUnit", - "simultaneityFactor", - "cosPhi", - "remark", - "voltageV", - ].includes(value); + return projectDeviceUpdateFields.includes( + value as ProjectDeviceUpdateField + ); } function isPlainObject(value: unknown): value is Record { diff --git a/src/domain/models/project-device-structure-project-command.model.ts b/src/domain/models/project-device-structure-project-command.model.ts new file mode 100644 index 0000000..7dbc912 --- /dev/null +++ b/src/domain/models/project-device-structure-project-command.model.ts @@ -0,0 +1,171 @@ +import { + assertCircuitDeviceRowInsertProjectCommand, + circuitDeviceRowInsertCommandType, + circuitDeviceRowStructureCommandSchemaVersion, + type CircuitDeviceRowSnapshot, +} from "./circuit-device-row-structure-project-command.model.js"; +import { + assertProjectDeviceUpdateProjectCommand, + projectDeviceUpdateCommandSchemaVersion, + projectDeviceUpdateCommandType, + projectDeviceUpdateFields, + type ProjectDeviceUpdateValues, +} from "./project-device-project-command.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const projectDeviceInsertCommandType = + "project-device.insert" as const; +export const projectDeviceDeleteCommandType = + "project-device.delete" as const; +export const projectDeviceStructureCommandSchemaVersion = 1 as const; + +export interface ProjectDeviceSnapshot + extends ProjectDeviceUpdateValues { + id: string; + projectId: string; +} + +export interface ProjectDeviceInsertCommandPayload { + projectDevice: ProjectDeviceSnapshot; + linkedRows: CircuitDeviceRowSnapshot[]; +} + +export interface ProjectDeviceDeleteCommandPayload { + projectDeviceId: string; + expectedProjectId: string; +} + +export interface ProjectDeviceInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectDeviceStructureCommandSchemaVersion; + type: typeof projectDeviceInsertCommandType; +} + +export interface ProjectDeviceDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectDeviceStructureCommandSchemaVersion; + type: typeof projectDeviceDeleteCommandType; +} + +export type ProjectDeviceStructureProjectCommand = + | ProjectDeviceInsertProjectCommand + | ProjectDeviceDeleteProjectCommand; + +export function createProjectDeviceInsertProjectCommand( + projectDevice: ProjectDeviceSnapshot, + linkedRows: CircuitDeviceRowSnapshot[] = [] +): ProjectDeviceInsertProjectCommand { + const command: ProjectDeviceInsertProjectCommand = { + schemaVersion: projectDeviceStructureCommandSchemaVersion, + type: projectDeviceInsertCommandType, + payload: { projectDevice, linkedRows }, + }; + assertProjectDeviceInsertProjectCommand(command); + return command; +} + +export function createProjectDeviceDeleteProjectCommand( + projectDeviceId: string, + expectedProjectId: string +): ProjectDeviceDeleteProjectCommand { + const command: ProjectDeviceDeleteProjectCommand = { + schemaVersion: projectDeviceStructureCommandSchemaVersion, + type: projectDeviceDeleteCommandType, + payload: { projectDeviceId, expectedProjectId }, + }; + assertProjectDeviceDeleteProjectCommand(command); + return command; +} + +export function assertProjectDeviceInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectDeviceInsertProjectCommand { + if ( + command.schemaVersion !== projectDeviceStructureCommandSchemaVersion || + command.type !== projectDeviceInsertCommandType + ) { + throw new Error("Unsupported project-device insert command."); + } + if (!isPlainObject(command.payload)) { + throw new Error("Project-device insert payload must be an object."); + } + const { projectDevice, linkedRows } = command.payload; + if (!isPlainObject(projectDevice)) { + throw new Error( + "Project-device insert command requires a project device." + ); + } + assertNonEmptyString(projectDevice.id, "projectDevice.id"); + assertNonEmptyString( + projectDevice.projectId, + "projectDevice.projectId" + ); + assertProjectDeviceUpdateProjectCommand({ + schemaVersion: projectDeviceUpdateCommandSchemaVersion, + type: projectDeviceUpdateCommandType, + payload: { + projectDeviceId: projectDevice.id, + changes: projectDeviceUpdateFields.map((field) => ({ + field, + value: projectDevice[field], + })), + }, + }); + + if (!Array.isArray(linkedRows)) { + throw new Error( + "Project-device insert linkedRows must be an array." + ); + } + const rowIds = new Set(); + for (const row of linkedRows) { + assertCircuitDeviceRowInsertProjectCommand({ + schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, + type: circuitDeviceRowInsertCommandType, + payload: { row }, + }); + if (row.linkedProjectDeviceId !== null) { + throw new Error( + "Restored project-device rows must currently be disconnected." + ); + } + if (rowIds.has(row.id)) { + throw new Error( + "Project-device insert command contains duplicate row ids." + ); + } + rowIds.add(row.id); + } +} + +export function assertProjectDeviceDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectDeviceDeleteProjectCommand { + if ( + command.schemaVersion !== projectDeviceStructureCommandSchemaVersion || + command.type !== projectDeviceDeleteCommandType + ) { + throw new Error("Unsupported project-device delete command."); + } + if (!isPlainObject(command.payload)) { + throw new Error("Project-device delete payload must be an object."); + } + assertNonEmptyString( + command.payload.projectDeviceId, + "projectDeviceId" + ); + assertNonEmptyString( + command.payload.expectedProjectId, + "expectedProjectId" + ); +} + +function assertNonEmptyString(value: unknown, field: string) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} must be a non-empty string.`); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/project-device-structure-project-command.store.ts b/src/domain/ports/project-device-structure-project-command.store.ts new file mode 100644 index 0000000..3a0d6fa --- /dev/null +++ b/src/domain/ports/project-device-structure-project-command.store.ts @@ -0,0 +1,26 @@ +import type { ProjectDeviceStructureProjectCommand } from "../models/project-device-structure-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteProjectDeviceStructureCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: ProjectDeviceStructureProjectCommand; +} + +export interface ExecutedProjectDeviceStructureCommand { + revision: AppendedProjectRevision; + inverse: ProjectDeviceStructureProjectCommand; +} + +export interface ProjectDeviceStructureProjectCommandStore { + execute( + input: ExecuteProjectDeviceStructureCommandInput + ): ExecutedProjectDeviceStructureCommand; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index f0f65fb..33b9de8 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -45,6 +45,12 @@ import { assertProjectDeviceUpdateProjectCommand, projectDeviceUpdateCommandType, } from "../models/project-device-project-command.model.js"; +import { + assertProjectDeviceDeleteProjectCommand, + assertProjectDeviceInsertProjectCommand, + projectDeviceDeleteCommandType, + projectDeviceInsertCommandType, +} from "../models/project-device-structure-project-command.model.js"; import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js"; import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js"; import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js"; @@ -64,6 +70,7 @@ import type { } from "../ports/project-history.store.js"; import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js"; import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js"; +import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js"; import type { ProjectRevisionSource } from "../ports/project-revision.store.js"; interface DispatchProjectCommandInput { @@ -85,6 +92,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore, + private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore, private readonly projectDeviceStore: ProjectDeviceProjectCommandStore, private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore, private readonly historyStore: ProjectHistoryStore @@ -238,6 +246,20 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case projectDeviceInsertCommandType: { + assertProjectDeviceInsertProjectCommand(input.command); + return this.projectDeviceStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case projectDeviceDeleteCommandType: { + assertProjectDeviceDeleteProjectCommand(input.command); + return this.projectDeviceStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } default: throw new Error( `Unsupported project command type: ${input.command.type}.` diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 32de37f..7077539 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -9,6 +9,7 @@ import { CircuitStructureProjectCommandRepository } from "../../db/repositories/ import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js"; +import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); @@ -24,6 +25,8 @@ export const circuitSectionReorderProjectCommandStore = new CircuitSectionReorderProjectCommandRepository(db); export const circuitSectionRenumberProjectCommandStore = new CircuitSectionRenumberProjectCommandRepository(db); +export const projectDeviceStructureProjectCommandStore = + new ProjectDeviceStructureProjectCommandRepository(db); export const projectDeviceProjectCommandStore = new ProjectDeviceProjectCommandRepository(db); export const projectDeviceRowSyncProjectCommandStore = @@ -37,6 +40,7 @@ export const projectCommandService = new ProjectCommandService( circuitStructureProjectCommandStore, circuitSectionReorderProjectCommandStore, circuitSectionRenumberProjectCommandStore, + projectDeviceStructureProjectCommandStore, projectDeviceProjectCommandStore, projectDeviceRowSyncProjectCommandStore, projectHistoryStore diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index 7741c9d..1eb7c3f 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -46,6 +46,10 @@ import { assertProjectDeviceUpdateProjectCommand, createProjectDeviceUpdateProjectCommand, } from "../src/domain/models/project-device-project-command.model.js"; +import { + createProjectDeviceDeleteProjectCommand, + createProjectDeviceInsertProjectCommand, +} from "../src/domain/models/project-device-structure-project-command.model.js"; describe("serialized project commands", () => { it("round-trips a versioned command envelope", () => { @@ -353,6 +357,89 @@ describe("project-device update project commands", () => { }); }); +describe("project-device structure project commands", () => { + const projectDevice = { + id: "project-device-1", + projectId: "project-1", + name: "Luminaire", + displayName: "Office lighting", + phaseType: "single_phase" as const, + connectionKind: "fixed", + costGroup: "440", + category: "lighting", + quantity: 4, + powerPerUnit: 0.04, + simultaneityFactor: 0.8, + cosPhi: 0.95, + remark: "DALI", + voltageV: 230, + }; + const disconnectedRow = { + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: null, + legacyConsumerId: null, + sortOrder: 10, + name: "Luminaire", + displayName: "Local lighting", + phaseType: "single_phase", + connectionKind: "fixed", + costGroup: "440", + category: "lighting", + level: null, + roomId: null, + roomNumberSnapshot: null, + roomNameSnapshot: null, + quantity: 4, + powerPerUnit: 0.04, + simultaneityFactor: 0.8, + cosPhi: 0.95, + remark: "DALI", + overriddenFields: '["displayName"]', + }; + + it("captures complete device and disconnected-row restore snapshots", () => { + const insert = createProjectDeviceInsertProjectCommand( + projectDevice, + [disconnectedRow] + ); + const remove = createProjectDeviceDeleteProjectCommand( + projectDevice.id, + projectDevice.projectId + ); + + assert.deepEqual(insert.payload, { + projectDevice, + linkedRows: [disconnectedRow], + }); + assert.deepEqual(remove.payload, { + projectDeviceId: "project-device-1", + expectedProjectId: "project-1", + }); + }); + + it("rejects invalid device values and rows that are already linked", () => { + assert.throws( + () => + createProjectDeviceInsertProjectCommand({ + ...projectDevice, + quantity: -1, + }), + /outside its allowed range/ + ); + assert.throws( + () => + createProjectDeviceInsertProjectCommand(projectDevice, [ + { + ...disconnectedRow, + linkedProjectDeviceId: "project-device-1", + }, + ]), + /must currently be disconnected/ + ); + }); +}); + describe("circuit device-row structure project commands", () => { const row = { id: "row-1", diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 947a0fe..0a51dcb 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -18,6 +18,7 @@ import { DistributionBoardRepository } from "../src/db/repositories/distribution import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; +import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.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"; @@ -39,6 +40,7 @@ import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js"; import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js"; +import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; function createTestDatabase(): DatabaseContext { @@ -95,6 +97,7 @@ function createService(context: DatabaseContext) { new CircuitStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db), + new ProjectDeviceStructureProjectCommandRepository(context.db), new ProjectDeviceProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) @@ -325,6 +328,58 @@ describe("project command service", () => { } }); + it("dispatches project-device insertion and deletion", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const inserted = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createProjectDeviceInsertProjectCommand({ + id: "project-device-new", + projectId: "project-1", + name: "Socket", + displayName: "Office socket", + phaseType: "single_phase", + connectionKind: null, + costGroup: "440", + category: "socket", + quantity: 2, + powerPerUnit: 0.2, + simultaneityFactor: 0.5, + cosPhi: null, + remark: null, + voltageV: 230, + }), + }); + assert.equal(inserted.history.undoDepth, 1); + assert.equal( + context.db + .select() + .from(projectDevices) + .where(eq(projectDevices.id, "project-device-new")) + .get()?.displayName, + "Office socket" + ); + + const undone = createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal(undone.history.redoDepth, 1); + assert.equal( + context.db + .select() + .from(projectDevices) + .where(eq(projectDevices.id, "project-device-new")) + .get(), + undefined + ); + } finally { + context.close(); + } + }); + it("rejects stale undo and preserves the domain value and stack", () => { const context = createTestDatabase(); try { diff --git a/tests/project-device-structure-project-command.repository.test.ts b/tests/project-device-structure-project-command.repository.test.ts new file mode 100644 index 0000000..817a53f --- /dev/null +++ b/tests/project-device-structure-project-command.repository.test.ts @@ -0,0 +1,503 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { asc, eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { 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 { + createProjectDeviceDeleteProjectCommand, + createProjectDeviceInsertProjectCommand, + type ProjectDeviceSnapshot, +} from "../src/domain/models/project-device-structure-project-command.model.js"; + +function newProjectDeviceSnapshot(): ProjectDeviceSnapshot { + return { + id: "project-device-new", + projectId: "project-1", + name: "Socket", + displayName: "Office socket", + phaseType: "single_phase", + connectionKind: null, + costGroup: "440", + category: "socket", + quantity: 2, + powerPerUnit: 0.2, + simultaneityFactor: 0.5, + cosPhi: null, + remark: null, + voltageV: 230, + }; +} + +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(projectDevices) + .values([ + { + id: "project-device-1", + projectId: "project-1", + name: "Luminaire", + displayName: "Office lighting", + phaseType: "single_phase", + connectionKind: "fixed", + costGroup: "440", + category: "lighting", + quantity: 4, + powerPerUnit: 0.04, + simultaneityFactor: 0.8, + cosPhi: 0.95, + remark: "DALI", + voltageV: 230, + }, + { + id: "project-device-foreign", + projectId: "project-2", + name: "Foreign device", + displayName: "Foreign device", + phaseType: "three_phase", + quantity: 1, + powerPerUnit: 1, + simultaneityFactor: 1, + }, + ]) + .run(); + context.db + .insert(circuits) + .values([ + { + id: "circuit-1", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + }, + { + id: "circuit-foreign", + circuitListId: foreignBoard.id, + sectionId: foreignSection.id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + }, + ]) + .run(); + context.db + .insert(circuitDeviceRows) + .values([ + { + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "project-device-1", + sortOrder: 10, + name: "Luminaire", + displayName: "Local row 1", + phaseType: "single_phase", + quantity: 1, + powerPerUnit: 0.03, + simultaneityFactor: 1, + overriddenFields: '["displayName","quantity"]', + }, + { + id: "row-2", + circuitId: "circuit-1", + linkedProjectDeviceId: "project-device-1", + sortOrder: 20, + name: "Luminaire", + displayName: "Local row 2", + phaseType: "single_phase", + quantity: 2, + powerPerUnit: 0.03, + simultaneityFactor: 1, + overriddenFields: '["displayName"]', + }, + ]) + .run(); + return context; +} + +function getProjectDevice( + context: DatabaseContext, + id: string +) { + return context.db + .select() + .from(projectDevices) + .where(eq(projectDevices.id, id)) + .get(); +} + +function getLinkedRowState(context: DatabaseContext) { + return context.db + .select({ + id: circuitDeviceRows.id, + linkedProjectDeviceId: + circuitDeviceRows.linkedProjectDeviceId, + displayName: circuitDeviceRows.displayName, + quantity: circuitDeviceRows.quantity, + overriddenFields: circuitDeviceRows.overriddenFields, + }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, "circuit-1")) + .orderBy(asc(circuitDeviceRows.id)) + .all(); +} + +describe("project-device structure project-command repository", () => { + it("inserts a stable project device and supports undo and redo", () => { + const context = createTestDatabase(); + try { + const store = + new ProjectDeviceStructureProjectCommandRepository( + context.db + ); + const command = createProjectDeviceInsertProjectCommand( + newProjectDeviceSnapshot() + ); + const inserted = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.equal( + getProjectDevice(context, "project-device-new") + ?.displayName, + "Office socket" + ); + + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: + inserted.revision.changeSetId, + command: inserted.inverse, + }); + assert.equal( + getProjectDevice(context, "project-device-new"), + undefined + ); + + store.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: + inserted.revision.changeSetId, + command, + }); + assert.deepEqual( + new ProjectHistoryRepository(context.db).getState( + "project-1" + ), + { + projectId: "project-1", + currentRevision: 3, + undoDepth: 1, + redoDepth: 0, + undoChangeSetId: inserted.revision.changeSetId, + redoChangeSetId: null, + } + ); + } finally { + context.close(); + } + }); + + it("deletes a device and restores its exact linked rows", () => { + const context = createTestDatabase(); + try { + const store = + new ProjectDeviceStructureProjectCommandRepository( + context.db + ); + const beforeRows = getLinkedRowState(context); + const command = createProjectDeviceDeleteProjectCommand( + "project-device-1", + "project-1" + ); + const deleted = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.equal( + getProjectDevice(context, "project-device-1"), + undefined + ); + assert.deepEqual( + getLinkedRowState(context).map( + (row) => row.linkedProjectDeviceId + ), + [null, null] + ); + + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: + deleted.revision.changeSetId, + command: deleted.inverse, + }); + assert.equal( + getProjectDevice(context, "project-device-1") + ?.displayName, + "Office lighting" + ); + assert.deepEqual(getLinkedRowState(context), beforeRows); + + store.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: + deleted.revision.changeSetId, + command, + }); + assert.deepEqual( + getLinkedRowState(context).map( + (row) => row.linkedProjectDeviceId + ), + [null, null] + ); + } finally { + context.close(); + } + }); + + it("rejects unsafe user links, duplicate ids and foreign deletes", () => { + const context = createTestDatabase(); + try { + const store = + new ProjectDeviceStructureProjectCommandRepository( + context.db + ); + const disconnectedRow = context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get(); + assert.ok(disconnectedRow); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceInsertProjectCommand( + newProjectDeviceSnapshot(), + [ + { + ...disconnectedRow, + linkedProjectDeviceId: null, + }, + ] + ), + }), + /cannot reconnect existing rows/ + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceInsertProjectCommand({ + ...newProjectDeviceSnapshot(), + id: "project-device-1", + }), + }), + /id already exists/ + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceDeleteProjectCommand( + "project-device-foreign", + "project-1" + ), + }), + /does not belong to project/ + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); + + it("refuses undo when a disconnected row changed", () => { + const context = createTestDatabase(); + try { + const store = + new ProjectDeviceStructureProjectCommandRepository( + context.db + ); + const deleted = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceDeleteProjectCommand( + "project-device-1", + "project-1" + ), + }); + context.db + .update(circuitDeviceRows) + .set({ displayName: "Changed while disconnected" }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: + deleted.revision.changeSetId, + command: deleted.inverse, + }), + /row changed before insertion/ + ); + assert.equal( + getProjectDevice(context, "project-device-1"), + undefined + ); + assert.deepEqual( + getLinkedRowState(context).map( + (row) => row.linkedProjectDeviceId + ), + [null, null] + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 1 + ); + } finally { + context.close(); + } + }); + + it("rolls back deletion, disconnected links and history together", () => { + const context = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_project_device_structure_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced project-device structure history failure'); + END; + `); + const store = + new ProjectDeviceStructureProjectCommandRepository( + context.db + ); + const beforeRows = getLinkedRowState(context); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceDeleteProjectCommand( + "project-device-1", + "project-1" + ), + }), + /forced project-device structure history failure/ + ); + assert.equal( + getProjectDevice(context, "project-device-1") + ?.displayName, + "Office lighting" + ); + assert.deepEqual(getLinkedRowState(context), beforeRows); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); + + it("rolls back insertion for a stale project revision", () => { + const context = createTestDatabase(); + try { + const store = + new ProjectDeviceStructureProjectCommandRepository( + context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createProjectDeviceInsertProjectCommand( + newProjectDeviceSnapshot() + ), + }), + /at revision 0, expected 1/ + ); + assert.equal( + getProjectDevice(context, "project-device-new"), + undefined + ); + } finally { + context.close(); + } + }); +});