Isolate project device sync transactions

This commit is contained in:
2026-07-23 18:57:18 +02:00
parent cf356bbedc
commit 91baa6c76c
10 changed files with 414 additions and 126 deletions
@@ -331,7 +331,9 @@ Implemented foundation:
- circuit-device-row creation/deletion and reserve-state changes use an explicitly injected transaction store - circuit-device-row creation/deletion and reserve-state changes use an explicitly injected transaction store
- a new circuit and all of its initial device rows use the same transaction store - a new circuit and all of its initial device rows use the same transaction store
- device-row moves, reserve-state updates and optional target creation share one transaction - device-row moves, reserve-state updates and optional target creation share one transaction
- project-device synchronization, disconnect and reconnect use a separate injected transaction store
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks - circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
- circuit and device-row persistence value mapping is separated from the general repositories - circuit and device-row persistence value mapping is separated from the general repositories
## Phase 12: Project Revisions and Persistent Undo / Redo ## Phase 12: Project Revisions and Persistent Undo / Redo
+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/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.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", "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.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",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.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", "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.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",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js", "db:backup": "node scripts/db-backup.js",
@@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "../client.js"; import { db } from "../client.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
@@ -125,64 +125,6 @@ export class CircuitDeviceRowRepository {
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId)); await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
} }
updateLinkedRowsTransactional(
projectDeviceId: string,
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
) {
db.transaction((tx) => {
for (const change of changes) {
const result = tx
.update(circuitDeviceRows)
.set(toCircuitDeviceRowUpdateValues(change.input))
.where(
and(
eq(circuitDeviceRows.id, change.rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("A linked row changed before the operation could be completed.");
}
}
});
}
disconnectLinkedRowsTransactional(projectDeviceId: string, rowIds: string[]) {
db.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: null })
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("A linked row changed before the operation could be completed.");
}
}
});
}
reconnectRowsTransactional(projectDeviceId: string, rowIds: string[]) {
db.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: projectDeviceId })
.where(and(eq(circuitDeviceRows.id, rowId), isNull(circuitDeviceRows.linkedProjectDeviceId)))
.run();
if (result.changes !== 1) {
throw new Error("A disconnected row changed before the operation could be undone.");
}
}
});
}
async delete(rowId: string) { async delete(rowId: string) {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)); await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
} }
@@ -0,0 +1,81 @@
import { and, eq, isNull } from "drizzle-orm";
import type {
ProjectDeviceLinkedRowValues,
ProjectDeviceRowSyncStore,
} from "../../domain/ports/project-device-row-sync.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { toCircuitDeviceRowUpdateValues } from "./circuit-device-row.persistence.js";
export class ProjectDeviceRowSyncRepository implements ProjectDeviceRowSyncStore {
constructor(private readonly database: AppDatabase) {}
updateLinkedRows(
projectDeviceId: string,
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
) {
this.database.transaction((tx) => {
for (const change of changes) {
const result = tx
.update(circuitDeviceRows)
.set(toCircuitDeviceRowUpdateValues(change.input))
.where(
and(
eq(circuitDeviceRows.id, change.rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"A linked row changed before the operation could be completed."
);
}
}
});
}
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]) {
this.database.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: null })
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"A linked row changed before the operation could be completed."
);
}
}
});
}
reconnectRows(projectDeviceId: string, rowIds: string[]) {
this.database.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: projectDeviceId })
.where(
and(
eq(circuitDeviceRows.id, rowId),
isNull(circuitDeviceRows.linkedProjectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"A disconnected row changed before the operation could be undone."
);
}
}
});
}
}
@@ -0,0 +1,28 @@
export interface ProjectDeviceLinkedRowValues {
linkedProjectDeviceId?: string;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}
export interface ProjectDeviceRowSyncStore {
updateLinkedRows(
projectDeviceId: string,
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
): void;
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]): void;
reconnectRows(projectDeviceId: string, rowIds: string[]): void;
}
@@ -1,5 +1,6 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import type { ProjectDeviceRowSyncStore } from "../ports/project-device-row-sync.store.js";
import { import {
projectDeviceSyncFields, projectDeviceSyncFields,
type ProjectDeviceSyncField, type ProjectDeviceSyncField,
@@ -14,10 +15,8 @@ type SyncDependencies = {
CircuitDeviceRowRepository, CircuitDeviceRowRepository,
| "listLinkedByProjectDevice" | "listLinkedByProjectDevice"
| "listLinkStatesByIds" | "listLinkStatesByIds"
| "updateLinkedRowsTransactional"
| "disconnectLinkedRowsTransactional"
| "reconnectRowsTransactional"
>; >;
deviceRowSyncStore: ProjectDeviceRowSyncStore;
}; };
export interface ProjectDeviceSyncRestoreRow { export interface ProjectDeviceSyncRestoreRow {
@@ -71,10 +70,19 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
export class ProjectDeviceSyncService { export class ProjectDeviceSyncService {
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"]; private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"]; private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
private readonly deviceRowSyncStore?: SyncDependencies["deviceRowSyncStore"];
constructor(deps?: Partial<SyncDependencies>) { constructor(deps?: Partial<SyncDependencies>) {
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository(); this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
this.deviceRowSyncStore = deps?.deviceRowSyncStore;
}
private getDeviceRowSyncStore() {
if (!this.deviceRowSyncStore) {
throw new Error("Project-device row synchronization is not configured.");
}
return this.deviceRowSyncStore;
} }
async getPreview(projectId: string, projectDeviceId: string) { async getPreview(projectId: string, projectDeviceId: string) {
@@ -137,7 +145,7 @@ export class ProjectDeviceSyncService {
next.overriddenFields = serializeOverriddenFields(remainingOverrides); next.overriddenFields = serializeOverriddenFields(remainingOverrides);
changes.push({ rowId: row.id, input: next }); changes.push({ rowId: row.id, input: next });
} }
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes); this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
return { return {
preview: await this.getPreview(projectId, projectDeviceId), preview: await this.getPreview(projectId, projectDeviceId),
@@ -150,7 +158,10 @@ export class ProjectDeviceSyncService {
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds); const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const disconnectedRowIds = selectedRows.map((row) => row.id); const disconnectedRowIds = selectedRows.map((row) => row.id);
this.deviceRowRepository.disconnectLinkedRowsTransactional(projectDeviceId, disconnectedRowIds); this.getDeviceRowSyncStore().disconnectLinkedRows(
projectDeviceId,
disconnectedRowIds
);
return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } }; return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
} }
@@ -175,7 +186,7 @@ export class ProjectDeviceSyncService {
return { rowId: row.id, input: next }; return { rowId: row.id, input: next };
}); });
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes); this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
return this.getPreview(projectId, projectDeviceId); return this.getPreview(projectId, projectDeviceId);
} }
@@ -189,7 +200,7 @@ export class ProjectDeviceSyncService {
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) { if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
throw new Error("One or more rows cannot be reconnected."); throw new Error("One or more rows cannot be reconnected.");
} }
this.deviceRowRepository.reconnectRowsTransactional(projectDeviceId, uniqueRowIds); this.getDeviceRowSyncStore().reconnectRows(projectDeviceId, uniqueRowIds);
return this.getPreview(projectId, projectDeviceId); return this.getPreview(projectId, projectDeviceId);
} }
@@ -0,0 +1,7 @@
import { db } from "../../db/client.js";
import { ProjectDeviceRowSyncRepository } from "../../db/repositories/project-device-row-sync.repository.js";
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
export const projectDeviceSyncService = new ProjectDeviceSyncService({
deviceRowSyncStore: new ProjectDeviceRowSyncRepository(db),
});
@@ -1,7 +1,7 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js"; import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
import { import {
createProjectDeviceSchema, createProjectDeviceSchema,
disconnectProjectDeviceRowsSchema, disconnectProjectDeviceRowsSchema,
@@ -13,8 +13,6 @@ import {
const globalDeviceRepository = new GlobalDeviceRepository(); const globalDeviceRepository = new GlobalDeviceRepository();
const projectDeviceRepository = new ProjectDeviceRepository(); const projectDeviceRepository = new ProjectDeviceRepository();
const projectDeviceSyncService = new ProjectDeviceSyncService();
export async function listProjectDevicesByProject(req: Request, res: Response) { export async function listProjectDevicesByProject(req: Request, res: Response) {
const { projectId } = req.params; const { projectId } = req.params;
if (typeof projectId !== "string") { if (typeof projectId !== "string") {
@@ -0,0 +1,269 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectDeviceRowSyncRepository } from "../src/db/repositories/project-device-row-sync.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 { projects } from "../src/db/schema/projects.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const [section] = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.limit(1)
.all();
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Beleuchtung",
sortOrder: 10,
isReserve: 0,
})
.run();
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,
installedPowerPerUnitKw: 0.04,
demandFactor: 0.8,
})
.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",
quantity: 1,
powerPerUnit: 0.03,
simultaneityFactor: 1,
},
{
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 20,
name: "Old luminaire",
displayName: "Local row 2",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
},
])
.run();
return context;
}
function synchronizedValues(displayName: string) {
return {
linkedProjectDeviceId: "project-device-1",
name: "Luminaire",
displayName,
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
};
}
function listRows(context: DatabaseContext) {
return context.db
.select()
.from(circuitDeviceRows)
.all()
.sort((left, right) => left.id.localeCompare(right.id));
}
describe("project-device row sync repository", () => {
it("commits synchronized values for all selected linked rows", () => {
const context = createTestDatabase();
try {
const repository = new ProjectDeviceRowSyncRepository(context.db);
repository.updateLinkedRows("project-device-1", [
{ rowId: "row-1", input: synchronizedValues("Synced row 1") },
{ rowId: "row-2", input: synchronizedValues("Synced row 2") },
]);
assert.deepEqual(
listRows(context).map((row) => [
row.displayName,
row.quantity,
row.powerPerUnit,
row.simultaneityFactor,
]),
[
["Synced row 1", 4, 0.04, 0.8],
["Synced row 2", 4, 0.04, 0.8],
]
);
} finally {
context.close();
}
});
it("rolls back earlier synchronized values when a later row update fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_second_sync_update
BEFORE UPDATE ON circuit_device_rows
WHEN OLD.id = 'row-2' AND NEW.display_name = 'Synced'
BEGIN
SELECT RAISE(ABORT, 'forced sync failure');
END;
`);
const repository = new ProjectDeviceRowSyncRepository(context.db);
assert.throws(
() =>
repository.updateLinkedRows("project-device-1", [
{ rowId: "row-1", input: synchronizedValues("Synced") },
{ rowId: "row-2", input: synchronizedValues("Synced") },
]),
/forced sync failure/
);
assert.deepEqual(
listRows(context).map((row) => [row.displayName, row.quantity]),
[
["Local row 1", 1],
["Local row 2", 2],
]
);
} finally {
context.close();
}
});
it("disconnects all selected rows without changing local values", () => {
const context = createTestDatabase();
try {
const repository = new ProjectDeviceRowSyncRepository(context.db);
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]);
assert.deepEqual(
listRows(context).map((row) => [
row.linkedProjectDeviceId,
row.displayName,
row.quantity,
]),
[
[null, "Local row 1", 1],
[null, "Local row 2", 2],
]
);
} finally {
context.close();
}
});
it("rolls back earlier disconnects when a later row update fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_second_disconnect
BEFORE UPDATE ON circuit_device_rows
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id IS NULL
BEGIN
SELECT RAISE(ABORT, 'forced disconnect failure');
END;
`);
const repository = new ProjectDeviceRowSyncRepository(context.db);
assert.throws(
() =>
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]),
/forced disconnect failure/
);
assert.deepEqual(
listRows(context).map((row) => row.linkedProjectDeviceId),
["project-device-1", "project-device-1"]
);
} finally {
context.close();
}
});
it("reconnects all selected disconnected rows", () => {
const context = createTestDatabase();
try {
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
const repository = new ProjectDeviceRowSyncRepository(context.db);
repository.reconnectRows("project-device-1", ["row-1", "row-2"]);
assert.deepEqual(
listRows(context).map((row) => row.linkedProjectDeviceId),
["project-device-1", "project-device-1"]
);
} finally {
context.close();
}
});
it("rolls back earlier reconnects when a later row update fails", () => {
const context = createTestDatabase();
try {
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
context.sqlite.exec(`
CREATE TRIGGER fail_second_reconnect
BEFORE UPDATE ON circuit_device_rows
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id = 'project-device-1'
BEGIN
SELECT RAISE(ABORT, 'forced reconnect failure');
END;
`);
const repository = new ProjectDeviceRowSyncRepository(context.db);
assert.throws(
() => repository.reconnectRows("project-device-1", ["row-1", "row-2"]),
/forced reconnect failure/
);
assert.deepEqual(
listRows(context).map((row) => row.linkedProjectDeviceId),
[null, null]
);
} finally {
context.close();
}
});
});
+5 -55
View File
@@ -1,8 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js"; import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js";
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
import { db } from "../src/db/client.js";
function projectDevice() { function projectDevice() {
return { return {
@@ -72,14 +70,16 @@ function createService() {
async listLinkStatesByIds() { async listLinkStatesByIds() {
return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never; return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never;
}, },
updateLinkedRowsTransactional(_projectDeviceId, changes) { } as never,
deviceRowSyncStore: {
updateLinkedRows(_projectDeviceId, changes) {
transactionCalls += 1; transactionCalls += 1;
for (const change of changes) { for (const change of changes) {
updates.push({ rowId: change.rowId, ...change.input }); updates.push({ rowId: change.rowId, ...change.input });
Object.assign(rows.find((row) => row.id === change.rowId)!, change.input); Object.assign(rows.find((row) => row.id === change.rowId)!, change.input);
} }
}, },
disconnectLinkedRowsTransactional(_projectDeviceId, rowIds) { disconnectLinkedRows(_projectDeviceId, rowIds) {
transactionCalls += 1; transactionCalls += 1;
for (const rowId of rowIds) { for (const rowId of rowIds) {
const row = rows.find((entry) => entry.id === rowId)!; const row = rows.find((entry) => entry.id === rowId)!;
@@ -87,7 +87,7 @@ function createService() {
Object.assign(row, { linkedProjectDeviceId: null }); Object.assign(row, { linkedProjectDeviceId: null });
} }
}, },
reconnectRowsTransactional(projectDeviceId, rowIds) { reconnectRows(projectDeviceId, rowIds) {
transactionCalls += 1; transactionCalls += 1;
for (const rowId of rowIds) { for (const rowId of rowIds) {
Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId }); Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId });
@@ -160,54 +160,4 @@ describe("project device synchronization", () => {
assert.equal(rows[0].linkedProjectDeviceId, "pd1"); assert.equal(rows[0].linkedProjectDeviceId, "pd1");
}); });
it("executes multi-row updates inside one synchronous transaction", () => {
const repository = new CircuitDeviceRowRepository();
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
let transactionCalls = 0;
let updateCalls = 0;
let returnedPromise = false;
(db as unknown as { transaction: (callback: (tx: unknown) => unknown) => void }).transaction = (callback) => {
transactionCalls += 1;
const fakeTx = {
update() {
return {
set() {
return {
where() {
return {
run() {
updateCalls += 1;
return { changes: 1 };
},
};
},
};
},
};
},
};
const result = callback(fakeTx);
returnedPromise = Boolean(result && typeof (result as Promise<unknown>).then === "function");
};
try {
const input = {
linkedProjectDeviceId: "pd1",
name: "Luminaire",
displayName: "Office lighting",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
};
repository.updateLinkedRowsTransactional("pd1", [
{ rowId: "r1", input },
{ rowId: "r2", input },
]);
assert.equal(transactionCalls, 1);
assert.equal(updateCalls, 2);
assert.equal(returnedPromise, false);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
}); });