Add project revision foundation

This commit is contained in:
2026-07-23 21:17:48 +02:00
parent 6b6d2c2a42
commit 903d977443
16 changed files with 2052 additions and 9 deletions
@@ -0,0 +1,24 @@
CREATE TABLE `project_change_sets` (
`id` text PRIMARY KEY NOT NULL,
`project_revision_id` text NOT NULL,
`command_type` text NOT NULL,
`payload_schema_version` integer NOT NULL,
`forward_payload_json` text NOT NULL,
`inverse_payload_json` text NOT NULL,
FOREIGN KEY (`project_revision_id`) REFERENCES `project_revisions`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_change_sets_revision_unique` ON `project_change_sets` (`project_revision_id`);--> statement-breakpoint
CREATE TABLE `project_revisions` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`revision_number` integer NOT NULL,
`created_at_iso` text NOT NULL,
`actor_id` text,
`source` text NOT NULL,
`description` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_revisions_project_number_unique` ON `project_revisions` (`project_id`,`revision_number`);--> statement-breakpoint
ALTER TABLE `projects` ADD `current_revision` integer DEFAULT 0 NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -85,6 +85,13 @@
"when": 1784832541202,
"tag": "0011_project_device_canonical_fields",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1784833957929,
"tag": "0012_project_revision_foundation",
"breakpoints": true
}
]
}
@@ -0,0 +1,123 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import type {
AppendProjectRevisionInput,
AppendedProjectRevision,
ProjectRevisionStore,
} from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectRevisions } from "../schema/project-revisions.js";
import { projects } from "../schema/projects.js";
export class ProjectRevisionConflictError extends Error {
constructor(
readonly projectId: string,
readonly expectedRevision: number,
readonly actualRevision: number | null
) {
super(
actualRevision === null
? `Project ${projectId} does not exist.`
: `Project ${projectId} is at revision ${actualRevision}, expected ${expectedRevision}.`
);
this.name = "ProjectRevisionConflictError";
}
}
export class ProjectRevisionRepository implements ProjectRevisionStore {
constructor(private readonly database: AppDatabase) {}
getCurrentRevision(projectId: string) {
const project = this.database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, projectId))
.get();
return project?.currentRevision ?? null;
}
append(input: AppendProjectRevisionInput): AppendedProjectRevision {
this.validateInput(input);
const revisionId = crypto.randomUUID();
const changeSetId = crypto.randomUUID();
const revisionNumber = input.expectedRevision + 1;
const createdAtIso = new Date().toISOString();
return this.database.transaction((tx) => {
const revisionUpdate = tx
.update(projects)
.set({ currentRevision: revisionNumber })
.where(
and(
eq(projects.id, input.projectId),
eq(projects.currentRevision, input.expectedRevision)
)
)
.run();
if (revisionUpdate.changes !== 1) {
const current = tx
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
current?.currentRevision ?? null
);
}
tx.insert(projectRevisions)
.values({
id: revisionId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
actorId: input.actorId,
source: input.source,
description: input.description,
})
.run();
tx.insert(projectChangeSets)
.values({
id: changeSetId,
projectRevisionId: revisionId,
commandType: input.commandType,
payloadSchemaVersion: input.forward.schemaVersion,
forwardPayloadJson: JSON.stringify(input.forward.data),
inversePayloadJson: JSON.stringify(input.inverse.data),
})
.run();
return {
revisionId,
changeSetId,
projectId: input.projectId,
revisionNumber,
createdAtIso,
};
});
}
private validateInput(input: AppendProjectRevisionInput) {
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."
);
}
}
}
@@ -19,6 +19,7 @@ export class ProjectRepository {
name: input.name,
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
currentRevision: 0,
};
await db.insert(projects).values(project);
return project;
+19
View File
@@ -0,0 +1,19 @@
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { projectRevisions } from "./project-revisions.js";
export const projectChangeSets = sqliteTable(
"project_change_sets",
{
id: text("id").primaryKey(),
projectRevisionId: text("project_revision_id")
.notNull()
.references(() => projectRevisions.id, { onDelete: "cascade" }),
commandType: text("command_type").notNull(),
payloadSchemaVersion: integer("payload_schema_version").notNull(),
forwardPayloadJson: text("forward_payload_json").notNull(),
inversePayloadJson: text("inverse_payload_json").notNull(),
},
(table) => [
unique("project_change_sets_revision_unique").on(table.projectRevisionId),
]
);
+23
View File
@@ -0,0 +1,23 @@
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
export const projectRevisions = sqliteTable(
"project_revisions",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
revisionNumber: integer("revision_number").notNull(),
createdAtIso: text("created_at_iso").notNull(),
actorId: text("actor_id"),
source: text("source").notNull(),
description: text("description"),
},
(table) => [
unique("project_revisions_project_number_unique").on(
table.projectId,
table.revisionNumber
),
]
);
+1
View File
@@ -5,4 +5,5 @@ export const projects = sqliteTable("projects", {
name: text("name").notNull(),
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
currentRevision: integer("current_revision").notNull().default(0),
});
@@ -0,0 +1,43 @@
export type ProjectRevisionSource =
| "user"
| "undo"
| "redo"
| "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;
}
export interface AppendedProjectRevision {
revisionId: string;
changeSetId: string;
projectId: string;
revisionNumber: number;
createdAtIso: string;
}
export interface ProjectRevisionStore {
getCurrentRevision(projectId: string): number | null;
append(input: AppendProjectRevisionInput): AppendedProjectRevision;
}
+1
View File
@@ -3,6 +3,7 @@ export interface ProjectDto {
name: string;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
currentRevision: number;
}
export interface DistributionBoardDto {