pmx-canvas 0.6.5 → 0.7.1

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 (181) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +139 -14
  2. package/CHANGELOG.md +70 -0
  3. package/Readme.md +32 -6
  4. package/dist/canvas/global.css +510 -31
  5. package/dist/canvas/index.js +92 -77
  6. package/dist/types/cli/agent.d.ts +3 -0
  7. package/dist/types/cli/commands/backup.d.ts +1 -0
  8. package/dist/types/cli/commands/boards.d.ts +1 -0
  9. package/dist/types/cli/commands/export.d.ts +1 -0
  10. package/dist/types/cli/daemon.d.ts +5 -5
  11. package/dist/types/cli/shared.d.ts +3 -1
  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/nodes/AttachmentNode.d.ts +6 -0
  19. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  20. package/dist/types/client/state/boards-store.d.ts +79 -0
  21. package/dist/types/client/state/canvas-store.d.ts +2 -0
  22. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  23. package/dist/types/client/state/session-store.d.ts +7 -0
  24. package/dist/types/client/types.d.ts +2 -2
  25. package/dist/types/json-render/catalog.d.ts +1 -1
  26. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  27. package/dist/types/json-render/server.d.ts +6 -0
  28. package/dist/types/mcp/canvas-access.d.ts +10 -3
  29. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  30. package/dist/types/server/agent-presence.d.ts +1 -1
  31. package/dist/types/server/attribution.d.ts +11 -0
  32. package/dist/types/server/ax-context.d.ts +3 -1
  33. package/dist/types/server/ax-interaction.d.ts +2 -2
  34. package/dist/types/server/ax-state.d.ts +13 -3
  35. package/dist/types/server/backup.d.ts +36 -0
  36. package/dist/types/server/board-export.d.ts +45 -0
  37. package/dist/types/server/canvas-db.d.ts +63 -9
  38. package/dist/types/server/canvas-operations.d.ts +23 -0
  39. package/dist/types/server/canvas-provenance.d.ts +1 -1
  40. package/dist/types/server/canvas-state.d.ts +140 -3
  41. package/dist/types/server/context-brief.d.ts +111 -0
  42. package/dist/types/server/context-reads.d.ts +55 -0
  43. package/dist/types/server/document-import.d.ts +66 -0
  44. package/dist/types/server/index.d.ts +103 -4
  45. package/dist/types/server/mutation-history.d.ts +1 -0
  46. package/dist/types/server/operations/invoker.d.ts +2 -0
  47. package/dist/types/server/operations/mcp.d.ts +7 -1
  48. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  49. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  50. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  51. package/dist/types/server/operations/ops/export.d.ts +2 -0
  52. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  53. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  54. package/dist/types/server/operations/ops/query.d.ts +2 -0
  55. package/dist/types/server/operations/registry.d.ts +9 -1
  56. package/dist/types/server/operations/types.d.ts +1 -1
  57. package/dist/types/server/server.d.ts +1 -0
  58. package/dist/types/server/trace-manager.d.ts +7 -0
  59. package/dist/types/server/webpage-node.d.ts +22 -1
  60. package/dist/types/server/workbench-auth.d.ts +3 -0
  61. package/dist/types/shared/boards.d.ts +6 -0
  62. package/docs/RELEASE.md +5 -0
  63. package/docs/cli.md +73 -0
  64. package/docs/http-api.md +198 -2
  65. package/docs/mcp.md +71 -6
  66. package/docs/node-types.md +26 -0
  67. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  68. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  69. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  70. package/docs/plans/plan-013-static-board-export.md +47 -0
  71. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  72. package/docs/product-vision-2026-09.md +118 -18
  73. package/docs/sdk.md +50 -0
  74. package/package.json +1 -1
  75. package/skills/pmx-canvas/SKILL.md +68 -11
  76. package/skills/pmx-canvas/evals/evals.json +130 -0
  77. package/skills/pmx-canvas/references/full-reference.md +43 -7
  78. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  79. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  80. package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
  81. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  82. package/skills/pmx-canvas-testing/SKILL.md +11 -2
  83. package/src/cli/agent.ts +4 -0
  84. package/src/cli/commands/apps.ts +1 -1
  85. package/src/cli/commands/ax.ts +13 -0
  86. package/src/cli/commands/backup.ts +66 -0
  87. package/src/cli/commands/boards.ts +71 -0
  88. package/src/cli/commands/export.ts +37 -0
  89. package/src/cli/commands/nodes.ts +4 -1
  90. package/src/cli/commands/pins.ts +6 -2
  91. package/src/cli/commands/pump.ts +3 -0
  92. package/src/cli/commands/query.ts +33 -7
  93. package/src/cli/commands/view.ts +7 -5
  94. package/src/cli/daemon.ts +37 -7
  95. package/src/cli/index.ts +19 -9
  96. package/src/cli/shared.ts +11 -2
  97. package/src/client/App.tsx +21 -4
  98. package/src/client/canvas/AttentionToast.tsx +5 -3
  99. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  100. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  101. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  102. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  103. package/src/client/canvas/CanvasNode.tsx +0 -1
  104. package/src/client/canvas/CanvasViewport.tsx +3 -0
  105. package/src/client/canvas/CommandPalette.tsx +107 -4
  106. package/src/client/canvas/ContextMenu.tsx +4 -1
  107. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  108. package/src/client/canvas/ExportDialog.tsx +199 -0
  109. package/src/client/canvas/HomeView.tsx +277 -0
  110. package/src/client/canvas/Presentation.tsx +39 -9
  111. package/src/client/canvas/SelectionBar.tsx +0 -2
  112. package/src/client/canvas/SessionPanel.tsx +28 -1
  113. package/src/client/canvas/ToolRail.tsx +38 -3
  114. package/src/client/canvas/TopBar.tsx +34 -18
  115. package/src/client/canvas/import-files.ts +19 -1
  116. package/src/client/canvas/kind-colors.ts +1 -0
  117. package/src/client/icons.tsx +2 -0
  118. package/src/client/nodes/AttachmentNode.tsx +145 -0
  119. package/src/client/nodes/BoardNode.tsx +35 -0
  120. package/src/client/nodes/FileNode.tsx +6 -0
  121. package/src/client/nodes/MarkdownNode.tsx +47 -19
  122. package/src/client/nodes/ax-node-actions.ts +2 -5
  123. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  124. package/src/client/state/attention-bridge.ts +3 -1
  125. package/src/client/state/boards-store.ts +167 -0
  126. package/src/client/state/canvas-store.ts +22 -0
  127. package/src/client/state/intent-bridge.ts +30 -2
  128. package/src/client/state/node-factory.ts +1 -0
  129. package/src/client/state/session-store.ts +5 -0
  130. package/src/client/state/sse-bridge.ts +52 -6
  131. package/src/client/theme/global.css +510 -31
  132. package/src/client/types.ts +2 -0
  133. package/src/json-render/server.ts +16 -9
  134. package/src/mcp/canvas-access.ts +59 -22
  135. package/src/mcp/context-uri-template.ts +28 -0
  136. package/src/mcp/server.ts +122 -15
  137. package/src/server/agent-context.ts +116 -29
  138. package/src/server/attribution.ts +29 -0
  139. package/src/server/ax-context.ts +4 -2
  140. package/src/server/ax-interaction.ts +1 -0
  141. package/src/server/ax-state-manager.ts +72 -3
  142. package/src/server/ax-state.ts +41 -0
  143. package/src/server/backup.ts +178 -0
  144. package/src/server/board-export.ts +686 -0
  145. package/src/server/canvas-db.ts +599 -107
  146. package/src/server/canvas-operations.ts +68 -9
  147. package/src/server/canvas-provenance.ts +1 -0
  148. package/src/server/canvas-schema.ts +12 -0
  149. package/src/server/canvas-state.ts +774 -45
  150. package/src/server/context-brief.ts +372 -0
  151. package/src/server/context-reads.ts +238 -0
  152. package/src/server/demo-state.json +659 -2
  153. package/src/server/document-import.ts +223 -0
  154. package/src/server/index.ts +231 -5
  155. package/src/server/mutation-history.ts +10 -13
  156. package/src/server/operations/composites.ts +26 -4
  157. package/src/server/operations/http.ts +9 -3
  158. package/src/server/operations/index.ts +8 -0
  159. package/src/server/operations/invoker.ts +14 -3
  160. package/src/server/operations/mcp.ts +50 -11
  161. package/src/server/operations/ops/app.ts +5 -0
  162. package/src/server/operations/ops/ax-read.ts +10 -6
  163. package/src/server/operations/ops/ax-timeline.ts +75 -0
  164. package/src/server/operations/ops/backup.ts +134 -0
  165. package/src/server/operations/ops/batch.ts +8 -1
  166. package/src/server/operations/ops/boards.ts +351 -0
  167. package/src/server/operations/ops/export.ts +74 -0
  168. package/src/server/operations/ops/ext-app.ts +4 -0
  169. package/src/server/operations/ops/imports.ts +236 -0
  170. package/src/server/operations/ops/nodes.ts +57 -5
  171. package/src/server/operations/ops/query.ts +206 -9
  172. package/src/server/operations/registry.ts +123 -31
  173. package/src/server/operations/types.ts +1 -1
  174. package/src/server/server.ts +146 -2
  175. package/src/server/spatial-analysis.ts +9 -17
  176. package/src/server/trace-manager.ts +19 -0
  177. package/src/server/web-artifacts.ts +2 -1
  178. package/src/server/webpage-node.ts +173 -13
  179. package/src/server/workbench-auth.ts +11 -0
  180. package/src/shared/boards.ts +18 -0
  181. package/src/shared/node-sizes.ts +1 -0
