Compare commits

...

6 Commits

Author SHA1 Message Date
Julien Calixte
acce53ce47 fix(docker): copy pnpm-workspace.yaml into the install layer
pnpm 11 defaults strictDepBuilds to true, so a dependency build script
that is not pre-decided in allowBuilds becomes a hard error. The install
layer only copied package.json and the lockfile, so the vue-demi build
decision in pnpm-workspace.yaml was absent and the build failed.
2026-06-20 10:03:00 +02:00
Julien Calixte
72ef29413a feat(editor): wire autosave and add JSON import/export 2026-06-20 10:02:42 +02:00
Julien Calixte
75ebb39848 feat(editor): add debounced model autosave composable 2026-06-20 10:01:44 +02:00
Julien Calixte
4c55ff9ffa feat(model): add IndexedDB-backed model repository 2026-06-20 10:01:44 +02:00
Julien Calixte
9f71febca0 feat(model): round-trip models as versioned JSON 2026-06-20 10:01:43 +02:00
Julien Calixte
1c571059df chore(deps): add idb for local persistence 2026-06-20 10:01:38 +02:00
7 changed files with 344 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml* ./ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile RUN corepack enable && pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm build RUN pnpm build

View File

@@ -17,6 +17,7 @@
"@vue-flow/controls": "^1.1.3", "@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2", "@vue-flow/core": "^1.48.2",
"daisyui": "^5.5.23", "daisyui": "^5.5.23",
"idb": "^8.0.3",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.34" "vue": "^3.5.34"
}, },

8
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
daisyui: daisyui:
specifier: ^5.5.23 specifier: ^5.5.23
version: 5.5.23 version: 5.5.23
idb:
specifier: ^8.0.3
version: 8.0.3
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) version: 3.0.4(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))
@@ -739,6 +742,9 @@ packages:
hookable@5.5.3: hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
idb@8.0.3:
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
is-what@5.5.0: is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1522,6 +1528,8 @@ snapshots:
hookable@5.5.3: {} hookable@5.5.3: {}
idb@8.0.3: {}
is-what@5.5.0: {} is-what@5.5.0: {}
jiti@2.7.0: {} jiti@2.7.0: {}

View File

