Add component persistence foundation

This commit is contained in:
2026-07-30 19:15:00 +02:00
parent a88c4b2086
commit e7607c90c4
15 changed files with 3146 additions and 12 deletions
+9
View File
@@ -335,6 +335,15 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
- Die vollständige Verteilungs-Testfixture liegt unter
`tests/support/distribution-board-fixture.ts` und ist kein exportierter
Produktions-Schreibweg.
- Migration `0024` bildet die additive relationale Grundlage für geschützte
Stromkreisgruppen und Verteilerkomponenten. Die drei bestehenden
Stromkreisabschnitte erhalten Kategorie und Gruppennummer 1. Separate
1:1-Tabellen halten künftig Stromkreis- und Komponenten-Schutzgeräte; die
bisherigen flachen Schutzfelder bleiben in dieser Übergangsphase unverändert.
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot-, Transfer-, Command- und UI-Integration
folgen in den nächsten abgegrenzten Arbeitspaketen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
+4 -1
View File
@@ -14,7 +14,10 @@ requirements and intended sequencing, not proof of implementation.
component model in phased work packages.
- [x] Phase A: central protection catalog, validation, defaults and pure grouped
BMK rules.
- [ ] Phase B: persistence and snapshot/transfer compatibility model.
- [x] Phase B1: additive relational schema, group backfill, component/protection
tables and shared BMK uniqueness.
- [ ] Phase B2: snapshot/transfer schema upgrade and deterministic compatibility
mapping.
- [ ] Phase C: persistent commands and new-board defaults.
- [ ] Phase D: group numbering, moves and destructive operations.
- [ ] Phase E: editor projection and editing.
@@ -3,8 +3,9 @@
## Status
This document defines the agreed target behavior for distribution-board
components, protected circuit groups and circuit protection devices. It is a
future specification, not proof of implementation.
components, protected circuit groups and circuit protection devices. Phase A
and the relational Phase-B1 foundation are implemented; later sections remain
requirements and are not proof of runtime or editor support.
Full rule-based protection and cable sizing is deliberately deferred. This
phase establishes the structure and manually selected technical values that a
@@ -544,6 +545,9 @@ Acceptance:
### B. Persistence and Compatibility Model
Status: In progress. Relational foundation B1 is complete; logical
snapshot/transfer integration B2 is pending.
- add group category and group number
- add distribution-board component persistence
- add one-to-one circuit protection persistence
@@ -552,6 +556,20 @@ Acceptance:
- add snapshot/transfer schema upgrade
- preserve existing identifiers and protection values
Implemented in B1:
- additive migration `0024` adds group identity to circuit sections and maps
the three established sections to group 1 without changing UUIDs
- separate component and circuit/component protection tables preserve the
one-to-one ownership boundaries
- a trigger-maintained circuit-list registry rejects normalized BMK collisions
across circuits and distribution-board components
- the existing flat circuit protection fields remain untouched until their
deterministic snapshot/runtime transition is implemented
- clean and populated pre-migration databases are covered by migration tests
- newly created legacy-compatible board structures receive the same initial
group identity while their persisted command format remains unchanged
Acceptance:
- a current database migrates without data loss
+2 -2
View File
@@ -15,8 +15,8 @@
"build:web": "next build",
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
"start": "node dist/server/index.js",
"test": "tsx --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/circuit-group-numbering.test.ts 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.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.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.persistence.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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/circuit-group-numbering.test.ts 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.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.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.persistence.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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test": "tsx --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts 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.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.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.persistence.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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/protection-device.test.ts tests/distribution-board-component.test.ts tests/distribution-board-component-schema-migration.test.ts tests/circuit-group-numbering.test.ts 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.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.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.persistence.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-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.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",
+72 -3
View File
@@ -8,12 +8,21 @@ const requiredTables = [
"circuit_sections",
"circuits",
"circuit_device_rows",
"circuit_list_equipment_identifiers",
"circuit_protection_devices",
"distribution_board_components",
"distribution_board_component_protection_devices",
"legacy_consumer_circuit_migrations",
"legacy_consumer_migration_reports",
];
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
const rows = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?, ?, ?)")
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'table' AND name IN (${tablePlaceholders})`
)
.all(...requiredTables);
const existing = new Set(rows.map((row) => row.name));
@@ -48,6 +57,16 @@ const circuitColumns = new Set(
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
);
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
const requiredCircuitSectionColumns = ["category", "group_number"];
const circuitSectionColumns = new Set(
db
.prepare("PRAGMA table_info(circuit_sections)")
.all()
.map((column) => column.name)
);
const missingCircuitSectionColumns = requiredCircuitSectionColumns.filter(
(name) => !circuitSectionColumns.has(name)
);
const requiredProjectColumns = [
"internal_project_number",
"external_project_number",
@@ -61,7 +80,11 @@ const projectColumns = new Set(
const missingProjectColumns = requiredProjectColumns.filter(
(name) => !projectColumns.has(name)
);
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
const requiredDistributionBoardColumns = [
"floor_id",
"supply_type",
"simultaneity_factor",
];
const distributionBoardColumns = new Set(
db
.prepare("PRAGMA table_info(distribution_boards)")
@@ -72,6 +95,28 @@ const missingDistributionBoardColumns =
requiredDistributionBoardColumns.filter(
(name) => !distributionBoardColumns.has(name)
);
const requiredEquipmentIdentifierTriggers = [
"circuits_equipment_identifier_insert",
"circuits_equipment_identifier_update",
"circuits_equipment_identifier_delete",
"distribution_board_components_equipment_identifier_insert",
"distribution_board_components_equipment_identifier_update",
"distribution_board_components_equipment_identifier_delete",
];
const equipmentIdentifierTriggers = new Set(
db
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'trigger'`
)
.all()
.map((trigger) => trigger.name)
);
const missingEquipmentIdentifierTriggers =
requiredEquipmentIdentifierTriggers.filter(
(name) => !equipmentIdentifierTriggers.has(name)
);
const integrityCheck = db.pragma("integrity_check", { simple: true });
const foreignKeyViolations = db.pragma("foreign_key_check");
@@ -80,11 +125,19 @@ console.log("Required tables:", requiredTables.join(", "));
console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
console.log(
"Required circuit-section columns:",
requiredCircuitSectionColumns.join(", ")
);
console.log("Required project columns:", requiredProjectColumns.join(", "));
console.log(
"Required distribution-board columns:",
requiredDistributionBoardColumns.join(", ")
);
console.log(
"Required BMK synchronization triggers:",
requiredEquipmentIdentifierTriggers.join(", ")
);
console.log("Integrity:", integrityCheck);
console.log("Foreign-key violations:", foreignKeyViolations.length);
@@ -111,6 +164,14 @@ if (missingCircuitColumns.length > 0) {
process.exit(1);
}
if (missingCircuitSectionColumns.length > 0) {
console.error(
"Missing circuit-section columns:",
missingCircuitSectionColumns.join(", ")
);
process.exit(1);
}
if (missingProjectColumns.length > 0) {
console.error("Missing project columns:", missingProjectColumns.join(", "));
process.exit(1);
@@ -124,9 +185,17 @@ if (missingDistributionBoardColumns.length > 0) {
process.exit(1);
}
if (missingEquipmentIdentifierTriggers.length > 0) {
console.error(
"Missing BMK synchronization triggers:",
missingEquipmentIdentifierTriggers.join(", ")
);
process.exit(1);
}
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
console.error("Database integrity or foreign-key verification failed.");
process.exit(1);
}
console.log("Circuit-first schema verification passed.");
console.log("Circuit-first and distribution-component schema verification passed.");
@@ -0,0 +1,150 @@
CREATE TABLE `circuit_list_equipment_identifiers` (
`owner_type` text NOT NULL,
`owner_id` text NOT NULL,
`circuit_list_id` text NOT NULL,
`equipment_identifier` text NOT NULL,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_owner_unique` ON `circuit_list_equipment_identifiers` (`owner_type`,`owner_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_list_identifier_unique` ON `circuit_list_equipment_identifiers` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
CREATE TABLE `circuit_protection_devices` (
`circuit_id` text PRIMARY KEY NOT NULL,
`type` text NOT NULL,
`rated_current_a` real NOT NULL,
`fuse_utilization_category` text,
`trip_characteristic` text,
`rcd_type` text,
`rated_residual_current_ma` real,
FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `distribution_board_component_protection_devices` (
`component_id` text PRIMARY KEY NOT NULL,
`type` text NOT NULL,
`rated_current_a` real NOT NULL,
`fuse_utilization_category` text,
`trip_characteristic` text,
`rcd_type` text,
`rated_residual_current_ma` real,
FOREIGN KEY (`component_id`) REFERENCES `distribution_board_components`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `distribution_board_components` (
`id` text PRIMARY KEY NOT NULL,
`circuit_list_id` text NOT NULL,
`section_id` text,
`equipment_identifier` text NOT NULL,
`name` text NOT NULL,
`role` text NOT NULL,
`placement` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `distribution_board_components_list_identifier_unique` ON `distribution_board_components` (`circuit_list_id`,`equipment_identifier`);--> statement-breakpoint
CREATE UNIQUE INDEX `distribution_board_components_section_role_unique` ON `distribution_board_components` (`section_id`,`role`);--> statement-breakpoint
ALTER TABLE `circuit_sections` ADD `category` text;--> statement-breakpoint
ALTER TABLE `circuit_sections` ADD `group_number` integer;--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_sections_list_category_group_unique` ON `circuit_sections` (`circuit_list_id`,`category`,`group_number`);--> statement-breakpoint
UPDATE `circuit_sections`
SET
`category` = CASE `key`
WHEN 'lighting' THEN 'lighting'
WHEN 'single_phase' THEN 'single_phase'
WHEN 'three_phase' THEN 'three_phase'
END,
`group_number` = 1
WHERE `key` IN ('lighting', 'single_phase', 'three_phase');--> statement-breakpoint
INSERT INTO `circuit_list_equipment_identifiers` (
`owner_type`,
`owner_id`,
`circuit_list_id`,
`equipment_identifier`
)
SELECT
'circuit',
`id`,
`circuit_list_id`,
`equipment_identifier`
FROM `circuits`;--> statement-breakpoint
CREATE UNIQUE INDEX `circuit_list_equipment_identifiers_normalized_unique`
ON `circuit_list_equipment_identifiers` (
`circuit_list_id`,
lower(trim(`equipment_identifier`))
);--> statement-breakpoint
CREATE UNIQUE INDEX `distribution_board_components_fixed_role_unique`
ON `distribution_board_components` (`circuit_list_id`, `role`)
WHERE `role` IN ('main_switch', 'surge_protective_device');--> statement-breakpoint
CREATE TRIGGER `circuits_equipment_identifier_insert`
AFTER INSERT ON `circuits`
BEGIN
INSERT INTO `circuit_list_equipment_identifiers` (
`owner_type`,
`owner_id`,
`circuit_list_id`,
`equipment_identifier`
)
VALUES (
'circuit',
NEW.`id`,
NEW.`circuit_list_id`,
NEW.`equipment_identifier`
);
END;--> statement-breakpoint
CREATE TRIGGER `circuits_equipment_identifier_update`
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier` ON `circuits`
BEGIN
UPDATE `circuit_list_equipment_identifiers`
SET
`circuit_list_id` = NEW.`circuit_list_id`,
`equipment_identifier` = NEW.`equipment_identifier`
WHERE
`owner_type` = 'circuit'
AND `owner_id` = OLD.`id`;
END;--> statement-breakpoint
CREATE TRIGGER `circuits_equipment_identifier_delete`
AFTER DELETE ON `circuits`
BEGIN
DELETE FROM `circuit_list_equipment_identifiers`
WHERE
`owner_type` = 'circuit'
AND `owner_id` = OLD.`id`;
END;--> statement-breakpoint
CREATE TRIGGER `distribution_board_components_equipment_identifier_insert`
AFTER INSERT ON `distribution_board_components`
BEGIN
INSERT INTO `circuit_list_equipment_identifiers` (
`owner_type`,
`owner_id`,
`circuit_list_id`,
`equipment_identifier`
)
VALUES (
'distribution_board_component',
NEW.`id`,
NEW.`circuit_list_id`,
NEW.`equipment_identifier`
);
END;--> statement-breakpoint
CREATE TRIGGER `distribution_board_components_equipment_identifier_update`
AFTER UPDATE OF `circuit_list_id`, `equipment_identifier`
ON `distribution_board_components`
BEGIN
UPDATE `circuit_list_equipment_identifiers`
SET
`circuit_list_id` = NEW.`circuit_list_id`,
`equipment_identifier` = NEW.`equipment_identifier`
WHERE
`owner_type` = 'distribution_board_component'
AND `owner_id` = OLD.`id`;
END;--> statement-breakpoint
CREATE TRIGGER `distribution_board_components_equipment_identifier_delete`
AFTER DELETE ON `distribution_board_components`
BEGIN
DELETE FROM `circuit_list_equipment_identifiers`
WHERE
`owner_type` = 'distribution_board_component'
AND `owner_id` = OLD.`id`;
END;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -169,6 +169,13 @@
"when": 1785343645615,
"tag": "0023_cheerful_night_thrasher",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1785431250141,
"tag": "0024_damp_mister_sinister",
"breakpoints": true
}
]
}
@@ -24,6 +24,7 @@ import type {
DistributionBoardStructureProjectCommandStore,
ExecuteDistributionBoardStructureCommandInput,
} from "../../domain/ports/distribution-board-structure-project-command.store.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
@@ -168,7 +169,10 @@ export class DistributionBoardStructureProjectCommandRepository
.values(structure.distributionBoard)
.run();
database.insert(circuitLists).values(structure.circuitList).run();
database.insert(circuitSections).values(structure.sections).run();
database
.insert(circuitSections)
.values(structure.sections.map(withInitialGroupIdentity))
.run();
return createDistributionBoardDeleteProjectCommand(structure);
}
@@ -409,9 +413,48 @@ function structureMatches(
left.sortOrder - right.sortOrder ||
left.id.localeCompare(right.id)
);
return expectedSections.every((section, index) =>
sameRecord(section, actual.sections[index])
);
return expectedSections.every((section, index) => {
const actualSection = actual.sections[index];
if (
actualSection.category !== initialCategoryBySectionKey(section.key) ||
actualSection.groupNumber !== initialGroupNumberBySectionKey(section.key)
) {
return false;
}
const {
category: _category,
groupNumber: _groupNumber,
...comparableActualSection
} = actualSection;
return sameRecord(section, comparableActualSection);
});
}
function withInitialGroupIdentity(
section: DistributionBoardStructureSnapshot["sections"][number]
): typeof circuitSections.$inferInsert {
return {
...section,
category: initialCategoryBySectionKey(section.key),
groupNumber: initialGroupNumberBySectionKey(section.key),
};
}
function initialCategoryBySectionKey(
key: string
): CircuitGroupCategory | null {
if (
key === "lighting" ||
key === "single_phase" ||
key === "three_phase"
) {
return key;
}
return null;
}
function initialGroupNumberBySectionKey(key: string): number | null {
return initialCategoryBySectionKey(key) === null ? null : 1;
}
function sameRecord(
@@ -0,0 +1,26 @@
import { sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js";
export const circuitListEquipmentIdentifiers = sqliteTable(
"circuit_list_equipment_identifiers",
{
ownerType: text("owner_type")
.$type<"circuit" | "distribution_board_component">()
.notNull(),
ownerId: text("owner_id").notNull(),
circuitListId: text("circuit_list_id")
.notNull()
.references(() => circuitLists.id, { onDelete: "cascade" }),
equipmentIdentifier: text("equipment_identifier").notNull(),
},
(table) => [
unique("circuit_list_equipment_identifiers_owner_unique").on(
table.ownerType,
table.ownerId
),
unique("circuit_list_equipment_identifiers_list_identifier_unique").on(
table.circuitListId,
table.equipmentIdentifier
),
]
);
@@ -0,0 +1,27 @@
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import { circuits } from "./circuits.js";
export const circuitProtectionDevices = sqliteTable(
"circuit_protection_devices",
{
circuitId: text("circuit_id")
.primaryKey()
.references(() => circuits.id, { onDelete: "cascade" }),
type: text("type").$type<ProtectionDeviceType>().notNull(),
ratedCurrentA: real("rated_current_a").notNull(),
fuseUtilizationCategory: text(
"fuse_utilization_category"
).$type<FuseUtilizationCategory>(),
tripCharacteristic: text(
"trip_characteristic"
).$type<BreakerTripCharacteristic>(),
rcdType: text("rcd_type").$type<RcdType>(),
ratedResidualCurrentMa: real("rated_residual_current_ma"),
}
);
+8
View File
@@ -1,5 +1,6 @@
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { circuitLists } from "./circuit-lists.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
export const circuitSections = sqliteTable(
"circuit_sections",
@@ -12,10 +13,17 @@ export const circuitSections = sqliteTable(
displayName: text("display_name").notNull(),
prefix: text("prefix").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
category: text("category").$type<CircuitGroupCategory>(),
groupNumber: integer("group_number"),
},
(table) => [
unique("circuit_sections_list_key_unique").on(table.circuitListId, table.key),
unique("circuit_sections_list_prefix_unique").on(table.circuitListId, table.prefix),
unique("circuit_sections_list_category_group_unique").on(
table.circuitListId,
table.category,
table.groupNumber
),
]
);
@@ -0,0 +1,29 @@
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type {
BreakerTripCharacteristic,
FuseUtilizationCategory,
ProtectionDeviceType,
RcdType,
} from "../../shared/constants/protection-device.js";
import { distributionBoardComponents } from "./distribution-board-components.js";
export const distributionBoardComponentProtectionDevices = sqliteTable(
"distribution_board_component_protection_devices",
{
componentId: text("component_id")
.primaryKey()
.references(() => distributionBoardComponents.id, {
onDelete: "cascade",
}),
type: text("type").$type<ProtectionDeviceType>().notNull(),
ratedCurrentA: real("rated_current_a").notNull(),
fuseUtilizationCategory: text(
"fuse_utilization_category"
).$type<FuseUtilizationCategory>(),
tripCharacteristic: text(
"trip_characteristic"
).$type<BreakerTripCharacteristic>(),
rcdType: text("rcd_type").$type<RcdType>(),
ratedResidualCurrentMa: real("rated_residual_current_ma"),
}
);
@@ -0,0 +1,37 @@
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import type {
DistributionBoardComponentPlacement,
DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js";
import { circuitLists } from "./circuit-lists.js";
import { circuitSections } from "./circuit-sections.js";
export const distributionBoardComponents = sqliteTable(
"distribution_board_components",
{
id: text("id").primaryKey(),
circuitListId: text("circuit_list_id")
.notNull()
.references(() => circuitLists.id, { onDelete: "cascade" }),
sectionId: text("section_id").references(() => circuitSections.id, {
onDelete: "cascade",
}),
equipmentIdentifier: text("equipment_identifier").notNull(),
name: text("name").notNull(),
role: text("role").$type<DistributionBoardComponentRole>().notNull(),
placement: text("placement")
.$type<DistributionBoardComponentPlacement>()
.notNull(),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [
unique("distribution_board_components_list_identifier_unique").on(
table.circuitListId,
table.equipmentIdentifier
),
unique("distribution_board_components_section_role_unique").on(
table.sectionId,
table.role
),
]
);
@@ -0,0 +1,466 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { describe, it } from "node:test";
import Database from "better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
const migrationPath = path.resolve(
"src",
"db",
"migrations",
"0024_damp_mister_sinister.sql"
);
function applyComponentSchemaMigration(sqlite: Database.Database): void {
const migrationSql = fs.readFileSync(migrationPath, "utf8");
for (const statement of migrationSql.split("--> statement-breakpoint")) {
if (statement.trim()) {
sqlite.exec(statement);
}
}
}
function createLegacySchema(sqlite: Database.Database): void {
sqlite.pragma("foreign_keys = ON");
sqlite.exec(`
CREATE TABLE circuit_lists (
id text PRIMARY KEY NOT NULL
);
CREATE TABLE circuit_sections (
id text PRIMARY KEY NOT NULL,
circuit_list_id text NOT NULL REFERENCES circuit_lists(id) ON DELETE CASCADE,
key text NOT NULL,
display_name text NOT NULL,
prefix text NOT NULL,
sort_order integer DEFAULT 0 NOT NULL
);
CREATE TABLE circuits (
id text PRIMARY KEY NOT NULL,
circuit_list_id text NOT NULL REFERENCES circuit_lists(id) ON DELETE CASCADE,
equipment_identifier text NOT NULL,
protection_type text,
protection_rated_current real,
protection_characteristic text
);
`);
}
describe("distribution board component schema migration", () => {
it("creates the additive schema on an empty database", () => {
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
const tables = (
context.sqlite
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'table'
ORDER BY name`
)
.all() as Array<{ name: string }>
).map((table) => table.name);
assert.equal(tables.includes("distribution_board_components"), true);
assert.equal(tables.includes("circuit_protection_devices"), true);
assert.deepEqual(
tables.includes("distribution_board_component_protection_devices"),
true
);
assert.deepEqual(
tables.includes("circuit_list_equipment_identifiers"),
true
);
const sectionColumns = (
context.sqlite
.prepare("PRAGMA table_info(circuit_sections)")
.all() as Array<{ name: string }>
).map((column) => column.name);
assert.equal(sectionColumns.includes("category"), true);
assert.equal(sectionColumns.includes("group_number"), true);
} finally {
context.close();
}
});
it("backfills groups and the BMK registry without rewriting existing planning data", () => {
const sqlite = new Database(":memory:");
try {
createLegacySchema(sqlite);
sqlite.exec(`
INSERT INTO circuit_lists (id) VALUES ('list-1');
INSERT INTO circuit_sections (
id,
circuit_list_id,
key,
display_name,
prefix,
sort_order
) VALUES
('lighting', 'list-1', 'lighting', 'Beleuchtung', '-1F', 10),
('single', 'list-1', 'single_phase', '1-phasig', '-2F', 20),
('three', 'list-1', 'three_phase', '3-phasig', '-3F', 30),
('unassigned', 'list-1', 'unassigned', 'Nicht zugeordnet', '-9F', 40);
INSERT INTO circuits (
id,
circuit_list_id,
equipment_identifier,
protection_type,
protection_rated_current,
protection_characteristic
) VALUES
('circuit-1', 'list-1', '-1F1', 'legacy fuse', 13, 'legacy'),
('circuit-2', 'list-1', '-2F4', NULL, NULL, NULL);
`);
applyComponentSchemaMigration(sqlite);
assert.deepEqual(
sqlite
.prepare(
`SELECT
id,
category,
group_number AS groupNumber
FROM circuit_sections
ORDER BY sort_order`
)
.all(),
[
{ id: "lighting", category: "lighting", groupNumber: 1 },
{ id: "single", category: "single_phase", groupNumber: 1 },
{ id: "three", category: "three_phase", groupNumber: 1 },
{ id: "unassigned", category: null, groupNumber: null },
]
);
assert.deepEqual(
sqlite
.prepare(
`SELECT
id,
equipment_identifier AS equipmentIdentifier,
protection_type AS protectionType,
protection_rated_current AS protectionRatedCurrent,
protection_characteristic AS protectionCharacteristic
FROM circuits
ORDER BY id`
)
.all(),
[
{
id: "circuit-1",
equipmentIdentifier: "-1F1",
protectionType: "legacy fuse",
protectionRatedCurrent: 13,
protectionCharacteristic: "legacy",
},
{
id: "circuit-2",
equipmentIdentifier: "-2F4",
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
},
]
);
assert.deepEqual(
sqlite
.prepare(
`SELECT
owner_type AS ownerType,
owner_id AS ownerId,
equipment_identifier AS equipmentIdentifier
FROM circuit_list_equipment_identifiers
ORDER BY owner_id`
)
.all(),
[
{
ownerType: "circuit",
ownerId: "circuit-1",
equipmentIdentifier: "-1F1",
},
{
ownerType: "circuit",
ownerId: "circuit-2",
equipmentIdentifier: "-2F4",
},
]
);
assert.deepEqual(
sqlite
.prepare("SELECT count(*) AS count FROM circuit_protection_devices")
.get(),
{ count: 0 }
);
} finally {
sqlite.close();
}
});
it("keeps the shared BMK registry synchronized and rejects normalized collisions", () => {
const sqlite = new Database(":memory:");
try {
createLegacySchema(sqlite);
sqlite.exec(`
INSERT INTO circuit_lists (id) VALUES ('list-1');
INSERT INTO circuit_sections (
id,
circuit_list_id,
key,
display_name,
prefix
) VALUES ('lighting', 'list-1', 'lighting', 'Beleuchtung', '-1F');
INSERT INTO circuits (
id,
circuit_list_id,
equipment_identifier
) VALUES ('circuit-1', 'list-1', '-1F1');
`);
applyComponentSchemaMigration(sqlite);
sqlite
.prepare(
`INSERT INTO distribution_board_components (
id,
circuit_list_id,
equipment_identifier,
name,
role,
placement,
sort_order
) VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
"main-switch",
"list-1",
"-Q0",
"Hauptschalter",
"main_switch",
"header",
10
);
assert.throws(() =>
sqlite
.prepare(
`INSERT INTO distribution_board_components (
id,
circuit_list_id,
equipment_identifier,
name,
role,
placement
) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
"duplicate",
"list-1",
" -q0 ",
"Duplikat",
"auxiliary",
"footer"
)
);
assert.throws(() =>
sqlite
.prepare(
`INSERT INTO distribution_board_components (
id,
circuit_list_id,
equipment_identifier,
name,
role,
placement
) VALUES (?, ?, ?, ?, ?, ?)`
)
.run(
"circuit-duplicate",
"list-1",
"-1f1",
"Duplikat",
"auxiliary",
"footer"
)
);
sqlite
.prepare(
`INSERT INTO circuits (
id,
circuit_list_id,
equipment_identifier
) VALUES (?, ?, ?)`
)
.run("circuit-2", "list-1", "-1F2");
sqlite
.prepare(
`UPDATE circuits
SET equipment_identifier = ?
WHERE id = ?`
)
.run("-1F3", "circuit-2");
assert.deepEqual(
sqlite
.prepare(
`SELECT equipment_identifier AS equipmentIdentifier
FROM circuit_list_equipment_identifiers
WHERE owner_type = 'circuit' AND owner_id = 'circuit-2'`
)
.get(),
{ equipmentIdentifier: "-1F3" }
);
assert.throws(() =>
sqlite
.prepare(
`UPDATE circuits
SET equipment_identifier = ?
WHERE id = ?`
)
.run("-q0", "circuit-2")
);
assert.deepEqual(
sqlite
.prepare(
`SELECT equipment_identifier AS equipmentIdentifier
FROM circuits
WHERE id = 'circuit-2'`
)
.get(),
{ equipmentIdentifier: "-1F3" }
);
sqlite
.prepare("DELETE FROM distribution_board_components WHERE id = ?")
.run("main-switch");
assert.deepEqual(
sqlite
.prepare(
`SELECT count(*) AS count
FROM circuit_list_equipment_identifiers
WHERE owner_type = 'distribution_board_component'`
)
.get(),
{ count: 0 }
);
} finally {
sqlite.close();
}
});
it("stores independent one-to-one circuit and component protection state", () => {
const sqlite = new Database(":memory:");
try {
createLegacySchema(sqlite);
sqlite.exec(`
INSERT INTO circuit_lists (id) VALUES ('list-1');
INSERT INTO circuit_sections (
id,
circuit_list_id,
key,
display_name,
prefix
) VALUES ('lighting', 'list-1', 'lighting', 'Beleuchtung', '-1F');
INSERT INTO circuits (
id,
circuit_list_id,
equipment_identifier
) VALUES ('circuit-1', 'list-1', '-1F1');
`);
applyComponentSchemaMigration(sqlite);
sqlite.exec(`
INSERT INTO circuit_protection_devices (
circuit_id,
type,
rated_current_a,
trip_characteristic
) VALUES ('circuit-1', 'LS', 10, 'B');
INSERT INTO distribution_board_components (
id,
circuit_list_id,
section_id,
equipment_identifier,
name,
role,
placement
) VALUES (
'group-rcd',
'list-1',
'lighting',
'-1Q1.0',
'Gruppen-FI',
'group_residual_current_protection',
'group'
);
INSERT INTO distribution_board_component_protection_devices (
component_id,
type,
rated_current_a,
rcd_type,
rated_residual_current_ma
) VALUES ('group-rcd', 'FI', 40, 'A', 30);
`);
assert.deepEqual(
sqlite
.prepare("SELECT * FROM circuit_protection_devices")
.get(),
{
circuit_id: "circuit-1",
type: "LS",
rated_current_a: 10,
fuse_utilization_category: null,
trip_characteristic: "B",
rcd_type: null,
rated_residual_current_ma: null,
}
);
assert.deepEqual(
sqlite
.prepare(
"SELECT * FROM distribution_board_component_protection_devices"
)
.get(),
{
component_id: "group-rcd",
type: "FI",
rated_current_a: 40,
fuse_utilization_category: null,
trip_characteristic: null,
rcd_type: "A",
rated_residual_current_ma: 30,
}
);
sqlite.prepare("DELETE FROM circuits WHERE id = ?").run("circuit-1");
sqlite
.prepare("DELETE FROM distribution_board_components WHERE id = ?")
.run("group-rcd");
assert.deepEqual(
sqlite
.prepare("SELECT count(*) AS count FROM circuit_protection_devices")
.get(),
{ count: 0 }
);
assert.deepEqual(
sqlite
.prepare(
`SELECT count(*) AS count
FROM distribution_board_component_protection_devices`
)
.get(),
{ count: 0 }
);
} finally {
sqlite.close();
}
});
});