@@ -6,6 +6,8 @@
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';
9
11
  import { tourSchema, type Tour } from '../shared/tour.js';
10
12
  import { existsSync, mkdirSync } from 'node:fs';
11
13
  import { dirname } from 'node:path';
@@ -42,7 +44,7 @@ import {
42
44
 
43
45
  // ── Schema ──────────────────────────────────────────────────────
44
46
 
45
- const SCHEMA_VERSION = 1;
47
+ const SCHEMA_VERSION = 2;
46
48
 
47
49
  export type CanvasTheme = CanvasThemeName;
48
50
 
@@ -56,8 +58,29 @@ const SCHEMA_SQL = `
56
58
  value TEXT NOT NULL
57
59
  );
58
60
 
59
- 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 (
60
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,
61
84
  type TEXT NOT NULL,
62
85
  pos_x REAL NOT NULL,
63
86
  pos_y REAL NOT NULL,
@@ -66,21 +89,25 @@ const SCHEMA_SQL = `
66
89
  z_index INTEGER NOT NULL DEFAULT 0,
67
90
  collapsed INTEGER NOT NULL DEFAULT 0,
68
91
  pinned INTEGER NOT NULL DEFAULT 0,
69
- data TEXT NOT NULL
92
+ data TEXT NOT NULL,
93
+ PRIMARY KEY (board_id, id)
70
94
  );
