Add atomic device row updates
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitDeviceRowUpdateProjectCommand,
|
||||
createCircuitDeviceRowUpdateProjectCommand,
|
||||
type CircuitDeviceRowUpdateField,
|
||||
type CircuitDeviceRowUpdatePatch,
|
||||
type CircuitDeviceRowUpdateValues,
|
||||
} from "../../domain/models/circuit-device-row-project-command.model.js";
|
||||
import type {
|
||||
CircuitDeviceRowProjectCommandStore,
|
||||
ExecuteCircuitDeviceRowUpdateCommandInput,
|
||||
} from "../../domain/ports/circuit-device-row-project-command.store.js";
|
||||
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.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 { rooms } from "../schema/rooms.js";
|
||||
import {
|
||||
toCircuitDeviceRowPatchValues,
|
||||
type CircuitDeviceRowPatchInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
|
||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||
|
||||
export class CircuitDeviceRowProjectCommandRepository
|
||||
implements CircuitDeviceRowProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
executeUpdate(input: ExecuteCircuitDeviceRowUpdateCommandInput) {
|
||||
assertCircuitDeviceRowUpdateProjectCommand(input.command);
|
||||
|
||||
return this.database.transaction((tx) => {
|
||||
const current = tx
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, input.command.payload.rowId))
|
||||
.get();
|
||||
if (!current) {
|
||||
throw new Error("Invalid device row id.");
|
||||
}
|
||||
|
||||
const circuit = tx
|
||||
.select({ circuitListId: circuits.circuitListId })
|
||||
.from(circuits)
|
||||
.where(eq(circuits.id, current.circuitId))
|
||||
.get();
|
||||
const owningList = circuit
|
||||
? tx
|
||||
.select({ id: circuitLists.id })
|
||||
.from(circuitLists)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitLists.id, circuit.circuitListId),
|
||||
eq(circuitLists.projectId, input.projectId)
|
||||
)
|
||||
)
|
||||
.get()
|
||||
: null;
|
||||
if (!owningList) {
|
||||
throw new Error("Circuit device row does not belong to project.");
|
||||
}
|
||||
|
||||
const patch = Object.fromEntries(
|
||||
input.command.payload.changes.map((change) => [
|
||||
change.field,
|
||||
change.value,
|
||||
])
|
||||
) as CircuitDeviceRowPatchInput;
|
||||
this.assertLinkedProjectDevice(tx, input.projectId, patch);
|
||||
this.assertRoom(tx, input.projectId, patch);
|
||||
|
||||
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
||||
current,
|
||||
patch
|
||||
);
|
||||
if (
|
||||
overriddenFields !== undefined &&
|
||||
patch.overriddenFields === undefined
|
||||
) {
|
||||
patch.overriddenFields = overriddenFields;
|
||||
}
|
||||
|
||||
const appliedForward = createCircuitDeviceRowUpdateProjectCommand(
|
||||
current.id,
|
||||
patch as CircuitDeviceRowUpdatePatch
|
||||
);
|
||||
const inversePatch = Object.fromEntries(
|
||||
appliedForward.payload.changes.map((change) => [
|
||||
change.field,
|
||||
getCircuitDeviceRowFieldValue(current, change.field),
|
||||
])
|
||||
) as CircuitDeviceRowUpdatePatch;
|
||||
const inverse = createCircuitDeviceRowUpdateProjectCommand(
|
||||
current.id,
|
||||
inversePatch
|
||||
);
|
||||
|
||||
const update = tx
|
||||
.update(circuitDeviceRows)
|
||||
.set(toCircuitDeviceRowPatchValues(patch))
|
||||
.where(eq(circuitDeviceRows.id, current.id))
|
||||
.run();
|
||||
if (update.changes !== 1) {
|
||||
throw new Error("Circuit device row changed before command execution.");
|
||||
}
|
||||
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: appliedForward,
|
||||
inverse,
|
||||
});
|
||||
|
||||
return { revision, inverse };
|
||||
});
|
||||
}
|
||||
|
||||
private assertLinkedProjectDevice(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
patch: CircuitDeviceRowPatchInput
|
||||
) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(patch, "linkedProjectDeviceId") ||
|
||||
patch.linkedProjectDeviceId === null ||
|
||||
patch.linkedProjectDeviceId === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const device = database
|
||||
.select({ id: projectDevices.id })
|
||||
.from(projectDevices)
|
||||
.where(
|
||||
and(
|
||||
eq(projectDevices.id, patch.linkedProjectDeviceId),
|
||||
eq(projectDevices.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!device) {
|
||||
throw new Error("Invalid linked project device id.");
|
||||
}
|
||||
}
|
||||
|
||||
private assertRoom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
patch: CircuitDeviceRowPatchInput
|
||||
) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(patch, "roomId") ||
|
||||
patch.roomId === null ||
|
||||
patch.roomId === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const room = database
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId)))
|
||||
.get();
|
||||
if (!room) {
|
||||
throw new Error("Invalid room id.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCircuitDeviceRowFieldValue<
|
||||
TField extends CircuitDeviceRowUpdateField,
|
||||
>(
|
||||
row: CircuitDeviceRow,
|
||||
field: TField
|
||||
): CircuitDeviceRowUpdateValues[TField] {
|
||||
return row[field] as unknown as CircuitDeviceRowUpdateValues[TField];
|
||||
}
|
||||
@@ -20,9 +20,26 @@ export interface CircuitDeviceRowUpdateInput {
|
||||
overriddenFields?: string;
|
||||
}
|
||||
|
||||
export type CircuitDeviceRowPatchInput = Partial<CircuitDeviceRowUpdateInput> & {
|
||||
export interface CircuitDeviceRowPatchInput {
|
||||
linkedProjectDeviceId?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
phaseType?: string | null;
|
||||
connectionKind?: string | null;
|
||||
costGroup?: string | null;
|
||||
category?: string | null;
|
||||
level?: string | null;
|
||||
roomId?: string | null;
|
||||
roomNumberSnapshot?: string | null;
|
||||
roomNameSnapshot?: string | null;
|
||||
quantity?: number;
|
||||
powerPerUnit?: number;
|
||||
simultaneityFactor?: number;
|
||||
cosPhi?: number | null;
|
||||
remark?: string | null;
|
||||
overriddenFields?: string | null;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowCreateInput extends CircuitDeviceRowUpdateInput {
|
||||
circuitId: string;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitDeviceRowUpdateCommandType =
|
||||
"circuit-device-row.update" as const;
|
||||
export const circuitDeviceRowUpdateCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitDeviceRowUpdateValues {
|
||||
linkedProjectDeviceId: string | null;
|
||||
sortOrder: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
phaseType: string | null;
|
||||
connectionKind: string | null;
|
||||
costGroup: string | null;
|
||||
category: string | null;
|
||||
level: string | null;
|
||||
roomId: string | null;
|
||||
roomNumberSnapshot: string | null;
|
||||
roomNameSnapshot: string | null;
|
||||
quantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi: number | null;
|
||||
remark: string | null;
|
||||
overriddenFields: string | null;
|
||||
}
|
||||
|
||||
export type CircuitDeviceRowUpdateField = keyof CircuitDeviceRowUpdateValues;
|
||||
export type CircuitDeviceRowUpdatePatch =
|
||||
Partial<CircuitDeviceRowUpdateValues>;
|
||||
|
||||
export interface CircuitDeviceRowUpdateFieldChange<
|
||||
TField extends CircuitDeviceRowUpdateField = CircuitDeviceRowUpdateField,
|
||||
> {
|
||||
field: TField;
|
||||
value: CircuitDeviceRowUpdateValues[TField];
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowUpdateCommandPayload {
|
||||
rowId: string;
|
||||
changes: CircuitDeviceRowUpdateFieldChange[];
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowUpdateProjectCommand
|
||||
extends SerializedProjectCommand<CircuitDeviceRowUpdateCommandPayload> {
|
||||
schemaVersion: typeof circuitDeviceRowUpdateCommandSchemaVersion;
|
||||
type: typeof circuitDeviceRowUpdateCommandType;
|
||||
}
|
||||
|
||||
export function createCircuitDeviceRowUpdateProjectCommand(
|
||||
rowId: string,
|
||||
patch: CircuitDeviceRowUpdatePatch
|
||||
): CircuitDeviceRowUpdateProjectCommand {
|
||||
const changes = Object.entries(patch).map(([field, value]) => ({
|
||||
field: field as CircuitDeviceRowUpdateField,
|
||||
value,
|
||||
})) as CircuitDeviceRowUpdateFieldChange[];
|
||||
const command: CircuitDeviceRowUpdateProjectCommand = {
|
||||
schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion,
|
||||
type: circuitDeviceRowUpdateCommandType,
|
||||
payload: { rowId, changes },
|
||||
};
|
||||
assertCircuitDeviceRowUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitDeviceRowUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitDeviceRowUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitDeviceRowUpdateCommandSchemaVersion ||
|
||||
command.type !== circuitDeviceRowUpdateCommandType
|
||||
) {
|
||||
throw new Error("Unsupported circuit device-row update command.");
|
||||
}
|
||||
if (!isPlainObject(command.payload)) {
|
||||
throw new Error("Circuit device-row update payload must be an object.");
|
||||
}
|
||||
const rowId = command.payload.rowId;
|
||||
const changes = command.payload.changes;
|
||||
if (typeof rowId !== "string" || !rowId.trim()) {
|
||||
throw new Error("Circuit device-row update command requires a row id.");
|
||||
}
|
||||
if (!Array.isArray(changes) || changes.length === 0) {
|
||||
throw new Error(
|
||||
"Circuit device-row update command requires at least one change."
|
||||
);
|
||||
}
|
||||
|
||||
const seenFields = new Set<string>();
|
||||
for (const change of changes) {
|
||||
if (!isPlainObject(change) || typeof change.field !== "string") {
|
||||
throw new Error(
|
||||
"Circuit device-row update command contains an invalid change."
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isCircuitDeviceRowUpdateField(change.field) ||
|
||||
seenFields.has(change.field)
|
||||
) {
|
||||
throw new Error(
|
||||
"Circuit device-row update command contains an invalid or duplicate field."
|
||||
);
|
||||
}
|
||||
assertCircuitDeviceRowUpdateFieldValue(change.field, change.value);
|
||||
seenFields.add(change.field);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCircuitDeviceRowUpdateFieldValue(
|
||||
field: CircuitDeviceRowUpdateField,
|
||||
value: unknown
|
||||
) {
|
||||
if (field === "name" || field === "displayName") {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${field} must be a non-empty string.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (field === "sortOrder") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error("sortOrder must be a finite number.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
field === "quantity" ||
|
||||
field === "powerPerUnit" ||
|
||||
field === "simultaneityFactor"
|
||||
) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative finite number.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (field === "cosPhi") {
|
||||
if (
|
||||
value !== null &&
|
||||
(typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
||||
) {
|
||||
throw new Error("cosPhi must be a positive finite number or null.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value !== null && typeof value !== "string") {
|
||||
throw new Error(`${field} must be a string or null.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isCircuitDeviceRowUpdateField(
|
||||
value: string
|
||||
): value is CircuitDeviceRowUpdateField {
|
||||
return [
|
||||
"linkedProjectDeviceId",
|
||||
"sortOrder",
|
||||
"name",
|
||||
"displayName",
|
||||
"phaseType",
|
||||
"connectionKind",
|
||||
"costGroup",
|
||||
"category",
|
||||
"level",
|
||||
"roomId",
|
||||
"roomNumberSnapshot",
|
||||
"roomNameSnapshot",
|
||||
"quantity",
|
||||
"powerPerUnit",
|
||||
"simultaneityFactor",
|
||||
"cosPhi",
|
||||
"remark",
|
||||
"overriddenFields",
|
||||
].includes(value);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CircuitDeviceRowUpdateProjectCommand } from "../models/circuit-device-row-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitDeviceRowUpdateCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
command: CircuitDeviceRowUpdateProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExecutedCircuitDeviceRowUpdateCommand {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitDeviceRowUpdateProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowProjectCommandStore {
|
||||
executeUpdate(
|
||||
input: ExecuteCircuitDeviceRowUpdateCommandInput
|
||||
): ExecutedCircuitDeviceRowUpdateCommand;
|
||||
}
|
||||
@@ -17,11 +17,7 @@ import type {
|
||||
UpdateCircuitInput,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
|
||||
import {
|
||||
parseOverriddenFields,
|
||||
serializeOverriddenFields,
|
||||
} from "./project-device-sync.service.js";
|
||||
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
|
||||
|
||||
export class CircuitWriteService {
|
||||
private readonly circuitRepository: CircuitRepository;
|
||||
@@ -256,21 +252,10 @@ export class CircuitWriteService {
|
||||
throw new Error("Invalid device row id.");
|
||||
}
|
||||
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
||||
let overriddenFields = input.overriddenFields;
|
||||
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
||||
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
||||
const inputValues = input as Record<string, unknown>;
|
||||
const currentValues = current as unknown as Record<string, unknown>;
|
||||
for (const field of projectDeviceSyncFields) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(input, field) &&
|
||||
(inputValues[field] ?? null) !== (currentValues[field] ?? null)
|
||||
) {
|
||||
overrides.add(field);
|
||||
}
|
||||
}
|
||||
overriddenFields = serializeOverriddenFields(overrides);
|
||||
}
|
||||
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
|
||||
current,
|
||||
input
|
||||
);
|
||||
await this.deviceRowRepository.updateFields(rowId, {
|
||||
...input,
|
||||
...(overriddenFields !== undefined ? { overriddenFields } : {}),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
projectDeviceSyncFields,
|
||||
type ProjectDeviceSyncField,
|
||||
} from "../../shared/constants/project-device-sync-fields.js";
|
||||
|
||||
export function parseOverriddenFields(
|
||||
value: string | null | undefined
|
||||
): ProjectDeviceSyncField[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((field): field is ProjectDeviceSyncField =>
|
||||
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeOverriddenFields(
|
||||
fields: Iterable<ProjectDeviceSyncField>
|
||||
): string | undefined {
|
||||
const fieldSet = new Set(fields);
|
||||
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
|
||||
return unique.length > 0 ? JSON.stringify(unique) : undefined;
|
||||
}
|
||||
|
||||
export function deriveOverriddenFieldsForLocalEdit(
|
||||
current: {
|
||||
linkedProjectDeviceId: string | null | undefined;
|
||||
overriddenFields: string | null | undefined;
|
||||
} & Partial<Record<ProjectDeviceSyncField, unknown>>,
|
||||
patch: Partial<Record<ProjectDeviceSyncField | "overriddenFields", unknown>>
|
||||
): string | null | undefined {
|
||||
if (
|
||||
!current.linkedProjectDeviceId ||
|
||||
Object.prototype.hasOwnProperty.call(patch, "overriddenFields")
|
||||
) {
|
||||
return patch.overriddenFields as string | null | undefined;
|
||||
}
|
||||
|
||||
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
||||
for (const field of projectDeviceSyncFields) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(patch, field) &&
|
||||
(patch[field] ?? null) !== (current[field] ?? null)
|
||||
) {
|
||||
overrides.add(field);
|
||||
}
|
||||
}
|
||||
return serializeOverriddenFields(overrides);
|
||||
}
|
||||
@@ -5,6 +5,15 @@ import {
|
||||
projectDeviceSyncFields,
|
||||
type ProjectDeviceSyncField,
|
||||
} from "../../shared/constants/project-device-sync-fields.js";
|
||||
import {
|
||||
parseOverriddenFields,
|
||||
serializeOverriddenFields,
|
||||
} from "./project-device-overrides.js";
|
||||
|
||||
export {
|
||||
parseOverriddenFields,
|
||||
serializeOverriddenFields,
|
||||
} from "./project-device-overrides.js";
|
||||
|
||||
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
|
||||
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
|
||||
@@ -25,29 +34,6 @@ export interface ProjectDeviceSyncRestoreRow {
|
||||
overriddenFields: ProjectDeviceSyncField[];
|
||||
}
|
||||
|
||||
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((field): field is ProjectDeviceSyncField =>
|
||||
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeOverriddenFields(fields: Iterable<ProjectDeviceSyncField>): string | undefined {
|
||||
const fieldSet = new Set(fields);
|
||||
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
|
||||
return unique.length > 0 ? JSON.stringify(unique) : undefined;
|
||||
}
|
||||
|
||||
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
||||
return projectDevice[field];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { db } from "../../db/client.js";
|
||||
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
|
||||
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
|
||||
|
||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||
export const circuitDeviceRowProjectCommandStore =
|
||||
new CircuitDeviceRowProjectCommandRepository(db);
|
||||
|
||||
Reference in New Issue
Block a user