Add project device sync history

This commit is contained in:
2026-07-24 08:47:25 +02:00
parent 4b4603b71b
commit 2668fc2f16
14 changed files with 1180 additions and 21 deletions
+3 -1
View File
@@ -232,7 +232,9 @@ restore the rows and remove only the unchanged generated circuit. Complete
in-section Circuit reorders are persisted separately and change sort positions in-section Circuit reorders are persisted separately and change sort positions
without changing equipment identifiers. Explicit complete-section renumbering without changing equipment identifiers. Explicit complete-section renumbering
is persisted through a separate collision-safe command and is never triggered is persisted through a separate collision-safe command and is never triggered
by sorting or moving. 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.
Required operations: Required operations:
+18 -11
View File
@@ -25,17 +25,17 @@ concurrency checks.
The public dispatcher currently supports `circuit.update`, `circuit.insert`, The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit.delete`, `circuit-device-row.update`, `circuit.delete`, `circuit-device-row.update`,
`circuit-device-row.insert`, `circuit-device-row.delete` and `circuit-device-row.insert`, `circuit-device-row.delete`,
`circuit-device-row.move` plus `circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
`circuit-device-row.move-with-new-circuit` and `circuit.reorder-section`, all `circuit.reorder-section`, `circuit.renumber-section` and
with `circuit.renumber-section`, all with schema version `1`. Other command `project-device.sync-rows`, all with schema version `1`. Other command types
types are rejected. Insert commands contain the complete entity or circuit are rejected. Insert commands contain the complete entity or circuit block
block with stable ids; delete commands include the expected parent identity. with stable ids; delete commands include the expected parent identity. Circuit
Circuit snapshots contain zero, one or multiple complete device rows. Move snapshots contain zero, one or multiple complete device rows. Move commands
commands contain each row's expected and target circuit plus its exact expected contain each row's expected and target circuit plus its exact expected and
and target sort order. This makes deletion and moves undoable without target sort order. This makes deletion and moves undoable without generating
generating replacement identities or renumbering circuits. The editor does not replacement identities or renumbering circuits. The editor does not consume
consume these endpoints yet. 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
@@ -56,6 +56,13 @@ targets occupied by other sections, then applies swaps through collision-safe
temporary identifiers. Undo restores the exact prior identifiers; sort temporary identifiers. Undo restores the exact prior identifiers; sort
positions and device rows remain unchanged. positions and device rows remain unchanged.
`project-device.sync-rows` represents `synchronize`, `disconnect` and
`reconnect` operations. Every selected row carries complete expected and target
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
All rows, the inverse command, revision and history-stack transition commit or
roll back together. Disconnect/reconnect may only change the link; stale row
snapshots and cross-project devices or rows are rejected.
Example: Example:
```json ```json
+9 -2
View File
@@ -102,8 +102,15 @@ jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
bleiben unverändert. `circuit.renumber-section` bildet die getrennte, bleiben unverändert. `circuit.renumber-section` bildet die getrennte,
ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
ändert weder Sortierung noch Gerätezeilen. Synchronisierung muss vor der ändert weder Sortierung noch Gerätezeilen.
Frontend-Umstellung noch als persistentes Command modelliert werden. `project-device.sync-rows` persistiert Synchronisierung, Trennen und erneutes
Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den
vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder,
einschließlich ProjectDevice-Verknüpfung und lokaler Override-Metadaten. Damit
werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt
exakt die vorherigen lokalen Werte wieder her. Die bestehenden
Projektgeräte-Endpunkte verwenden diesen Command bis zur Frontend-Umstellung
noch nicht.
## Projektgeräte ## Projektgeräte
@@ -192,18 +192,21 @@ Completed foundation:
- `circuit.renumber-section` is the separate explicit renumber operation; it - `circuit.renumber-section` is the separate explicit renumber operation; it
stores every expected/target equipment identifier, uses collision-safe stores every expected/target equipment identifier, uses collision-safe
temporary values and restores the exact prior identifiers on undo temporary values and restores the exact prior identifiers on undo
- `project-device.sync-rows` records synchronization, disconnect and reconnect
as one atomic multi-row operation; complete expected/target sync snapshots
protect local values, links and override metadata against silent stale writes
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 plus section-reorder/renumber slices; the generic field/insert/delete/move, section-reorder/renumber and ProjectDevice row-sync
versioned command envelope is complete slices; the generic versioned command envelope is complete
- route the remaining structural, synchronization 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
command coverage is complete for structural, destructive and synchronization command coverage is complete for structural and destructive operations; then
operations route the existing synchronization UI through its persistent command
- do not model IFCGUID as an overloaded circuit equipment identifier - do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests - keep database backup/restore checks separate from project history tests
@@ -406,6 +406,9 @@ Implemented foundation:
- explicit section renumber commands persist every expected and target - explicit section renumber commands persist every expected and target
equipment identifier, apply swaps collision-safely and restore exact prior equipment identifier, apply swaps collision-safely and restore exact prior
identifiers without changing sort positions or device rows identifiers without changing sort positions or device rows
- ProjectDevice row synchronization, disconnect and reconnect persist complete
expected/target sync snapshots in one atomic multi-row command; undo restores
local values, links and override metadata without silent overwrites
- revision metadata, change-set payloads and the project counter are committed - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/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-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/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-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,151 @@
import { and, eq, inArray } from "drizzle-orm";
import {
assertProjectDeviceRowSyncProjectCommand,
createProjectDeviceRowSyncProjectCommand,
invertProjectDeviceRowSyncOperation,
projectDeviceSyncRowSnapshotFields,
type ProjectDeviceSyncRowSnapshot,
} from "../../domain/models/project-device-row-sync-project-command.model.js";
import type {
ExecuteProjectDeviceRowSyncCommandInput,
ProjectDeviceRowSyncProjectCommandStore,
} from "../../domain/ports/project-device-row-sync-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 { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
export class ProjectDeviceRowSyncProjectCommandRepository
implements ProjectDeviceRowSyncProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectDeviceRowSyncCommandInput) {
assertProjectDeviceRowSyncProjectCommand(input.command);
return this.database.transaction((tx) => {
const projectDevice = tx
.select({ id: projectDevices.id })
.from(projectDevices)
.where(
and(
eq(
projectDevices.id,
input.command.payload.projectDeviceId
),
eq(projectDevices.projectId, input.projectId)
)
)
.get();
if (!projectDevice) {
throw new Error(
"Project device does not belong to project."
);
}
const rowIds = input.command.payload.rows.map(
(row) => row.rowId
);
const persistedRows = tx
.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 !== rowIds.length ||
persistedRows.some(
(persisted) => persisted.projectId !== input.projectId
)
) {
throw new Error(
"One or more sync rows do not belong to project."
);
}
const persistedById = new Map(
persistedRows.map((persisted) => [
persisted.row.id,
persisted.row,
])
);
for (const assignment of input.command.payload.rows) {
const persisted = persistedById.get(assignment.rowId);
if (
!persisted ||
!snapshotMatchesRow(assignment.expected, persisted)
) {
throw new Error(
"Project-device row changed before sync execution."
);
}
}
const inverse = createProjectDeviceRowSyncProjectCommand(
projectDevice.id,
invertProjectDeviceRowSyncOperation(
input.command.payload.operation
),
input.command.payload.rows.map((row) => ({
rowId: row.rowId,
expected: row.target,
target: row.expected,
}))
);
for (const assignment of input.command.payload.rows) {
const updated = tx
.update(circuitDeviceRows)
.set(assignment.target)
.where(eq(circuitDeviceRows.id, assignment.rowId))
.run();
if (updated.changes !== 1) {
throw new Error(
"Project-device row changed during sync execution."
);
}
}
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 };
});
}
}
function snapshotMatchesRow(
snapshot: ProjectDeviceSyncRowSnapshot,
row: CircuitDeviceRow
) {
return projectDeviceSyncRowSnapshotFields.every(
(field) => snapshot[field] === row[field]
);
}
@@ -0,0 +1,245 @@
import {
assertCircuitDeviceRowUpdateProjectCommand,
circuitDeviceRowUpdateCommandSchemaVersion,
circuitDeviceRowUpdateCommandType,
type CircuitDeviceRowUpdateField,
type CircuitDeviceRowUpdateValues,
} from "./circuit-device-row-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectDeviceRowSyncCommandType =
"project-device.sync-rows" as const;
export const projectDeviceRowSyncCommandSchemaVersion = 1 as const;
export const projectDeviceSyncRowSnapshotFields = [
"linkedProjectDeviceId",
"name",
"displayName",
"phaseType",
"connectionKind",
"costGroup",
"category",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
"remark",
"overriddenFields",
] as const satisfies readonly CircuitDeviceRowUpdateField[];
export type ProjectDeviceSyncRowSnapshot = Pick<
CircuitDeviceRowUpdateValues,
(typeof projectDeviceSyncRowSnapshotFields)[number]
>;
export type ProjectDeviceRowSyncOperation =
| "synchronize"
| "disconnect"
| "reconnect";
export interface ProjectDeviceRowSyncAssignment {
rowId: string;
expected: ProjectDeviceSyncRowSnapshot;
target: ProjectDeviceSyncRowSnapshot;
}
export interface ProjectDeviceRowSyncCommandPayload {
projectDeviceId: string;
operation: ProjectDeviceRowSyncOperation;
rows: ProjectDeviceRowSyncAssignment[];
}
export interface ProjectDeviceRowSyncProjectCommand
extends SerializedProjectCommand<ProjectDeviceRowSyncCommandPayload> {
schemaVersion: typeof projectDeviceRowSyncCommandSchemaVersion;
type: typeof projectDeviceRowSyncCommandType;
}
export function createProjectDeviceRowSyncProjectCommand(
projectDeviceId: string,
operation: ProjectDeviceRowSyncOperation,
rows: ProjectDeviceRowSyncAssignment[]
): ProjectDeviceRowSyncProjectCommand {
const command: ProjectDeviceRowSyncProjectCommand = {
schemaVersion: projectDeviceRowSyncCommandSchemaVersion,
type: projectDeviceRowSyncCommandType,
payload: { projectDeviceId, operation, rows },
};
assertProjectDeviceRowSyncProjectCommand(command);
return command;
}
export function assertProjectDeviceRowSyncProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectDeviceRowSyncProjectCommand {
if (
command.schemaVersion !== projectDeviceRowSyncCommandSchemaVersion ||
command.type !== projectDeviceRowSyncCommandType
) {
throw new Error("Unsupported project-device row sync command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Project-device row sync payload must be an object."
);
}
const { projectDeviceId, operation, rows } = command.payload;
assertNonEmptyString(projectDeviceId, "projectDeviceId");
if (
operation !== "synchronize" &&
operation !== "disconnect" &&
operation !== "reconnect"
) {
throw new Error(
"Project-device row sync operation is invalid."
);
}
if (!Array.isArray(rows) || rows.length === 0) {
throw new Error(
"Project-device row sync command requires at least one row."
);
}
const rowIds = new Set<string>();
for (const row of rows) {
if (!isPlainObject(row)) {
throw new Error(
"Project-device row sync command contains an invalid row."
);
}
assertNonEmptyString(row.rowId, "rowId");
if (rowIds.has(row.rowId)) {
throw new Error(
"Project-device row sync command contains duplicate row ids."
);
}
assertProjectDeviceSyncRowSnapshot(row.expected);
assertProjectDeviceSyncRowSnapshot(row.target);
const expected =
row.expected as ProjectDeviceSyncRowSnapshot;
const target = row.target as ProjectDeviceSyncRowSnapshot;
if (snapshotsEqual(expected, target)) {
throw new Error(
"Project-device row sync command contains a no-op row."
);
}
assertOperationTransition(
operation,
projectDeviceId,
expected,
target
);
rowIds.add(row.rowId);
}
}
export function invertProjectDeviceRowSyncOperation(
operation: ProjectDeviceRowSyncOperation
): ProjectDeviceRowSyncOperation {
if (operation === "disconnect") {
return "reconnect";
}
if (operation === "reconnect") {
return "disconnect";
}
return "synchronize";
}
function assertProjectDeviceSyncRowSnapshot(snapshot: unknown) {
if (!isPlainObject(snapshot)) {
throw new Error(
"Project-device sync row snapshot must be an object."
);
}
const snapshotKeys = Object.keys(snapshot);
if (
snapshotKeys.length !== projectDeviceSyncRowSnapshotFields.length ||
projectDeviceSyncRowSnapshotFields.some(
(field) =>
!Object.prototype.hasOwnProperty.call(snapshot, field)
)
) {
throw new Error(
"Project-device sync row snapshot must contain every sync field."
);
}
assertCircuitDeviceRowUpdateProjectCommand({
schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion,
type: circuitDeviceRowUpdateCommandType,
payload: {
rowId: "__sync_snapshot_validation__",
changes: projectDeviceSyncRowSnapshotFields.map((field) => ({
field,
value: snapshot[field],
})),
},
});
}
function assertOperationTransition(
operation: ProjectDeviceRowSyncOperation,
projectDeviceId: string,
expected: ProjectDeviceSyncRowSnapshot,
target: ProjectDeviceSyncRowSnapshot
) {
if (operation === "synchronize") {
if (
expected.linkedProjectDeviceId !== projectDeviceId ||
target.linkedProjectDeviceId !== projectDeviceId
) {
throw new Error(
"Synchronized rows must remain linked to the selected project device."
);
}
return;
}
const expectedLink =
operation === "disconnect" ? projectDeviceId : null;
const targetLink =
operation === "disconnect" ? null : projectDeviceId;
if (
expected.linkedProjectDeviceId !== expectedLink ||
target.linkedProjectDeviceId !== targetLink
) {
throw new Error(
"Project-device link transition does not match the operation."
);
}
if (!snapshotsEqualWithoutLink(expected, target)) {
throw new Error(
"Disconnect and reconnect must not change local row values."
);
}
}
function snapshotsEqual(
left: ProjectDeviceSyncRowSnapshot,
right: ProjectDeviceSyncRowSnapshot
) {
return projectDeviceSyncRowSnapshotFields.every(
(field) => left[field] === right[field]
);
}
function snapshotsEqualWithoutLink(
left: ProjectDeviceSyncRowSnapshot,
right: ProjectDeviceSyncRowSnapshot
) {
return projectDeviceSyncRowSnapshotFields
.filter((field) => field !== "linkedProjectDeviceId")
.every((field) => left[field] === right[field]);
}
function assertNonEmptyString(
value: unknown,
field: string
): asserts value is 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 { ProjectDeviceRowSyncProjectCommand } from "../models/project-device-row-sync-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectDeviceRowSyncCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectDeviceRowSyncProjectCommand;
}
export interface ExecutedProjectDeviceRowSyncCommand {
revision: AppendedProjectRevision;
inverse: ProjectDeviceRowSyncProjectCommand;
}
export interface ProjectDeviceRowSyncProjectCommandStore {
execute(
input: ExecuteProjectDeviceRowSyncCommandInput
): ExecutedProjectDeviceRowSyncCommand;
}
@@ -37,6 +37,10 @@ import {
assertSerializedProjectCommand, assertSerializedProjectCommand,
type SerializedProjectCommand, type SerializedProjectCommand,
} from "../models/project-command.model.js"; } from "../models/project-command.model.js";
import {
assertProjectDeviceRowSyncProjectCommand,
projectDeviceRowSyncCommandType,
} from "../models/project-device-row-sync-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";
@@ -54,6 +58,7 @@ import type {
ProjectHistoryDirection, ProjectHistoryDirection,
ProjectHistoryStore, ProjectHistoryStore,
} from "../ports/project-history.store.js"; } from "../ports/project-history.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-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 {
@@ -75,6 +80,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 projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly historyStore: ProjectHistoryStore private readonly historyStore: ProjectHistoryStore
) {} ) {}
@@ -212,6 +218,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case projectDeviceRowSyncCommandType: {
assertProjectDeviceRowSyncProjectCommand(input.command);
return this.projectDeviceRowSyncStore.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}.`
@@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-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);
@@ -22,6 +23,8 @@ export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db); new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore = export const circuitSectionRenumberProjectCommandStore =
new CircuitSectionRenumberProjectCommandRepository(db); new CircuitSectionRenumberProjectCommandRepository(db);
export const projectDeviceRowSyncProjectCommandStore =
new ProjectDeviceRowSyncProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService( export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore, circuitProjectCommandStore,
@@ -31,5 +34,6 @@ export const projectCommandService = new ProjectCommandService(
circuitStructureProjectCommandStore, circuitStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore, circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore, circuitSectionRenumberProjectCommandStore,
projectDeviceRowSyncProjectCommandStore,
projectHistoryStore projectHistoryStore
); );
+102
View File
@@ -37,6 +37,11 @@ import {
assertCircuitSectionRenumberProjectCommand, assertCircuitSectionRenumberProjectCommand,
createCircuitSectionRenumberProjectCommand, createCircuitSectionRenumberProjectCommand,
} from "../src/domain/models/circuit-section-renumber-project-command.model.js"; } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
import {
assertProjectDeviceRowSyncProjectCommand,
createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
} from "../src/domain/models/project-device-row-sync-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", () => {
@@ -191,6 +196,103 @@ describe("circuit device-row update project commands", () => {
}); });
}); });
describe("project-device row sync project commands", () => {
const linkedSnapshot: ProjectDeviceSyncRowSnapshot = {
linkedProjectDeviceId: "project-device-1",
name: "Leuchte",
displayName: "Lokaler Name",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 0.9,
remark: null,
overriddenFields: '["displayName"]',
};
it("captures complete expected and target snapshots for synchronization", () => {
const command = createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: {
...linkedSnapshot,
displayName: "Projektgerät",
overriddenFields: null,
},
},
]
);
assert.doesNotThrow(() =>
assertProjectDeviceRowSyncProjectCommand(command)
);
assert.equal(
command.payload.rows[0]?.target.displayName,
"Projektgerät"
);
});
it("permits link-only disconnects and rejects invalid sync transitions", () => {
assert.doesNotThrow(() =>
createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: {
...linkedSnapshot,
linkedProjectDeviceId: null,
},
},
]
)
);
assert.throws(
() =>
createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: {
...linkedSnapshot,
linkedProjectDeviceId: null,
quantity: 2,
},
},
]
),
/must not change local row values/
);
assert.throws(
() =>
createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: linkedSnapshot,
},
]
),
/no-op row/
);
});
});
describe("circuit device-row structure project commands", () => { describe("circuit device-row structure project commands", () => {
const row = { const row = {
id: "row-1", id: "row-1",
+89
View File
@@ -16,10 +16,12 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/reposi
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-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";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
@@ -34,6 +36,7 @@ import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js"; import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js"; import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
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 { 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 {
@@ -90,6 +93,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 ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db) new ProjectHistoryRepository(context.db)
); );
} }
@@ -177,6 +181,91 @@ describe("project command service", () => {
} }
}); });
it("dispatches project-device row synchronization and its inverse", () => {
const context = createTestDatabase();
try {
context.db
.insert(projectDevices)
.values({
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
})
.run();
context.db
.update(circuitDeviceRows)
.set({
linkedProjectDeviceId: "project-device-1",
overriddenFields: '["displayName"]',
})
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
const expected = {
linkedProjectDeviceId: "project-device-1",
name: "Leuchte",
displayName: "Leuchte",
phaseType: null,
connectionKind: null,
costGroup: null,
category: null,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: '["displayName"]',
};
const service = createService(context);
const synchronized = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected,
target: {
...expected,
name: "Luminaire",
displayName: "Office lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
overriddenFields: null,
},
},
]
),
});
assert.equal(synchronized.history.undoDepth, 1);
assert.equal(getRowQuantity(context), 4);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.redoDepth, 1);
assert.equal(getRowQuantity(context), 1);
assert.equal(
context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.overriddenFields,
'["displayName"]'
);
} 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,507 @@
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 { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-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 {
createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
} from "../src/domain/models/project-device-row-sync-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
}
function createTestDatabase(): TestFixture {
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",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
},
{
id: "project-device-foreign",
projectId: "project-2",
name: "Foreign device",
displayName: "Foreign device",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.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: "Old luminaire",
displayName: "Local row 1",
phaseType: "single_phase",
category: "lighting",
quantity: 1,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName","quantity"]',
},
{
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 20,
name: "Old luminaire",
displayName: "Local row 2",
phaseType: "single_phase",
category: "lighting",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
overriddenFields: '["displayName"]',
},
{
id: "row-foreign",
circuitId: "circuit-foreign",
linkedProjectDeviceId: "project-device-foreign",
sortOrder: 10,
name: "Foreign device",
displayName: "Foreign device",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
])
.run();
return { context };
}
function getRow(context: DatabaseContext, rowId: string) {
const row = context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.get();
assert.ok(row);
return row;
}
function snapshot(
context: DatabaseContext,
rowId: string
): ProjectDeviceSyncRowSnapshot {
const row = getRow(context, rowId);
return {
linkedProjectDeviceId: row.linkedProjectDeviceId,
name: row.name,
displayName: row.displayName,
phaseType: row.phaseType,
connectionKind: row.connectionKind,
costGroup: row.costGroup,
category: row.category,
quantity: row.quantity,
powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark,
overriddenFields: row.overriddenFields,
};
}
function syncCommand(context: DatabaseContext) {
return createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
["row-1", "row-2"].map((rowId) => {
const expected = snapshot(context, rowId);
return {
rowId,
expected,
target: {
...expected,
name: "Luminaire",
displayName: "Office lighting",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
overriddenFields: null,
},
};
})
);
}
function listOwnRows(context: DatabaseContext) {
return context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, "circuit-1"))
.orderBy(asc(circuitDeviceRows.id))
.all();
}
describe("project-device row sync project-command repository", () => {
it("synchronizes multiple rows and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const command = syncCommand(fixture.context);
const synchronized = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
row.overriddenFields,
row.sortOrder,
]),
[
["Office lighting", 4, null, 10],
["Office lighting", 4, null, 20],
]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
synchronized.revision.changeSetId,
command: synchronized.inverse,
});
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
row.overriddenFields,
]),
[
["Local row 1", 1, '["displayName","quantity"]'],
["Local row 2", 2, '["displayName"]'],
]
);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
synchronized.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: synchronized.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("disconnects and reconnects rows without changing local values", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const before = listOwnRows(fixture.context);
const command = createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
["row-1", "row-2"].map((rowId) => {
const expected = snapshot(fixture.context, rowId);
return {
rowId,
expected,
target: {
...expected,
linkedProjectDeviceId: null,
},
};
})
);
const disconnected = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.linkedProjectDeviceId,
row.displayName,
row.quantity,
]),
before.map((row) => [null, row.displayName, row.quantity])
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
disconnected.revision.changeSetId,
command: disconnected.inverse,
});
assert.deepEqual(
listOwnRows(fixture.context).map(
(row) => row.linkedProjectDeviceId
),
["project-device-1", "project-device-1"]
);
} finally {
fixture.context.close();
}
});
it("rejects stale snapshots and cross-project device or row access", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
const staleCommand = syncCommand(fixture.context);
fixture.context.db
.update(circuitDeviceRows)
.set({ displayName: "Changed elsewhere" })
.where(eq(circuitDeviceRows.id, "row-1"))
.run();
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: staleCommand,
}),
/changed before sync execution/
);
const expected = snapshot(fixture.context, "row-foreign");
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-foreign",
"disconnect",
[
{
rowId: "row-foreign",
expected,
target: {
...expected,
linkedProjectDeviceId: null,
},
},
]
),
}),
/does not belong to project/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
[
{
rowId: "row-foreign",
expected: {
...expected,
linkedProjectDeviceId: "project-device-1",
},
target: {
...expected,
linkedProjectDeviceId: null,
},
},
]
),
}),
/rows do not belong to project/
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back every row and revision after a late history failure", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_sync_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced sync history failure');
END;
`);
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: syncCommand(fixture.context),
}),
/forced sync history failure/
);
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
]),
[
["Local row 1", 1],
["Local row 2", 2],
]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back synchronized rows for a stale project revision", () => {
const fixture = createTestDatabase();
try {
const store = new ProjectDeviceRowSyncProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: syncCommand(fixture.context),
}),
/at revision 0, expected 1/
);
assert.deepEqual(
listOwnRows(fixture.context).map((row) => [
row.displayName,
row.quantity,
]),
[
["Local row 1", 1],
["Local row 2", 2],
]
);
} finally {
fixture.context.close();
}
});
});