Add persistent history stacks
This commit is contained in:
@@ -220,8 +220,10 @@ After saving, the row becomes linked to the new project device.
|
|||||||
|
|
||||||
## Undo / Redo
|
## Undo / Redo
|
||||||
|
|
||||||
Session-local undo/redo exists for structural and destructive operations.
|
Session-local UI undo/redo exists for structural and destructive operations.
|
||||||
Persistent project-wide undo/redo remains future work.
|
The server already persists immutable revisions and project-wide undo/redo
|
||||||
|
eligibility for the first internal commands. Public command execution,
|
||||||
|
server-side undo/redo endpoints and the UI cutover remain future work.
|
||||||
|
|
||||||
Required operations:
|
Required operations:
|
||||||
|
|
||||||
@@ -271,7 +273,7 @@ Users must be able to override sizing suggestions.
|
|||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
- persistent project revisions and undo/redo
|
- complete persistent project command coverage and the undo/redo UI cutover
|
||||||
- named logical snapshots and restore
|
- named logical snapshots and restore
|
||||||
- Revit/CSV/IFCGUID round-trip
|
- Revit/CSV/IFCGUID round-trip
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ All paths below are mounted below `/api`. There is no Consumer application API.
|
|||||||
|
|
||||||
Project responses from `GET /projects` and `GET /projects/:projectId` include
|
Project responses from `GET /projects` and `GET /projects/:projectId` include
|
||||||
`currentRevision`. It currently exposes the persisted revision foundation only;
|
`currentRevision`. It currently exposes the persisted revision foundation only;
|
||||||
no public history, undo or redo endpoint exists yet.
|
no public undo or redo command exists yet.
|
||||||
|
|
||||||
|
### Project History State
|
||||||
|
|
||||||
|
- `GET /projects/:projectId/history`
|
||||||
|
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
|
||||||
|
change-set id for both persistent stacks
|
||||||
|
- is read-only; the editor does not consume it yet
|
||||||
|
|
||||||
## Circuit-First Endpoints
|
## Circuit-First Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -73,9 +73,12 @@ angeschlossen. Für Circuit- und Gerätezeilen-Feldänderungen existieren intern
|
|||||||
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
|
Command-Stores, die Fachänderung, automatisch erzeugtes inverses Kommando und
|
||||||
Revision gemeinsam committen beziehungsweise zurückrollen können.
|
Revision gemeinsam committen beziehungsweise zurückrollen können.
|
||||||
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
|
||||||
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Die Stores sind noch
|
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
|
||||||
nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo ist daher
|
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
|
||||||
noch nicht verfügbar.
|
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
|
||||||
|
`GET /api/projects/:projectId/history` lesbar. Die schreibenden Command-Stores
|
||||||
|
sind noch nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo
|
||||||
|
ist daher noch nicht verfügbar.
|
||||||
|
|
||||||
## Projektgeräte
|
## Projektgeräte
|
||||||
|
|
||||||
|
|||||||
@@ -160,16 +160,24 @@ Completed foundation:
|
|||||||
- integration tests apply the generated inverse as a new `undo` revision and
|
- integration tests apply the generated inverse as a new `undo` revision and
|
||||||
verify rollback for stale revisions and late history failures on both update
|
verify rollback for stale revisions and late history failures on both update
|
||||||
command types
|
command types
|
||||||
|
- project-scoped relational undo/redo stacks persist eligible original change
|
||||||
|
sets independently from immutable audit revisions
|
||||||
|
- stack transitions enforce LIFO order, keep undo/redo revisions out of the
|
||||||
|
eligibility stacks and clear the redo branch on a new user command
|
||||||
|
- domain writes, audit revisions and stack transitions share one transaction;
|
||||||
|
a forced stack failure rolls all of them back
|
||||||
|
- `GET /api/projects/:projectId/history` exposes current revision, stack depths
|
||||||
|
and top change-set ids without enabling mutation endpoints
|
||||||
|
|
||||||
Remaining constraints before implementing history:
|
Remaining constraints before completing persistent history:
|
||||||
|
|
||||||
- extend the concrete project-scoped command union and executors beyond the
|
- extend the concrete project-scoped command union and executors beyond the
|
||||||
internal Circuit and CircuitDeviceRow update slices; the generic versioned
|
internal Circuit and CircuitDeviceRow update slices; the generic versioned
|
||||||
command envelope is complete
|
command envelope is complete
|
||||||
- route domain writes and revision recording through one shared project
|
- route the remaining structural, synchronization and destructive domain
|
||||||
transaction boundary; the current revision repository only commits history
|
writes through the shared command, revision and stack transaction boundary
|
||||||
records and the counter together
|
- add public command execution plus undo/redo endpoints; read-only revision and
|
||||||
- expose revision state and history eligibility through the application API
|
stack eligibility state is already exposed
|
||||||
- replace the session-local frontend command stack only after server-side
|
- replace the session-local frontend command stack only after server-side
|
||||||
command execution and inverse application are complete
|
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
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ Tasks:
|
|||||||
- route domain writes and history records through one shared transaction
|
- route domain writes and history records through one shared transaction
|
||||||
- define concrete serializable domain command descriptions and executors
|
- define concrete serializable domain command descriptions and executors
|
||||||
outside React
|
outside React
|
||||||
- persist undo/redo eligibility on the server
|
- [x] 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
|
||||||
@@ -379,6 +379,12 @@ Implemented foundation:
|
|||||||
- an internal typed `circuit-device-row.update` command uses the same boundary,
|
- an internal typed `circuit-device-row.update` command uses the same boundary,
|
||||||
records derived ProjectDevice override metadata and rejects cross-project
|
records derived ProjectDevice override metadata and rejects cross-project
|
||||||
rows, device links and rooms
|
rows, device links and rooms
|
||||||
|
- relational project-wide undo/redo stacks persist eligible original change
|
||||||
|
sets, enforce LIFO order and clear redo on a new user command
|
||||||
|
- stack transitions are part of the domain/revision transaction and have
|
||||||
|
late-failure rollback coverage
|
||||||
|
- a read-only project history endpoint exposes revision, stack depths and top
|
||||||
|
change-set ids
|
||||||
- revision metadata, change-set payloads and the project counter are committed
|
- revision metadata, change-set payloads and the project counter are committed
|
||||||
in one SQLite transaction
|
in one SQLite transaction
|
||||||
- a stale expected revision produces no history writes
|
- a stale expected revision produces no history writes
|
||||||
|
|||||||
+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/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.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-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.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-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.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-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.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,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
@@ -92,6 +92,13 @@
|
|||||||
"when": 1784833957929,
|
"when": 1784833957929,
|
||||||
"tag": "0012_project_revision_foundation",
|
"tag": "0012_project_revision_foundation",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1784835703230,
|
||||||
|
"tag": "0013_project_history_stacks",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
toCircuitDeviceRowPatchValues,
|
toCircuitDeviceRowPatchValues,
|
||||||
type CircuitDeviceRowPatchInput,
|
type CircuitDeviceRowPatchInput,
|
||||||
} from "./circuit-device-row.persistence.js";
|
} from "./circuit-device-row.persistence.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||||
@@ -117,6 +118,12 @@ export class CircuitDeviceRowProjectCommandRepository
|
|||||||
forward: appliedForward,
|
forward: appliedForward,
|
||||||
inverse,
|
inverse,
|
||||||
});
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
return { revision, inverse };
|
return { revision, inverse };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
toCircuitPatchValues,
|
toCircuitPatchValues,
|
||||||
type CircuitPatchPersistenceInput,
|
type CircuitPatchPersistenceInput,
|
||||||
} from "./circuit.persistence.js";
|
} from "./circuit.persistence.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
|
||||||
type CircuitRow = typeof circuits.$inferSelect;
|
type CircuitRow = typeof circuits.$inferSelect;
|
||||||
@@ -96,6 +97,12 @@ export class CircuitProjectCommandRepository
|
|||||||
forward: input.command,
|
forward: input.command,
|
||||||
inverse,
|
inverse,
|
||||||
});
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
return { revision, inverse };
|
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;
|
source: ProjectRevisionSource;
|
||||||
description?: string;
|
description?: string;
|
||||||
actorId?: string;
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
command: CircuitDeviceRowUpdateProjectCommand;
|
command: CircuitDeviceRowUpdateProjectCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ExecuteCircuitUpdateCommandInput {
|
|||||||
source: ProjectRevisionSource;
|
source: ProjectRevisionSource;
|
||||||
description?: string;
|
description?: string;
|
||||||
actorId?: string;
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
command: CircuitUpdateProjectCommand;
|
command: CircuitUpdateProjectCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,15 @@ export interface ProjectDto {
|
|||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectHistoryStateDto {
|
||||||
|
projectId: string;
|
||||||
|
currentRevision: number;
|
||||||
|
undoDepth: number;
|
||||||
|
redoDepth: number;
|
||||||
|
undoChangeSetId: string | null;
|
||||||
|
redoChangeSetId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DistributionBoardDto {
|
export interface DistributionBoardDto {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
FloorDto,
|
FloorDto,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
|
ProjectHistoryStateDto,
|
||||||
ProjectDeviceDisconnectResultDto,
|
ProjectDeviceDisconnectResultDto,
|
||||||
ProjectDeviceSyncPreviewDto,
|
ProjectDeviceSyncPreviewDto,
|
||||||
ProjectDeviceSyncRestoreRowDto,
|
ProjectDeviceSyncRestoreRowDto,
|
||||||
@@ -54,6 +55,12 @@ export function getProject(projectId: string) {
|
|||||||
return request<ProjectDto>(`/api/projects/${projectId}`);
|
return request<ProjectDto>(`/api/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProjectHistory(projectId: string) {
|
||||||
|
return request<ProjectHistoryStateDto>(
|
||||||
|
`/api/projects/${projectId}/history`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createProject(name: string) {
|
export function createProject(name: string) {
|
||||||
return request<ProjectDto>("/api/projects", {
|
return request<ProjectDto>("/api/projects", {
|
||||||
method: "POST",
|
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);
|
||||||
|
}
|
||||||
@@ -13,12 +13,14 @@ 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";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
projectRouter.get("/", listProjects);
|
projectRouter.get("/", listProjects);
|
||||||
projectRouter.post("/", createProject);
|
projectRouter.post("/", createProject);
|
||||||
projectRouter.get("/:projectId", getProject);
|
projectRouter.get("/:projectId", getProject);
|
||||||
|
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||||
projectRouter.put("/:projectId", updateProjectSettings);
|
projectRouter.put("/:projectId", updateProjectSettings);
|
||||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ describe("circuit device-row project-command repository", () => {
|
|||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
expectedRevision: 1,
|
expectedRevision: 1,
|
||||||
source: "undo",
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: forward.revision.changeSetId,
|
||||||
command: forward.inverse,
|
command: forward.inverse,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ describe("circuit project-command repository", () => {
|
|||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
expectedRevision: 1,
|
expectedRevision: 1,
|
||||||
source: "undo",
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: forward.revision.changeSetId,
|
||||||
description: "Stromkreisänderung rückgängig machen",
|
description: "Stromkreisänderung rückgängig machen",
|
||||||
command: forward.inverse,
|
command: forward.inverse,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
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 { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
|
||||||
|
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
|
import { projectHistoryStackEntries } from "../src/db/schema/project-history-stack-entries.js";
|
||||||
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
|
||||||
|
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.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();
|
||||||
|
const board = new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const section = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.get();
|
||||||
|
assert.ok(section);
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Bestand",
|
||||||
|
sortOrder: 10,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCircuitName(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select({ displayName: circuits.displayName })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.get()?.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowQuantity(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select({ quantity: circuitDeviceRows.quantity })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.get()?.quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project history repository", () => {
|
||||||
|
it("persists project-wide undo and redo stacks across repository instances", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const circuitsStore = new CircuitProjectCommandRepository(context.db);
|
||||||
|
const rowsStore = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||||
|
const circuitCommand = createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Neu",
|
||||||
|
});
|
||||||
|
const circuitUpdate = circuitsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: circuitCommand,
|
||||||
|
});
|
||||||
|
const rowCommand = createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 2,
|
||||||
|
});
|
||||||
|
const rowUpdate = rowsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: rowCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reloadedHistory = new ProjectHistoryRepository(context.db);
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 2,
|
||||||
|
undoDepth: 2,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: rowUpdate.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
circuitsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
command: circuitUpdate.inverse,
|
||||||
|
}),
|
||||||
|
/not the top undo command/
|
||||||
|
);
|
||||||
|
assert.equal(getCircuitName(context), "Neu");
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.get()?.currentRevision,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
rowsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: rowUpdate.revision.changeSetId,
|
||||||
|
command: rowUpdate.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(getRowQuantity(context), 1);
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 3,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 1,
|
||||||
|
undoChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
redoChangeSetId: rowUpdate.revision.changeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
circuitsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 3,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
command: circuitUpdate.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(getCircuitName(context), "Bestand");
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 4,
|
||||||
|
undoDepth: 0,
|
||||||
|
redoDepth: 2,
|
||||||
|
undoChangeSetId: null,
|
||||||
|
redoChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
circuitsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 4,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
command: circuitCommand,
|
||||||
|
});
|
||||||
|
assert.equal(getCircuitName(context), "Neu");
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 5,
|
||||||
|
undoDepth: 1,
|
||||||
|
redoDepth: 1,
|
||||||
|
undoChangeSetId: circuitUpdate.revision.changeSetId,
|
||||||
|
redoChangeSetId: rowUpdate.revision.changeSetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const branchedRowUpdate = rowsStore.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 5,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(getRowQuantity(context), 3);
|
||||||
|
assert.deepEqual(reloadedHistory.getState("project-1"), {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 6,
|
||||||
|
undoDepth: 2,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: branchedRowUpdate.revision.changeSetId,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
});
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 6);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 6);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectHistoryStackEntries).all().length,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back domain and revision writes when stack persistence fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_history_stack_insert
|
||||||
|
BEFORE INSERT ON project_history_stack_entries
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced history stack failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const store = new CircuitProjectCommandRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
store.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: "Darf nicht bleiben",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/forced history stack failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(getCircuitName(context), "Bestand");
|
||||||
|
assert.deepEqual(
|
||||||
|
new ProjectHistoryRepository(context.db).getState("project-1"),
|
||||||
|
{
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 0,
|
||||||
|
undoDepth: 0,
|
||||||
|
redoDepth: 0,
|
||||||
|
undoChangeSetId: null,
|
||||||
|
redoChangeSetId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
|
||||||
|
assert.equal(context.db.select().from(projectChangeSets).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an unknown project", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
assert.equal(
|
||||||
|
new ProjectHistoryRepository(context.db).getState("missing"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user