Compare commits
6 Commits
bc90a9bca9
...
acce53ce47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acce53ce47 | ||
|
|
72ef29413a | ||
|
|
75ebb39848 | ||
|
|
4c55ff9ffa | ||
|
|
9f71febca0 | ||
|
|
1c571059df |
@@ -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
|
||||||
|
|||||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
83
src/composables/useAutosave.ts
Normal file
83
src/composables/useAutosave.ts
Normal 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
139
src/model/io.ts
Normal 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
48
src/model/repository.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user