mind-map/packages/graph-store/src/index.ts
oliver 74f0d8e78a feat: add topology view mode with router icons and link-style edges
Canvas-global nodeViewMode (bubble/card/topology) can be set at create or via prefs; topology draws NE routers with straight blue center links and exposes router icon to agents.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 22:35:53 +08:00

845 lines
27 KiB
TypeScript

import { DatabaseSync } from "node:sqlite";
import fs from "node:fs";
import path from "node:path";
import {
applyOps,
cloneGraph,
createEmptyGraph,
resolveAnchorNodeId,
toSnapshot,
type MutableGraph,
} from "@mind-map/graph-core";
import type {
CanvasMeta,
MindEdge,
MindNode,
Op,
} from "@mind-map/shared";
import { newId, now } from "@mind-map/shared";
const SCHEMA = `
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS canvases (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
focus_node_id TEXT,
viewport_json TEXT NOT NULL,
prefs_json TEXT NOT NULL,
rev INTEGER NOT NULL DEFAULT 0,
agent_mode TEXT NOT NULL DEFAULT 'edit',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
canvas_id TEXT NOT NULL,
text TEXT NOT NULL,
note TEXT,
collapsed INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
pos_json TEXT,
side_pref INTEGER NOT NULL DEFAULT 0,
style_preset TEXT NOT NULL DEFAULT 'default',
version INTEGER NOT NULL DEFAULT 1,
tags_json TEXT NOT NULL DEFAULT '[]',
links_json TEXT NOT NULL DEFAULT '[]',
image_url TEXT,
due_at INTEGER,
start_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS edges (
id TEXT PRIMARY KEY,
canvas_id TEXT NOT NULL,
"from" TEXT NOT NULL,
"to" TEXT NOT NULL,
kind TEXT NOT NULL,
is_primary_parent INTEGER NOT NULL DEFAULT 0,
"order" REAL NOT NULL DEFAULT 0,
label TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
note TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
canvas_id TEXT NOT NULL,
change_set_id TEXT NOT NULL,
op_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS change_sets (
id TEXT PRIMARY KEY,
canvas_id TEXT NOT NULL,
base_rev INTEGER NOT NULL,
origin TEXT NOT NULL,
summary TEXT NOT NULL,
inverse_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
undone_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_nodes_canvas ON nodes(canvas_id);
CREATE INDEX IF NOT EXISTS idx_edges_canvas ON edges(canvas_id);
CREATE INDEX IF NOT EXISTS idx_events_canvas ON events(canvas_id);
`;
function rowNode(r: Record<string, unknown>): MindNode {
return {
id: String(r.id),
canvasId: String(r.canvas_id),
text: String(r.text),
note: r.note != null ? String(r.note) : undefined,
collapsed: Boolean(r.collapsed),
pinned: Boolean(r.pinned),
pos: r.pos_json ? (JSON.parse(String(r.pos_json)) as { x: number; y: number }) : null,
sidePref: Number(r.side_pref) as -1 | 0 | 1,
stylePreset: String(r.style_preset) as MindNode["stylePreset"],
version: Number(r.version),
tags: JSON.parse(String(r.tags_json ?? "[]")) as string[],
links: JSON.parse(String(r.links_json ?? "[]")) as MindNode["links"],
imageUrl: r.image_url != null ? String(r.image_url) : undefined,
dueAt: r.due_at != null ? Number(r.due_at) : undefined,
startAt: r.start_at != null ? Number(r.start_at) : undefined,
accentColor:
r.accent_color != null ? (String(r.accent_color) as MindNode["accentColor"]) : undefined,
icon: r.icon != null ? (String(r.icon) as MindNode["icon"]) : undefined,
alias: r.alias != null ? String(r.alias) : undefined,
description: r.description_json
? (JSON.parse(String(r.description_json)) as MindNode["description"])
: undefined,
createdAt: Number(r.created_at),
updatedAt: Number(r.updated_at),
deletedAt: r.deleted_at != null ? Number(r.deleted_at) : null,
};
}
function rowEdge(r: Record<string, unknown>): MindEdge {
return {
id: String(r.id),
canvasId: String(r.canvas_id),
from: String(r.from),
to: String(r.to),
kind: String(r.kind) as MindEdge["kind"],
isPrimaryParent: Boolean(r.is_primary_parent),
order: Number(r.order),
label: r.label != null ? String(r.label) : undefined,
tags: JSON.parse(String(r.tags_json ?? "[]")) as string[],
note: r.note != null ? String(r.note) : undefined,
weight: r.weight != null ? Number(r.weight) : undefined,
lineStyle:
r.line_style != null ? (String(r.line_style) as MindEdge["lineStyle"]) : undefined,
direction:
r.direction != null ? (String(r.direction) as MindEdge["direction"]) : undefined,
version: Number(r.version),
createdAt: Number(r.created_at),
updatedAt: Number(r.updated_at),
deletedAt: r.deleted_at != null ? Number(r.deleted_at) : null,
};
}
export class SqliteGraphStore {
readonly db: DatabaseSync;
private cache = new Map<string, MutableGraph>();
constructor(dbPath: string) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
this.db = new DatabaseSync(dbPath);
this.db.exec("PRAGMA journal_mode = WAL;");
this.db.exec("PRAGMA foreign_keys = ON;");
this.db.exec(SCHEMA);
this.migrateSchema();
}
private migrateSchema() {
const verRow = this.db.prepare("SELECT value FROM meta WHERE key = ?").get("schema_version") as
| { value: string }
| undefined;
let ver = verRow ? Number(verRow.value) : 0;
if (!verRow) {
this.db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", "1");
ver = 1;
}
const tableInfo = (table: string) =>
(this.db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(
(c) => c.name,
);
const ensureCol = (table: string, col: string, ddl: string) => {
if (!tableInfo(table).includes(col)) {
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
}
};
if (ver < 2) {
ensureCol("nodes", "links_json", `links_json TEXT NOT NULL DEFAULT '[]'`);
ensureCol("nodes", "image_url", `image_url TEXT`);
ensureCol("nodes", "due_at", `due_at INTEGER`);
ensureCol("nodes", "start_at", `start_at INTEGER`);
ensureCol("edges", "tags_json", `tags_json TEXT NOT NULL DEFAULT '[]'`);
ensureCol("edges", "note", `note TEXT`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "2");
ver = 2;
}
if (ver < 3) {
ensureCol("nodes", "accent_color", `accent_color TEXT`);
ensureCol("nodes", "icon", `icon TEXT`);
ensureCol("edges", "weight", `weight REAL`);
ensureCol("edges", "line_style", `line_style TEXT`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "3");
ver = 3;
}
if (ver < 4) {
ensureCol("nodes", "alias", `alias TEXT`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "4");
ver = 4;
}
if (ver < 5) {
ensureCol("edges", "direction", `direction TEXT`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
canvas_id TEXT NOT NULL,
name TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_canvas ON snapshots(canvas_id);
`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "5");
ver = 5;
}
if (ver < 6) {
ensureCol("nodes", "description_json", `description_json TEXT`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "6");
}
}
close(): void {
this.db.close();
}
listCanvases(): CanvasMeta[] {
const rows = this.db
.prepare(
`SELECT * FROM canvases WHERE deleted_at IS NULL ORDER BY updated_at DESC`,
)
.all() as Record<string, unknown>[];
return rows.map((r) => this.rowCanvas(r));
}
private rowCanvas(r: Record<string, unknown>): CanvasMeta {
const rawPrefs = JSON.parse(String(r.prefs_json || "{}")) as Record<string, unknown>;
const anchorNodeId =
rawPrefs.anchorNodeId != null ? String(rawPrefs.anchorNodeId) : null;
const prefs = {
themeId: "abyss",
branchColoring: true,
density: "comfortable",
riskProfile: "fast",
showRelationEdges: true,
nodeBadges: "icons",
showHoverCard: true,
inspectorMode: "selection",
nodeViewMode: "card",
...rawPrefs,
} as CanvasMeta["prefs"];
delete (prefs as Record<string, unknown>).anchorNodeId;
return {
id: String(r.id),
title: String(r.title),
focusNodeId: r.focus_node_id != null ? String(r.focus_node_id) : null,
anchorNodeId,
viewport: JSON.parse(String(r.viewport_json)),
prefs,
rev: Number(r.rev),
agentMode: String(r.agent_mode) as CanvasMeta["agentMode"],
createdAt: Number(r.created_at),
updatedAt: Number(r.updated_at),
deletedAt: r.deleted_at != null ? Number(r.deleted_at) : null,
};
}
createCanvas(
title?: string,
rootText?: string,
prefsOverride?: Partial<CanvasMeta["prefs"]>,
): MutableGraph {
const g = createEmptyGraph(title ?? "未命名画布", rootText, prefsOverride);
this.persistGraph(g, true);
this.cache.set(g.canvas.id, g);
return g;
}
loadCanvas(canvasId: string): MutableGraph {
const cached = this.cache.get(canvasId);
if (cached) return cloneGraph(cached);
const crow = this.db.prepare(`SELECT * FROM canvases WHERE id = ?`).get(canvasId) as
| Record<string, unknown>
| undefined;
if (!crow || crow.deleted_at != null) throw new Error(`canvas not found: ${canvasId}`);
const nodes = (
this.db.prepare(`SELECT * FROM nodes WHERE canvas_id = ?`).all(canvasId) as Record<
string,
unknown
>[]
).map(rowNode);
const edges = (
this.db.prepare(`SELECT * FROM edges WHERE canvas_id = ?`).all(canvasId) as Record<
string,
unknown
>[]
).map(rowEdge);
const g: MutableGraph = {
canvas: this.rowCanvas(crow),
nodes: new Map(nodes.map((n) => [n.id, n])),
edges: new Map(edges.map((e) => [e.id, e])),
};
if (!g.canvas.anchorNodeId) {
resolveAnchorNodeId(g);
}
this.cache.set(canvasId, g);
return cloneGraph(g);
}
getSnapshot(canvasId: string) {
return toSnapshot(this.loadCanvas(canvasId));
}
applyChangeSet(
canvasId: string,
ops: Op[],
opts: { origin: "user" | "ai"; summary?: string },
) {
const g = this.loadCanvas(canvasId);
const baseRev = g.canvas.rev;
const result = applyOps(g, ops);
result.graph.canvas.rev = baseRev + 1;
const changeSetId = newId("cs");
this.db.exec("BEGIN");
try {
this.persistGraph(result.graph, false);
this.db
.prepare(
`INSERT INTO change_sets(id, canvas_id, base_rev, origin, summary, inverse_json, created_at)
VALUES(?,?,?,?,?,?,?)`,
)
.run(
changeSetId,
canvasId,
baseRev,
opts.origin,
opts.summary ?? `${ops.length} ops`,
JSON.stringify(result.inverseOps),
now(),
);
const ins = this.db.prepare(
`INSERT INTO events(canvas_id, change_set_id, op_json, created_at) VALUES(?,?,?,?)`,
);
for (const op of ops) {
ins.run(canvasId, changeSetId, JSON.stringify(op), now());
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
this.cache.set(canvasId, result.graph);
return {
changeSetId,
rev: result.graph.canvas.rev,
snapshot: toSnapshot(result.graph),
affectedNodeIds: result.affectedNodeIds,
inverseOps: result.inverseOps,
autoFitPadding: result.autoFitPadding,
};
}
undoChangeSet(changeSetId: string) {
const row = this.db
.prepare(`SELECT * FROM change_sets WHERE id = ?`)
.get(changeSetId) as Record<string, unknown> | undefined;
if (!row) throw new Error("changeset not found");
if (row.undone_at != null) throw new Error("already undone");
const canvasId = String(row.canvas_id);
const inverse = JSON.parse(String(row.inverse_json)) as Op[];
// Undo must not fail on stale expectedVersion from the forward pass.
const cleaned = inverse.map((op) => {
if (!op || typeof op !== "object") return op;
const { expectedVersion: _ev, ...rest } = op as Op & {
expectedVersion?: number;
};
return rest as Op;
});
const result = this.applyChangeSet(canvasId, cleaned, {
origin: "user",
summary: `undo ${changeSetId}`,
});
this.db
.prepare(`UPDATE change_sets SET undone_at = ? WHERE id = ?`)
.run(now(), changeSetId);
return result;
}
/** Most recent change set that can still be undone (for mindmap_undo without id). */
latestUndoableChangeSetId(canvasId: string): string | null {
const row = this.db
.prepare(
`SELECT id FROM change_sets
WHERE canvas_id = ? AND undone_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
)
.get(canvasId) as { id: string } | undefined;
return row?.id ?? null;
}
/** Latest undo companion (summary starts with "undo ") — redo by undoing it. */
latestRedoableChangeSetId(canvasId: string): string | null {
const row = this.db
.prepare(
`SELECT id, summary FROM change_sets
WHERE canvas_id = ? AND undone_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
)
.get(canvasId) as { id: string; summary: string } | undefined;
if (!row) return null;
if (!String(row.summary).startsWith("undo ")) return null;
return row.id;
}
listChangeSets(canvasId: string, limit = 30) {
const rows = this.db
.prepare(
`SELECT id, base_rev, origin, summary, created_at, undone_at
FROM change_sets WHERE canvas_id = ?
ORDER BY created_at DESC LIMIT ?`,
)
.all(canvasId, limit) as Record<string, unknown>[];
return rows.map((r) => ({
id: String(r.id),
baseRev: Number(r.base_rev),
origin: String(r.origin),
summary: String(r.summary),
createdAt: Number(r.created_at),
undone: r.undone_at != null,
isUndoCompanion: String(r.summary).startsWith("undo "),
}));
}
createSnapshot(canvasId: string, name: string) {
const snap = this.getSnapshot(canvasId);
const id = newId("snap");
const t = now();
this.db
.prepare(
`INSERT INTO snapshots(id, canvas_id, name, snapshot_json, created_at) VALUES(?,?,?,?,?)`,
)
.run(id, canvasId, name || `snapshot-${t}`, JSON.stringify(snap), t);
return { id, name: name || `snapshot-${t}`, createdAt: t };
}
listSnapshots(canvasId: string, limit = 20) {
const rows = this.db
.prepare(
`SELECT id, name, created_at FROM snapshots WHERE canvas_id = ?
ORDER BY created_at DESC LIMIT ?`,
)
.all(canvasId, limit) as Record<string, unknown>[];
return rows.map((r) => ({
id: String(r.id),
name: String(r.name),
createdAt: Number(r.created_at),
}));
}
restoreSnapshot(snapshotId: string) {
const row = this.db
.prepare(`SELECT * FROM snapshots WHERE id = ?`)
.get(snapshotId) as Record<string, unknown> | undefined;
if (!row) throw new Error("snapshot not found");
const canvasId = String(row.canvas_id);
const snap = JSON.parse(String(row.snapshot_json)) as {
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
};
const g = this.loadCanvas(canvasId);
// Soft-delete current live nodes/edges then restore snapshot rows.
const t = now();
for (const n of g.nodes.values()) {
if (!n.deletedAt) {
n.deletedAt = t;
n.version += 1;
n.updatedAt = t;
}
}
for (const e of g.edges.values()) {
if (!e.deletedAt) {
e.deletedAt = t;
e.version += 1;
e.updatedAt = t;
}
}
for (const n of snap.nodes) {
g.nodes.set(n.id, {
...n,
canvasId,
deletedAt: n.deletedAt ?? null,
updatedAt: t,
});
}
for (const e of snap.edges) {
g.edges.set(e.id, {
...e,
canvasId,
deletedAt: e.deletedAt ?? null,
updatedAt: t,
});
}
g.canvas.title = snap.canvas.title;
g.canvas.focusNodeId = snap.canvas.focusNodeId;
g.canvas.anchorNodeId = snap.canvas.anchorNodeId;
g.canvas.prefs = { ...g.canvas.prefs, ...snap.canvas.prefs };
g.canvas.viewport = { ...snap.canvas.viewport };
g.canvas.rev += 1;
g.canvas.updatedAt = t;
const changeSetId = newId("cs");
this.db.exec("BEGIN");
try {
this.persistGraph(g, false);
this.db
.prepare(
`INSERT INTO change_sets(id, canvas_id, base_rev, origin, summary, inverse_json, created_at)
VALUES(?,?,?,?,?,?,?)`,
)
.run(
changeSetId,
canvasId,
g.canvas.rev - 1,
"user",
`restore snapshot ${snapshotId}`,
JSON.stringify([]),
t,
);
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
this.cache.set(canvasId, g);
return {
changeSetId,
rev: g.canvas.rev,
snapshot: toSnapshot(g),
affectedNodeIds: [...g.nodes.keys()],
};
}
search(
canvasId: string,
query: string,
limit = 20,
scope:
| "text"
| "note"
| "tags"
| "description"
| "edge_label"
| "edge_note"
| "all" = "text",
) {
const q = query.trim().toLowerCase();
if (!q) return [];
const g = this.loadCanvas(canvasId);
type NodeHit = {
kind: "node";
id: string;
text: string;
score: number;
matchIn: ("text" | "note" | "tags" | "description")[];
};
type EdgeHit = {
kind: "edge";
id: string;
from: string;
to: string;
fromText: string;
toText: string;
label?: string;
note?: string;
score: number;
matchIn: ("edge_label" | "edge_note")[];
};
const hits: (NodeHit | EdgeHit)[] = [];
const wantNodes =
scope === "text" ||
scope === "note" ||
scope === "tags" ||
scope === "description" ||
scope === "all";
const wantEdgeLabel = scope === "edge_label" || scope === "all";
const wantEdgeNote = scope === "edge_note" || scope === "all";
if (wantNodes) {
for (const n of g.nodes.values()) {
if (n.deletedAt) continue;
const matchIn: ("text" | "note" | "tags" | "description")[] = [];
const t = n.text.toLowerCase();
const note = (n.note ?? "").toLowerCase();
const tags = (n.tags ?? []).map((x) => x.toLowerCase());
const desc = n.description
? JSON.stringify(n.description).toLowerCase()
: "";
const wantText = scope === "text" || scope === "all";
const wantNote = scope === "note" || scope === "all";
const wantTags = scope === "tags" || scope === "all";
const wantDesc = scope === "description" || scope === "all";
if (wantText && t.includes(q)) matchIn.push("text");
if (wantNote && note.includes(q)) matchIn.push("note");
if (wantTags && tags.some((tag) => tag.includes(q))) matchIn.push("tags");
if (wantDesc && desc.includes(q)) matchIn.push("description");
if (!matchIn.length) continue;
let score = 0.4;
if (matchIn.includes("text")) score = t.startsWith(q) ? 1 : 0.75;
else if (matchIn.includes("tags")) score = 0.55;
else if (matchIn.includes("description")) score = 0.5;
else score = 0.45;
hits.push({ kind: "node", id: n.id, text: n.text, score, matchIn });
}
}
if (wantEdgeLabel || wantEdgeNote) {
for (const e of g.edges.values()) {
if (e.deletedAt) continue;
const matchIn: ("edge_label" | "edge_note")[] = [];
const label = (e.label ?? "").toLowerCase();
const note = (e.note ?? "").toLowerCase();
if (wantEdgeLabel && label.includes(q)) matchIn.push("edge_label");
if (wantEdgeNote && note.includes(q)) matchIn.push("edge_note");
if (!matchIn.length) continue;
const fromN = g.nodes.get(e.from);
const toN = g.nodes.get(e.to);
let score = 0.5;
if (matchIn.includes("edge_label")) {
score = label.startsWith(q) ? 0.95 : 0.7;
} else {
score = 0.55;
}
hits.push({
kind: "edge",
id: e.id,
from: e.from,
to: e.to,
fromText: fromN?.text ?? e.from,
toText: toN?.text ?? e.to,
...(e.label ? { label: e.label } : {}),
...(e.note ? { note: e.note } : {}),
score,
matchIn,
});
}
}
return hits.sort((a, b) => b.score - a.score).slice(0, limit);
}
renameCanvas(canvasId: string, title: string) {
const g = this.loadCanvas(canvasId);
g.canvas.title = title;
g.canvas.updatedAt = now();
this.db
.prepare(`UPDATE canvases SET title = ?, updated_at = ? WHERE id = ?`)
.run(title, g.canvas.updatedAt, canvasId);
this.cache.set(canvasId, g);
return g.canvas;
}
softDeleteCanvas(canvasId: string) {
const t = now();
this.db.prepare(`UPDATE canvases SET deleted_at = ?, updated_at = ? WHERE id = ?`).run(t, t, canvasId);
this.cache.delete(canvasId);
}
/** Permanent delete of a canvas and all related rows (P17). */
hardDeleteCanvas(canvasId: string) {
this.db.prepare(`DELETE FROM events WHERE canvas_id = ?`).run(canvasId);
this.db.prepare(`DELETE FROM change_sets WHERE canvas_id = ?`).run(canvasId);
this.db.prepare(`DELETE FROM edges WHERE canvas_id = ?`).run(canvasId);
this.db.prepare(`DELETE FROM nodes WHERE canvas_id = ?`).run(canvasId);
this.db.prepare(`DELETE FROM canvases WHERE id = ?`).run(canvasId);
this.cache.delete(canvasId);
}
listDeletedNodes(canvasId: string): MindNode[] {
const rows = this.db
.prepare(
`SELECT * FROM nodes WHERE canvas_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 100`,
)
.all(canvasId) as Record<string, unknown>[];
return rows.map(rowNode);
}
restoreNode(canvasId: string, nodeId: string) {
const g = this.loadCanvas(canvasId);
const focus = g.canvas.focusNodeId;
if (!g.nodes.get(nodeId)) throw new Error("not found");
const ops: Op[] = [{ type: "restore_node", nodeId }];
if (focus && focus !== nodeId) {
ops.push({
type: "link",
from: focus,
to: nodeId,
kind: "hierarchy",
asPrimary: true,
});
}
return this.applyChangeSet(canvasId, ops, {
origin: "user",
summary: `restore ${nodeId}`,
});
}
updateViewport(canvasId: string, viewport: CanvasMeta["viewport"]) {
const g = this.loadCanvas(canvasId);
g.canvas.viewport = viewport;
g.canvas.updatedAt = now();
this.db
.prepare(`UPDATE canvases SET viewport_json = ?, updated_at = ? WHERE id = ?`)
.run(JSON.stringify(viewport), g.canvas.updatedAt, canvasId);
this.cache.set(canvasId, g);
}
private persistGraph(g: MutableGraph, isNew: boolean) {
const c = g.canvas;
if (!c.anchorNodeId) resolveAnchorNodeId(g);
const prefsJson = JSON.stringify({
...c.prefs,
anchorNodeId: c.anchorNodeId ?? null,
});
if (isNew) {
this.db
.prepare(
`INSERT INTO canvases(id, title, focus_node_id, viewport_json, prefs_json, rev, agent_mode, created_at, updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
)
.run(
c.id,
c.title,
c.focusNodeId,
JSON.stringify(c.viewport),
prefsJson,
c.rev,
c.agentMode,
c.createdAt,
c.updatedAt,
);
} else {
this.db
.prepare(
`UPDATE canvases SET title=?, focus_node_id=?, viewport_json=?, prefs_json=?, rev=?, agent_mode=?, updated_at=? WHERE id=?`,
)
.run(
c.title,
c.focusNodeId,
JSON.stringify(c.viewport),
prefsJson,
c.rev,
c.agentMode,
c.updatedAt,
c.id,
);
}
const upsertNode = this.db.prepare(
`INSERT INTO nodes(id, canvas_id, text, note, collapsed, pinned, pos_json, side_pref, style_preset, version, tags_json, links_json, image_url, due_at, start_at, accent_color, icon, alias, description_json, created_at, updated_at, deleted_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
text=excluded.text, note=excluded.note, collapsed=excluded.collapsed, pinned=excluded.pinned,
pos_json=excluded.pos_json, side_pref=excluded.side_pref, style_preset=excluded.style_preset,
version=excluded.version, tags_json=excluded.tags_json, links_json=excluded.links_json,
image_url=excluded.image_url, due_at=excluded.due_at, start_at=excluded.start_at,
accent_color=excluded.accent_color, icon=excluded.icon, alias=excluded.alias,
description_json=excluded.description_json,
updated_at=excluded.updated_at, deleted_at=excluded.deleted_at`,
);
for (const n of g.nodes.values()) {
upsertNode.run(
n.id,
n.canvasId,
n.text,
n.note ?? null,
n.collapsed ? 1 : 0,
n.pinned ? 1 : 0,
n.pos ? JSON.stringify(n.pos) : null,
n.sidePref,
n.stylePreset,
n.version,
JSON.stringify(n.tags ?? []),
JSON.stringify(n.links ?? []),
n.imageUrl ?? null,
n.dueAt ?? null,
n.startAt ?? null,
n.accentColor ?? null,
n.icon ?? null,
n.alias ?? null,
n.description ? JSON.stringify(n.description) : null,
n.createdAt,
n.updatedAt,
n.deletedAt ?? null,
);
}
const upsertEdge = this.db.prepare(
`INSERT INTO edges(id, canvas_id, "from", "to", kind, is_primary_parent, "order", label, tags_json, note, weight, line_style, direction, version, created_at, updated_at, deleted_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
"from"=excluded."from", "to"=excluded."to", kind=excluded.kind, is_primary_parent=excluded.is_primary_parent,
"order"=excluded."order", label=excluded.label, tags_json=excluded.tags_json, note=excluded.note,
weight=excluded.weight, line_style=excluded.line_style, direction=excluded.direction,
version=excluded.version, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at`,
);
for (const e of g.edges.values()) {
upsertEdge.run(
e.id,
e.canvasId,
e.from,
e.to,
e.kind,
e.isPrimaryParent ? 1 : 0,
e.order,
e.label ?? null,
JSON.stringify(e.tags ?? []),
e.note ?? null,
e.weight ?? null,
e.lineStyle ?? null,
e.direction ?? null,
e.version,
e.createdAt,
e.updatedAt,
e.deletedAt ?? null,
);
}
}
}