From 2668fc2f16f25fb9156fbe7aedef0a405021a5a2 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 24 Jul 2026 08:47:25 +0200 Subject: [PATCH] Add project device sync history --- AGENTS.md | 4 +- docs/circuit-list-editor-api.md | 29 +- docs/current-architecture.md | 11 +- ...history-and-external-model-architecture.md | 13 +- docs/spec/07-implementation-phases-todo.md | 3 + package.json | 4 +- ...ice-row-sync-project-command.repository.ts | 151 ++++++ ...t-device-row-sync-project-command.model.ts | 245 +++++++++ ...t-device-row-sync-project-command.store.ts | 26 + .../services/project-command.service.ts | 13 + .../composition/project-command-stores.ts | 4 + tests/project-command.model.test.ts | 102 ++++ tests/project-command.service.test.ts | 89 +++ ...ow-sync-project-command.repository.test.ts | 507 ++++++++++++++++++ 14 files changed, 1180 insertions(+), 21 deletions(-) create mode 100644 src/db/repositories/project-device-row-sync-project-command.repository.ts create mode 100644 src/domain/models/project-device-row-sync-project-command.model.ts create mode 100644 src/domain/ports/project-device-row-sync-project-command.store.ts create mode 100644 tests/project-device-row-sync-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index d71cd84..472fda4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 without changing equipment identifiers. Explicit complete-section renumbering 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: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 4df579c..cef4241 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -25,17 +25,17 @@ concurrency checks. The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, -`circuit-device-row.insert`, `circuit-device-row.delete` and -`circuit-device-row.move` plus -`circuit-device-row.move-with-new-circuit` and `circuit.reorder-section`, all -with `circuit.renumber-section`, all with schema version `1`. Other command -types are rejected. Insert commands contain the complete entity or circuit -block with stable ids; delete commands include the expected parent identity. -Circuit snapshots contain zero, one or multiple complete device rows. Move -commands contain each row's expected and target circuit plus its exact expected -and target sort order. This makes deletion and moves undoable without -generating replacement identities or renumbering circuits. The editor does not -consume these endpoints yet. +`circuit-device-row.insert`, `circuit-device-row.delete`, +`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`, +`circuit.reorder-section`, `circuit.renumber-section` and +`project-device.sync-rows`, all with schema version `1`. Other command types +are rejected. Insert commands contain the complete entity or circuit block +with stable ids; delete commands include the expected parent identity. Circuit +snapshots contain zero, one or multiple complete device rows. Move commands +contain each row's expected and target circuit plus its exact expected and +target sort order. This makes deletion and moves undoable without generating +replacement identities or renumbering circuits. The editor does not consume +these endpoints yet. `circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move-with-new-circuit` atomically creates exactly one @@ -56,6 +56,13 @@ targets occupied by other sections, then applies swaps through collision-safe temporary identifiers. Undo restores the exact prior identifiers; sort 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: ```json diff --git a/docs/current-architecture.md b/docs/current-architecture.md index fe58dc7..04d8db2 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -102,8 +102,15 @@ jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo bleiben unverändert. `circuit.renumber-section` bildet die getrennte, ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und -ändert weder Sortierung noch Gerätezeilen. Synchronisierung muss vor der -Frontend-Umstellung noch als persistentes Command modelliert werden. +ändert weder Sortierung noch Gerätezeilen. +`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 diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index bdc2b53..0dd20f4 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -192,18 +192,21 @@ Completed foundation: - `circuit.renumber-section` is the separate explicit renumber operation; it stores every expected/target equipment identifier, uses collision-safe 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: - extend the concrete project-scoped command union and executors beyond the Circuit field/insert/delete and CircuitDeviceRow - field/insert/delete/move plus section-reorder/renumber slices; the generic - versioned command envelope is complete -- route the remaining structural, synchronization and destructive domain + field/insert/delete/move, section-reorder/renumber and ProjectDevice row-sync + slices; the generic versioned command envelope is complete +- route the remaining structural and destructive domain writes through the shared command, revision and stack transaction boundary - replace the session-local frontend command stack only after server-side - command coverage is complete for structural, destructive and synchronization - operations + command coverage is complete for structural and destructive operations; then + route the existing synchronization UI through its persistent command - do not model IFCGUID as an overloaded circuit equipment identifier - keep database backup/restore checks separate from project history tests diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index e7c1a1f..5ec800a 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -406,6 +406,9 @@ Implemented foundation: - explicit section renumber commands persist every expected and target equipment identifier, apply swaps collision-safely and restore exact prior 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 in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index be90451..cf70e9b 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/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": "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/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/project-device-row-sync-project-command.repository.ts b/src/db/repositories/project-device-row-sync-project-command.repository.ts new file mode 100644 index 0000000..e38a45f --- /dev/null +++ b/src/db/repositories/project-device-row-sync-project-command.repository.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] + ); +} diff --git a/src/domain/models/project-device-row-sync-project-command.model.ts b/src/domain/models/project-device-row-sync-project-command.model.ts new file mode 100644 index 0000000..ac34121 --- /dev/null +++ b/src/domain/models/project-device-row-sync-project-command.model.ts @@ -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 { + 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 +): 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(); + 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 { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/project-device-row-sync-project-command.store.ts b/src/domain/ports/project-device-row-sync-project-command.store.ts new file mode 100644 index 0000000..0697a6f --- /dev/null +++ b/src/domain/ports/project-device-row-sync-project-command.store.ts @@ -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; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index ee0f7a5..ca30ce3 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -37,6 +37,10 @@ import { assertSerializedProjectCommand, type SerializedProjectCommand, } 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 { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js"; import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js"; @@ -54,6 +58,7 @@ import type { ProjectHistoryDirection, ProjectHistoryStore, } 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"; interface DispatchProjectCommandInput { @@ -75,6 +80,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore, + private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -212,6 +218,13 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case projectDeviceRowSyncCommandType: { + assertProjectDeviceRowSyncProjectCommand(input.command); + return this.projectDeviceRowSyncStore.execute({ + ...input, + command: input.command, + }).revision; + } default: throw new Error( `Unsupported project command type: ${input.command.type}.` diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 24fd1db..0373bb3 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-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 { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); @@ -22,6 +23,8 @@ export const circuitSectionReorderProjectCommandStore = new CircuitSectionReorderProjectCommandRepository(db); export const circuitSectionRenumberProjectCommandStore = new CircuitSectionRenumberProjectCommandRepository(db); +export const projectDeviceRowSyncProjectCommandStore = + new ProjectDeviceRowSyncProjectCommandRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, @@ -31,5 +34,6 @@ export const projectCommandService = new ProjectCommandService( circuitStructureProjectCommandStore, circuitSectionReorderProjectCommandStore, circuitSectionRenumberProjectCommandStore, + projectDeviceRowSyncProjectCommandStore, projectHistoryStore ); diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index 0d65373..236b2ba 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -37,6 +37,11 @@ import { assertCircuitSectionRenumberProjectCommand, createCircuitSectionRenumberProjectCommand, } 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", () => { 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", () => { const row = { id: "row-1", diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index dbe5d6e..d4dfce6 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -16,10 +16,12 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/reposi import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.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 { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; +import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { 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 { 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 { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; function createTestDatabase(): DatabaseContext { @@ -90,6 +93,7 @@ function createService(context: DatabaseContext) { new CircuitStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionRenumberProjectCommandRepository(context.db), + new ProjectDeviceRowSyncProjectCommandRepository(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", () => { const context = createTestDatabase(); try { diff --git a/tests/project-device-row-sync-project-command.repository.test.ts b/tests/project-device-row-sync-project-command.repository.test.ts new file mode 100644 index 0000000..de21da7 --- /dev/null +++ b/tests/project-device-row-sync-project-command.repository.test.ts @@ -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(); + } + }); +});