Add persistent history stacks

This commit is contained in:
2026-07-23 21:48:24 +02:00
parent 2d0aa11d9c
commit 1e4dd26bb8
24 changed files with 2294 additions and 15 deletions
@@ -0,0 +1,11 @@
CREATE TABLE `project_history_stack_entries` (
`change_set_id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`stack` text NOT NULL,
`position` integer NOT NULL,
FOREIGN KEY (`change_set_id`) REFERENCES `project_change_sets`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "project_history_stack_entries_stack_check" CHECK("project_history_stack_entries"."stack" in ('undo', 'redo'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `project_history_stack_entries_position_unique` ON `project_history_stack_entries` (`project_id`,`stack`,`position`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -92,6 +92,13 @@
"when": 1784833957929,
"tag": "0012_project_revision_foundation",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1784835703230,
"tag": "0013_project_history_stacks",
"breakpoints": true
}
]
}
@@ -21,6 +21,7 @@ import {
toCircuitDeviceRowPatchValues,
type CircuitDeviceRowPatchInput,
} from "./circuit-device-row.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
@@ -117,6 +118,12 @@ export class CircuitDeviceRowProjectCommandRepository
forward: appliedForward,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
@@ -18,6 +18,7 @@ import {
toCircuitPatchValues,
type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
type CircuitRow = typeof circuits.$inferSelect;
@@ -96,6 +97,12 @@ export class CircuitProjectCommandRepository
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
@@ -0,0 +1,121 @@
import { and, desc, eq, max } from "drizzle-orm";
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
export interface ApplyProjectHistoryTransitionInput {
projectId: string;
source: ProjectRevisionSource;
recordedChangeSetId: string;
targetChangeSetId?: string;
}
export function applyProjectHistoryTransition(
database: AppDatabase,
input: ApplyProjectHistoryTransitionInput
) {
if (input.source === "user") {
database
.delete(projectHistoryStackEntries)
.where(
and(
eq(projectHistoryStackEntries.projectId, input.projectId),
eq(projectHistoryStackEntries.stack, "redo")
)
)
.run();
push(
database,
input.projectId,
input.recordedChangeSetId,
"undo"
);
return;
}
if (input.source === "undo" || input.source === "redo") {
if (!input.targetChangeSetId) {
throw new Error(
`${input.source} requires a target change set.`
);
}
const sourceStack = input.source;
const targetStack = input.source === "undo" ? "redo" : "undo";
const top = database
.select({
changeSetId: projectHistoryStackEntries.changeSetId,
})
.from(projectHistoryStackEntries)
.where(
and(
eq(projectHistoryStackEntries.projectId, input.projectId),
eq(projectHistoryStackEntries.stack, sourceStack)
)
)
.orderBy(desc(projectHistoryStackEntries.position))
.limit(1)
.get();
if (!top || top.changeSetId !== input.targetChangeSetId) {
throw new Error(
`${input.source} target is not the top ${sourceStack} command.`
);
}
const nextPosition = getNextPosition(
database,
input.projectId,
targetStack
);
const moved = database
.update(projectHistoryStackEntries)
.set({ stack: targetStack, position: nextPosition })
.where(
and(
eq(projectHistoryStackEntries.projectId, input.projectId),
eq(
projectHistoryStackEntries.changeSetId,
input.targetChangeSetId
)
)
)
.run();
if (moved.changes !== 1) {
throw new Error("Project history stack changed during transition.");
}
}
}
function push(
database: AppDatabase,
projectId: string,
changeSetId: string,
stack: "undo" | "redo"
) {
database
.insert(projectHistoryStackEntries)
.values({
projectId,
changeSetId,
stack,
position: getNextPosition(database, projectId, stack),
})
.run();
}
function getNextPosition(
database: AppDatabase,
projectId: string,
stack: "undo" | "redo"
) {
const current = database
.select({ position: max(projectHistoryStackEntries.position) })
.from(projectHistoryStackEntries)
.where(
and(
eq(projectHistoryStackEntries.projectId, projectId),
eq(projectHistoryStackEntries.stack, stack)
)
)
.get();
return (current?.position ?? 0) + 1;
}
@@ -0,0 +1,44 @@
import { asc, eq } from "drizzle-orm";
import type {
ProjectHistoryState,
ProjectHistoryStore,
} from "../../domain/ports/project-history.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
import { projects } from "../schema/projects.js";
export class ProjectHistoryRepository implements ProjectHistoryStore {
constructor(private readonly database: AppDatabase) {}
getState(projectId: string): ProjectHistoryState | null {
const project = this.database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return null;
}
const entries = this.database
.select()
.from(projectHistoryStackEntries)
.where(eq(projectHistoryStackEntries.projectId, projectId))
.orderBy(
asc(projectHistoryStackEntries.stack),
asc(projectHistoryStackEntries.position)
)
.all();
const undoEntries = entries.filter((entry) => entry.stack === "undo");
const redoEntries = entries.filter((entry) => entry.stack === "redo");
return {
projectId,
currentRevision: project.currentRevision,
undoDepth: undoEntries.length,
redoDepth: redoEntries.length,
undoChangeSetId: undoEntries.at(-1)?.changeSetId ?? null,
redoChangeSetId: redoEntries.at(-1)?.changeSetId ?? null,
};
}
}
@@ -0,0 +1,35 @@
import { sql } from "drizzle-orm";
import {
check,
integer,
sqliteTable,
text,
unique,
} from "drizzle-orm/sqlite-core";
import { projectChangeSets } from "./project-change-sets.js";
import { projects } from "./projects.js";
export const projectHistoryStackEntries = sqliteTable(
"project_history_stack_entries",
{
changeSetId: text("change_set_id")
.primaryKey()
.references(() => projectChangeSets.id, { onDelete: "cascade" }),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
stack: text("stack").notNull(),
position: integer("position").notNull(),
},
(table) => [
check(
"project_history_stack_entries_stack_check",
sql`${table.stack} in ('undo', 'redo')`
),
unique("project_history_stack_entries_position_unique").on(
table.projectId,
table.stack,
table.position
),
]
);
@@ -10,6 +10,7 @@ export interface ExecuteCircuitDeviceRowUpdateCommandInput {
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitDeviceRowUpdateProjectCommand;
}
@@ -10,6 +10,7 @@ export interface ExecuteCircuitUpdateCommandInput {
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitUpdateProjectCommand;
}
+12
View File
@@ -0,0 +1,12 @@
export interface ProjectHistoryState {
projectId: string;
currentRevision: number;
undoDepth: number;
redoDepth: number;
undoChangeSetId: string | null;
redoChangeSetId: string | null;
}
export interface ProjectHistoryStore {
getState(projectId: string): ProjectHistoryState | null;
}
+9
View File
@@ -6,6 +6,15 @@ export interface ProjectDto {
currentRevision: number;
}
export interface ProjectHistoryStateDto {
projectId: string;
currentRevision: number;
undoDepth: number;
redoDepth: number;
undoChangeSetId: string | null;
redoChangeSetId: string | null;
}
export interface DistributionBoardDto {
id: string;
projectId: string;
+7
View File
@@ -8,6 +8,7 @@ import type {
FloorDto,
GlobalDeviceDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
ProjectDeviceDisconnectResultDto,
ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
@@ -54,6 +55,12 @@ export function getProject(projectId: string) {
return request<ProjectDto>(`/api/projects/${projectId}`);
}
export function getProjectHistory(projectId: string) {
return request<ProjectHistoryStateDto>(
`/api/projects/${projectId}/history`
);
}
export function createProject(name: string) {
return request<ProjectDto>("/api/projects", {
method: "POST",
@@ -0,0 +1,18 @@
import type { Request, Response } from "express";
import { db } from "../../db/client.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
const projectHistoryRepository = new ProjectHistoryRepository(db);
export function getProjectHistory(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const state = projectHistoryRepository.getState(projectId);
if (!state) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(state);
}
+2
View File
@@ -13,12 +13,14 @@ import { listCircuitListsByProject } from "../controllers/circuit-list.controlle
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getProjectHistory } from "../controllers/project-history.controller.js";
export const projectRouter = Router();
projectRouter.get("/", listProjects);
projectRouter.post("/", createProject);
projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);