From 0aaa4114d9e09029b201ff164a76136066e30a1e Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 20 Jun 2026 09:37:32 +0200 Subject: [PATCH] feat(store): add model store with snapshot undo/redo and loops --- src/store/model.ts | 239 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/store/model.ts diff --git a/src/store/model.ts b/src/store/model.ts new file mode 100644 index 0000000..d1d3621 --- /dev/null +++ b/src/store/model.ts @@ -0,0 +1,239 @@ +/** + * Model store (C5) — the live home of the source-of-truth Model plus undo/redo. + * + * Every structural write goes through an action here; the Vue Flow view is a + * projection downstream (ADR-0002). Undo is snapshot-based (T5): we deep-clone + * the whole Model onto a bounded history stack before each change. Models are + * small and the data is plain, so cloning is cheap and always correct — no + * command objects to keep in sync. + * + * Drags are a special case: positions change many times per gesture, so the + * restore point is taken once on drag start (`beginInteraction`) and the + * position writes during/after the drag don't record history. + */ +import { defineStore } from "pinia" +import { computed, ref } from "vue" +import { + emptyModel, + makeCloud, + makeConverter, + makeFlow, + makeStock, + midpoint, + newId, + nextName, +} from "@/model/factory" +import { detectLoops } from "@/model/loops" +import type { ConverterNode, Model, ModelNode, Position, StockNode } from "@/model/types" +import { canConnect, intentFor } from "@/model/validation" + +/** Ring-buffer depth for undo (F9 target: ≥50 steps). */ +const HISTORY_LIMIT = 50 + +export const useModelStore = defineStore("model", () => { + const model = ref(emptyModel()) + const past = ref([]) + const future = ref([]) + + const canUndo = computed(() => past.value.length > 0) + const canRedo = computed(() => future.value.length > 0) + const nodeCount = computed(() => model.value.nodes.length) + + // Feedback loops are derived from the wiring, never stored (ADR-0001). This + // re-runs only on *structural* change: detectLoops reads ids/kinds/links, not + // positions, so Vue's per-property reactivity skips it during node drags (F5). + const detected = computed(() => detectLoops(model.value)) + const loops = computed(() => detected.value.loops) + const loopsCapped = computed(() => detected.value.capped) + + function snapshot(): Model { + // Plain, deep, proxy-immune clone. JSON is safe here precisely because the + // Model is contractually JSON-serialisable (F8); structuredClone would choke + // on Vue reactive proxies that array reassignments leave nested in the state. + return JSON.parse(JSON.stringify(model.value)) as Model + } + + /** Push a restore point before a mutation and drop the redo branch. */ + function record(): void { + past.value.push(snapshot()) + if (past.value.length > HISTORY_LIMIT) past.value.shift() + future.value = [] + } + + function findNode(id: string): ModelNode | undefined { + return model.value.nodes.find((node) => node.id === id) + } + + function addStock(position: Position): StockNode { + record() + const node = makeStock(position, nextName(model.value.nodes, "stock")) + model.value.nodes.push(node) + return node + } + + function addConverter(position: Position): ConverterNode { + record() + const node = makeConverter(position, nextName(model.value.nodes, "converter")) + model.value.nodes.push(node) + return node + } + + /** Take the undo restore point at the start of a drag gesture. */ + function beginInteraction(): void { + record() + } + + /** Commit a node's new position. No history — the drag already recorded one. */ + function moveNode(id: string, position: Position): void { + const node = findNode(id) + if (node) node.position = { ...position } + } + + function renameNode(id: string, name: string): void { + const node = findNode(id) + if (!node || node.kind === "cloud") return + record() + node.name = name + } + + /** + * Create whatever source→target means under the structure guard: an + * Information Link (default `+` polarity, F6) onto a Flow/Converter, or a Flow + * node (valve at the midpoint) between two Stocks/Clouds. Invalid or duplicate + * connections are silently ignored — the gesture is also guarded upstream. + */ + function connect(sourceId: string, targetId: string): void { + const source = findNode(sourceId) + const target = findNode(targetId) + if (!source || !target || !canConnect(model.value, sourceId, targetId)) return + + const intent = intentFor(source, target) + record() + if (intent === "info") { + model.value.infoLinks.push({ + id: newId("link"), + source: sourceId, + target: targetId, + polarity: "+", + }) + } else if (intent === "flow") { + const name = nextName(model.value.nodes, "flow") + const position = midpoint(source.position, target.position) + model.value.nodes.push(makeFlow(position, name, sourceId, targetId)) + } + } + + /** + * Open-ended Flow: drop a Stock's connection on empty canvas to spawn a Sink + * Cloud there and a Flow into it (ADR-0003). Only Stocks originate boundary + * flows for now; other sources are ignored. + */ + function connectToNewCloud(sourceId: string, dropPosition: Position): void { + const source = findNode(sourceId) + if (!source || source.kind !== "stock") return + record() + const cloud = makeCloud({ x: dropPosition.x - 18, y: dropPosition.y - 18 }) + const name = nextName(model.value.nodes, "flow") + model.value.nodes.push(cloud) + model.value.nodes.push( + makeFlow(midpoint(source.position, cloud.position), name, sourceId, cloud.id), + ) + } + + /** + * Remove a node and everything that would otherwise dangle: Flow nodes that + * reference it as an end, Information Links touching any removed node, and then + * any Cloud left unreferenced (a Cloud exists only to bound a Flow, ADR-0003). + */ + function removeNode(id: string): void { + record() + const removed = new Set([id]) + for (const node of model.value.nodes) { + if (node.kind === "flow" && (node.source === id || node.target === id)) removed.add(node.id) + } + model.value.nodes = model.value.nodes.filter((node) => !removed.has(node.id)) + model.value.infoLinks = model.value.infoLinks.filter( + (link) => !removed.has(link.source) && !removed.has(link.target), + ) + pruneClouds() + } + + /** + * Flip an Information Link's polarity (F6). Polarity is captured on create + * (default `+`) and toggled in one click on the edge badge; it is the loop + * detector's only structural input (ADR-0001), so each flip is a real, + * undoable model change. + */ + function toggleLinkPolarity(id: string): void { + const link = model.value.infoLinks.find((l) => l.id === id) + if (!link) return + record() + link.polarity = link.polarity === "+" ? "-" : "+" + } + + /** + * Remove a single Information Link by id. Links are edges in the projection, + * not nodes, so they need their own delete path (selecting a link and pressing + * Delete). No cascade: a link touches nothing that depends on it. + */ + function removeInfoLink(id: string): void { + if (!model.value.infoLinks.some((link) => link.id === id)) return + record() + model.value.infoLinks = model.value.infoLinks.filter((link) => link.id !== id) + } + + /** Drop Clouds no longer referenced by any Flow. */ + function pruneClouds(): void { + const referenced = new Set() + for (const node of model.value.nodes) { + if (node.kind === "flow") { + referenced.add(node.source) + referenced.add(node.target) + } + } + model.value.nodes = model.value.nodes.filter( + (node) => node.kind !== "cloud" || referenced.has(node.id), + ) + } + + /** Replace the whole document (load / import / new). Clears history. */ + function setModel(next: Model): void { + model.value = next + past.value = [] + future.value = [] + } + + function undo(): void { + if (!canUndo.value) return + future.value.push(snapshot()) + model.value = past.value.pop() as Model + } + + function redo(): void { + if (!canRedo.value) return + past.value.push(snapshot()) + model.value = future.value.pop() as Model + } + + return { + model, + canUndo, + canRedo, + nodeCount, + loops, + loopsCapped, + addStock, + addConverter, + connect, + connectToNewCloud, + beginInteraction, + moveNode, + renameNode, + removeNode, + removeInfoLink, + toggleLinkPolarity, + setModel, + undo, + redo, + } +})