Make device sync atomic and undoable

This commit is contained in:
2026-07-22 19:47:10 +02:00
parent 6f8b292b6b
commit b2f5ce77a5
11 changed files with 481 additions and 65 deletions
@@ -10,9 +10,22 @@ type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProj
type SyncDependencies = {
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice" | "update">;
deviceRowRepository: Pick<
CircuitDeviceRowRepository,
| "listLinkedByProjectDevice"
| "listLinkStatesByIds"
| "updateLinkedRowsTransactional"
| "disconnectLinkedRowsTransactional"
| "reconnectRowsTransactional"
>;
};
export interface ProjectDeviceSyncRestoreRow {
rowId: string;
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
overriddenFields: ProjectDeviceSyncField[];
}
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
if (!value) {
return [];
@@ -106,7 +119,13 @@ export class ProjectDeviceSyncService {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const selectedFields = [...new Set(fields)];
const undoRows: ProjectDeviceSyncRestoreRow[] = selectedRows.map((row) => ({
rowId: row.id,
values: Object.fromEntries(selectedFields.map((field) => [field, row[field] ?? null])),
overriddenFields: parseOverriddenFields(row.overriddenFields),
}));
const changes = [];
for (const row of selectedRows) {
const next = this.copyRow(row);
for (const field of selectedFields) {
@@ -116,24 +135,62 @@ export class ProjectDeviceSyncService {
(field) => !selectedFields.includes(field)
);
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
await this.deviceRowRepository.update(row.id, next);
changes.push({ rowId: row.id, input: next });
}
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
return this.getPreview(projectId, projectDeviceId);
return {
preview: await this.getPreview(projectId, projectDeviceId),
undo: { rows: undoRows },
};
}
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const disconnectedRowIds = selectedRows.map((row) => row.id);
for (const row of selectedRows) {
await this.deviceRowRepository.update(row.id, {
...this.copyRow(row),
linkedProjectDeviceId: undefined,
});
this.deviceRowRepository.disconnectLinkedRowsTransactional(projectDeviceId, disconnectedRowIds);
return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
}
async restore(
projectId: string,
projectDeviceId: string,
restoreRows: ProjectDeviceSyncRestoreRow[]
) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, restoreRows.map((row) => row.rowId));
const restoreById = new Map(restoreRows.map((row) => [row.rowId, row]));
const changes = selectedRows.map((row) => {
const restore = restoreById.get(row.id)!;
const next = this.copyRow(row);
for (const [field, value] of Object.entries(restore.values) as Array<
[ProjectDeviceSyncField, string | number | null]
>) {
Object.assign(next, { [field]: value ?? undefined });
}
next.overriddenFields = serializeOverriddenFields(restore.overriddenFields);
return { rowId: row.id, input: next };
});
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
return this.getPreview(projectId, projectDeviceId);
}
async reconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) {
throw new Error("Project device not found.");
}
return { disconnectedRowIds: selectedRows.map((row) => row.id) };
const uniqueRowIds = [...new Set(rowIds)];
const linkStates = await this.deviceRowRepository.listLinkStatesByIds(projectId, uniqueRowIds);
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
throw new Error("One or more rows cannot be reconnected.");
}
this.deviceRowRepository.reconnectRowsTransactional(projectDeviceId, uniqueRowIds);
return this.getPreview(projectId, projectDeviceId);
}
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {