Files
FPT/src/lib/versioning-db.ts
T
admGitAICDS d023534a03
Deploy App / deploy (push) Successful in 1m48s
Versionierung und Aenderungshistorie je Szenario
Version A.B: B automatisch je Bearbeitungssitzung (10-Minuten-Fenster,
unveraenderte Staende erzeugen keine), A manuell mit Pflichtkommentar.

Eine Version haelt den vollstaendigen Zustand als PlanInput-JSON -- dadurch
ist die Versionsauswahl in allen vier Analysewerkzeugen fast kostenlos,
bei Monte-Carlo je Szenario einzeln.

Wiederherstellen erhaelt die IDs (sonst verlieren Kind-Szenarien ihre
Diff-Basis) und legt den Stand selbst als neue Version an. Wo ein Bezug
trotzdem bricht, warnt der Dialog vorher namentlich.

Statischer Waechter-Test: jeder schreibende Endpunkt loest eine Version aus.
Migration gegen echtes Postgres verifiziert.

Spezifikation 0.19 (3.8 und 9.28 neu), 25 Tests (139 -> 164).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:09:10 +02:00

284 lines
9.9 KiB
TypeScript

// Datenbank-Anbindung der Versionierung. Die Entscheidungslogik (wann eine Version entsteht,
// wie sie nummeriert wird) liegt in `versioning.ts` und ist dort ohne Datenbank getestet.
import { prisma } from "@/lib/db";
import { planInclude, toPlanInput } from "@/lib/queries";
import {
assessRestore,
decideVersion,
nextMajor,
planRestore,
restoreComment,
type LatestVersion,
type RestoreImpact,
type VersionRef,
} from "@/lib/versioning";
import type { PlanInput } from "@/lib/types";
import type { Prisma } from "@/generated/prisma/client";
const asJson = (plan: PlanInput) => plan as unknown as Prisma.InputJsonValue;
async function loadPlanInput(scenarioId: string): Promise<PlanInput | null> {
const scenario = await prisma.scenario.findUnique({ where: { id: scenarioId }, include: planInclude });
return scenario ? toPlanInput(scenario) : null;
}
async function loadLatest(scenarioId: string): Promise<LatestVersion | null> {
const v = await prisma.scenarioVersion.findFirst({
where: { scenarioId },
orderBy: [{ major: "desc" }, { minor: "desc" }],
});
if (!v) return null;
return {
id: v.id,
major: v.major,
minor: v.minor,
isMajor: v.isMajor,
createdById: v.createdById,
updatedAt: v.updatedAt,
snapshot: v.snapshot as unknown as PlanInput,
};
}
// Wird nach JEDEM inhaltsveraendernden Schreibvorgang aufgerufen. Legt je nach Entscheidung
// eine neue Nebenversion an, aktualisiert die laufende oder tut nichts.
//
// Bewusst fehlertolerant: Schlaegt das Festhalten der Version fehl, darf das die eigentliche
// Aenderung des Nutzers nicht scheitern lassen -- die Historie ist Begleitinformation, nicht
// der Zweck der Anfrage.
export async function touchScenario(scenarioId: string, userId: string): Promise<void> {
try {
const next = await loadPlanInput(scenarioId);
if (!next) return;
const latest = await loadLatest(scenarioId);
const decision = decideVersion(latest, next, userId, new Date());
if (decision.action === "skip") return;
if (decision.action === "update") {
await prisma.scenarioVersion.update({
where: { id: decision.versionId },
data: { snapshot: asJson(next) },
});
return;
}
await prisma.scenarioVersion.create({
data: {
scenarioId,
major: decision.major,
minor: decision.minor,
createdById: userId,
snapshot: asJson(next),
},
});
} catch (err) {
console.error("Versionierung fehlgeschlagen", { scenarioId, err });
}
}
// Legt den aktuellen Stand als HAUPTVERSION fest. Anders als eine Nebenversion wird sie nie
// zusammengefasst und nie nachtraeglich veraendert.
export async function createMajorVersion(
scenarioId: string,
userId: string,
comment: string
): Promise<VersionRef | null> {
const snapshot = await loadPlanInput(scenarioId);
if (!snapshot) return null;
const latest = await loadLatest(scenarioId);
const ref = nextMajor(latest);
await prisma.$transaction([
prisma.scenarioVersion.create({
data: {
scenarioId,
major: ref.major,
minor: ref.minor,
comment: comment.trim(),
isMajor: true,
createdById: userId,
snapshot: asJson(snapshot),
},
}),
prisma.scenario.update({ where: { id: scenarioId }, data: { currentMajor: ref.major } }),
]);
return ref;
}
// Welche Bezuege wuerde ein Wiederherstellen in den Kind-Szenarien brechen? Wird vor dem
// eigentlichen Wiederherstellen abgefragt, damit der Dialog warnen kann.
export async function restoreImpact(scenarioId: string, snapshot: PlanInput): Promise<RestoreImpact> {
const children = await prisma.scenario.findMany({
where: { parentScenarioId: scenarioId },
select: {
name: true,
elements: { select: { sourceElementId: true } },
phases: { select: { sourcePhaseId: true } },
},
});
return assessRestore(
snapshot,
children.map((c) => ({
name: c.name,
elementSourceIds: c.elements.map((e) => e.sourceElementId).filter((x): x is string => !!x),
phaseSourceIds: c.phases.map((p) => p.sourcePhaseId).filter((x): x is string => !!x),
}))
);
}
// Setzt das Szenario auf den Stand einer Version zurueck.
//
// IDs von Phasen und Elementen werden ERHALTEN (der Snapshot traegt sie mit). Wuerden hier
// neue IDs entstehen, verloeren alle Kind-Szenarien ihre Diff-Basis: `sourceElementId` zeigt
// auf die IDs dieses Szenarios, und jedes Element wuerde schlagartig als "neu" statt als
// "geaendert" gelten.
//
// Das Wiederherstellen loescht KEINE Historie -- es legt anschliessend eine neue Version an.
export async function restoreVersion(
scenarioId: string,
versionId: string,
userId: string
): Promise<VersionRef | null> {
const version = await prisma.scenarioVersion.findFirst({ where: { id: versionId, scenarioId } });
if (!version) return null;
const snap = version.snapshot as unknown as PlanInput;
// Was zu tun ist, entscheidet die reine Funktion `planRestore` (dort getestet); hier wird
// der Plan nur noch ausgefuehrt.
const [curPhases, curElements, curPersons] = await Promise.all([
prisma.phase.findMany({ where: { scenarioId }, select: { id: true } }),
prisma.financialElement.findMany({ where: { scenarioId }, select: { id: true } }),
prisma.person.findMany({ where: { scenarioId }, select: { role: true } }),
]);
const plan = planRestore(snap, {
phaseIds: curPhases.map((p) => p.id),
elementIds: curElements.map((e) => e.id),
personRoles: curPersons.map((p) => p.role),
});
const phaseById = new Map(snap.phases.map((p) => [p.id, p]));
const elementById = new Map(snap.elements.map((e) => [e.id, e]));
await prisma.$transaction(async (tx) => {
// Profil.
await tx.scenario.update({
where: { id: scenarioId },
data: {
householdType: snap.householdType,
inflationRateDefault: snap.inflationRateDefault,
initialCash: snap.initialCash,
startYear: snap.startYear ?? null,
},
});
// Personen: an der Rolle festgemacht (je Szenario eindeutig).
for (const p of snap.persons) {
await tx.person.upsert({
where: { scenarioId_role: { scenarioId, role: p.role } },
create: { id: p.id, scenarioId, role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge },
update: { name: p.name, age: p.age, retirementAge: p.retirementAge },
});
}
if (plan.deletePersonRoles.length > 0) {
await tx.person.deleteMany({
where: { scenarioId, role: { in: plan.deletePersonRoles as never } },
});
}
// Was der alte Stand nicht kannte, verschwindet -- samt seiner Werte (Cascade).
if (plan.deletePhaseIds.length > 0) {
await tx.phase.deleteMany({ where: { id: { in: plan.deletePhaseIds } } });
}
if (plan.deleteElementIds.length > 0) {
await tx.financialElement.deleteMany({ where: { id: { in: plan.deleteElementIds } } });
}
// Sequenznummern zuerst auf negative Werte parken: Sie sind je Szenario eindeutig, und
// beim Zurueckgehen koennen sich alte und neue Nummern ueberschneiden.
for (const [i, id] of plan.parkPhaseIds.entries()) {
await tx.phase.update({ where: { id }, data: { sequenceNumber: -1 - i } });
}
const phaseData = (id: string) => {
const ph = phaseById.get(id)!;
return {
sequenceNumber: ph.sequenceNumber,
name: ph.name,
durationYears: ph.durationYears,
cashTransition: (ph.cashTransition ?? {}) as Prisma.InputJsonValue,
sourcePhaseId: ph.sourcePhaseId ?? null,
};
};
for (const id of plan.updatePhaseIds) await tx.phase.update({ where: { id }, data: phaseData(id) });
for (const id of plan.createPhaseIds) {
await tx.phase.create({ data: { id, scenarioId, ...phaseData(id) } });
}
const elementData = (id: string) => {
const el = elementById.get(id)!;
return {
category: el.category,
name: el.name,
ownerRole: el.ownerRole ?? null,
orderIndex: el.orderIndex,
sourceElementId: el.sourceElementId ?? null,
};
};
for (const id of plan.updateElementIds) {
await tx.financialElement.update({ where: { id }, data: elementData(id) });
}
for (const id of plan.createElementIds) {
await tx.financialElement.create({ data: { id, scenarioId, ...elementData(id) } });
}
// Werte vollstaendig ersetzen statt abgleichen -- der Snapshot ist die Wahrheit.
const allElementIds = [...plan.updateElementIds, ...plan.createElementIds];
if (allElementIds.length > 0) {
await tx.elementPhaseValue.deleteMany({ where: { elementId: { in: allElementIds } } });
await tx.elementTransitionValue.deleteMany({ where: { elementId: { in: allElementIds } } });
}
for (const { elementId, phaseId } of plan.phaseValueKeys) {
await tx.elementPhaseValue.create({
data: {
elementId,
phaseId,
data: elementById.get(elementId)!.phaseValues[phaseId] as Prisma.InputJsonValue,
},
});
}
for (const { elementId, fromPhaseId } of plan.transitionValueKeys) {
await tx.elementTransitionValue.create({
data: {
elementId,
fromPhaseId,
data: elementById.get(elementId)!.transitionValues[fromPhaseId] as Prisma.InputJsonValue,
},
});
}
});
// Der wiederhergestellte Stand ist selbst eine neue Version -- mit Herkunftsvermerk, und
// ohne dass irgendetwas aus der Historie verloren geht.
const latest = await loadLatest(scenarioId);
const current = await loadPlanInput(scenarioId);
if (!current) return null;
const created = await prisma.scenarioVersion.create({
data: {
scenarioId,
major: latest?.major ?? 1,
minor: (latest?.minor ?? 0) + 1,
comment: restoreComment({ major: version.major, minor: version.minor }),
createdById: userId,
snapshot: asJson(current),
},
});
return { major: created.major, minor: created.minor };
}