71
95
 
72
96
  CREATE TABLE IF NOT EXISTS edges (
73
- id TEXT PRIMARY KEY,
97
+ board_id TEXT NOT NULL,
98
+ id TEXT NOT NULL,
74
99
  from_node TEXT NOT NULL,
75
100
  to_node TEXT NOT NULL,
76
101
  type TEXT NOT NULL,
77
102
  label TEXT,
78
103
  style TEXT,
79
- animated INTEGER NOT NULL DEFAULT 0
104
+ animated INTEGER NOT NULL DEFAULT 0,
105
+ PRIMARY KEY (board_id, id)
80
106
  );
81
107
 
82
108
  CREATE TABLE IF NOT EXISTS annotations (
83
- id TEXT PRIMARY KEY,
109
+ board_id TEXT NOT NULL,
110
+ id TEXT NOT NULL,
84
111
  type TEXT NOT NULL,
85
112
  points TEXT NOT NULL,
86
113
  bounds TEXT NOT NULL,
@@ -88,20 +115,26 @@ const SCHEMA_SQL = `
88
115
  width REAL NOT NULL,
89
116
  text TEXT,
90
117
  label TEXT,
91
- created_at TEXT NOT NULL
118
+ created_at TEXT NOT NULL,
119
+ PRIMARY KEY (board_id, id)
92
120
  );
93
121
 
94
122
  CREATE TABLE IF NOT EXISTS context_pins (
95
- node_id TEXT PRIMARY KEY
123
+ board_id TEXT NOT NULL,
124
+ node_id TEXT NOT NULL,
125
+ PRIMARY KEY (board_id, node_id)
96
126
  );
97
127
 
98
128
  CREATE TABLE IF NOT EXISTS ax_state (
99
- key TEXT PRIMARY KEY,
100
- 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)
101
133
  );
102
134
 
103
135
  CREATE TABLE IF NOT EXISTS snapshots (
104
136
  id TEXT PRIMARY KEY,
137
+ board_id TEXT,
105
138
  name TEXT NOT NULL,
106
139
  created_at TEXT NOT NULL,
107
140
  node_count INTEGER NOT NULL,
@@ -168,6 +201,39 @@ const SCHEMA_SQL = `
168
201
  json_bytes INTEGER NOT NULL
169
202
  );
