Add project device structure history

This commit is contained in:
2026-07-24 09:09:59 +02:00
parent 0bc6c7372f
commit ac16cca77a
15 changed files with 1262 additions and 26 deletions
+3
View File
@@ -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 are persisted as one atomic multi-row command with complete expected/target row
snapshots, including link and override metadata. Canonical ProjectDevice field snapshots, including link and override metadata. Canonical ProjectDevice field
updates are also persisted and never synchronize linked rows implicitly. 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: Required operations:
+16 -8
View File
@@ -28,14 +28,15 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit-device-row.insert`, `circuit-device-row.delete`, `circuit-device-row.insert`, `circuit-device-row.delete`,
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`, `circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
`circuit.reorder-section`, `circuit.renumber-section` and `circuit.reorder-section`, `circuit.renumber-section` and
`project-device.update` and `project-device.sync-rows`, all with schema version `project-device.update`, `project-device.insert`, `project-device.delete` and
`1`. Other command types are rejected. Insert commands contain the complete `project-device.sync-rows`, all with schema version `1`. Other command types
entity or circuit block with stable ids; delete commands include the expected are rejected. Insert commands contain the complete entity or circuit block
parent identity. Circuit snapshots contain zero, one or multiple complete with stable ids; delete commands include the expected parent identity. Circuit
device rows. Move commands contain each row's expected and target circuit plus snapshots contain zero, one or multiple complete device rows. Move commands
its exact expected and target sort order. This makes deletion and moves contain each row's expected and target circuit plus its exact expected and
undoable without generating replacement identities or renumbering circuits. target sort order. This makes deletion and moves undoable without generating
The editor does not consume these endpoints yet. 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` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one `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 ownership and never writes linked `CircuitDeviceRow` values; synchronization
remains a separate explicit command. 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: Example:
```json ```json
+8
View File
@@ -117,6 +117,14 @@ gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel
atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch atomar. Verknüpfte Stromkreiszeilen werden dabei bewusst nicht automatisch
synchronisiert. Der bestehende ProjectDevice-`PUT`-Endpunkt verwendet diesen synchronisiert. Der bestehende ProjectDevice-`PUT`-Endpunkt verwendet diesen
Command bis zum UI-Cutover noch nicht. 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 ## Projektgeräte
@@ -197,13 +197,17 @@ Completed foundation:
protect local values, links and override metadata against silent stale writes protect local values, links and override metadata against silent stale writes
- `project-device.update` persists canonical source-device field changes - `project-device.update` persists canonical source-device field changes
independently and never synchronizes linked rows implicitly 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: Remaining constraints before completing persistent history:
- extend the concrete project-scoped command union and executors beyond the - extend the concrete project-scoped command union and executors beyond the
Circuit field/insert/delete and CircuitDeviceRow Circuit field/insert/delete and CircuitDeviceRow
field/insert/delete/move, section-reorder/renumber, ProjectDevice field-update field/insert/delete/move, section-reorder/renumber and ProjectDevice
and row-sync slices; the generic versioned command envelope is complete field/insert/delete/row-sync slices; the generic versioned command envelope
is complete
- route the remaining structural and destructive domain - route the remaining structural and destructive domain
writes through the shared command, revision and stack transaction boundary writes through the shared command, revision and stack transaction boundary
- replace the session-local frontend command stack only after server-side - replace the session-local frontend command stack only after server-side
@@ -411,6 +411,8 @@ Implemented foundation:
local values, links and override metadata without silent overwrites local values, links and override metadata without silent overwrites
- canonical ProjectDevice field updates persist independently with exact - canonical ProjectDevice field updates persist independently with exact
inverses and never overwrite linked CircuitDeviceRow values implicitly 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 - 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/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-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: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,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,
};
}
@@ -24,6 +24,21 @@ export type ProjectDeviceUpdateField =
export type ProjectDeviceUpdatePatch = export type ProjectDeviceUpdatePatch =
Partial<ProjectDeviceUpdateValues>; Partial<ProjectDeviceUpdateValues>;
export const projectDeviceUpdateFields = [
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"voltageV",
] as const satisfies readonly ProjectDeviceUpdateField[];
export interface ProjectDeviceUpdateFieldChange< export interface ProjectDeviceUpdateFieldChange<
TField extends ProjectDeviceUpdateField = ProjectDeviceUpdateField, TField extends ProjectDeviceUpdateField = ProjectDeviceUpdateField,
> { > {
@@ -173,20 +188,9 @@ function assertProjectDeviceUpdateFieldValue(
function isProjectDeviceUpdateField( function isProjectDeviceUpdateField(
value: string value: string
): value is ProjectDeviceUpdateField { ): value is ProjectDeviceUpdateField {
return [ return projectDeviceUpdateFields.includes(
"name", value as ProjectDeviceUpdateField
"displayName", );
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"voltageV",
].includes(value);
} }
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -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<ProjectDeviceInsertCommandPayload> {
schemaVersion: typeof projectDeviceStructureCommandSchemaVersion;
type: typeof projectDeviceInsertCommandType;
}
export interface ProjectDeviceDeleteProjectCommand
extends SerializedProjectCommand<ProjectDeviceDeleteCommandPayload> {
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<unknown>
): 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<string>();
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<unknown>
): 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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -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;
}
@@ -45,6 +45,12 @@ import {
assertProjectDeviceUpdateProjectCommand, assertProjectDeviceUpdateProjectCommand,
projectDeviceUpdateCommandType, projectDeviceUpdateCommandType,
} from "../models/project-device-project-command.model.js"; } 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 { 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 { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-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"; } from "../ports/project-history.store.js";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.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 { 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"; import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
interface DispatchProjectCommandInput { interface DispatchProjectCommandInput {
@@ -85,6 +92,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore, private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore, private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore, private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly historyStore: ProjectHistoryStore private readonly historyStore: ProjectHistoryStore
@@ -238,6 +246,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).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: default:
throw new Error( throw new Error(
`Unsupported project command type: ${input.command.type}.` `Unsupported project command type: ${input.command.type}.`
@@ -9,6 +9,7 @@ import { CircuitStructureProjectCommandRepository } from "../../db/repositories/
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.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 { 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"; import { ProjectCommandService } from "../../domain/services/project-command.service.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
@@ -24,6 +25,8 @@ export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db); new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore = export const circuitSectionRenumberProjectCommandStore =
new CircuitSectionRenumberProjectCommandRepository(db); new CircuitSectionRenumberProjectCommandRepository(db);
export const projectDeviceStructureProjectCommandStore =
new ProjectDeviceStructureProjectCommandRepository(db);
export const projectDeviceProjectCommandStore = export const projectDeviceProjectCommandStore =
new ProjectDeviceProjectCommandRepository(db); new ProjectDeviceProjectCommandRepository(db);
export const projectDeviceRowSyncProjectCommandStore = export const projectDeviceRowSyncProjectCommandStore =
@@ -37,6 +40,7 @@ export const projectCommandService = new ProjectCommandService(
circuitStructureProjectCommandStore, circuitStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore, circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore, circuitSectionRenumberProjectCommandStore,
projectDeviceStructureProjectCommandStore,
projectDeviceProjectCommandStore, projectDeviceProjectCommandStore,
projectDeviceRowSyncProjectCommandStore, projectDeviceRowSyncProjectCommandStore,
projectHistoryStore projectHistoryStore
+87
View File
@@ -46,6 +46,10 @@ import {
assertProjectDeviceUpdateProjectCommand, assertProjectDeviceUpdateProjectCommand,
createProjectDeviceUpdateProjectCommand, createProjectDeviceUpdateProjectCommand,
} from "../src/domain/models/project-device-project-command.model.js"; } 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", () => { describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => { 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", () => { describe("circuit device-row structure project commands", () => {
const row = { const row = {
id: "row-1", id: "row-1",
+55
View File
@@ -18,6 +18,7 @@ import { DistributionBoardRepository } from "../src/db/repositories/distribution
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.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 { 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 { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.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 { 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 { 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 { 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"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext { function createTestDatabase(): DatabaseContext {
@@ -95,6 +97,7 @@ function createService(context: DatabaseContext) {
new CircuitStructureProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectDeviceStructureProjectCommandRepository(context.db),
new ProjectDeviceProjectCommandRepository(context.db), new ProjectDeviceProjectCommandRepository(context.db),
new ProjectDeviceRowSyncProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectHistoryRepository(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", () => { it("rejects stale undo and preserves the domain value and stack", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
@@ -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();
}
});
});