pmx-canvas 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/pmx-canvas/extension.mjs +67 -3
- package/CHANGELOG.md +73 -0
- package/Readme.md +35 -5
- package/dist/canvas/global.css +498 -28
- package/dist/canvas/index.js +110 -95
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +4 -0
- package/dist/types/cli/commands/backup.d.ts +1 -0
- package/dist/types/cli/commands/boards.d.ts +1 -0
- package/dist/types/cli/commands/export.d.ts +1 -0
- package/dist/types/cli/commands/record.d.ts +12 -0
- package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
- package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
- package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -0
- package/dist/types/client/canvas/Presentation.d.ts +13 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
- package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- package/dist/types/client/state/boards-store.d.ts +79 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/presentation.d.ts +3 -0
- package/dist/types/client/state/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +3 -3
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +8 -0
- package/dist/types/mcp/canvas-access.d.ts +10 -3
- package/dist/types/mcp/context-uri-template.d.ts +6 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/attribution.d.ts +11 -0
- package/dist/types/server/ax-context.d.ts +3 -1
- package/dist/types/server/ax-interaction.d.ts +2 -2
- package/dist/types/server/ax-state.d.ts +13 -3
- package/dist/types/server/backup.d.ts +36 -0
- package/dist/types/server/board-export.d.ts +45 -0
- package/dist/types/server/canvas-db.d.ts +65 -9
- package/dist/types/server/canvas-operations.d.ts +45 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +146 -3
- package/dist/types/server/canvas-validation.d.ts +3 -0
- package/dist/types/server/context-brief.d.ts +111 -0
- package/dist/types/server/context-reads.d.ts +55 -0
- package/dist/types/server/document-import.d.ts +66 -0
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +107 -4
- package/dist/types/server/mutation-history.d.ts +1 -0
- package/dist/types/server/operations/invoker.d.ts +2 -0
- package/dist/types/server/operations/mcp.d.ts +7 -1
- package/dist/types/server/operations/ops/app.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
- package/dist/types/server/operations/ops/backup.d.ts +2 -0
- package/dist/types/server/operations/ops/boards.d.ts +10 -0
- package/dist/types/server/operations/ops/export.d.ts +2 -0
- package/dist/types/server/operations/ops/imports.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +4 -2
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/trace-manager.d.ts +7 -0
- package/dist/types/server/web-artifacts.d.ts +3 -0
- package/dist/types/server/webpage-node.d.ts +22 -1
- package/dist/types/server/workbench-auth.d.ts +3 -0
- package/dist/types/shared/boards.d.ts +6 -0
- package/dist/types/shared/tour.d.ts +72 -0
- package/docs/RELEASE.md +5 -0
- package/docs/cli.md +157 -0
- package/docs/http-api.md +277 -6
- package/docs/mcp.md +96 -7
- package/docs/node-types.md +106 -0
- package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
- package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
- package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
- package/docs/plans/plan-013-static-board-export.md +47 -0
- package/docs/plans/plan-014-connected-board-memory.md +140 -0
- package/docs/product-vision-2026-09.md +118 -18
- package/docs/sdk.md +50 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +62 -5
- package/skills/pmx-canvas/references/full-reference.md +44 -8
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +4 -0
- package/src/cli/agent.ts +9 -0
- package/src/cli/commands/ax.ts +13 -0
- package/src/cli/commands/backup.ts +66 -0
- package/src/cli/commands/boards.ts +71 -0
- package/src/cli/commands/export.ts +30 -0
- package/src/cli/commands/nodes.ts +14 -1
- package/src/cli/commands/pins.ts +6 -2
- package/src/cli/commands/pump.ts +3 -0
- package/src/cli/commands/query.ts +33 -7
- package/src/cli/commands/record.ts +218 -0
- package/src/cli/commands/view.ts +7 -5
- package/src/cli/index.ts +21 -9
- package/src/client/App.tsx +28 -4
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- package/src/client/canvas/BoardCopyDialog.tsx +153 -0
- package/src/client/canvas/BoardFolderDialog.tsx +120 -0
- package/src/client/canvas/BoardLinkDialog.tsx +93 -0
- package/src/client/canvas/BoardSwitcher.tsx +149 -0
- package/src/client/canvas/CanvasNode.tsx +8 -0
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/CommandPalette.tsx +107 -4
- package/src/client/canvas/ContextMenu.tsx +4 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
- package/src/client/canvas/ExportDialog.tsx +197 -0
- package/src/client/canvas/HomeView.tsx +277 -0
- package/src/client/canvas/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +49 -7
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/icons.tsx +2 -0
- package/src/client/mermaid-entry.ts +39 -0
- package/src/client/nodes/AttachmentNode.tsx +148 -0
- package/src/client/nodes/BoardNode.tsx +35 -0
- package/src/client/nodes/FileNode.tsx +6 -0
- package/src/client/nodes/HtmlNode.tsx +12 -13
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- package/src/client/nodes/ax-node-actions.ts +2 -5
- package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
- package/src/client/state/boards-store.ts +167 -0
- package/src/client/state/canvas-store.ts +22 -0
- package/src/client/state/intent-bridge.ts +30 -2
- package/src/client/state/intent-store.ts +5 -1
- package/src/client/state/node-factory.ts +1 -0
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +8 -0
- package/src/client/state/sse-bridge.ts +52 -5
- package/src/client/theme/global.css +498 -28
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +45 -9
- package/src/mcp/canvas-access.ts +59 -22
- package/src/mcp/context-uri-template.ts +28 -0
- package/src/mcp/server.ts +122 -15
- package/src/server/agent-context.ts +116 -29
- package/src/server/agent-presence.ts +15 -9
- package/src/server/attribution.ts +29 -0
- package/src/server/ax-context.ts +4 -2
- package/src/server/ax-interaction.ts +1 -0
- package/src/server/ax-state-manager.ts +72 -3
- package/src/server/ax-state.ts +41 -0
- package/src/server/backup.ts +178 -0
- package/src/server/board-export.ts +686 -0
- package/src/server/canvas-db.ts +612 -105
- package/src/server/canvas-operations.ts +183 -31
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +22 -0
- package/src/server/canvas-state.ts +812 -48
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +670 -9
- package/src/server/document-import.ts +223 -0
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +243 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +29 -5
- package/src/server/operations/http.ts +9 -3
- package/src/server/operations/index.ts +8 -0
- package/src/server/operations/invoker.ts +14 -3
- package/src/server/operations/mcp.ts +50 -11
- package/src/server/operations/ops/app.ts +15 -0
- package/src/server/operations/ops/ax-read.ts +10 -6
- package/src/server/operations/ops/ax-timeline.ts +75 -0
- package/src/server/operations/ops/backup.ts +134 -0
- package/src/server/operations/ops/batch.ts +20 -4
- package/src/server/operations/ops/boards.ts +351 -0
- package/src/server/operations/ops/export.ts +74 -0
- package/src/server/operations/ops/ext-app.ts +4 -0
- package/src/server/operations/ops/groups.ts +14 -7
- package/src/server/operations/ops/imports.ts +220 -0
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +99 -21
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +129 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +155 -8
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +11 -3
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/server/workboard.ts +3 -3
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
- package/src/shared/tour.ts +87 -0
package/src/server/canvas-db.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Database } from 'bun:sqlite';
|
|
9
|
+
import { CONTEXT_READS_SCHEMA_SQL } from './context-reads.js';
|
|
10
|
+
import { MIGRATED_SNAPSHOT_CATEGORY, normalizeBoardCategory } from '../shared/boards.js';
|
|
11
|
+
import { tourSchema, type Tour } from '../shared/tour.js';
|
|
9
12
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
10
13
|
import { dirname } from 'node:path';
|
|
11
14
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
@@ -41,7 +44,7 @@ import {
|
|
|
41
44
|
|
|
42
45
|
// ── Schema ──────────────────────────────────────────────────────
|
|
43
46
|
|
|
44
|
-
const SCHEMA_VERSION =
|
|
47
|
+
const SCHEMA_VERSION = 2;
|
|
45
48
|
|
|
46
49
|
export type CanvasTheme = CanvasThemeName;
|
|
47
50
|
|
|
@@ -55,8 +58,29 @@ const SCHEMA_SQL = `
|
|
|
55
58
|
value TEXT NOT NULL
|
|
56
59
|
);
|
|
57
60
|
|
|
58
|
-
CREATE TABLE IF NOT EXISTS
|
|
61
|
+
CREATE TABLE IF NOT EXISTS context_brief_cursors (
|
|
62
|
+
board_id TEXT NOT NULL,
|
|
63
|
+
consumer TEXT NOT NULL,
|
|
64
|
+
revision INTEGER NOT NULL,
|
|
65
|
+
PRIMARY KEY (board_id, consumer)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE TABLE IF NOT EXISTS boards (
|
|
59
69
|
id TEXT PRIMARY KEY,
|
|
70
|
+
name TEXT NOT NULL,
|
|
71
|
+
created_at TEXT NOT NULL,
|
|
72
|
+
last_opened_at TEXT,
|
|
73
|
+
viewport_x REAL NOT NULL DEFAULT 0,
|
|
74
|
+
viewport_y REAL NOT NULL DEFAULT 0,
|
|
75
|
+
viewport_scale REAL NOT NULL DEFAULT 1,
|
|
76
|
+
tour TEXT NOT NULL DEFAULT 'null',
|
|
77
|
+
category TEXT,
|
|
78
|
+
readme_node_id TEXT
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
82
|
+
board_id TEXT NOT NULL,
|
|
83
|
+
id TEXT NOT NULL,
|
|
60
84
|
type TEXT NOT NULL,
|
|
61
85
|
pos_x REAL NOT NULL,
|
|
62
86
|
pos_y REAL NOT NULL,
|
|
@@ -65,21 +89,25 @@ const SCHEMA_SQL = `
|
|
|
65
89
|
z_index INTEGER NOT NULL DEFAULT 0,
|
|
66
90
|
collapsed INTEGER NOT NULL DEFAULT 0,
|
|
67
91
|
pinned INTEGER NOT NULL DEFAULT 0,
|
|
68
|
-
data TEXT NOT NULL
|
|
92
|
+
data TEXT NOT NULL,
|
|
93
|
+
PRIMARY KEY (board_id, id)
|
|
69
94
|
);
|
|
70
95
|
|
|
71
96
|
CREATE TABLE IF NOT EXISTS edges (
|
|
72
|
-
|
|
97
|
+
board_id TEXT NOT NULL,
|
|
98
|
+
id TEXT NOT NULL,
|
|
73
99
|
from_node TEXT NOT NULL,
|
|
74
100
|
to_node TEXT NOT NULL,
|
|
75
101
|
type TEXT NOT NULL,
|
|
76
102
|
label TEXT,
|
|
77
103
|
style TEXT,
|
|
78
|
-
animated INTEGER NOT NULL DEFAULT 0
|
|
104
|
+
animated INTEGER NOT NULL DEFAULT 0,
|
|
105
|
+
PRIMARY KEY (board_id, id)
|
|
79
106
|
);
|
|
80
107
|
|
|
81
108
|
CREATE TABLE IF NOT EXISTS annotations (
|
|
82
|
-
|
|
109
|
+
board_id TEXT NOT NULL,
|
|
110
|
+
id TEXT NOT NULL,
|
|
83
111
|
type TEXT NOT NULL,
|
|
84
112
|
points TEXT NOT NULL,
|
|
85
113
|
bounds TEXT NOT NULL,
|
|
@@ -87,20 +115,26 @@ const SCHEMA_SQL = `
|
|
|
87
115
|
width REAL NOT NULL,
|
|
88
116
|
text TEXT,
|
|
89
117
|
label TEXT,
|
|
90
|
-
created_at TEXT NOT NULL
|
|
118
|
+
created_at TEXT NOT NULL,
|
|
119
|
+
PRIMARY KEY (board_id, id)
|
|
91
120
|
);
|
|
92
121
|
|
|
93
122
|
CREATE TABLE IF NOT EXISTS context_pins (
|
|
94
|
-
|
|
123
|
+
board_id TEXT NOT NULL,
|
|
124
|
+
node_id TEXT NOT NULL,
|
|
125
|
+
PRIMARY KEY (board_id, node_id)
|
|
95
126
|
);
|
|
96
127
|
|
|
97
128
|
CREATE TABLE IF NOT EXISTS ax_state (
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
board_id TEXT NOT NULL,
|
|
130
|
+
key TEXT NOT NULL,
|
|
131
|
+
value TEXT NOT NULL,
|
|
132
|
+
PRIMARY KEY (board_id, key)
|
|
100
133
|
);
|
|
101
134
|
|
|
102
135
|
CREATE TABLE IF NOT EXISTS snapshots (
|
|
103
136
|
id TEXT PRIMARY KEY,
|
|
137
|
+
board_id TEXT,
|
|
104
138
|
name TEXT NOT NULL,
|
|
105
139
|
created_at TEXT NOT NULL,
|
|
106
140
|
node_count INTEGER NOT NULL,
|
|
@@ -167,6 +201,39 @@ const SCHEMA_SQL = `
|
|
|
167
201
|
json_bytes INTEGER NOT NULL
|
|
168
202
|
);
|
|
169
203
|
|
|
204
|
+
CREATE TABLE IF NOT EXISTS attachment_bytes (
|
|
205
|
+
sha256 TEXT PRIMARY KEY,
|
|
206
|
+
data BLOB NOT NULL,
|
|
207
|
+
size INTEGER NOT NULL
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
211
|
+
id TEXT PRIMARY KEY,
|
|
212
|
+
board_id TEXT NOT NULL,
|
|
213
|
+
sha256 TEXT NOT NULL,
|
|
214
|
+
name TEXT NOT NULL,
|
|
215
|
+
mime TEXT NOT NULL,
|
|
216
|
+
size INTEGER NOT NULL,
|
|
217
|
+
created_at TEXT NOT NULL
|
|
218
|
+
);
|
|
219
|
+
CREATE INDEX IF NOT EXISTS idx_attachments_board ON attachments (board_id);
|
|
220
|
+
|
|
221
|
+
CREATE TABLE IF NOT EXISTS document_imports (
|
|
222
|
+
id TEXT PRIMARY KEY,
|
|
223
|
+
board_id TEXT NOT NULL,
|
|
224
|
+
attachment_id TEXT NOT NULL,
|
|
225
|
+
status TEXT NOT NULL,
|
|
226
|
+
position_x REAL NOT NULL,
|
|
227
|
+
position_y REAL NOT NULL,
|
|
228
|
+
sections TEXT,
|
|
229
|
+
warnings TEXT,
|
|
230
|
+
agent_description TEXT,
|
|
231
|
+
reason TEXT,
|
|
232
|
+
committed_node_ids TEXT,
|
|
233
|
+
created_at TEXT NOT NULL,
|
|
234
|
+
updated_at TEXT NOT NULL
|
|
235
|
+
);
|
|
236
|
+
|
|
170
237
|
CREATE TABLE IF NOT EXISTS ax_events (
|
|
171
238
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
172
239
|
id TEXT NOT NULL UNIQUE,
|
|
@@ -260,6 +327,7 @@ function parsePersistedAxState(raw: string | null | undefined): PmxAxState {
|
|
|
260
327
|
// ── Persisted State Interface ───────────────────────────────────
|
|
261
328
|
|
|
262
329
|
export interface PersistedCanvasState {
|
|
330
|
+
tour?: Tour;
|
|
263
331
|
version: number;
|
|
264
332
|
theme?: CanvasTheme;
|
|
265
333
|
viewport: ViewportState;
|
|
@@ -268,11 +336,48 @@ export interface PersistedCanvasState {
|
|
|
268
336
|
annotations?: CanvasAnnotation[];
|
|
269
337
|
contextPins: string[];
|
|
270
338
|
ax?: PmxAxState;
|
|
339
|
+
revisionState?: PersistedRevisionState;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface PersistedRevisionState {
|
|
343
|
+
revision: number;
|
|
344
|
+
floor: number;
|
|
345
|
+
tombstones: Array<{ nodeId: string; revision: number; deletedBy: import('./attribution.js').ActorAttribution }>;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Durable, delivery-based context cursor. This is separate from the bounded diagnostic read log. */
|
|
349
|
+
export function readContextBriefCursor(db: Database, boardId: string, consumer: string): number | null {
|
|
350
|
+
return (
|
|
351
|
+
db
|
|
352
|
+
.query<{ revision: number }, [string, string]>(
|
|
353
|
+
'SELECT revision FROM context_brief_cursors WHERE board_id = ? AND consumer = ?',
|
|
354
|
+
)
|
|
355
|
+
.get(boardId, consumer)?.revision ?? null
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Monotonic update prevents a stale concurrent response from regressing a consumer. */
|
|
360
|
+
export function advanceContextBriefCursor(db: Database, boardId: string, consumer: string, revision: number): void {
|
|
361
|
+
db.run(
|
|
362
|
+
`INSERT INTO context_brief_cursors (board_id, consumer, revision) VALUES (?, ?, ?)
|
|
363
|
+
ON CONFLICT(board_id, consumer) DO UPDATE SET revision = MAX(revision, excluded.revision)`,
|
|
364
|
+
[boardId, consumer, revision],
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function readTour(raw: string | undefined): Tour | undefined {
|
|
369
|
+
if (!raw || raw === 'null') return undefined;
|
|
370
|
+
return tourSchema.parse(JSON.parse(raw));
|
|
271
371
|
}
|
|
272
372
|
|
|
273
373
|
// ── Database Management ─────────────────────────────────────────
|
|
274
374
|
|
|
275
|
-
export
|
|
375
|
+
export interface OpenCanvasDbOptions {
|
|
376
|
+
/** Name for the board a pre-boards (0.6.x) database migrates into. */
|
|
377
|
+
migratedBoardName?: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function openCanvasDb(dbPath: string, options: OpenCanvasDbOptions = {}): Database {
|
|
276
381
|
const dir = dirname(dbPath);
|
|
277
382
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
278
383
|
|
|
@@ -286,12 +391,22 @@ export function openCanvasDb(dbPath: string): Database {
|
|
|
286
391
|
// worth the fsync.
|
|
287
392
|
db.exec('PRAGMA synchronous=FULL');
|
|
288
393
|
db.exec('PRAGMA busy_timeout=5000');
|
|
394
|
+
if (needsBoardsMigration(db)) migrateToBoards(db, dbPath, options.migratedBoardName ?? 'Board');
|
|
289
395
|
db.exec(SCHEMA_SQL);
|
|
396
|
+
db.exec(CONTEXT_READS_SCHEMA_SQL);
|
|
290
397
|
|
|
291
398
|
// Additive columns for pre-existing DBs (fresh installs already get them via SCHEMA_SQL above).
|
|
292
399
|
ensureColumn(db, 'ax_events', 'agent_id', 'agent_id TEXT');
|
|
293
400
|
ensureColumn(db, 'ax_steering', 'agent_id', 'agent_id TEXT');
|
|
294
401
|
ensureColumn(db, 'ax_steering', 'target', 'target TEXT');
|
|
402
|
+
ensureColumn(db, 'context_reads', 'board_id', 'board_id TEXT');
|
|
403
|
+
ensureColumn(db, 'boards', 'category', 'category TEXT');
|
|
404
|
+
ensureColumn(db, 'boards', 'readme_node_id', 'readme_node_id TEXT');
|
|
405
|
+
ensureColumn(db, 'nodes', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}' ");
|
|
406
|
+
ensureColumn(db, 'nodes', 'content_revision', 'content_revision INTEGER NOT NULL DEFAULT 0');
|
|
407
|
+
ensureColumn(db, 'snapshot_nodes', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}' ");
|
|
408
|
+
ensureColumn(db, 'snapshot_nodes', 'content_revision', 'content_revision INTEGER NOT NULL DEFAULT 0');
|
|
409
|
+
ensureColumn(db, 'document_imports', 'committed_node_ids', 'committed_node_ids TEXT');
|
|
295
410
|
|
|
296
411
|
// Set schema version if not present
|
|
297
412
|
const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('schema_version');
|
|
@@ -302,6 +417,151 @@ export function openCanvasDb(dbPath: string): Database {
|
|
|
302
417
|
return db;
|
|
303
418
|
}
|
|
304
419
|
|
|
420
|
+
// ── Many boards migration (schema 1 → 2) ────────────────────────
|
|
421
|
+
|
|
422
|
+
/** A pre-boards database has a `nodes` table without a `board_id` column. */
|
|
423
|
+
function needsBoardsMigration(db: Database): boolean {
|
|
424
|
+
const columns = db.query<{ name: string }, []>('PRAGMA table_info(nodes)').all();
|
|
425
|
+
return columns.length > 0 && !columns.some((column) => column.name === 'board_id');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function createBoardId(): string {
|
|
429
|
+
return `board-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Moves a one-board (0.6.x) database into the boards layout. The current board
|
|
434
|
+
* becomes a named board and stays the open one. Every snapshot becomes a board
|
|
435
|
+
* of its own: in 0.6 each session replaced the one board, so a snapshot is
|
|
436
|
+
* effectively a past board (the real work of earlier sessions survives only
|
|
437
|
+
* there). SQLite cannot change a primary key in place, so the board-scoped
|
|
438
|
+
* tables are rebuilt. A copy of the file is written first (`<db>.pre-boards`),
|
|
439
|
+
* and everything runs in one transaction.
|
|
440
|
+
*/
|
|
441
|
+
function migrateToBoards(db: Database, dbPath: string, boardName: string): void {
|
|
442
|
+
const metaValue = (key: string): string | undefined =>
|
|
443
|
+
db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get(key)?.value;
|
|
444
|
+
// Schema 1 always created `snapshots` alongside `nodes`.
|
|
445
|
+
const snapshotCount = db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM snapshots').get()?.n ?? 0;
|
|
446
|
+
const hasBoard = metaValue('state_populated') === '1';
|
|
447
|
+
|
|
448
|
+
if ((hasBoard || snapshotCount > 0) && dbPath !== ':memory:') {
|
|
449
|
+
const backupPath = `${dbPath}.pre-boards`;
|
|
450
|
+
if (!existsSync(backupPath)) db.run('VACUUM INTO ?', [backupPath]);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const boardId = createBoardId();
|
|
454
|
+
const rebuilt: Array<{ table: string; columns: string }> = [
|
|
455
|
+
{ table: 'nodes', columns: 'id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data' },
|
|
456
|
+
{ table: 'edges', columns: 'id, from_node, to_node, type, label, style, animated' },
|
|
457
|
+
{ table: 'annotations', columns: 'id, type, points, bounds, color, width, text, label, created_at' },
|
|
458
|
+
{ table: 'context_pins', columns: 'node_id' },
|
|
459
|
+
{ table: 'ax_state', columns: 'key, value' },
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
const migrate = db.transaction(() => {
|
|
463
|
+
const existing = new Set(
|
|
464
|
+
db
|
|
465
|
+
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
466
|
+
.all()
|
|
467
|
+
.map((r) => r.name),
|
|
468
|
+
);
|
|
469
|
+
for (const { table } of rebuilt) {
|
|
470
|
+
if (existing.has(table)) db.exec(`ALTER TABLE ${table} RENAME TO ${table}_v1`);
|
|
471
|
+
}
|
|
472
|
+
if (existing.has('snapshots')) ensureColumn(db, 'snapshots', 'board_id', 'board_id TEXT');
|
|
473
|
+
db.exec(SCHEMA_SQL);
|
|
474
|
+
|
|
475
|
+
if (hasBoard) {
|
|
476
|
+
const now = new Date().toISOString();
|
|
477
|
+
db.run(
|
|
478
|
+
'INSERT INTO boards (id, name, created_at, last_opened_at, viewport_x, viewport_y, viewport_scale, tour) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
479
|
+
[
|
|
480
|
+
boardId,
|
|
481
|
+
boardName,
|
|
482
|
+
now,
|
|
483
|
+
now,
|
|
484
|
+
Number(metaValue('viewport_x') ?? 0),
|
|
485
|
+
Number(metaValue('viewport_y') ?? 0),
|
|
486
|
+
Number(metaValue('viewport_scale') ?? 1) || 1,
|
|
487
|
+
metaValue('tour') ?? 'null',
|
|
488
|
+
],
|
|
489
|
+
);
|
|
490
|
+
for (const { table, columns } of rebuilt) {
|
|
491
|
+
if (!existing.has(table)) continue;
|
|
492
|
+
db.run(`INSERT INTO ${table} (board_id, ${columns}) SELECT ?, ${columns} FROM ${table}_v1`, [boardId]);
|
|
493
|
+
}
|
|
494
|
+
db.run(
|
|
495
|
+
"INSERT INTO meta (key, value) VALUES ('active_board', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
496
|
+
[boardId],
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
snapshotsToBoards(db);
|
|
500
|
+
for (const { table } of rebuilt) {
|
|
501
|
+
if (existing.has(table)) db.exec(`DROP TABLE ${table}_v1`);
|
|
502
|
+
}
|
|
503
|
+
db.run("DELETE FROM meta WHERE key IN ('viewport_x', 'viewport_y', 'viewport_scale', 'tour', 'state_populated')");
|
|
504
|
+
db.run("UPDATE meta SET value = ? WHERE key = 'schema_version'", [String(SCHEMA_VERSION)]);
|
|
505
|
+
});
|
|
506
|
+
migrate();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Each schema-1 snapshot becomes a board named after it; the snapshot rows are then removed. */
|
|
510
|
+
function snapshotsToBoards(db: Database): void {
|
|
511
|
+
interface SnapshotRow {
|
|
512
|
+
id: string;
|
|
513
|
+
name: string;
|
|
514
|
+
created_at: string;
|
|
515
|
+
}
|
|
516
|
+
const snapshots = db
|
|
517
|
+
.query<SnapshotRow, []>('SELECT id, name, created_at FROM snapshots WHERE board_id IS NULL ORDER BY created_at')
|
|
518
|
+
.all();
|
|
519
|
+
for (const snapshot of snapshots) {
|
|
520
|
+
const meta = new Map(
|
|
521
|
+
db
|
|
522
|
+
.query<{ key: string; value: string }, [string]>('SELECT key, value FROM snapshot_meta WHERE snapshot_id = ?')
|
|
523
|
+
.all(snapshot.id)
|
|
524
|
+
.map((row) => [row.key, row.value]),
|
|
525
|
+
);
|
|
526
|
+
const boardId = createBoardId();
|
|
527
|
+
db.run(
|
|
528
|
+
'INSERT INTO boards (id, name, category, created_at, viewport_x, viewport_y, viewport_scale, tour) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
529
|
+
[
|
|
530
|
+
boardId,
|
|
531
|
+
snapshot.name,
|
|
532
|
+
MIGRATED_SNAPSHOT_CATEGORY,
|
|
533
|
+
snapshot.created_at,
|
|
534
|
+
Number(meta.get('viewport_x') ?? 0),
|
|
535
|
+
Number(meta.get('viewport_y') ?? 0),
|
|
536
|
+
Number(meta.get('viewport_scale') ?? 1) || 1,
|
|
537
|
+
meta.get('tour') ?? 'null',
|
|
538
|
+
],
|
|
539
|
+
);
|
|
540
|
+
const nodeColumns = 'id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data';
|
|
541
|
+
const edgeColumns = 'id, from_node, to_node, type, label, style, animated';
|
|
542
|
+
const annotationColumns = 'id, type, points, bounds, color, width, text, label, created_at';
|
|
543
|
+
db.run(
|
|
544
|
+
`INSERT INTO nodes (board_id, ${nodeColumns}) SELECT ?, ${nodeColumns} FROM snapshot_nodes WHERE snapshot_id = ?`,
|
|
545
|
+
[boardId, snapshot.id],
|
|
546
|
+
);
|
|
547
|
+
db.run(
|
|
548
|
+
`INSERT INTO edges (board_id, ${edgeColumns}) SELECT ?, ${edgeColumns} FROM snapshot_edges WHERE snapshot_id = ?`,
|
|
549
|
+
[boardId, snapshot.id],
|
|
550
|
+
);
|
|
551
|
+
db.run(
|
|
552
|
+
`INSERT INTO annotations (board_id, ${annotationColumns}) SELECT ?, ${annotationColumns} FROM snapshot_annotations WHERE snapshot_id = ?`,
|
|
553
|
+
[boardId, snapshot.id],
|
|
554
|
+
);
|
|
555
|
+
db.run('INSERT INTO context_pins (board_id, node_id) SELECT ?, node_id FROM snapshot_pins WHERE snapshot_id = ?', [
|
|
556
|
+
boardId,
|
|
557
|
+
snapshot.id,
|
|
558
|
+
]);
|
|
559
|
+
const ax = meta.get('ax_state');
|
|
560
|
+
if (ax) db.run("INSERT INTO ax_state (board_id, key, value) VALUES (?, 'state', ?)", [boardId, ax]);
|
|
561
|
+
deleteSnapshotFromDB(db, snapshot.id);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
305
565
|
export function checkpointCanvasDb(db: Database): void {
|
|
306
566
|
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
307
567
|
}
|
|
@@ -367,17 +627,36 @@ function deleteMissingRows(
|
|
|
367
627
|
db: Database,
|
|
368
628
|
table: 'nodes' | 'edges' | 'annotations' | 'context_pins',
|
|
369
629
|
idColumn: 'id' | 'node_id',
|
|
630
|
+
boardId: string,
|
|
370
631
|
keep: Set<string>,
|
|
371
632
|
): void {
|
|
372
|
-
const rows = db
|
|
633
|
+
const rows = db
|
|
634
|
+
.query<{ id: string }, [string]>(`SELECT ${idColumn} AS id FROM ${table} WHERE board_id = ?`)
|
|
635
|
+
.all(boardId);
|
|
373
636
|
if (rows.length === 0) return;
|
|
374
|
-
const remove = db.prepare(`DELETE FROM ${table} WHERE ${idColumn} = ?`);
|
|
637
|
+
const remove = db.prepare(`DELETE FROM ${table} WHERE board_id = ? AND ${idColumn} = ?`);
|
|
375
638
|
for (const row of rows) {
|
|
376
|
-
if (!keep.has(row.id)) remove.run(row.id);
|
|
639
|
+
if (!keep.has(row.id)) remove.run(boardId, row.id);
|
|
377
640
|
}
|
|
378
641
|
}
|
|
379
642
|
|
|
380
|
-
|
|
643
|
+
/** The theme is workspace-wide: it is saved even while no board is open. */
|
|
644
|
+
export function saveThemeToDB(db: Database, theme: CanvasTheme | undefined): void {
|
|
645
|
+
db.run(
|
|
646
|
+
`INSERT INTO meta (key, value) VALUES ('theme', ?)
|
|
647
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
648
|
+
WHERE value IS NOT excluded.value`,
|
|
649
|
+
[normalizeCanvasTheme(theme)],
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function readThemeFromDB(db: Database): CanvasTheme | undefined {
|
|
654
|
+
const value = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('theme')?.value;
|
|
655
|
+
return value ? normalizeCanvasTheme(value) : undefined;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Saves one board's state; every other board's rows are untouched. */
|
|
659
|
+
export function saveStateToDB(db: Database, boardId: string, state: PersistedCanvasState): void {
|
|
381
660
|
const transaction = db.transaction(() => {
|
|
382
661
|
// Rows are upserted only when a column actually differs, and rows that are
|
|
383
662
|
// gone are deleted by id. This used to be DELETE-all + INSERT-all, which
|
|
@@ -388,34 +667,43 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
388
667
|
// no-op — SQLite performs no update, so no page is written. The comparison
|
|
389
668
|
// is against the DB itself rather than an in-memory cache, so nothing can
|
|
390
669
|
// go stale and silently skip a write that was actually needed.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
670
|
+
saveThemeToDB(db, state.theme);
|
|
671
|
+
const tour = JSON.stringify(state.tour ?? null);
|
|
672
|
+
db.run(
|
|
673
|
+
`UPDATE boards SET viewport_x = ?, viewport_y = ?, viewport_scale = ?, tour = ?
|
|
674
|
+
WHERE id = ? AND (viewport_x IS NOT ? OR viewport_y IS NOT ? OR viewport_scale IS NOT ? OR tour IS NOT ?)`,
|
|
675
|
+
[
|
|
676
|
+
state.viewport.x,
|
|
677
|
+
state.viewport.y,
|
|
678
|
+
state.viewport.scale,
|
|
679
|
+
tour,
|
|
680
|
+
boardId,
|
|
681
|
+
state.viewport.x,
|
|
682
|
+
state.viewport.y,
|
|
683
|
+
state.viewport.scale,
|
|
684
|
+
tour,
|
|
685
|
+
],
|
|
395
686
|
);
|
|
396
|
-
upsertMeta.run('theme', normalizeCanvasTheme(state.theme));
|
|
397
|
-
upsertMeta.run('viewport_x', String(state.viewport.x));
|
|
398
|
-
upsertMeta.run('viewport_y', String(state.viewport.y));
|
|
399
|
-
upsertMeta.run('viewport_scale', String(state.viewport.scale));
|
|
400
|
-
// Mark DB as populated (for migration detection)
|
|
401
|
-
upsertMeta.run('state_populated', '1');
|
|
402
687
|
|
|
403
688
|
// Save nodes
|
|
404
689
|
const upsertNode = db.prepare(
|
|
405
|
-
`INSERT INTO nodes (id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data)
|
|
406
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
407
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
690
|
+
`INSERT INTO nodes (board_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data, attribution, content_revision)
|
|
691
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
692
|
+
ON CONFLICT(board_id, id) DO UPDATE SET
|
|
408
693
|
type = excluded.type, pos_x = excluded.pos_x, pos_y = excluded.pos_y,
|
|
409
694
|
width = excluded.width, height = excluded.height, z_index = excluded.z_index,
|
|
410
|
-
collapsed = excluded.collapsed, pinned = excluded.pinned, data = excluded.data
|
|
695
|
+
collapsed = excluded.collapsed, pinned = excluded.pinned, data = excluded.data,
|
|
696
|
+
attribution = excluded.attribution, content_revision = excluded.content_revision
|
|
411
697
|
WHERE type IS NOT excluded.type OR pos_x IS NOT excluded.pos_x
|
|
412
698
|
OR pos_y IS NOT excluded.pos_y OR width IS NOT excluded.width
|
|
413
699
|
OR height IS NOT excluded.height OR z_index IS NOT excluded.z_index
|
|
414
700
|
OR collapsed IS NOT excluded.collapsed OR pinned IS NOT excluded.pinned
|
|
415
|
-
OR data IS NOT excluded.data
|
|
701
|
+
OR data IS NOT excluded.data OR attribution IS NOT excluded.attribution
|
|
702
|
+
OR content_revision IS NOT excluded.content_revision`,
|
|
416
703
|
);
|
|
417
704
|
for (const node of state.nodes) {
|
|
418
705
|
upsertNode.run(
|
|
706
|
+
boardId,
|
|
419
707
|
node.id,
|
|
420
708
|
node.type,
|
|
421
709
|
node.position.x,
|
|
@@ -426,15 +714,17 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
426
714
|
node.collapsed ? 1 : 0,
|
|
427
715
|
node.pinned ? 1 : 0,
|
|
428
716
|
JSON.stringify(node.data),
|
|
717
|
+
JSON.stringify({ createdBy: node.createdBy, lastEditedBy: node.lastEditedBy }),
|
|
718
|
+
node.contentRevision ?? 0,
|
|
429
719
|
);
|
|
430
720
|
}
|
|
431
|
-
deleteMissingRows(db, 'nodes', 'id', new Set(state.nodes.map((node) => node.id)));
|
|
721
|
+
deleteMissingRows(db, 'nodes', 'id', boardId, new Set(state.nodes.map((node) => node.id)));
|
|
432
722
|
|
|
433
723
|
// Save edges
|
|
434
724
|
const upsertEdge = db.prepare(
|
|
435
|
-
`INSERT INTO edges (id, from_node, to_node, type, label, style, animated)
|
|
436
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
437
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
725
|
+
`INSERT INTO edges (board_id, id, from_node, to_node, type, label, style, animated)
|
|
726
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
727
|
+
ON CONFLICT(board_id, id) DO UPDATE SET
|
|
438
728
|
from_node = excluded.from_node, to_node = excluded.to_node, type = excluded.type,
|
|
439
729
|
label = excluded.label, style = excluded.style, animated = excluded.animated
|
|
440
730
|
WHERE from_node IS NOT excluded.from_node OR to_node IS NOT excluded.to_node
|
|
@@ -443,6 +733,7 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
443
733
|
);
|
|
444
734
|
for (const edge of state.edges) {
|
|
445
735
|
upsertEdge.run(
|
|
736
|
+
boardId,
|
|
446
737
|
edge.id,
|
|
447
738
|
edge.from,
|
|
448
739
|
edge.to,
|
|
@@ -452,14 +743,14 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
452
743
|
edge.animated ? 1 : 0,
|
|
453
744
|
);
|
|
454
745
|
}
|
|
455
|
-
deleteMissingRows(db, 'edges', 'id', new Set(state.edges.map((edge) => edge.id)));
|
|
746
|
+
deleteMissingRows(db, 'edges', 'id', boardId, new Set(state.edges.map((edge) => edge.id)));
|
|
456
747
|
|
|
457
748
|
// Save annotations
|
|
458
749
|
const annotations = state.annotations ?? [];
|
|
459
750
|
const upsertAnnotation = db.prepare(
|
|
460
|
-
`INSERT INTO annotations (id, type, points, bounds, color, width, text, label, created_at)
|
|
461
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
462
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
751
|
+
`INSERT INTO annotations (board_id, id, type, points, bounds, color, width, text, label, created_at)
|
|
752
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
753
|
+
ON CONFLICT(board_id, id) DO UPDATE SET
|
|
463
754
|
type = excluded.type, points = excluded.points, bounds = excluded.bounds,
|
|
464
755
|
color = excluded.color, width = excluded.width, text = excluded.text,
|
|
465
756
|
label = excluded.label, created_at = excluded.created_at
|
|
@@ -470,6 +761,7 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
470
761
|
);
|
|
471
762
|
for (const annotation of annotations) {
|
|
472
763
|
upsertAnnotation.run(
|
|
764
|
+
boardId,
|
|
473
765
|
annotation.id,
|
|
474
766
|
annotation.type,
|
|
475
767
|
JSON.stringify(annotation.points),
|
|
@@ -481,51 +773,53 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
|
|
|
481
773
|
annotation.createdAt,
|
|
482
774
|
);
|
|
483
775
|
}
|
|
484
|
-
deleteMissingRows(db, 'annotations', 'id', new Set(annotations.map((annotation) => annotation.id)));
|
|
776
|
+
deleteMissingRows(db, 'annotations', 'id', boardId, new Set(annotations.map((annotation) => annotation.id)));
|
|
485
777
|
|
|
486
778
|
// Save context pins (node_id is the whole row — nothing to update)
|
|
487
|
-
const insertPin = db.prepare('INSERT OR IGNORE INTO context_pins (node_id) VALUES (?)');
|
|
779
|
+
const insertPin = db.prepare('INSERT OR IGNORE INTO context_pins (board_id, node_id) VALUES (?, ?)');
|
|
488
780
|
for (const pinId of state.contextPins) {
|
|
489
|
-
insertPin.run(pinId);
|
|
781
|
+
insertPin.run(boardId, pinId);
|
|
490
782
|
}
|
|
491
|
-
deleteMissingRows(db, 'context_pins', 'node_id', new Set(state.contextPins));
|
|
783
|
+
deleteMissingRows(db, 'context_pins', 'node_id', boardId, new Set(state.contextPins));
|
|
492
784
|
|
|
493
785
|
db.run(
|
|
494
|
-
`INSERT INTO ax_state (key, value) VALUES (?, ?)
|
|
495
|
-
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
786
|
+
`INSERT INTO ax_state (board_id, key, value) VALUES (?, ?, ?)
|
|
787
|
+
ON CONFLICT(board_id, key) DO UPDATE SET value = excluded.value
|
|
496
788
|
WHERE value IS NOT excluded.value`,
|
|
497
|
-
['state', JSON.stringify(state.ax ?? createEmptyAxState())],
|
|
789
|
+
[boardId, 'state', JSON.stringify(state.ax ?? createEmptyAxState())],
|
|
790
|
+
);
|
|
791
|
+
db.run(
|
|
792
|
+
`INSERT INTO ax_state (board_id, key, value) VALUES (?, ?, ?)
|
|
793
|
+
ON CONFLICT(board_id, key) DO UPDATE SET value = excluded.value
|
|
794
|
+
WHERE value IS NOT excluded.value`,
|
|
795
|
+
[boardId, 'revision', JSON.stringify(state.revisionState ?? { revision: 0, floor: 0, tombstones: [] })],
|
|
498
796
|
);
|
|
499
797
|
});
|
|
500
798
|
|
|
501
799
|
transaction();
|
|
502
800
|
}
|
|
503
801
|
|
|
504
|
-
/**
|
|
505
|
-
export function
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get(key);
|
|
519
|
-
return row ? Number(row.value) : 0;
|
|
520
|
-
};
|
|
802
|
+
/** Loads one board (the open board when `boardId` is omitted); null when there is no such board. */
|
|
803
|
+
export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvasState | null {
|
|
804
|
+
const id = boardId ?? getActiveBoardIdFromDB(db);
|
|
805
|
+
if (!id) return null;
|
|
806
|
+
interface BoardRow {
|
|
807
|
+
viewport_x: number;
|
|
808
|
+
viewport_y: number;
|
|
809
|
+
viewport_scale: number;
|
|
810
|
+
tour: string;
|
|
811
|
+
}
|
|
812
|
+
const board = db
|
|
813
|
+
.query<BoardRow, [string]>('SELECT viewport_x, viewport_y, viewport_scale, tour FROM boards WHERE id = ?')
|
|
814
|
+
.get(id);
|
|
815
|
+
if (!board) return null;
|
|
521
816
|
|
|
522
817
|
const viewport: ViewportState = {
|
|
523
|
-
x:
|
|
524
|
-
y:
|
|
525
|
-
scale:
|
|
818
|
+
x: board.viewport_x,
|
|
819
|
+
y: board.viewport_y,
|
|
820
|
+
scale: board.viewport_scale || 1,
|
|
526
821
|
};
|
|
527
|
-
const
|
|
528
|
-
const theme = themeValue ? normalizeCanvasTheme(themeValue) : undefined;
|
|
822
|
+
const theme = readThemeFromDB(db);
|
|
529
823
|
|
|
530
824
|
// Load nodes
|
|
531
825
|
interface NodeRow {
|
|
@@ -539,18 +833,28 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
|
|
|
539
833
|
collapsed: number;
|
|
540
834
|
pinned: number;
|
|
541
835
|
data: string;
|
|
836
|
+
attribution: string;
|
|
837
|
+
content_revision: number;
|
|
542
838
|
}
|
|
543
|
-
const nodeRows = db.query<NodeRow, []>('SELECT * FROM nodes').all();
|
|
544
|
-
const nodes: CanvasNodeState[] = nodeRows.map((row) =>
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
839
|
+
const nodeRows = db.query<NodeRow, [string]>('SELECT * FROM nodes WHERE board_id = ?').all(id);
|
|
840
|
+
const nodes: CanvasNodeState[] = nodeRows.map((row) => {
|
|
841
|
+
const attribution = JSON.parse(row.attribution || '{}') as Partial<
|
|
842
|
+
Pick<CanvasNodeState, 'createdBy' | 'lastEditedBy'>
|
|
843
|
+
>;
|
|
844
|
+
return {
|
|
845
|
+
id: row.id,
|
|
846
|
+
type: row.type as CanvasNodeState['type'],
|
|
847
|
+
position: { x: row.pos_x, y: row.pos_y },
|
|
848
|
+
size: { width: row.width, height: row.height },
|
|
849
|
+
zIndex: row.z_index,
|
|
850
|
+
collapsed: row.collapsed === 1,
|
|
851
|
+
pinned: row.pinned === 1,
|
|
852
|
+
data: JSON.parse(row.data) as Record<string, unknown>,
|
|
853
|
+
createdBy: attribution.createdBy,
|
|
854
|
+
lastEditedBy: attribution.lastEditedBy,
|
|
855
|
+
contentRevision: row.content_revision,
|
|
856
|
+
};
|
|
857
|
+
});
|
|
554
858
|
|
|
555
859
|
// Load edges
|
|
556
860
|
interface EdgeRow {
|
|
@@ -562,7 +866,7 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
|
|
|
562
866
|
style: string | null;
|
|
563
867
|
animated: number;
|
|
564
868
|
}
|
|
565
|
-
const edgeRows = db.query<EdgeRow, []>('SELECT * FROM edges').all();
|
|
869
|
+
const edgeRows = db.query<EdgeRow, [string]>('SELECT * FROM edges WHERE board_id = ?').all(id);
|
|
566
870
|
const edges: CanvasEdge[] = edgeRows.map((row) => ({
|
|
567
871
|
id: row.id,
|
|
568
872
|
from: row.from_node,
|
|
@@ -585,7 +889,7 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
|
|
|
585
889
|
label: string | null;
|
|
586
890
|
created_at: string;
|
|
587
891
|
}
|
|
588
|
-
const annotationRows = db.query<AnnotationRow, []>('SELECT * FROM annotations').all();
|
|
892
|
+
const annotationRows = db.query<AnnotationRow, [string]>('SELECT * FROM annotations WHERE board_id = ?').all(id);
|
|
589
893
|
const annotations: CanvasAnnotation[] = annotationRows.map((row) => ({
|
|
590
894
|
id: row.id,
|
|
591
895
|
type: row.type as CanvasAnnotation['type'],
|
|
@@ -602,36 +906,207 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
|
|
|
602
906
|
interface PinRow {
|
|
603
907
|
node_id: string;
|
|
604
908
|
}
|
|
605
|
-
const pinRows = db.query<PinRow, []>('SELECT node_id FROM context_pins').all();
|
|
909
|
+
const pinRows = db.query<PinRow, [string]>('SELECT node_id FROM context_pins WHERE board_id = ?').all(id);
|
|
606
910
|
const contextPins = pinRows.map((row) => row.node_id);
|
|
607
911
|
|
|
608
|
-
const axRow = db
|
|
912
|
+
const axRow = db
|
|
913
|
+
.query<{ value: string }, [string, string]>('SELECT value FROM ax_state WHERE board_id = ? AND key = ?')
|
|
914
|
+
.get(id, 'state');
|
|
609
915
|
|
|
916
|
+
const revisionRaw = db
|
|
917
|
+
.query<{ value: string }, [string, string]>('SELECT value FROM ax_state WHERE board_id = ? AND key = ?')
|
|
918
|
+
.get(id, 'revision')?.value;
|
|
610
919
|
return {
|
|
611
920
|
version: 1,
|
|
612
921
|
theme,
|
|
922
|
+
tour: readTour(board.tour),
|
|
613
923
|
viewport,
|
|
614
924
|
nodes,
|
|
615
925
|
edges,
|
|
616
926
|
annotations,
|
|
617
927
|
contextPins,
|
|
618
928
|
ax: parsePersistedAxState(axRow?.value),
|
|
929
|
+
revisionState: revisionRaw ? (JSON.parse(revisionRaw) as PersistedRevisionState) : undefined,
|
|
619
930
|
};
|
|
620
931
|
}
|
|
621
932
|
|
|
933
|
+
// ── Boards ──────────────────────────────────────────────────────
|
|
934
|
+
|
|
935
|
+
export interface CanvasBoard {
|
|
936
|
+
id: string;
|
|
937
|
+
name: string;
|
|
938
|
+
/** A human-chosen shelf on Home ("Planning", "Research"); null is uncategorized. */
|
|
939
|
+
category: string | null;
|
|
940
|
+
createdAt: string;
|
|
941
|
+
lastOpenedAt: string | null;
|
|
942
|
+
nodeCount: number;
|
|
943
|
+
/** Markdown node used as this board's introduction. */
|
|
944
|
+
readmeNodeId: string | null;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
interface BoardListRow {
|
|
948
|
+
id: string;
|
|
949
|
+
name: string;
|
|
950
|
+
category: string | null;
|
|
951
|
+
created_at: string;
|
|
952
|
+
last_opened_at: string | null;
|
|
953
|
+
node_count: number;
|
|
954
|
+
readme_node_id: string | null;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const BOARD_LIST_SQL = `SELECT b.id, b.name, b.category, b.created_at, b.last_opened_at, b.readme_node_id,
|
|
958
|
+
(SELECT COUNT(*) FROM nodes n WHERE n.board_id = b.id) AS node_count
|
|
959
|
+
FROM boards b`;
|
|
960
|
+
|
|
961
|
+
function rowToBoard(row: BoardListRow): CanvasBoard {
|
|
962
|
+
return {
|
|
963
|
+
id: row.id,
|
|
964
|
+
name: row.name,
|
|
965
|
+
category: row.category,
|
|
966
|
+
createdAt: row.created_at,
|
|
967
|
+
lastOpenedAt: row.last_opened_at,
|
|
968
|
+
nodeCount: row.node_count,
|
|
969
|
+
readmeNodeId: row.readme_node_id,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Most recently opened first, then never-opened boards, newest first. */
|
|
974
|
+
export function listBoardsFromDB(db: Database): CanvasBoard[] {
|
|
975
|
+
return db
|
|
976
|
+
.query<BoardListRow, []>(`${BOARD_LIST_SQL} ORDER BY b.last_opened_at DESC NULLS LAST, b.created_at DESC`)
|
|
977
|
+
.all()
|
|
978
|
+
.map(rowToBoard);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export function getBoardFromDB(db: Database, id: string): CanvasBoard | null {
|
|
982
|
+
const row = db.query<BoardListRow, [string]>(`${BOARD_LIST_SQL} WHERE b.id = ?`).get(id);
|
|
983
|
+
return row ? rowToBoard(row) : null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function createBoardInDB(db: Database, name: string, category: string | null = null): CanvasBoard {
|
|
987
|
+
category = normalizeBoardCategory(category);
|
|
988
|
+
const id = createBoardId();
|
|
989
|
+
const createdAt = new Date(Date.now()).toISOString();
|
|
990
|
+
db.run('INSERT INTO boards (id, name, category, created_at) VALUES (?, ?, ?, ?)', [id, name, category, createdAt]);
|
|
991
|
+
return { id, name, category, createdAt, lastOpenedAt: null, nodeCount: 0, readmeNodeId: null };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Rename and/or re-shelve a board; `category: null` removes it from its category. */
|
|
995
|
+
export function updateBoardInDB(
|
|
996
|
+
db: Database,
|
|
997
|
+
id: string,
|
|
998
|
+
patch: { name?: string; category?: string | null; readmeNodeId?: string | null },
|
|
999
|
+
): boolean {
|
|
1000
|
+
const category = patch.category === undefined ? undefined : normalizeBoardCategory(patch.category);
|
|
1001
|
+
if (!db.query<{ id: string }, [string]>('SELECT id FROM boards WHERE id = ?').get(id)) return false;
|
|
1002
|
+
if (patch.name !== undefined) db.run('UPDATE boards SET name = ? WHERE id = ?', [patch.name, id]);
|
|
1003
|
+
if (category !== undefined) db.run('UPDATE boards SET category = ? WHERE id = ?', [category, id]);
|
|
1004
|
+
if (patch.readmeNodeId !== undefined) {
|
|
1005
|
+
db.run('UPDATE boards SET readme_node_id = ? WHERE id = ?', [patch.readmeNodeId, id]);
|
|
1006
|
+
}
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Create and populate an inactive board as one SQLite transaction. */
|
|
1011
|
+
export function createBoardWithStateInDB(
|
|
1012
|
+
db: Database,
|
|
1013
|
+
name: string,
|
|
1014
|
+
category: string | null,
|
|
1015
|
+
state: PersistedCanvasState,
|
|
1016
|
+
readmeNodeId: string | null,
|
|
1017
|
+
): CanvasBoard {
|
|
1018
|
+
return db.transaction(() => {
|
|
1019
|
+
const board = createBoardInDB(db, name, category);
|
|
1020
|
+
saveStateToDB(db, board.id, state);
|
|
1021
|
+
updateBoardInDB(db, board.id, { readmeNodeId });
|
|
1022
|
+
return { ...board, nodeCount: state.nodes.length, readmeNodeId };
|
|
1023
|
+
})();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Deletes a board with its rows and its snapshots. */
|
|
1027
|
+
export function deleteBoardFromDB(db: Database, id: string): boolean {
|
|
1028
|
+
const transaction = db.transaction(() => {
|
|
1029
|
+
for (const table of ['nodes', 'edges', 'annotations', 'context_pins', 'ax_state']) {
|
|
1030
|
+
db.run(`DELETE FROM ${table} WHERE board_id = ?`, [id]);
|
|
1031
|
+
}
|
|
1032
|
+
const snapshotIds = db
|
|
1033
|
+
.query<{ id: string }, [string]>('SELECT id FROM snapshots WHERE board_id = ?')
|
|
1034
|
+
.all(id)
|
|
1035
|
+
.map((row) => row.id);
|
|
1036
|
+
for (const snapshotId of snapshotIds) deleteSnapshotFromDB(db, snapshotId);
|
|
1037
|
+
db.run('DELETE FROM document_imports WHERE board_id = ?', [id]);
|
|
1038
|
+
db.run('DELETE FROM attachments WHERE board_id = ?', [id]);
|
|
1039
|
+
db.run('DELETE FROM attachment_bytes WHERE sha256 NOT IN (SELECT sha256 FROM attachments)');
|
|
1040
|
+
db.run("DELETE FROM meta WHERE key = 'active_board' AND value = ?", [id]);
|
|
1041
|
+
return db.run('DELETE FROM boards WHERE id = ?', [id]).changes > 0;
|
|
1042
|
+
});
|
|
1043
|
+
return transaction();
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export function readMetaFromDB(db: Database, key: string): string | null {
|
|
1047
|
+
return db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get(key)?.value ?? null;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Write a workspace-wide meta value; null removes it. */
|
|
1051
|
+
export function writeMetaToDB(db: Database, key: string, value: string | null): void {
|
|
1052
|
+
if (value === null) {
|
|
1053
|
+
db.run('DELETE FROM meta WHERE key = ?', [key]);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
db.run('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [
|
|
1057
|
+
key,
|
|
1058
|
+
value,
|
|
1059
|
+
]);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** The board that was open last, if it still exists. */
|
|
1063
|
+
export function getActiveBoardIdFromDB(db: Database): string | null {
|
|
1064
|
+
const id = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('active_board')?.value;
|
|
1065
|
+
if (!id) return null;
|
|
1066
|
+
return db.query<{ id: string }, [string]>('SELECT id FROM boards WHERE id = ?').get(id) ? id : null;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export function setActiveBoardIdInDB(db: Database, id: string | null): void {
|
|
1070
|
+
if (!id) {
|
|
1071
|
+
db.run("DELETE FROM meta WHERE key = 'active_board'");
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const latestOpenedAt = db
|
|
1075
|
+
.query<{ value: string }, []>('SELECT MAX(last_opened_at) AS value FROM boards')
|
|
1076
|
+
.get()?.value;
|
|
1077
|
+
const now = Date.now();
|
|
1078
|
+
const openedAt = new Date(Math.max(now, latestOpenedAt ? Date.parse(latestOpenedAt) + 1 : now)).toISOString();
|
|
1079
|
+
db.run(
|
|
1080
|
+
"INSERT INTO meta (key, value) VALUES ('active_board', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
1081
|
+
[id],
|
|
1082
|
+
);
|
|
1083
|
+
db.run('UPDATE boards SET last_opened_at = ? WHERE id = ?', [openedAt, id]);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
622
1086
|
// ── Snapshot Persistence ────────────────────────────────────────
|
|
623
1087
|
|
|
624
|
-
export function saveSnapshotToDB(
|
|
1088
|
+
export function saveSnapshotToDB(
|
|
1089
|
+
db: Database,
|
|
1090
|
+
boardId: string,
|
|
1091
|
+
snapshot: CanvasSnapshot,
|
|
1092
|
+
state: PersistedCanvasState,
|
|
1093
|
+
): void {
|
|
625
1094
|
const transaction = db.transaction(() => {
|
|
626
1095
|
// Insert snapshot metadata
|
|
627
|
-
db.run('INSERT INTO snapshots (id, name, created_at, node_count, edge_count) VALUES (?, ?, ?, ?, ?)', [
|
|
1096
|
+
db.run('INSERT INTO snapshots (id, board_id, name, created_at, node_count, edge_count) VALUES (?, ?, ?, ?, ?, ?)', [
|
|
628
1097
|
snapshot.id,
|
|
1098
|
+
boardId,
|
|
629
1099
|
snapshot.name,
|
|
630
1100
|
snapshot.createdAt,
|
|
631
1101
|
state.nodes.length,
|
|
632
1102
|
state.edges.length,
|
|
633
1103
|
]);
|
|
634
1104
|
|
|
1105
|
+
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
|
|
1106
|
+
snapshot.id,
|
|
1107
|
+
'tour',
|
|
1108
|
+
JSON.stringify(state.tour ?? null),
|
|
1109
|
+
]);
|
|
635
1110
|
// Insert snapshot viewport meta
|
|
636
1111
|
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
|
|
637
1112
|
snapshot.id,
|
|
@@ -656,8 +1131,8 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
|
|
|
656
1131
|
|
|
657
1132
|
// Insert snapshot nodes
|
|
658
1133
|
const insertNode = db.prepare(
|
|
659
|
-
`INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data)
|
|
660
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1134
|
+
`INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data, attribution, content_revision)
|
|
1135
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
661
1136
|
);
|
|
662
1137
|
for (const node of state.nodes) {
|
|
663
1138
|
insertNode.run(
|
|
@@ -672,6 +1147,8 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
|
|
|
672
1147
|
node.collapsed ? 1 : 0,
|
|
673
1148
|
node.pinned ? 1 : 0,
|
|
674
1149
|
JSON.stringify(node.data),
|
|
1150
|
+
JSON.stringify({ createdBy: node.createdBy, lastEditedBy: node.lastEditedBy }),
|
|
1151
|
+
node.contentRevision ?? 0,
|
|
675
1152
|
);
|
|
676
1153
|
}
|
|
677
1154
|
|
|
@@ -718,16 +1195,24 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
|
|
|
718
1195
|
for (const pinId of state.contextPins) {
|
|
719
1196
|
insertPin.run(snapshot.id, pinId);
|
|
720
1197
|
}
|
|
1198
|
+
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
|
|
1199
|
+
snapshot.id,
|
|
1200
|
+
'revision_state',
|
|
1201
|
+
JSON.stringify(state.revisionState ?? { revision: 0, floor: 0, tombstones: [] }),
|
|
1202
|
+
]);
|
|
721
1203
|
});
|
|
722
1204
|
|
|
723
1205
|
transaction();
|
|
724
1206
|
}
|
|
725
1207
|
|
|
1208
|
+
/** By id or name (most recent match) on `boardId`. */
|
|
726
1209
|
export function loadSnapshotFromDB(
|
|
727
1210
|
db: Database,
|
|
728
1211
|
idOrName: string,
|
|
1212
|
+
boardId: string | null,
|
|
729
1213
|
): { snapshot: CanvasSnapshot; state: PersistedCanvasState } | null {
|
|
730
|
-
|
|
1214
|
+
if (!boardId) return null;
|
|
1215
|
+
|
|
731
1216
|
interface SnapshotRow {
|
|
732
1217
|
id: string;
|
|
733
1218
|
name: string;
|
|
@@ -735,12 +1220,16 @@ export function loadSnapshotFromDB(
|
|
|
735
1220
|
node_count: number;
|
|
736
1221
|
edge_count: number;
|
|
737
1222
|
}
|
|
738
|
-
let snapshotRow = db
|
|
1223
|
+
let snapshotRow = db
|
|
1224
|
+
.query<SnapshotRow, [string, string]>('SELECT * FROM snapshots WHERE board_id = ? AND id = ?')
|
|
1225
|
+
.get(boardId, idOrName);
|
|
739
1226
|
|
|
740
1227
|
if (!snapshotRow) {
|
|
741
1228
|
snapshotRow = db
|
|
742
|
-
.query<SnapshotRow, [string]>(
|
|
743
|
-
|
|
1229
|
+
.query<SnapshotRow, [string, string]>(
|
|
1230
|
+
'SELECT * FROM snapshots WHERE board_id = ? AND name = ? ORDER BY created_at DESC LIMIT 1',
|
|
1231
|
+
)
|
|
1232
|
+
.get(boardId, idOrName);
|
|
744
1233
|
}
|
|
745
1234
|
|
|
746
1235
|
if (!snapshotRow) return null;
|
|
@@ -781,20 +1270,30 @@ export function loadSnapshotFromDB(
|
|
|
781
1270
|
collapsed: number;
|
|
782
1271
|
pinned: number;
|
|
783
1272
|
data: string;
|
|
1273
|
+
attribution: string;
|
|
1274
|
+
content_revision: number;
|
|
784
1275
|
}
|
|
785
1276
|
const nodeRows = db
|
|
786
1277
|
.query<NodeRow, [string]>('SELECT * FROM snapshot_nodes WHERE snapshot_id = ?')
|
|
787
1278
|
.all(snapshotRow.id);
|
|
788
|
-
const nodes: CanvasNodeState[] = nodeRows.map((row) =>
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1279
|
+
const nodes: CanvasNodeState[] = nodeRows.map((row) => {
|
|
1280
|
+
const attribution = JSON.parse(row.attribution || '{}') as Partial<
|
|
1281
|
+
Pick<CanvasNodeState, 'createdBy' | 'lastEditedBy'>
|
|
1282
|
+
>;
|
|
1283
|
+
return {
|
|
1284
|
+
id: row.id,
|
|
1285
|
+
type: row.type as CanvasNodeState['type'],
|
|
1286
|
+
position: { x: row.pos_x, y: row.pos_y },
|
|
1287
|
+
size: { width: row.width, height: row.height },
|
|
1288
|
+
zIndex: row.z_index,
|
|
1289
|
+
collapsed: row.collapsed === 1,
|
|
1290
|
+
pinned: row.pinned === 1,
|
|
1291
|
+
data: JSON.parse(row.data) as Record<string, unknown>,
|
|
1292
|
+
createdBy: attribution.createdBy,
|
|
1293
|
+
lastEditedBy: attribution.lastEditedBy,
|
|
1294
|
+
contentRevision: row.content_revision,
|
|
1295
|
+
};
|
|
1296
|
+
});
|
|
798
1297
|
|
|
799
1298
|
// Load snapshot edges
|
|
800
1299
|
interface EdgeRow {
|
|
@@ -859,24 +1358,32 @@ export function loadSnapshotFromDB(
|
|
|
859
1358
|
snapshot,
|
|
860
1359
|
state: {
|
|
861
1360
|
version: 1,
|
|
1361
|
+
tour: readTour(metaMap.get('tour')),
|
|
862
1362
|
viewport,
|
|
863
1363
|
nodes,
|
|
864
1364
|
edges,
|
|
865
1365
|
annotations,
|
|
866
1366
|
contextPins,
|
|
867
1367
|
ax: parsePersistedAxState(metaMap.get('ax_state')),
|
|
1368
|
+
revisionState: metaMap.has('revision_state')
|
|
1369
|
+
? (JSON.parse(metaMap.get('revision_state')!) as PersistedRevisionState)
|
|
1370
|
+
: undefined,
|
|
868
1371
|
},
|
|
869
1372
|
};
|
|
870
1373
|
}
|
|
871
1374
|
|
|
872
|
-
export function listSnapshotsFromDB(
|
|
1375
|
+
export function listSnapshotsFromDB(
|
|
1376
|
+
db: Database,
|
|
1377
|
+
boardId: string,
|
|
1378
|
+
options: CanvasSnapshotListOptions = {},
|
|
1379
|
+
): CanvasSnapshot[] {
|
|
873
1380
|
const query = options.query?.trim().toLowerCase();
|
|
874
1381
|
const before = normalizeSnapshotTimestamp(options.before);
|
|
875
1382
|
const after = normalizeSnapshotTimestamp(options.after);
|
|
876
1383
|
const limit = options.all ? undefined : (normalizePositiveInteger(options.limit) ?? 20);
|
|
877
1384
|
|
|
878
|
-
let sql = 'SELECT * FROM snapshots WHERE
|
|
879
|
-
const params: string[] = [];
|
|
1385
|
+
let sql = 'SELECT * FROM snapshots WHERE board_id = ?';
|
|
1386
|
+
const params: string[] = [boardId];
|
|
880
1387
|
|
|
881
1388
|
if (query) {
|
|
882
1389
|
sql += ' AND (LOWER(id) LIKE ? OR LOWER(name) LIKE ?)';
|
|
@@ -916,8 +1423,8 @@ export function listSnapshotsFromDB(db: Database, options: CanvasSnapshotListOpt
|
|
|
916
1423
|
}));
|
|
917
1424
|
}
|
|
918
1425
|
|
|
919
|
-
export function renameSnapshotInDB(db: Database, id: string, name: string): boolean {
|
|
920
|
-
return db.run('UPDATE snapshots SET name = ? WHERE id = ?', [name, id]).changes > 0;
|
|
1426
|
+
export function renameSnapshotInDB(db: Database, boardId: string, id: string, name: string): boolean {
|
|
1427
|
+
return db.run('UPDATE snapshots SET name = ? WHERE board_id = ? AND id = ?', [name, boardId, id]).changes > 0;
|
|
921
1428
|
}
|
|
922
1429
|
|
|
923
1430
|
export function deleteSnapshotFromDB(db: Database, id: string): boolean {
|