170
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
+
171
237
  CREATE TABLE IF NOT EXISTS ax_events (
172
238
  seq INTEGER PRIMARY KEY AUTOINCREMENT,
173
239
  id TEXT NOT NULL UNIQUE,
@@ -270,6 +336,33 @@ export interface PersistedCanvasState {
270
336
  annotations?: CanvasAnnotation[];
271
337
  contextPins: string[];
272
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
+ );
273
366
  }
274
367
 
275
368
  function readTour(raw: string | undefined): Tour | undefined {
@@ -279,7 +372,12 @@ function readTour(raw: string | undefined): Tour | undefined {
279
372
 
280
373
  // ── Database Management ─────────────────────────────────────────
281
374
 
282
- 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 {
283
381
  const dir = dirname(dbPath);
284
382
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
285
383
 
@@ -293,12 +391,22 @@ export function openCanvasDb(dbPath: string): Database {
293
391
  // worth the fsync.
294
392
  db.exec('PRAGMA synchronous=FULL');
295
393
  db.exec('PRAGMA busy_timeout=5000');
394
+ if (needsBoardsMigration(db)) migrateToBoards(db, dbPath, options.migratedBoardName ?? 'Board');
296
395
  db.exec(SCHEMA_SQL);
396
+ db.exec(CONTEXT_READS_SCHEMA_SQL);
297
397
 
298
398
  // Additive columns for pre-existing DBs (fresh installs already get them via SCHEMA_SQL above).
299
399
  ensureColumn(db, 'ax_events', 'agent_id', 'agent_id TEXT');
300
400
  ensureColumn(db, 'ax_steering', 'agent_id', 'agent_id TEXT');
301
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');
302
410
 
303
411
  // Set schema version if not present
304
412
  const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('schema_version');
@@ -309,6 +417,151 @@ export function openCanvasDb(dbPath: string): Database {
309
417
  return db;
310
418
  }
311
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
+
312
565
  export function checkpointCanvasDb(db: Database): void {
313
566
  db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
314
567
  }
@@ -374,17 +627,36 @@ function deleteMissingRows(
374
627
  db: Database,
375
628
  table: 'nodes' | 'edges' | 'annotations' | 'context_pins',
376
629
  idColumn: 'id' | 'node_id',
630
+ boardId: string,
377
631
  keep: Set<string>,
378
632
  ): void {
379
- 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);
380
636
  if (rows.length === 0) return;
381
- const remove = db.prepare(`DELETE FROM ${table} WHERE ${idColumn} = ?`);
637
+ const remove = db.prepare(`DELETE FROM ${table} WHERE board_id = ? AND ${idColumn} = ?`);
382
638
  for (const row of rows) {
383
- if (!keep.has(row.id)) remove.run(row.id);
639
+ if (!keep.has(row.id)) remove.run(boardId, row.id);
384
640
  }
385
641
  }
386
642
 
387
- 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 {
388
660
  const transaction = db.transaction(() => {
389
661
  // Rows are upserted only when a column actually differs, and rows that are
390
662
  // gone are deleted by id. This used to be DELETE-all + INSERT-all, which
@@ -395,35 +667,43 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
395
667
  // no-op — SQLite performs no update, so no page is written. The comparison
396
668
  // is against the DB itself rather than an in-memory cache, so nothing can
397
669
  // go stale and silently skip a write that was actually needed.
398
- const upsertMeta = db.prepare(
399
- `INSERT INTO meta (key, value) VALUES (?, ?)
400
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
401
- 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
+ ],
402
686
  );
403
- upsertMeta.run('theme', normalizeCanvasTheme(state.theme));
404
- upsertMeta.run('tour', JSON.stringify(state.tour ?? null));
405
- upsertMeta.run('viewport_x', String(state.viewport.x));
406
- upsertMeta.run('viewport_y', String(state.viewport.y));
407
- upsertMeta.run('viewport_scale', String(state.viewport.scale));
408
- // Mark DB as populated (for migration detection)
409
- upsertMeta.run('state_populated', '1');
410
687
 
411
688
  // Save nodes
412
689
  const upsertNode = db.prepare(
413
- `INSERT INTO nodes (id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data)
414
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
415
- 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
416
693
  type = excluded.type, pos_x = excluded.pos_x, pos_y = excluded.pos_y,
417
694
  width = excluded.width, height = excluded.height, z_index = excluded.z_index,
418
- 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
419
697
  WHERE type IS NOT excluded.type OR pos_x IS NOT excluded.pos_x
420
698
  OR pos_y IS NOT excluded.pos_y OR width IS NOT excluded.width
421
699
  OR height IS NOT excluded.height OR z_index IS NOT excluded.z_index
422
700
  OR collapsed IS NOT excluded.collapsed OR pinned IS NOT excluded.pinned
423
- 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`,
424
703
  );
425
704
  for (const node of state.nodes) {
426
705
  upsertNode.run(
706
+ boardId,
427
707
  node.id,
428
708
  node.type,
429
709
  node.position.x,
@@ -434,15 +714,17 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
434
714
  node.collapsed ? 1 : 0,
435
715
  node.pinned ? 1 : 0,
436
716
  JSON.stringify(node.data),
717
+ JSON.stringify({ createdBy: node.createdBy, lastEditedBy: node.lastEditedBy }),
718
+ node.contentRevision ?? 0,
437
719
  );
438
720
  }
439
- 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)));
440
722
 
441
723
  // Save edges
442
724
  const upsertEdge = db.prepare(
443
- `INSERT INTO edges (id, from_node, to_node, type, label, style, animated)
444
- VALUES (?, ?, ?, ?, ?, ?, ?)
445
- 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
446
728
  from_node = excluded.from_node, to_node = excluded.to_node, type = excluded.type,
447
729
  label = excluded.label, style = excluded.style, animated = excluded.animated
448
730
  WHERE from_node IS NOT excluded.from_node OR to_node IS NOT excluded.to_node
@@ -451,6 +733,7 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
451
733
  );
452
734
  for (const edge of state.edges) {
453
735
  upsertEdge.run(
736
+ boardId,
454
737
  edge.id,
455
738
  edge.from,
456
739
  edge.to,
@@ -460,14 +743,14 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
460
743
  edge.animated ? 1 : 0,
461
744
  );
462
745
  }
463
- 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)));
464
747
 
465
748
  // Save annotations
466
749
  const annotations = state.annotations ?? [];
467
750
  const upsertAnnotation = db.prepare(
468
- `INSERT INTO annotations (id, type, points, bounds, color, width, text, label, created_at)
469
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
470
- 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
471
754
  type = excluded.type, points = excluded.points, bounds = excluded.bounds,
472
755
  color = excluded.color, width = excluded.width, text = excluded.text,
473
756
  label = excluded.label, created_at = excluded.created_at
@@ -478,6 +761,7 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
478
761
  );
479
762
  for (const annotation of annotations) {
480
763
  upsertAnnotation.run(
764
+ boardId,
481
765
  annotation.id,
482
766
  annotation.type,
483
767
  JSON.stringify(annotation.points),
@@ -489,51 +773,53 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
489
773
  annotation.createdAt,
490
774
  );
491
775
  }
492
- deleteMissingRows(db, 'annotations', 'id', new Set(annotations.map((annotation) => annotation.id)));
776
+ deleteMissingRows(db, 'annotations', 'id', boardId, new Set(annotations.map((annotation) => annotation.id)));
493
777
 
494
778
  // Save context pins (node_id is the whole row — nothing to update)
495
- 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 (?, ?)');
496
780
  for (const pinId of state.contextPins) {
497
- insertPin.run(pinId);
781
+ insertPin.run(boardId, pinId);
498
782
  }
499
- deleteMissingRows(db, 'context_pins', 'node_id', new Set(state.contextPins));
783
+ deleteMissingRows(db, 'context_pins', 'node_id', boardId, new Set(state.contextPins));
500
784
 
501
785
  db.run(
502
- `INSERT INTO ax_state (key, value) VALUES (?, ?)
503
- 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
504
788
  WHERE value IS NOT excluded.value`,
505
- ['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: [] })],
506
796
  );
507
797
  });
508
798
 
509
799
  transaction();
510
800
  }
511
801
 
512
- /** Check if the DB has been populated with canvas state at least once. */
513
- export function isDbPopulated(db: Database): boolean {
514
- const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('state_populated');
515
- return row?.value === '1';
516
- }
517
-
518
- export function loadStateFromDB(db: Database): PersistedCanvasState | null {
519
- const schemaVersion = db
520
- .query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?')
521
- .get('schema_version');
522
- if (!schemaVersion) return null;
523
-
524
- // Load viewport
525
- const getMetaValue = (key: string): number => {
526
- const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get(key);
527
- return row ? Number(row.value) : 0;
528
- };
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;
529
816
 
530
817
  const viewport: ViewportState = {
531
- x: getMetaValue('viewport_x'),
532
- y: getMetaValue('viewport_y'),
533
- scale: getMetaValue('viewport_scale') || 1,
818
+ x: board.viewport_x,
819
+ y: board.viewport_y,
820
+ scale: board.viewport_scale || 1,
534
821
  };
535
- const themeValue = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('theme')?.value;
536
- const theme = themeValue ? normalizeCanvasTheme(themeValue) : undefined;
822
+ const theme = readThemeFromDB(db);
537
823
 
538
824
  // Load nodes
539
825
  interface NodeRow {
@@ -547,18 +833,28 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
547
833
  collapsed: number;
548
834
  pinned: number;
549
835
  data: string;
836
+ attribution: string;
837
+ content_revision: number;
550
838
  }
551
- const nodeRows = db.query<NodeRow, []>('SELECT * FROM nodes').all();
552
- const nodes: CanvasNodeState[] = nodeRows.map((row) => ({
553
- id: row.id,
554
- type: row.type as CanvasNodeState['type'],
555
- position: { x: row.pos_x, y: row.pos_y },
556
- size: { width: row.width, height: row.height },
557
- zIndex: row.z_index,
558
- collapsed: row.collapsed === 1,
559
- pinned: row.pinned === 1,
560
- data: JSON.parse(row.data) as Record<string, unknown>,
561
- }));
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
+ });
562
858
 
563
859
  // Load edges
564
860
  interface EdgeRow {
@@ -570,7 +866,7 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
570
866
  style: string | null;
571
867
  animated: number;
572
868
  }
573
- const edgeRows = db.query<EdgeRow, []>('SELECT * FROM edges').all();
869
+ const edgeRows = db.query<EdgeRow, [string]>('SELECT * FROM edges WHERE board_id = ?').all(id);
574
870
  const edges: CanvasEdge[] = edgeRows.map((row) => ({
575
871
  id: row.id,
576
872
  from: row.from_node,
@@ -593,7 +889,7 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
593
889
  label: string | null;
594
890
  created_at: string;
595
891
  }
596
- const annotationRows = db.query<AnnotationRow, []>('SELECT * FROM annotations').all();
892
+ const annotationRows = db.query<AnnotationRow, [string]>('SELECT * FROM annotations WHERE board_id = ?').all(id);
597
893
  const annotations: CanvasAnnotation[] = annotationRows.map((row) => ({
598
894
  id: row.id,
599
895
  type: row.type as CanvasAnnotation['type'],
@@ -610,31 +906,196 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
610
906
  interface PinRow {
611
907
  node_id: string;
612
908
  }
613
- 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);
614
910
  const contextPins = pinRows.map((row) => row.node_id);
615
911
 
616
- 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');
617
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;
618
919
  return {
619
920
  version: 1,
620
921
  theme,
621
- tour: readTour(db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('tour')?.value),
922
+ tour: readTour(board.tour),
622
923
  viewport,
623
924
  nodes,
624
925
  edges,
625
926
  annotations,
626
927
  contextPins,
627
928
  ax: parsePersistedAxState(axRow?.value),
929
+ revisionState: revisionRaw ? (JSON.parse(revisionRaw) as PersistedRevisionState) : undefined,
628
930
  };
629
931
  }
630
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
+
631
1086
  // ── Snapshot Persistence ────────────────────────────────────────
632
1087
 
633
- 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 {
634
1094
  const transaction = db.transaction(() => {
635
1095
  // Insert snapshot metadata
636
- 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 (?, ?, ?, ?, ?, ?)', [
637
1097
  snapshot.id,
1098
+ boardId,
638
1099
  snapshot.name,
639
1100
  snapshot.createdAt,
640
1101
  state.nodes.length,
@@ -670,8 +1131,8 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
670
1131
 
671
1132
  // Insert snapshot nodes
672
1133
  const insertNode = db.prepare(
673
- `INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data)
674
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
675
1136
  );
676
1137
  for (const node of state.nodes) {
677
1138
  insertNode.run(
@@ -686,6 +1147,8 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
686
1147
  node.collapsed ? 1 : 0,
687
1148
  node.pinned ? 1 : 0,
688
1149
  JSON.stringify(node.data),
1150
+ JSON.stringify({ createdBy: node.createdBy, lastEditedBy: node.lastEditedBy }),
1151
+ node.contentRevision ?? 0,
689
1152
  );
690
1153
  }
691
1154
 
@@ -732,16 +1195,24 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
732
1195
  for (const pinId of state.contextPins) {
733
1196
  insertPin.run(snapshot.id, pinId);
734
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
+ ]);
735
1203
  });
