Add serializable project commands

This commit is contained in:
2026-07-23 21:23:31 +02:00
parent 903d977443
commit b79faed320
9 changed files with 206 additions and 39 deletions
+5 -3
View File
@@ -65,9 +65,11 @@ liegt über einen Host-Mount außerhalb des Containers.
Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
und atomar fortschreiben. Bestehende Fachoperationen sind aber noch nicht an und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
diese Grenze angeschlossen; serverseitiges Undo/Redo ist daher noch nicht versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach
verfügbar. 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 ## Projektgeräte
@@ -140,6 +140,11 @@ Completed foundation:
- projects carry a monotonic `currentRevision` counter starting at zero - projects carry a monotonic `currentRevision` counter starting at zero
- project revision metadata and versioned forward/inverse change-set payloads - project revision metadata and versioned forward/inverse change-set payloads
have separate persistence entities 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 - the SQLite revision repository advances the project counter and stores both
history records atomically history records atomically
- revision appends use an expected-revision compare-and-set and reject stale - revision appends use an expected-revision compare-and-set and reject stale
@@ -147,7 +152,8 @@ Completed foundation:
Remaining constraints before implementing history: 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 - route domain writes and revision recording through one shared project
transaction boundary; the current revision repository only commits history transaction boundary; the current revision repository only commits history
records and the counter together records and the counter together
+4 -1
View File
@@ -359,7 +359,8 @@ Tasks:
forward/inverse payloads forward/inverse payloads
- [x] add optimistic checks when appending a revision - [x] add optimistic checks when appending a revision
- route domain writes and history records through one shared transaction - 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 - persist undo/redo eligibility on the server
- add named and periodic logical project snapshots - add named and periodic logical project snapshots
- restore a historical snapshot as a new revision - restore a historical snapshot as a new revision
@@ -369,6 +370,8 @@ Implemented foundation:
- projects start at revision zero and advance monotonically - projects start at revision zero and advance monotonically
- each revision has one separately stored logical change set - 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 - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "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": "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-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:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
@@ -1,5 +1,6 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { serializeProjectCommand } from "../../domain/models/project-command.model.js";
import type { import type {
AppendProjectRevisionInput, AppendProjectRevisionInput,
AppendedProjectRevision, AppendedProjectRevision,
@@ -40,6 +41,8 @@ export class ProjectRevisionRepository implements ProjectRevisionStore {
append(input: AppendProjectRevisionInput): AppendedProjectRevision { append(input: AppendProjectRevisionInput): AppendedProjectRevision {
this.validateInput(input); this.validateInput(input);
const forwardPayloadJson = serializeProjectCommand(input.forward);
const inversePayloadJson = serializeProjectCommand(input.inverse);
const revisionId = crypto.randomUUID(); const revisionId = crypto.randomUUID();
const changeSetId = crypto.randomUUID(); const changeSetId = crypto.randomUUID();
const revisionNumber = input.expectedRevision + 1; const revisionNumber = input.expectedRevision + 1;
@@ -86,10 +89,10 @@ export class ProjectRevisionRepository implements ProjectRevisionStore {
.values({ .values({
id: changeSetId, id: changeSetId,
projectRevisionId: revisionId, projectRevisionId: revisionId,
commandType: input.commandType, commandType: input.forward.type,
payloadSchemaVersion: input.forward.schemaVersion, payloadSchemaVersion: input.forward.schemaVersion,
forwardPayloadJson: JSON.stringify(input.forward.data), forwardPayloadJson,
inversePayloadJson: JSON.stringify(input.inverse.data), inversePayloadJson,
}) })
.run(); .run();
@@ -107,16 +110,11 @@ export class ProjectRevisionRepository implements ProjectRevisionStore {
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) { if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) {
throw new Error("Expected project revision must be a non-negative integer."); 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 ( if (
!Number.isSafeInteger(input.forward.schemaVersion) ||
input.forward.schemaVersion < 1 ||
input.forward.schemaVersion !== input.inverse.schemaVersion input.forward.schemaVersion !== input.inverse.schemaVersion
) { ) {
throw new Error( throw new Error(
"Forward and inverse payloads require the same positive schema version." "Forward and inverse project commands require the same schema version."
); );
} }
} }
@@ -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<object>());
}
function assertJsonValue(value: unknown, parents: WeakSet<object>): 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<string, unknown> {
if (value === null || typeof value !== "object") {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
+4 -16
View File
@@ -1,3 +1,5 @@
import type { SerializedProjectCommand } from "../models/project-command.model.js";
export type ProjectRevisionSource = export type ProjectRevisionSource =
| "user" | "user"
| "undo" | "undo"
@@ -5,28 +7,14 @@ export type ProjectRevisionSource =
| "restore" | "restore"
| "migration"; | "migration";
export type ProjectChangeJsonValue =
| null
| boolean
| number
| string
| ProjectChangeJsonValue[]
| { [key: string]: ProjectChangeJsonValue };
export interface ProjectChangePayload {
schemaVersion: number;
data: ProjectChangeJsonValue;
}
export interface AppendProjectRevisionInput { export interface AppendProjectRevisionInput {
projectId: string; projectId: string;
expectedRevision: number; expectedRevision: number;
source: ProjectRevisionSource; source: ProjectRevisionSource;
commandType: string;
description?: string; description?: string;
actorId?: string; actorId?: string;
forward: ProjectChangePayload; forward: SerializedProjectCommand;
inverse: ProjectChangePayload; inverse: SerializedProjectCommand;
} }
export interface AppendedProjectRevision { export interface AppendedProjectRevision {
+74
View File
@@ -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<string, unknown> = {};
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/
);
});
});
+12 -3
View File
@@ -32,16 +32,17 @@ function appendTestRevision(
projectId: "project-1", projectId: "project-1",
expectedRevision, expectedRevision,
source: "user", source: "user",
commandType: "circuit.update",
description: "Stromkreis bearbeiten", description: "Stromkreis bearbeiten",
actorId: "test-user", actorId: "test-user",
forward: { forward: {
schemaVersion: 1, schemaVersion: 1,
data: { circuitId: "circuit-1", displayName: "Neu" }, type: "circuit.update",
payload: { circuitId: "circuit-1", displayName: "Neu" },
}, },
inverse: { inverse: {
schemaVersion: 1, 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", commandType: "circuit.update",
payloadSchemaVersion: 1, payloadSchemaVersion: 1,
forwardPayloadJson: JSON.stringify({ forwardPayloadJson: JSON.stringify({
schemaVersion: 1,
type: "circuit.update",
payload: {
circuitId: "circuit-1", circuitId: "circuit-1",
displayName: "Neu", displayName: "Neu",
},
}), }),
inversePayloadJson: JSON.stringify({ inversePayloadJson: JSON.stringify({
schemaVersion: 1,
type: "circuit.update",
payload: {
circuitId: "circuit-1", circuitId: "circuit-1",
displayName: "Alt", displayName: "Alt",
},
}), }),
}); });