From b79faed32002d0661ab273246aee97737a3b9cec Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 23 Jul 2026 21:23:31 +0200 Subject: [PATCH] Add serializable project commands --- docs/current-architecture.md | 8 +- ...history-and-external-model-architecture.md | 8 +- docs/spec/07-implementation-phases-todo.md | 5 +- package.json | 4 +- .../project-revision.repository.ts | 16 ++-- src/domain/models/project-command.model.ts | 87 +++++++++++++++++++ src/domain/ports/project-revision.store.ts | 20 +---- tests/project-command.model.test.ts | 74 ++++++++++++++++ tests/project-revision.repository.test.ts | 23 +++-- 9 files changed, 206 insertions(+), 39 deletions(-) create mode 100644 src/domain/models/project-command.model.ts create mode 100644 tests/project-command.model.test.ts diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 76721e0..b596295 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -65,9 +65,11 @@ liegt über einen Host-Mount außerhalb des Containers. Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch -und atomar fortschreiben. Bestehende Fachoperationen sind aber noch nicht an -diese Grenze angeschlossen; serverseitiges Undo/Redo ist daher noch nicht -verfügbar. +und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen +versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach +einem Neustart verlustfrei rekonstruiert werden. Konkrete Fachkommandos und +bestehende Fachoperationen sind aber noch nicht an diese Grenze angeschlossen; +serverseitiges Undo/Redo ist daher noch nicht verfügbar. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index da4be4d..6655669 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -140,6 +140,11 @@ Completed foundation: - projects carry a monotonic `currentRevision` counter starting at zero - project revision metadata and versioned forward/inverse change-set payloads have separate persistence entities +- forward and inverse payloads store complete serialized command envelopes; + command type and schema version are derived from those envelopes rather than + duplicated caller metadata +- command serialization rejects non-finite numbers, `undefined`, non-plain + objects and cyclic payloads before a revision transaction starts - the SQLite revision repository advances the project counter and stores both history records atomically - revision appends use an expected-revision compare-and-set and reject stale @@ -147,7 +152,8 @@ Completed foundation: Remaining constraints before implementing history: -- model project-scoped command descriptions independently from React callbacks +- define concrete project-scoped command unions and executors independently + from React callbacks; the generic versioned command envelope is complete - route domain writes and revision recording through one shared project transaction boundary; the current revision repository only commits history records and the counter together diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 9f3baa6..6475487 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -359,7 +359,8 @@ Tasks: forward/inverse payloads - [x] add optimistic checks when appending a revision - route domain writes and history records through one shared transaction -- model serializable domain command descriptions outside React +- define concrete serializable domain command descriptions and executors + outside React - persist undo/redo eligibility on the server - add named and periodic logical project snapshots - restore a historical snapshot as a new revision @@ -369,6 +370,8 @@ Implemented foundation: - projects start at revision zero and advance monotonically - each revision has one separately stored logical change set +- forward and inverse changes use complete, versioned JSON command envelopes +- invalid or lossy command payloads are rejected before persistence - revision metadata, change-set payloads and the project counter are committed in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index 579f7af..d7fd5ce 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test 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.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 tests/circuit-section-transaction.repository.test.ts tests/project-revision.repository.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test 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.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 tests/circuit-section-transaction.repository.test.ts tests/project-revision.repository.test.ts tests/database-backup.test.ts", + "test": "tsx --test 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.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 tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test 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.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 tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/project-revision.repository.ts b/src/db/repositories/project-revision.repository.ts index d68a633..4d5236d 100644 --- a/src/db/repositories/project-revision.repository.ts +++ b/src/db/repositories/project-revision.repository.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { and, eq } from "drizzle-orm"; +import { serializeProjectCommand } from "../../domain/models/project-command.model.js"; import type { AppendProjectRevisionInput, AppendedProjectRevision, @@ -40,6 +41,8 @@ export class ProjectRevisionRepository implements ProjectRevisionStore { append(input: AppendProjectRevisionInput): AppendedProjectRevision { this.validateInput(input); + const forwardPayloadJson = serializeProjectCommand(input.forward); + const inversePayloadJson = serializeProjectCommand(input.inverse); const revisionId = crypto.randomUUID(); const changeSetId = crypto.randomUUID(); const revisionNumber = input.expectedRevision + 1; @@ -86,10 +89,10 @@ export class ProjectRevisionRepository implements ProjectRevisionStore { .values({ id: changeSetId, projectRevisionId: revisionId, - commandType: input.commandType, + commandType: input.forward.type, payloadSchemaVersion: input.forward.schemaVersion, - forwardPayloadJson: JSON.stringify(input.forward.data), - inversePayloadJson: JSON.stringify(input.inverse.data), + forwardPayloadJson, + inversePayloadJson, }) .run(); @@ -107,16 +110,11 @@ export class ProjectRevisionRepository implements ProjectRevisionStore { if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) { throw new Error("Expected project revision must be a non-negative integer."); } - if (!input.commandType.trim()) { - throw new Error("Project revision command type is required."); - } if ( - !Number.isSafeInteger(input.forward.schemaVersion) || - input.forward.schemaVersion < 1 || input.forward.schemaVersion !== input.inverse.schemaVersion ) { throw new Error( - "Forward and inverse payloads require the same positive schema version." + "Forward and inverse project commands require the same schema version." ); } } diff --git a/src/domain/models/project-command.model.ts b/src/domain/models/project-command.model.ts new file mode 100644 index 0000000..1171493 --- /dev/null +++ b/src/domain/models/project-command.model.ts @@ -0,0 +1,87 @@ +export type ProjectCommandJsonValue = + | null + | boolean + | number + | string + | ProjectCommandJsonValue[] + | { [key: string]: ProjectCommandJsonValue }; + +export interface SerializedProjectCommand { + schemaVersion: number; + type: string; + payload: ProjectCommandJsonValue; +} + +export function serializeProjectCommand(command: SerializedProjectCommand): string { + assertSerializedProjectCommand(command); + return JSON.stringify(command); +} + +export function deserializeProjectCommand(serialized: string): SerializedProjectCommand { + const parsed: unknown = JSON.parse(serialized); + assertSerializedProjectCommand(parsed); + return parsed; +} + +export function assertSerializedProjectCommand( + value: unknown +): asserts value is SerializedProjectCommand { + if (!isPlainObject(value)) { + throw new Error("Project command must be an object."); + } + if ( + !Number.isSafeInteger(value.schemaVersion) || + (value.schemaVersion as number) < 1 + ) { + throw new Error("Project command schema version must be a positive integer."); + } + if (typeof value.type !== "string" || !value.type.trim()) { + throw new Error("Project command type is required."); + } + assertJsonValue(value.payload, new WeakSet()); +} + +function assertJsonValue(value: unknown, parents: WeakSet): void { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new Error("Project command numbers must be finite."); + } + return; + } + if (typeof value !== "object") { + throw new Error("Project command payload must contain JSON values only."); + } + if (parents.has(value)) { + throw new Error("Project command payload must not contain cycles."); + } + + parents.add(value); + if (Array.isArray(value)) { + for (const entry of value) { + assertJsonValue(entry, parents); + } + } else { + if (!isPlainObject(value)) { + throw new Error("Project command payload must contain plain objects only."); + } + for (const entry of Object.values(value)) { + assertJsonValue(entry, parents); + } + } + parents.delete(value); +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object") { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/src/domain/ports/project-revision.store.ts b/src/domain/ports/project-revision.store.ts index d259294..c99ad4e 100644 --- a/src/domain/ports/project-revision.store.ts +++ b/src/domain/ports/project-revision.store.ts @@ -1,3 +1,5 @@ +import type { SerializedProjectCommand } from "../models/project-command.model.js"; + export type ProjectRevisionSource = | "user" | "undo" @@ -5,28 +7,14 @@ export type ProjectRevisionSource = | "restore" | "migration"; -export type ProjectChangeJsonValue = - | null - | boolean - | number - | string - | ProjectChangeJsonValue[] - | { [key: string]: ProjectChangeJsonValue }; - -export interface ProjectChangePayload { - schemaVersion: number; - data: ProjectChangeJsonValue; -} - export interface AppendProjectRevisionInput { projectId: string; expectedRevision: number; source: ProjectRevisionSource; - commandType: string; description?: string; actorId?: string; - forward: ProjectChangePayload; - inverse: ProjectChangePayload; + forward: SerializedProjectCommand; + inverse: SerializedProjectCommand; } export interface AppendedProjectRevision { diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts new file mode 100644 index 0000000..f9d7202 --- /dev/null +++ b/tests/project-command.model.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + deserializeProjectCommand, + serializeProjectCommand, + type SerializedProjectCommand, +} from "../src/domain/models/project-command.model.js"; + +describe("serialized project commands", () => { + it("round-trips a versioned command envelope", () => { + const command: SerializedProjectCommand = { + schemaVersion: 1, + type: "circuit.update", + payload: { + circuitId: "circuit-1", + changes: { + displayName: "Werkstatt", + protectionRatedCurrent: 16, + isReserve: false, + remark: null, + }, + }, + }; + + assert.deepEqual( + deserializeProjectCommand(serializeProjectCommand(command)), + command + ); + }); + + it("rejects values that JSON would lose or silently change", () => { + const invalidPayloads: unknown[] = [ + { value: undefined }, + { value: Number.NaN }, + { value: Number.POSITIVE_INFINITY }, + { value: new Date() }, + ]; + + for (const payload of invalidPayloads) { + assert.throws(() => + serializeProjectCommand({ + schemaVersion: 1, + type: "test.invalid", + payload: payload as never, + }) + ); + } + }); + + it("rejects cyclic payloads before persistence", () => { + const payload: Record = {}; + payload.self = payload; + + assert.throws( + () => + serializeProjectCommand({ + schemaVersion: 1, + type: "test.cyclic", + payload: payload as never, + }), + /must not contain cycles/ + ); + }); + + it("rejects malformed serialized envelopes", () => { + assert.throws( + () => + deserializeProjectCommand( + JSON.stringify({ schemaVersion: 0, type: "", payload: null }) + ), + /schema version/ + ); + }); +}); diff --git a/tests/project-revision.repository.test.ts b/tests/project-revision.repository.test.ts index bb8bda4..ffea437 100644 --- a/tests/project-revision.repository.test.ts +++ b/tests/project-revision.repository.test.ts @@ -32,16 +32,17 @@ function appendTestRevision( projectId: "project-1", expectedRevision, source: "user", - commandType: "circuit.update", description: "Stromkreis bearbeiten", actorId: "test-user", forward: { schemaVersion: 1, - data: { circuitId: "circuit-1", displayName: "Neu" }, + type: "circuit.update", + payload: { circuitId: "circuit-1", displayName: "Neu" }, }, inverse: { schemaVersion: 1, - data: { circuitId: "circuit-1", displayName: "Alt" }, + type: "circuit.update", + payload: { circuitId: "circuit-1", displayName: "Alt" }, }, }); } @@ -85,12 +86,20 @@ describe("project revision repository", () => { commandType: "circuit.update", payloadSchemaVersion: 1, forwardPayloadJson: JSON.stringify({ - circuitId: "circuit-1", - displayName: "Neu", + schemaVersion: 1, + type: "circuit.update", + payload: { + circuitId: "circuit-1", + displayName: "Neu", + }, }), inversePayloadJson: JSON.stringify({ - circuitId: "circuit-1", - displayName: "Alt", + schemaVersion: 1, + type: "circuit.update", + payload: { + circuitId: "circuit-1", + displayName: "Alt", + }, }), });