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
),
]
);