Decouple runtime domain services

This commit is contained in:
2026-07-26 12:04:50 +02:00
parent 0cedeaa959
commit c1be85e408
9 changed files with 103 additions and 35 deletions
+2
View File
@@ -28,6 +28,8 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
forward-command variant preserves derived CircuitDeviceRow override metadata. forward-command variant preserves derived CircuitDeviceRow override metadata.
- Low-level `appendProjectRevision` persistence is adapter-internal and tested - Low-level `appendProjectRevision` persistence is adapter-internal and tested
directly; do not reintroduce a standalone runtime revision repository. directly; do not reintroduce a standalone runtime revision repository.
- Runtime domain services receive narrow reader/store dependencies explicitly;
concrete SQLite repositories are instantiated only under `src/server/composition`.
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard - General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
repositories expose only active reads. Runtime writes belong in typed command repositories expose only active reads. Runtime writes belong in typed command
repositories; direct integration fixtures belong under `tests/support`. repositories; direct integration fixtures belong under `tests/support`.
+8 -2
View File
@@ -47,6 +47,8 @@ liegt über einen Host-Mount außerhalb des Containers.
- `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe - `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe
- `src/server/index.ts` und `src/server/routes/` API-Komposition - `src/server/index.ts` und `src/server/routes/` API-Komposition
- `src/domain/services/` fachliche Command- und Synchronisierungsregeln - `src/domain/services/` fachliche Command- und Synchronisierungsregeln
- `src/server/composition/` Verdrahtung fachlicher Services mit konkreten
SQLite-Repositories
- `src/db/repositories/` Abfragen, Persistenzmapper und Transaktionsadapter - `src/db/repositories/` Abfragen, Persistenzmapper und Transaktionsadapter
- `src/db/schema/` und `src/db/migrations/` SQLite-Schema und Migrationen - `src/db/schema/` und `src/db/migrations/` SQLite-Schema und Migrationen
@@ -66,8 +68,9 @@ Der Editor besitzt keinen sitzungslokalen Undo-/Redo-Stapel mehr. Beim initialen
Laden und nach jedem Tree-Reload liest er den persistenten History-Status und Laden und nach jedem Tree-Reload liest er den persistenten History-Status und
gleicht dessen Revision mit `currentRevision` des Trees ab. Das Datenmodell gleicht dessen Revision mit `currentRevision` des Trees ab. Das Datenmodell
besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch Tabellen. Eine direkt getestete Persistence-Funktion schreibt diese
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen Historienmetadaten innerhalb der zentralen Command-Transaktion optimistisch und
atomar fort. Vorwärts- und Rückwärtskommandos besitzen einen
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten einem Neustart verlustfrei rekonstruiert werden. Alle aktuell unterstützten
Circuit-, Gerätezeilen-, Projektgeräte-, Projektstruktur- und Circuit-, Gerätezeilen-, Projektgeräte-, Projektstruktur- und
@@ -96,6 +99,9 @@ Store.
Die Low-Level-Funktion `appendProjectRevision` bleibt ein internes Detail dieser Die Low-Level-Funktion `appendProjectRevision` bleibt ein internes Detail dieser
Persistenzgrenze und wird direkt mit einer realen SQLite-Transaktion getestet. Persistenzgrenze und wird direkt mit einer realen SQLite-Transaktion getestet.
Ein eigenständiges Runtime-Revisions-Repository existiert nicht. Ein eigenständiges Runtime-Revisions-Repository existiert nicht.
`ProjectDeviceSyncService` und `CircuitNumberingService` kennen nur schmale,
fachlich benannte Reader-Interfaces. Ihre SQLite-Repositories werden
ausschließlich in `src/server/composition/` erzeugt und injiziert.
`circuit-device-row.insert` und `circuit-device-row.delete` sind atomare `circuit-device-row.insert` und `circuit-device-row.delete` sind atomare
Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen Strukturkommandos. Beim Löschen wird die vollständige Zeile im inversen
Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt. Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt.
@@ -352,6 +352,8 @@ Implemented foundation:
CircuitDeviceRow override metadata CircuitDeviceRow override metadata
- low-level revision append persistence is tested directly; the unused - low-level revision append persistence is tested directly; the unused
standalone runtime revision repository and store interface are removed standalone runtime revision repository and store interface are removed
- runtime project-device synchronization and circuit-numbering services depend
on narrow domain readers; server composition injects the SQLite repositories
- the legacy consumer UI and application read/write endpoints are removed after verified data cutover - the legacy consumer UI and application read/write endpoints are removed after verified data cutover
- retained legacy rows are accessible only through explicit database upgrade tooling - retained legacy rows are accessible only through explicit database upgrade tooling
- project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields - project devices no longer persist duplicate legacy power, phase, cosPhi or remark fields
@@ -1,5 +1,19 @@
import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; export interface CircuitNumberingSectionReader {
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; findById(
sectionId: string
): Promise<{ prefix: string } | null>;
}
export interface CircuitNumberingCircuitReader {
listBySection(
sectionId: string
): Promise<Array<{ equipmentIdentifier: string }>>;
}
export interface CircuitNumberingDependencies {
sectionRepository: CircuitNumberingSectionReader;
circuitRepository: CircuitNumberingCircuitReader;
}
function parseSuffix(equipmentIdentifier: string, prefix: string): number | null { function parseSuffix(equipmentIdentifier: string, prefix: string): number | null {
if (!equipmentIdentifier.startsWith(prefix)) { if (!equipmentIdentifier.startsWith(prefix)) {
@@ -13,15 +27,12 @@ function parseSuffix(equipmentIdentifier: string, prefix: string): number | null
} }
export class CircuitNumberingService { export class CircuitNumberingService {
private readonly sectionRepository: Pick<CircuitSectionRepository, "findById">; private readonly sectionRepository: CircuitNumberingSectionReader;
private readonly circuitRepository: Pick<CircuitRepository, "listBySection">; private readonly circuitRepository: CircuitNumberingCircuitReader;
constructor(deps?: { constructor(deps: CircuitNumberingDependencies) {
sectionRepository?: Pick<CircuitSectionRepository, "findById">; this.sectionRepository = deps.sectionRepository;
circuitRepository?: Pick<CircuitRepository, "listBySection">; this.circuitRepository = deps.circuitRepository;
}) {
this.sectionRepository = deps?.sectionRepository ?? new CircuitSectionRepository();
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
} }
async getNextIdentifier(sectionId: string) { async getNextIdentifier(sectionId: string) {
@@ -1,5 +1,3 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { import {
createProjectDeviceRowSyncProjectCommand, createProjectDeviceRowSyncProjectCommand,
projectDeviceSyncRowSnapshotFields, projectDeviceSyncRowSnapshotFields,
@@ -20,15 +18,44 @@ export {
serializeOverriddenFields, serializeOverriddenFields,
} from "./project-device-overrides.js"; } from "./project-device-overrides.js";
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>; export type ProjectDeviceSyncSource = {
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number]; id: string;
} & Pick<ProjectDeviceSyncRowSnapshot, ProjectDeviceSyncField>;
type SyncDependencies = { export type LinkedProjectDeviceRow = ProjectDeviceSyncRowSnapshot & {
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">; id: string;
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice">; circuitId: string;
equipmentIdentifier: string;
circuitDisplayName: string | null;
circuitListId: string;
circuitListName: string;
distributionBoardId: string;
distributionBoardName: string;
}; };
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) { export interface ProjectDeviceSyncSourceReader {
findById(
projectId: string,
projectDeviceId: string
): Promise<ProjectDeviceSyncSource | null>;
}
export interface LinkedProjectDeviceRowReader {
listLinkedByProjectDevice(
projectId: string,
projectDeviceId: string
): Promise<LinkedProjectDeviceRow[]>;
}
export interface ProjectDeviceSyncDependencies {
projectDeviceRepository: ProjectDeviceSyncSourceReader;
deviceRowRepository: LinkedProjectDeviceRowReader;
}
function sourceValue(
projectDevice: ProjectDeviceSyncSource,
field: ProjectDeviceSyncField
) {
return projectDevice[field]; return projectDevice[field];
} }
@@ -36,7 +63,10 @@ function valuesEqual(left: unknown, right: unknown) {
return (left ?? null) === (right ?? null); return (left ?? null) === (right ?? null);
} }
function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) { function buildDifferences(
projectDevice: ProjectDeviceSyncSource,
row: LinkedProjectDeviceRow
) {
return projectDeviceSyncFields return projectDeviceSyncFields
.filter((field) => !valuesEqual(row[field], sourceValue(projectDevice, field))) .filter((field) => !valuesEqual(row[field], sourceValue(projectDevice, field)))
.map((field) => ({ .map((field) => ({
@@ -48,12 +78,12 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
} }
export class ProjectDeviceSyncService { export class ProjectDeviceSyncService {
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"]; private readonly projectDeviceRepository: ProjectDeviceSyncSourceReader;
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"]; private readonly deviceRowRepository: LinkedProjectDeviceRowReader;
constructor(deps?: Partial<SyncDependencies>) { constructor(deps: ProjectDeviceSyncDependencies) {
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository(); this.projectDeviceRepository = deps.projectDeviceRepository;
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); this.deviceRowRepository = deps.deviceRowRepository;
} }
async getPreview(projectId: string, projectDeviceId: string) { async getPreview(projectId: string, projectDeviceId: string) {
@@ -154,10 +184,15 @@ export class ProjectDeviceSyncService {
); );
} }
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) { private resolveSelectedRows(
linkedRows: LinkedProjectDeviceRow[],
rowIds: string[]
) {
const uniqueRowIds = [...new Set(rowIds)]; const uniqueRowIds = [...new Set(rowIds)];
const byId = new Map(linkedRows.map((row) => [row.id, row])); const byId = new Map(linkedRows.map((row) => [row.id, row]));
const selectedRows = uniqueRowIds.map((rowId) => byId.get(rowId)).filter(Boolean) as LinkedRow[]; const selectedRows = uniqueRowIds
.map((rowId) => byId.get(rowId))
.filter(Boolean) as LinkedProjectDeviceRow[];
if (selectedRows.length !== uniqueRowIds.length) { if (selectedRows.length !== uniqueRowIds.length) {
throw new Error("One or more rows are not linked to this project device."); throw new Error("One or more rows are not linked to this project device.");
} }
@@ -166,7 +201,9 @@ export class ProjectDeviceSyncService {
} }
function toSyncSnapshot(row: LinkedRow): ProjectDeviceSyncRowSnapshot { function toSyncSnapshot(
row: LinkedProjectDeviceRow
): ProjectDeviceSyncRowSnapshot {
return { return {
linkedProjectDeviceId: row.linkedProjectDeviceId, linkedProjectDeviceId: row.linkedProjectDeviceId,
name: row.name, name: row.name,
@@ -1,3 +1,8 @@
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import { CircuitNumberingService } from "../../domain/services/circuit-numbering.service.js"; import { CircuitNumberingService } from "../../domain/services/circuit-numbering.service.js";
export const circuitNumberingService = new CircuitNumberingService(); export const circuitNumberingService = new CircuitNumberingService({
sectionRepository: new CircuitSectionRepository(),
circuitRepository: new CircuitRepository(),
});
@@ -1,3 +1,8 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js"; import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
export const projectDeviceSyncService = new ProjectDeviceSyncService(); export const projectDeviceSyncService = new ProjectDeviceSyncService({
projectDeviceRepository: new ProjectDeviceRepository(),
deviceRowRepository: new CircuitDeviceRowRepository(),
});
+2 -2
View File
@@ -7,7 +7,7 @@ describe("circuit numbering service", () => {
const service = new CircuitNumberingService({ const service = new CircuitNumberingService({
sectionRepository: { sectionRepository: {
async findById() { async findById() {
return { id: "s1", prefix: "-2F" } as never; return { prefix: "-2F" };
}, },
}, },
circuitRepository: { circuitRepository: {
@@ -18,7 +18,7 @@ describe("circuit numbering service", () => {
{ equipmentIdentifier: "-2F5" }, { equipmentIdentifier: "-2F5" },
{ equipmentIdentifier: "-2FX" }, { equipmentIdentifier: "-2FX" },
{ equipmentIdentifier: "-1F9" }, { equipmentIdentifier: "-1F9" },
] as never[]; ];
}, },
}, },
}); });
+2 -2
View File
@@ -59,12 +59,12 @@ function createService() {
const service = new ProjectDeviceSyncService({ const service = new ProjectDeviceSyncService({
projectDeviceRepository: { projectDeviceRepository: {
async findById() { async findById() {
return projectDevice() as never; return projectDevice();
}, },
}, },
deviceRowRepository: { deviceRowRepository: {
async listLinkedByProjectDevice() { async listLinkedByProjectDevice() {
return rows as never; return rows;
}, },
}, },
}); });