Add serializable project commands
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user