@@ -25,7 +25,9 @@ import {
VueFlow, VueFlow,
type XYPosition, type XYPosition,
} from "@vue-flow/core" } from "@vue-flow/core"
import { computed, nextTick, onBeforeUnmount, onMounted } from "vue" import { computed, nextTick, onBeforeUnmount, onMounted, useTemplateRef } from "vue"
import { useAutosave } from "@/composables/useAutosave"
import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection" import { project } from "@/model/projection"
import { type Sample, SAMPLES } from "@/model/samples" import { type Sample, SAMPLES } from "@/model/samples"
import { canConnect } from "@/model/validation" import { canConnect } from "@/model/validation"
@@ -62,6 +64,10 @@ const {
fitView, fitView,
} = useVueFlow("meadows") } = useVueFlow("meadows")
// Restore the last document on mount and persist every change (F7). Fit the
// view once a restored model has re-projected so it lands framed.
useAutosave({ onRestore: () => fitView({ padding: 0.2 }) })
onNodeDragStart(() => store.beginInteraction()) onNodeDragStart(() => store.beginInteraction())
onNodeDragStop(({ nodes: dragged }) => { onNodeDragStop(({ nodes: dragged }) => {
for (const node of dragged) store.moveNode(node.id, node.position) for (const node of dragged) store.moveNode(node.id, node.position)
@@ -180,6 +186,54 @@ async function loadSample(sample: Sample): Promise<void> {
fitView({ padding: 0.2 }) fitView({ padding: 0.2 })
} }
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
/** A filesystem-safe stem from the model name, e.g. "Coffee cooling" → "coffee-cooling". */
function fileStem(name: string): string {
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
return slug || "model"
}
/** Export the current Model as versioned JSON download (F8). */
function exportModel(): void {
const blob = new Blob([serializeModel(store.model)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = `${fileStem(store.model.name)}.json`
anchor.click()
URL.revokeObjectURL(url)
}
/** Import a Model from a JSON file: validate, confirm a destructive replace, load (F8). */
async function onImportFile(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// Reset first so picking the same file again still fires `change`.
input.value = ""
if (!file) return
const result = parseModel(await file.text())
if (!result.ok) {
window.alert(`Couldn't import “${file.name}”: ${result.error}`)
return
}
// Importing replaces the document and clears undo (T6 / F10×F7), so guard real work.
if (
store.nodeCount > 0 &&
!window.confirm(`Replace the current model with “${result.model.name}”?`)
) {
return
}
store.setModel(result.model)
await nextTick()
fitView({ padding: 0.2 })
}
function isTextEntry(target: EventTarget | null): boolean { function isTextEntry(target: EventTarget | null): boolean {
const el = target as HTMLElement | null const el = target as HTMLElement | null
return el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable === true return el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable === true
@@ -242,6 +296,15 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
</li> </li>
</ul> </ul>
</div> </div>
<button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
<button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
<input
ref="fileInput"
type="file"
accept="application/json,.json"
class="hidden"
@change="onImportFile"
/>
<button class="btn btn-ghost btn-sm" :disabled="!store.canUndo" @click="store.undo()"> <button class="btn btn-ghost btn-sm" :disabled="!store.canUndo" @click="store.undo()">
Undo Undo
</button> </button>

View File

@@ -0,0 +1,83 @@
/**
* Autosave (F7) — the binding between the Model store (C5) and the repository
* (C9). It restores the last document on mount, then persists every change with
* a short debounce so a burst of edits costs one write, well inside the ≤500 ms
* budget. The debounce window is the only data-loss risk, so pending edits are
* also flushed when the tab is hidden or closed.
*
* It lives in a composable, not the store: persistence needs component lifecycle
* (mount/unmount) and browser globals, which keep the store pure and testable.
* The repository is injected (defaulting to IndexedDB) so it stays swappable.
*/
import { nextTick, onBeforeUnmount, onMounted, watch } from "vue"
import { createRepository, type ModelRepository } from "@/model/repository"
import { useModelStore } from "@/store/model"
/** Coalesce a burst of edits into one write; comfortably under the 500 ms budget. */
const DEBOUNCE_MS = 400
interface AutosaveOptions {
repository?: ModelRepository
/** Called once after a saved Model is restored (e.g. to fit the view). */
onRestore?: () => void
}
export function useAutosave(options: AutosaveOptions = {}): { flush: () => void } {
const repository = options.repository ?? createRepository()
const store = useModelStore()
// Armed only after the initial load, so restoring a document doesn't echo back
// a redundant save of what we just read.
let ready = false
let timer: ReturnType<typeof setTimeout> | null = null
async function save(): Promise<void> {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
if (!ready) return
// Plain, proxy-free clone: idb structured-clones on put and would choke on
// the Vue reactive proxy (the same reason undo snapshots use JSON).
await repository.save(JSON.parse(JSON.stringify(store.model)) as typeof store.model)
}
function schedule(): void {
if (timer !== null) clearTimeout(timer)
timer = setTimeout(() => void save(), DEBOUNCE_MS)
}
function flush(): void {
if (timer !== null) void save()
}
function onVisibilityChange(): void {
if (document.visibilityState === "hidden") flush()
}
watch(
() => store.model,
() => {
if (ready) schedule()
},
{ deep: true },
)
onMounted(async () => {
const saved = await repository.load()
if (saved) store.setModel(saved)
// Let the watch tick from setModel pass before arming, so it isn't re-saved.
await nextTick()
if (saved) options.onRestore?.()
ready = true
document.addEventListener("visibilitychange", onVisibilityChange)
window.addEventListener("beforeunload", flush)
})
onBeforeUnmount(() => {
document.removeEventListener("visibilitychange", onVisibilityChange)
window.removeEventListener("beforeunload", flush)
flush()
})
return { flush }
}

139
src/model/io.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* Import/Export (C10, F8) — round-trip a Model as versioned JSON.
*
* Export is just pretty JSON: the Model is contractually plain, serialisable
* data (see types.ts), so `serializeModel` is lossless and human-readable.
* Import is the trust boundary — a file may be hand-edited, truncated, or from a
* future version — so `parseModel` validates shape, types, the schema version,
* and referential integrity before handing back a typed Model. It never throws;
* it returns a discriminated result so callers can surface a clear message.
*
* The validator deliberately does not re-implement the full structural guard
* (validation.ts): our own exports are valid by construction, and checking that
* every reference resolves is enough to keep the projection and loop detector
* from tripping over a dangling id.
*/
import {
type InformationLink,
type Model,
MODEL_VERSION,
type ModelNode,
type NodeKind,
type Polarity,
type Position,
} from "./types"
/** Pretty JSON so an exported Model is human-readable and diff-friendly (F8). */
export function serializeModel(model: Model): string {
return JSON.stringify(model, null, 2)
}
/** The outcome of an import: a validated Model, or a reason it was rejected. */
export type ParseResult = { ok: true; model: Model } | { ok: false; error: string }
const NODE_KINDS: readonly NodeKind[] = ["stock", "flow", "converter", "cloud"]
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value)
}
function isPosition(value: unknown): value is Position {
return isObject(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y)
}
function isPolarity(value: unknown): value is Polarity {
return value === "+" || value === "-"
}
/** Validate one node by kind, returning an error string or null when valid. */
function nodeError(value: unknown, index: number): string | null {
if (!isObject(value)) return `nodes[${index}] is not an object`
const at = `nodes[${index}]`
if (typeof value.id !== "string" || value.id === "") return `${at}.id must be a non-empty string`
if (typeof value.kind !== "string" || !NODE_KINDS.includes(value.kind as NodeKind)) {
return `${at}.kind must be one of ${NODE_KINDS.join(", ")}`
}
if (!isPosition(value.position)) return `${at}.position must be {x, y} numbers`
const kind = value.kind as NodeKind
if (kind !== "cloud" && typeof value.name !== "string") return `${at}.name must be a string`
if (kind === "flow") {
if (typeof value.source !== "string") return `${at}.source must be a string`
if (typeof value.target !== "string") return `${at}.target must be a string`
}
return null
}
/** Validate one Information Link, returning an error string or null when valid. */
function linkError(value: unknown, index: number): string | null {
if (!isObject(value)) return `infoLinks[${index}] is not an object`
const at = `infoLinks[${index}]`
if (typeof value.id !== "string" || value.id === "") return `${at}.id must be a non-empty string`
if (typeof value.source !== "string") return `${at}.source must be a string`
if (typeof value.target !== "string") return `${at}.target must be a string`
if (!isPolarity(value.polarity)) return `${at}.polarity must be "+" or "-"`
return null
}
/**
* Parse and validate JSON text into a Model. Returns `{ok:false, error}` for
* anything that isn't a well-formed, current-version Model with resolvable
* references — never throws.
*/
export function parseModel(text: string): ParseResult {
let data: unknown
try {
data = JSON.parse(text)
} catch {
return { ok: false, error: "Not valid JSON" }
}
if (!isObject(data)) return { ok: false, error: "Top level must be an object" }
if (data.version !== MODEL_VERSION) {
return {
ok: false,
error: `Unsupported model version ${String(data.version)} (expected ${MODEL_VERSION})`,
}
}
if (typeof data.id !== "string" || data.id === "")
return { ok: false, error: "id must be a non-empty string" }
if (typeof data.name !== "string") return { ok: false, error: "name must be a string" }
if (!Array.isArray(data.nodes)) return { ok: false, error: "nodes must be an array" }
if (!Array.isArray(data.infoLinks)) return { ok: false, error: "infoLinks must be an array" }
for (let i = 0; i < data.nodes.length; i++) {
const error = nodeError(data.nodes[i], i)
if (error) return { ok: false, error }
}
for (let i = 0; i < data.infoLinks.length; i++) {
const error = linkError(data.infoLinks[i], i)
if (error) return { ok: false, error }
}
// References must resolve, or the projection and loop detector would dangle.
const nodes = data.nodes as ModelNode[]
const ids = new Set(nodes.map((node) => node.id))
for (const node of nodes) {
if (node.kind !== "flow") continue
if (!ids.has(node.source))
return { ok: false, error: `flow ${node.id} references missing source ${node.source}` }
if (!ids.has(node.target))
return { ok: false, error: `flow ${node.id} references missing target ${node.target}` }
}
const links = data.infoLinks as InformationLink[]
for (const link of links) {
if (!ids.has(link.source))
return { ok: false, error: `link ${link.id} references missing source ${link.source}` }
if (!ids.has(link.target))
return { ok: false, error: `link ${link.id} references missing target ${link.target}` }
}
return {
ok: true,
model: { version: MODEL_VERSION, id: data.id, name: data.name, nodes, infoLinks: links },
}
}

48
src/model/repository.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* Model repository (C9, F7) — where the working document is persisted locally.
*
* IndexedDB (via `idb`) sits behind a tiny `ModelRepository` interface so the
* storage engine is no longer a hard-to-reverse choice (T4): a future sync
* backend (PouchDB, an API) is a new implementation behind the same two methods,
* with no caller change. Phase 1 is single-document, so the current Model is
* stored under one fixed key; a model list (C12) would key by `model.id` instead.
*/
import { type IDBPDatabase, openDB } from "idb"
import type { Model } from "./types"
export interface ModelRepository {
/** Persist the current Model, overwriting the previous autosave. */
save(model: Model): Promise<void>
/** Load the autosaved Model, or null if nothing has been saved yet. */
load(): Promise<Model | null>
}
const DB_NAME = "meadows"
const DB_VERSION = 1
const STORE = "models"
const CURRENT_KEY = "current"
let dbPromise: Promise<IDBPDatabase> | null = null
function db(): Promise<IDBPDatabase> {
// Memoise the open so we pay the handshake once, not per save.
dbPromise ??= openDB(DB_NAME, DB_VERSION, {
upgrade(database) {
if (!database.objectStoreNames.contains(STORE)) database.createObjectStore(STORE)
},
})
return dbPromise
}
/** The default IndexedDB-backed repository used by the app. */
export function createRepository(): ModelRepository {
return {
async save(model: Model): Promise<void> {
await (await db()).put(STORE, model, CURRENT_KEY)
},
async load(): Promise<Model | null> {
const model = await (await db()).get(STORE, CURRENT_KEY)
return (model as Model | undefined) ?? null
},
}
}