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
@@ -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."
);
}
}
@@ -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 =
| "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 {