736
1204
 
737
1205
  transaction();
738
1206
  }
739
1207
 
1208
+ /** By id or name (most recent match) on `boardId`. */
740
1209
  export function loadSnapshotFromDB(
741
1210
  db: Database,
742
1211
  idOrName: string,
1212
+ boardId: string | null,
743
1213
  ): { snapshot: CanvasSnapshot; state: PersistedCanvasState } | null {
744
- // Try by ID first, then by name (most recent match)
1214
+ if (!boardId) return null;
1215
+
745
1216
  interface SnapshotRow {
746
1217
  id: string;
747
1218
  name: string;
@@ -749,12 +1220,16 @@ export function loadSnapshotFromDB(
749
1220
  node_count: number;
750
1221
  edge_count: number;
751
1222
  }
752
- 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);
753
1226
 
754
1227
  if (!snapshotRow) {
755
1228
  snapshotRow = db
756
- .query<SnapshotRow, [string]>('SELECT * FROM snapshots WHERE name = ? ORDER BY created_at DESC LIMIT 1')
757
- .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);
758
1233
  }
759
1234
 
760
1235
  if (!snapshotRow) return null;
@@ -795,20 +1270,30 @@ export function loadSnapshotFromDB(
795
1270
  collapsed: number;
796
1271
  pinned: number;
797
1272
  data: string;
1273
+ attribution: string;
1274
+ content_revision: number;
798
1275
  }
