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.
Files changed (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -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 = 1;
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 nodes (
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
- id TEXT PRIMARY KEY,
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
- id TEXT PRIMARY KEY,
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
- node_id TEXT PRIMARY KEY
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
- key TEXT PRIMARY KEY,
99
- value TEXT NOT NULL
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 function openCanvasDb(dbPath: string): Database {
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.query<{ id: string }, []>(`SELECT ${idColumn} AS id FROM ${table}`).all();
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
- export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
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
- const upsertMeta = db.prepare(
392
- `INSERT INTO meta (key, value) VALUES (?, ?)
393
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
394
- WHERE value IS NOT excluded.value`,
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
- /** Check if the DB has been populated with canvas state at least once. */
505
- export function isDbPopulated(db: Database): boolean {
506
- const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('state_populated');
507
- return row?.value === '1';
508
- }
509
-
510
- export function loadStateFromDB(db: Database): PersistedCanvasState | null {
511
- const schemaVersion = db
512
- .query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?')
513
- .get('schema_version');
514
- if (!schemaVersion) return null;
515
-
516
- // Load viewport
517
- const getMetaValue = (key: string): number => {
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: getMetaValue('viewport_x'),
524
- y: getMetaValue('viewport_y'),
525
- scale: getMetaValue('viewport_scale') || 1,
818
+ x: board.viewport_x,
819
+ y: board.viewport_y,
820
+ scale: board.viewport_scale || 1,
526
821
  };
527
- const themeValue = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('theme')?.value;
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
- id: row.id,
546
- type: row.type as CanvasNodeState['type'],
547
- position: { x: row.pos_x, y: row.pos_y },
548
- size: { width: row.width, height: row.height },
549
- zIndex: row.z_index,
550
- collapsed: row.collapsed === 1,
551
- pinned: row.pinned === 1,
552
- data: JSON.parse(row.data) as Record<string, unknown>,
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.query<{ value: string }, [string]>('SELECT value FROM ax_state WHERE key = ?').get('state');
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(db: Database, snapshot: CanvasSnapshot, state: PersistedCanvasState): void {
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
- // Try by ID first, then by name (most recent match)
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.query<SnapshotRow, [string]>('SELECT * FROM snapshots WHERE id = ?').get(idOrName);
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]>('SELECT * FROM snapshots WHERE name = ? ORDER BY created_at DESC LIMIT 1')
743
- .get(idOrName);
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
- id: row.id,
790
- type: row.type as CanvasNodeState['type'],
791
- position: { x: row.pos_x, y: row.pos_y },
792
- size: { width: row.width, height: row.height },
793
- zIndex: row.z_index,
794
- collapsed: row.collapsed === 1,
795
- pinned: row.pinned === 1,
796
- data: JSON.parse(row.data) as Record<string, unknown>,
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(db: Database, options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
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 1=1';
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 {