Expose project revision timeline

This commit is contained in:
2026-07-25 21:57:03 +02:00
parent 4b47bc2bcc
commit 5a0c9019af
14 changed files with 365 additions and 8 deletions
+3
View File
@@ -227,6 +227,9 @@ and CircuitDeviceRow writes execute persistent project commands. Applying a
sorted view across multiple sections is one atomic `circuit.reorder-sections`
command and one undo step. Explicit renumbering uses the collision-safe
`circuit.renumber-section` command and is never triggered implicitly.
Immutable revision metadata is available through the paginated
`GET /api/projects/:projectId/history/revisions` endpoint; it does not expose
stored command payloads.
Insertions and generated move targets use client-generated stable UUIDs, and
undo restores the same ids from complete server snapshots. The tree response
supplies the optimistic `currentRevision`; obsolete direct field PATCH,
+10
View File
@@ -14,6 +14,16 @@ this value for optimistic concurrency checks.
- `GET /projects/:projectId/history`
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
change-set id for both persistent stacks
- `GET /projects/:projectId/history/revisions`
- returns revision metadata in descending `revisionNumber` order
- optional query: `limit` (default `25`, maximum `100`) and exclusive
`beforeRevision`
- returns `projectId`, `currentRevision`, `revisions` and
`nextBeforeRevision`; use the latter as the next page's
`beforeRevision`
- each revision contains ids, number, timestamp, actor, source, description,
command type and payload schema version
- stored forward/inverse command payloads are intentionally not exposed
- `POST /projects/:projectId/commands`
- executes a supported versioned command as a new user revision
- body: `{ "expectedRevision": 0, "command": { ... } }`
@@ -4,8 +4,9 @@
persistent project commands and project-wide toolbar undo/redo, including
moves, atomic multi-section reorders and explicit renumbering. Undo/redo
eligibility is restored from server history after a page reload.
- There is no browsable revision timeline yet; the editor currently exposes
only the next eligible Undo/Redo actions.
- A paginated revision-metadata API exists, but there is no browsable timeline
UI yet; the editor currently exposes only the next eligible Undo/Redo
actions.
- Named logical project snapshots and restore are not implemented yet.
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
+3 -1
View File
@@ -78,7 +78,9 @@ Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
`GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt
`GET /api/projects/:projectId/history` lesbar. Die unveränderliche,
absteigend paginierte Revisions-Timeline ist ohne Befehls-Payloads über
`GET /api/projects/:projectId/history/revisions` lesbar. Ein zentraler Dispatcher führt
die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare
@@ -170,6 +170,9 @@ Completed foundation:
a forced stack failure rolls all of them back
- `GET /api/projects/:projectId/history` exposes current revision, stack depths
and top change-set ids
- `GET /api/projects/:projectId/history/revisions` exposes a descending,
cursor-paginated metadata timeline without leaking persisted forward or
inverse command payloads
- a central application dispatcher executes the supported `circuit.update`
and `circuit-device-row.update` envelopes without coupling the domain service
to SQLite
@@ -226,7 +229,7 @@ Completed foundation:
Remaining constraints before completing project version history:
- add a browsable revision timeline plus named logical snapshots and restore
- add a browsable timeline UI plus named logical snapshots and restore
- extend project-scoped commands only when further project mutations are
deliberately added to history; the generic versioned envelope is complete
- do not model IFCGUID as an overloaded circuit equipment identifier
@@ -364,6 +364,8 @@ Tasks:
- [x] persist undo/redo eligibility on the server
- [x] expose public command, undo and redo endpoints for the first supported
update command types
- [x] expose a cursor-paginated, read-only revision metadata timeline without
command payloads
- add named and periodic logical project snapshots
- restore a historical snapshot as a new revision
- keep database backups separate from logical history
@@ -387,6 +389,8 @@ Implemented foundation:
late-failure rollback coverage
- a read-only project history endpoint exposes revision, stack depths and top
change-set ids
- a separate read-only timeline endpoint exposes immutable revision metadata
in descending, cursor-paginated pages without forward/inverse payloads
- a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-command.service.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-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts",
@@ -1,11 +1,14 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { and, asc, desc, eq, lt } from "drizzle-orm";
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type {
ProjectHistoryCommand,
ProjectHistoryDirection,
ListProjectRevisionsInput,
ProjectRevisionPage,
ProjectHistoryState,
ProjectHistoryStore,
} from "../../domain/ports/project-history.store.js";
import type { ProjectRevisionSource } from "../../domain/ports/project-revision.store.js";
import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
@@ -47,6 +50,74 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
};
}
listRevisions(
input: ListProjectRevisionsInput
): ProjectRevisionPage | null {
validateRevisionPageInput(input);
const project = this.database
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
return null;
}
const projectCondition = eq(
projectRevisions.projectId,
input.projectId
);
const rows = this.database
.select({
revisionId: projectRevisions.id,
changeSetId: projectChangeSets.id,
revisionNumber: projectRevisions.revisionNumber,
createdAtIso: projectRevisions.createdAtIso,
actorId: projectRevisions.actorId,
source: projectRevisions.source,
description: projectRevisions.description,
commandType: projectChangeSets.commandType,
payloadSchemaVersion: projectChangeSets.payloadSchemaVersion,
})
.from(projectRevisions)
.innerJoin(
projectChangeSets,
eq(
projectChangeSets.projectRevisionId,
projectRevisions.id
)
)
.where(
input.beforeRevision === undefined
? projectCondition
: and(
projectCondition,
lt(
projectRevisions.revisionNumber,
input.beforeRevision
)
)
)
.orderBy(desc(projectRevisions.revisionNumber))
.limit(input.limit + 1)
.all();
const hasMore = rows.length > input.limit;
const revisions = rows.slice(0, input.limit).map((row) => ({
...row,
source: row.source as ProjectRevisionSource,
}));
return {
projectId: input.projectId,
currentRevision: project.currentRevision,
revisions,
nextBeforeRevision:
hasMore && revisions.length > 0
? revisions.at(-1)!.revisionNumber
: null,
};
}
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
@@ -93,3 +164,22 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
};
}
}
function validateRevisionPageInput(input: ListProjectRevisionsInput) {
if (
!Number.isSafeInteger(input.limit) ||
input.limit < 1 ||
input.limit > 100
) {
throw new Error("Project revision page limit must be between 1 and 100.");
}
if (
input.beforeRevision !== undefined &&
(!Number.isSafeInteger(input.beforeRevision) ||
input.beforeRevision < 1)
) {
throw new Error(
"Project revision cursor must be a positive integer."
);
}
}
+29
View File
@@ -1,4 +1,5 @@
import type { SerializedProjectCommand } from "../models/project-command.model.js";
import type { ProjectRevisionSource } from "./project-revision.store.js";
export type ProjectHistoryDirection = "undo" | "redo";
@@ -16,8 +17,36 @@ export interface ProjectHistoryCommand {
command: SerializedProjectCommand;
}
export interface ProjectRevisionSummary {
revisionId: string;
changeSetId: string;
revisionNumber: number;
createdAtIso: string;
actorId: string | null;
source: ProjectRevisionSource;
description: string | null;
commandType: string;
payloadSchemaVersion: number;
}
export interface ProjectRevisionPage {
projectId: string;
currentRevision: number;
revisions: ProjectRevisionSummary[];
nextBeforeRevision: number | null;
}
export interface ListProjectRevisionsInput {
projectId: string;
limit: number;
beforeRevision?: number;
}
export interface ProjectHistoryStore {
getState(projectId: string): ProjectHistoryState | null;
listRevisions(
input: ListProjectRevisionsInput
): ProjectRevisionPage | null;
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
@@ -1,4 +1,5 @@
import type { Request, Response } from "express";
import { listProjectRevisionsQuerySchema } from "../../shared/validation/project-history.schemas.js";
import { projectHistoryStore } from "../composition/project-command-stores.js";
export function getProjectHistory(req: Request, res: Response) {
@@ -13,3 +14,23 @@ export function getProjectHistory(req: Request, res: Response) {
}
return res.json(state);
}
export function listProjectRevisions(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = listProjectRevisionsQuerySchema.safeParse(req.query);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const page = projectHistoryStore.listRevisions({
projectId,
...parsed.data,
});
if (!page) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(page);
}
+5 -1
View File
@@ -13,7 +13,10 @@ 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";
import {
getProjectHistory,
listProjectRevisions,
} from "../controllers/project-history.controller.js";
import {
executeProjectCommand,
redoProjectCommand,
@@ -26,6 +29,7 @@ projectRouter.get("/", listProjects);
projectRouter.post("/", createProject);
projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand);
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
@@ -0,0 +1,8 @@
import { z } from "zod";
export const listProjectRevisionsQuerySchema = z
.object({
limit: z.coerce.number().int().min(1).max(100).default(25),
beforeRevision: z.coerce.number().int().positive().optional(),
})
.strict();
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { listProjectRevisionsQuerySchema } from "../src/shared/validation/project-history.schemas.js";
describe("project revision timeline query", () => {
it("applies a bounded default page size", () => {
assert.deepEqual(
listProjectRevisionsQuerySchema.parse({}),
{ limit: 25 }
);
assert.deepEqual(
listProjectRevisionsQuerySchema.parse({
limit: "100",
beforeRevision: "42",
}),
{
limit: 100,
beforeRevision: 42,
}
);
});
it("rejects invalid cursors, page sizes and unknown query fields", () => {
for (const query of [
{ limit: "0" },
{ limit: "101" },
{ limit: "1.5" },
{ beforeRevision: "0" },
{ beforeRevision: "not-a-number" },
{ unexpected: "value" },
]) {
assert.equal(
listProjectRevisionsQuerySchema.safeParse(query).success,
false
);
}
});
});
+144
View File
@@ -83,6 +83,150 @@ function getRowQuantity(context: DatabaseContext) {
}
describe("project history repository", () => {
it("lists revision metadata in stable descending pages without payloads", () => {
const context = createTestDatabase();
try {
const circuitsStore = new CircuitProjectCommandRepository(context.db);
const rowsStore = new CircuitDeviceRowProjectCommandRepository(context.db);
const first = circuitsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
actorId: "planner-1",
description: "Stromkreis umbenennen",
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Neu",
}),
});
const second = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
description: "Anzahl ändern",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
}),
});
const third = rowsStore.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
description: "Anzahl zurücknehmen",
historyTargetChangeSetId: second.revision.changeSetId,
command: second.inverse,
});
const history = new ProjectHistoryRepository(context.db);
assert.deepEqual(
history.listRevisions({
projectId: "project-1",
limit: 2,
}),
{
projectId: "project-1",
currentRevision: 3,
revisions: [
{
revisionId: third.revision.revisionId,
changeSetId: third.revision.changeSetId,
revisionNumber: 3,
createdAtIso: third.revision.createdAtIso,
actorId: null,
source: "undo",
description: "Anzahl zurücknehmen",
commandType: "circuit-device-row.update",
payloadSchemaVersion: 1,
},
{
revisionId: second.revision.revisionId,
changeSetId: second.revision.changeSetId,
revisionNumber: 2,
createdAtIso: second.revision.createdAtIso,
actorId: null,
source: "user",
description: "Anzahl ändern",
commandType: "circuit-device-row.update",
payloadSchemaVersion: 1,
},
],
nextBeforeRevision: 2,
}
);
assert.deepEqual(
history.listRevisions({
projectId: "project-1",
limit: 2,
beforeRevision: 2,
}),
{
projectId: "project-1",
currentRevision: 3,
revisions: [
{
revisionId: first.revision.revisionId,
changeSetId: first.revision.changeSetId,
revisionNumber: 1,
createdAtIso: first.revision.createdAtIso,
actorId: "planner-1",
source: "user",
description: "Stromkreis umbenennen",
commandType: "circuit.update",
payloadSchemaVersion: 1,
},
],
nextBeforeRevision: null,
}
);
} finally {
context.close();
}
});
it("lists an empty timeline and rejects invalid page inputs", () => {
const context = createTestDatabase();
try {
const history = new ProjectHistoryRepository(context.db);
assert.deepEqual(
history.listRevisions({
projectId: "project-1",
limit: 25,
}),
{
projectId: "project-1",
currentRevision: 0,
revisions: [],
nextBeforeRevision: null,
}
);
assert.equal(
history.listRevisions({
projectId: "missing",
limit: 25,
}),
null
);
assert.throws(
() =>
history.listRevisions({
projectId: "project-1",
limit: 0,
}),
/between 1 and 100/
);
assert.throws(
() =>
history.listRevisions({
projectId: "project-1",
limit: 25,
beforeRevision: 0,
}),
/positive integer/
);
} finally {
context.close();
}
});
it("persists project-wide undo and redo stacks across repository instances", () => {
const context = createTestDatabase();
try {