799
1276
  const nodeRows = db
800
1277
  .query<NodeRow, [string]>('SELECT * FROM snapshot_nodes WHERE snapshot_id = ?')
801
1278
  .all(snapshotRow.id);
802
- const nodes: CanvasNodeState[] = nodeRows.map((row) => ({
803
- id: row.id,
804
- type: row.type as CanvasNodeState['type'],
805
- position: { x: row.pos_x, y: row.pos_y },
806
- size: { width: row.width, height: row.height },
807
- zIndex: row.z_index,
808
- collapsed: row.collapsed === 1,
809
- pinned: row.pinned === 1,
810
- data: JSON.parse(row.data) as Record<string, unknown>,
811
- }));
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
+ });
812
1297
 
813
1298
  // Load snapshot edges
814
1299
  interface EdgeRow {
@@ -880,18 +1365,25 @@ export function loadSnapshotFromDB(
880
1365
  annotations,
881
1366
  contextPins,
882
1367
  ax: parsePersistedAxState(metaMap.get('ax_state')),
1368
+ revisionState: metaMap.has('revision_state')
1369
+ ? (JSON.parse(metaMap.get('revision_state')!) as PersistedRevisionState)
1370
+ : undefined,
883
1371
  },
884
1372
  };
885
1373
  }
886
1374
 
