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` sorted view across multiple sections is one atomic `circuit.reorder-sections`
command and one undo step. Explicit renumbering uses the collision-safe command and one undo step. Explicit renumbering uses the collision-safe
`circuit.renumber-section` command and is never triggered implicitly. `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 Insertions and generated move targets use client-generated stable UUIDs, and
undo restores the same ids from complete server snapshots. The tree response undo restores the same ids from complete server snapshots. The tree response
supplies the optimistic `currentRevision`; obsolete direct field PATCH, 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` - `GET /projects/:projectId/history`
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top - returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
change-set id for both persistent stacks 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` - `POST /projects/:projectId/commands`
- executes a supported versioned command as a new user revision - executes a supported versioned command as a new user revision
- body: `{ "expectedRevision": 0, "command": { ... } }` - body: `{ "expectedRevision": 0, "command": { ... } }`
@@ -4,8 +4,9 @@
persistent project commands and project-wide toolbar undo/redo, including persistent project commands and project-wide toolbar undo/redo, including
moves, atomic multi-section reorders and explicit renumbering. Undo/redo moves, atomic multi-section reorders and explicit renumbering. Undo/redo
eligibility is restored from server history after a page reload. eligibility is restored from server history after a page reload.
- There is no browsable revision timeline yet; the editor currently exposes - A paginated revision-metadata API exists, but there is no browsable timeline
only the next eligible Undo/Redo actions. UI yet; the editor currently exposes only the next eligible Undo/Redo
actions.
- Named logical project snapshots and restore are not implemented yet. - Named logical project snapshots and restore are not implemented yet.
- No Revit/CSV/IFCGUID import and export workflow is 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. - 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, prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über 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 die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind öffentliche Command-, Undo- und Redo-Endpunkte aus. Zusätzlich sind
`circuit-device-row.insert` und `circuit-device-row.delete` als atomare `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 a forced stack failure rolls all of them back
- `GET /api/projects/:projectId/history` exposes current revision, stack depths - `GET /api/projects/:projectId/history` exposes current revision, stack depths
and top change-set ids 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` - a central application dispatcher executes the supported `circuit.update`
and `circuit-device-row.update` envelopes without coupling the domain service and `circuit-device-row.update` envelopes without coupling the domain service
to SQLite to SQLite
@@ -226,7 +229,7 @@ Completed foundation:
Remaining constraints before completing project version history: 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 - extend project-scoped commands only when further project mutations are
deliberately added to history; the generic versioned envelope is complete deliberately added to history; the generic versioned envelope is complete
- do not model IFCGUID as an overloaded circuit equipment identifier - do not model IFCGUID as an overloaded circuit equipment identifier
@@ -364,6 +364,8 @@ Tasks:
- [x] persist undo/redo eligibility on the server - [x] persist undo/redo eligibility on the server
- [x] expose public command, undo and redo endpoints for the first supported - [x] expose public command, undo and redo endpoints for the first supported
update command types update command types
- [x] expose a cursor-paginated, read-only revision metadata timeline without
command payloads
- 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
@@ -387,6 +389,8 @@ Implemented foundation:
late-failure rollback coverage late-failure rollback coverage
- a read-only project history endpoint exposes revision, stack depths and top - a read-only project history endpoint exposes revision, stack depths and top
change-set ids 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 - a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates field updates
+2 -2
View File
@@ -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-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-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: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",
@@ -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 { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type { import type {
ProjectHistoryCommand, ProjectHistoryCommand,
ProjectHistoryDirection, ProjectHistoryDirection,
ListProjectRevisionsInput,
ProjectRevisionPage,
ProjectHistoryState, ProjectHistoryState,
ProjectHistoryStore, ProjectHistoryStore,
} from "../../domain/ports/project-history.store.js"; } 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 type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js"; import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.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( getNextCommand(
projectId: string, projectId: string,
direction: ProjectHistoryDirection 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 { SerializedProjectCommand } from "../models/project-command.model.js";
import type { ProjectRevisionSource } from "./project-revision.store.js";
export type ProjectHistoryDirection = "undo" | "redo"; export type ProjectHistoryDirection = "undo" | "redo";
@@ -16,8 +17,36 @@ export interface ProjectHistoryCommand {
command: SerializedProjectCommand; 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 { export interface ProjectHistoryStore {
getState(projectId: string): ProjectHistoryState | null; getState(projectId: string): ProjectHistoryState | null;
listRevisions(
input: ListProjectRevisionsInput
): ProjectRevisionPage | null;
getNextCommand( getNextCommand(
projectId: string, projectId: string,
direction: ProjectHistoryDirection direction: ProjectHistoryDirection
@@ -1,4 +1,5 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { listProjectRevisionsQuerySchema } from "../../shared/validation/project-history.schemas.js";
import { projectHistoryStore } from "../composition/project-command-stores.js"; import { projectHistoryStore } from "../composition/project-command-stores.js";
export function getProjectHistory(req: Request, res: Response) { export function getProjectHistory(req: Request, res: Response) {
@@ -13,3 +14,23 @@ export function getProjectHistory(req: Request, res: Response) {
} }
return res.json(state); 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 { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js"; import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.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 { import {
executeProjectCommand, executeProjectCommand,
redoProjectCommand, redoProjectCommand,
@@ -26,6 +29,7 @@ projectRouter.get("/", listProjects);
projectRouter.post("/", createProject); projectRouter.post("/", createProject);
projectRouter.get("/:projectId", getProject); projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/history", getProjectHistory); projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand); projectRouter.post("/:projectId/commands", executeProjectCommand);
projectRouter.post("/:projectId/history/undo", undoProjectCommand); projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand); 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", () => { 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", () => { it("persists project-wide undo and redo stacks across repository instances", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {