Add project revision foundation
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
);
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user