887
- export function listSnapshotsFromDB(db: Database, options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
1375
+ export function listSnapshotsFromDB(
1376
+ db: Database,
1377
+ boardId: string,
1378
+ options: CanvasSnapshotListOptions = {},
1379
+ ): CanvasSnapshot[] {
888
1380
  const query = options.query?.trim().toLowerCase();
889
1381
  const before = normalizeSnapshotTimestamp(options.before);
890
1382
  const after = normalizeSnapshotTimestamp(options.after);
891
1383
  const limit = options.all ? undefined : (normalizePositiveInteger(options.limit) ?? 20);
892
1384
 
893
- let sql = 'SELECT * FROM snapshots WHERE 1=1';
894
- const params: string[] = [];
1385
+ let sql = 'SELECT * FROM snapshots WHERE board_id = ?';
1386
+ const params: string[] = [boardId];
895
1387
 
896
1388
  if (query) {
897
1389
  sql += ' AND (LOWER(id) LIKE ? OR LOWER(name) LIKE ?)';
@@ -931,8 +1423,8 @@ export function listSnapshotsFromDB(db: Database, options: CanvasSnapshotListOpt
931
1423
  }));
932
1424
  }
933
1425
 
934
- export function renameSnapshotInDB(db: Database, id: string, name: string): boolean {
935
- 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;
936
1428
  }
937
1429
 
938
1430
  export function deleteSnapshotFromDB(db: Database, id: string): boolean {