Add project revision foundation
This commit is contained in:
@@ -5,6 +5,10 @@
|
|||||||
The circuit-first editor uses tree, circuit, row and project-device endpoints.
|
The circuit-first editor uses tree, circuit, row and project-device endpoints.
|
||||||
All paths below are mounted below `/api`. There is no Consumer application API.
|
All paths below are mounted below `/api`. There is no Consumer application API.
|
||||||
|
|
||||||
|
Project responses from `GET /projects` and `GET /projects/:projectId` include
|
||||||
|
`currentRevision`. It currently exposes the persisted revision foundation only;
|
||||||
|
no public history, undo or redo endpoint exists yet.
|
||||||
|
|
||||||
## Circuit-First Endpoints
|
## Circuit-First Endpoints
|
||||||
|
|
||||||
### Distribution Board Setup
|
### Distribution Board Setup
|
||||||
|
|||||||
@@ -62,9 +62,12 @@ liegt über einen Host-Mount außerhalb des Containers.
|
|||||||
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
|
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
|
||||||
Viewport soweit möglich wieder her.
|
Viewport soweit möglich wieder her.
|
||||||
|
|
||||||
Die React-Historie ist derzeit sitzungslokal. Persistente Projektrevisionen,
|
Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits
|
||||||
optimistische Revisionsprüfungen und serverseitiges Undo/Redo sind die nächste
|
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
|
||||||
Architekturphase.
|
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
||||||
|
und atomar fortschreiben. Bestehende Fachoperationen sind aber noch nicht an
|
||||||
|
diese Grenze angeschlossen; serverseitiges Undo/Redo ist daher noch nicht
|
||||||
|
verfügbar.
|
||||||
|
|
||||||
## Projektgeräte
|
## Projektgeräte
|
||||||
|
|
||||||
|
|||||||
@@ -137,11 +137,23 @@ Completed foundation:
|
|||||||
- database backup and independent restore verification are automated
|
- database backup and independent restore verification are automated
|
||||||
- the Consumer application path is removed and stable domain IDs are preserved
|
- the Consumer application path is removed and stable domain IDs are preserved
|
||||||
- editor grid projection and safety rules are separated from React rendering
|
- editor grid projection and safety rules are separated from React rendering
|
||||||
|
- projects carry a monotonic `currentRevision` counter starting at zero
|
||||||
|
- project revision metadata and versioned forward/inverse change-set payloads
|
||||||
|
have separate persistence entities
|
||||||
|
- the SQLite revision repository advances the project counter and stores both
|
||||||
|
history records atomically
|
||||||
|
- revision appends use an expected-revision compare-and-set and reject stale
|
||||||
|
callers without writing partial history
|
||||||
|
|
||||||
Remaining constraints before implementing history:
|
Remaining constraints before implementing history:
|
||||||
|
|
||||||
- model project-scoped command descriptions independently from React callbacks
|
- model project-scoped command descriptions independently from React callbacks
|
||||||
- route all future history-enabled mutations through one project revision boundary
|
- route domain writes and revision recording through one shared project
|
||||||
|
transaction boundary; the current revision repository only commits history
|
||||||
|
records and the counter together
|
||||||
|
- expose revision state and history eligibility through the application API
|
||||||
|
- replace the session-local frontend command stack only after server-side
|
||||||
|
command execution and inverse application are complete
|
||||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
- keep database backup/restore checks separate from project history tests
|
- keep database backup/restore checks separate from project history tests
|
||||||
|
|
||||||
|
|||||||
@@ -354,14 +354,31 @@ Persist project-wide change history and restore points across reloads and applic
|
|||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- add project-scoped monotonic revisions
|
- [x] add project-scoped monotonic revision storage
|
||||||
- record immutable server-side change sets with before/after state
|
- [x] add immutable append-only server-side change-set storage with versioned
|
||||||
- add optimistic revision checks for stale commands
|
forward/inverse payloads
|
||||||
|
- [x] add optimistic checks when appending a revision
|
||||||
|
- route domain writes and history records through one shared transaction
|
||||||
|
- model serializable domain command descriptions 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
|
||||||
- keep database backups separate from logical history
|
- keep database backups separate from logical history
|
||||||
|
|
||||||
|
Implemented foundation:
|
||||||
|
|
||||||
|
- projects start at revision zero and advance monotonically
|
||||||
|
- each revision has one separately stored logical change set
|
||||||
|
- revision metadata, change-set payloads and the project counter are committed
|
||||||
|
in one SQLite transaction
|
||||||
|
- a stale expected revision produces no history writes
|
||||||
|
- integration coverage verifies sequential revisions, stale-command rejection
|
||||||
|
and rollback after a forced late persistence failure
|
||||||
|
|
||||||
|
The current editor operations are not connected to this history boundary yet.
|
||||||
|
Undo/redo therefore remains session-local until the remaining tasks are
|
||||||
|
implemented.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
- undo/redo remains available after a page reload
|
- undo/redo remains available after a page reload
|
||||||
|
|||||||
+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/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-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/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",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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,
|
"when": 1784832541202,
|
||||||
"tag": "0011_project_device_canonical_fields",
|
"tag": "0011_project_device_canonical_fields",
|
||||||
"breakpoints": true
|
"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,
|
name: input.name,
|
||||||
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
|
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
|
||||||
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
|
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
|
||||||
|
currentRevision: 0,
|
||||||
};
|
};
|
||||||
await db.insert(projects).values(project);
|
await db.insert(projects).values(project);
|
||||||
return 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(),
|
name: text("name").notNull(),
|
||||||
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
|
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
|
||||||
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
|
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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export interface ProjectDto {
|
|||||||
name: string;
|
name: string;
|
||||||
singlePhaseVoltageV: number;
|
singlePhaseVoltageV: number;
|
||||||
threePhaseVoltageV: number;
|
threePhaseVoltageV: number;
|
||||||
|
currentRevision: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistributionBoardDto {
|
export interface DistributionBoardDto {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import {
|
||||||
|
ProjectRevisionConflictError,
|
||||||
|
ProjectRevisionRepository,
|
||||||
|
} from "../src/db/repositories/project-revision.repository.js";
|
||||||
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTestRevision(
|
||||||
|
repository: ProjectRevisionRepository,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return repository.append({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision,
|
||||||
|
source: "user",
|
||||||
|
commandType: "circuit.update",
|
||||||
|
description: "Stromkreis bearbeiten",
|
||||||
|
actorId: "test-user",
|
||||||
|
forward: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
data: { circuitId: "circuit-1", displayName: "Neu" },
|
||||||
|
},
|
||||||
|
inverse: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
data: { circuitId: "circuit-1", displayName: "Alt" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project revision repository", () => {
|
||||||
|
it("appends immutable revision metadata and payloads with a monotonic number", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectRevisionRepository(context.db);
|
||||||
|
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 0);
|
||||||
|
const appended = appendTestRevision(repository, 0);
|
||||||
|
|
||||||
|
assert.equal(appended.projectId, "project-1");
|
||||||
|
assert.equal(appended.revisionNumber, 1);
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 1);
|
||||||
|
|
||||||
|
const revision = context.db
|
||||||
|
.select()
|
||||||
|
.from(projectRevisions)
|
||||||
|
.where(eq(projectRevisions.id, appended.revisionId))
|
||||||
|
.get();
|
||||||
|
const changeSet = context.db
|
||||||
|
.select()
|
||||||
|
.from(projectChangeSets)
|
||||||
|
.where(eq(projectChangeSets.id, appended.changeSetId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
assert.deepEqual(revision, {
|
||||||
|
id: appended.revisionId,
|
||||||
|
projectId: "project-1",
|
||||||
|
revisionNumber: 1,
|
||||||
|
createdAtIso: appended.createdAtIso,
|
||||||
|
actorId: "test-user",
|
||||||
|
source: "user",
|
||||||
|
description: "Stromkreis bearbeiten",
|
||||||
|
});
|
||||||
|
assert.deepEqual(changeSet, {
|
||||||
|
id: appended.changeSetId,
|
||||||
|
projectRevisionId: appended.revisionId,
|
||||||
|
commandType: "circuit.update",
|
||||||
|
payloadSchemaVersion: 1,
|
||||||
|
forwardPayloadJson: JSON.stringify({
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
displayName: "Neu",
|
||||||
|
}),
|
||||||
|
inversePayloadJson: JSON.stringify({
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
displayName: "Alt",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = appendTestRevision(repository, 1);
|
||||||
|
assert.equal(next.revisionNumber, 2);
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 2);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stale expected revision without writing history", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectRevisionRepository(context.db);
|
||||||
|
appendTestRevision(repository, 0);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => appendTestRevision(repository, 0),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ProjectRevisionConflictError &&
|
||||||
|
error.expectedRevision === 0 &&
|
||||||
|
error.actualRevision === 1
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 1);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the project counter and revision when change-set storage fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_project_change_set_insert
|
||||||
|
BEFORE INSERT ON project_change_sets
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced change-set failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new ProjectRevisionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => appendTestRevision(repository, 0),
|
||||||
|
/forced change-set failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repository.getCurrentRevision("project-1"), 0);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user