pmx-canvas 0.8.0 → 0.9.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 (341) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +601 -22
  3. package/CHANGELOG.md +66 -0
  4. package/Readme.md +131 -532
  5. package/dist/canvas/global.css +2759 -510
  6. package/dist/canvas/index.js +168 -107
  7. package/dist/canvas/mcp-app.html +2936 -632
  8. package/dist/json-render/index.css +81 -1
  9. package/dist/json-render/index.js +20 -20
  10. package/dist/types/client/canvas/BarHint.d.ts +12 -2
  11. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
  12. package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
  13. package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  15. package/dist/types/client/canvas/CommandBar.d.ts +3 -4
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
  17. package/dist/types/client/canvas/ContextChip.d.ts +3 -0
  18. package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
  19. package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
  20. package/dist/types/client/canvas/EmptyState.d.ts +4 -4
  21. package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
  22. package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
  23. package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
  24. package/dist/types/client/canvas/HomeView.d.ts +9 -7
  25. package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
  26. package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
  27. package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
  28. package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
  29. package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
  30. package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
  31. package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
  32. package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
  33. package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
  34. package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
  35. package/dist/types/client/canvas/canvas-area.d.ts +1 -1
  36. package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
  37. package/dist/types/client/canvas/graph-model.d.ts +37 -0
  38. package/dist/types/client/canvas/memory-layout.d.ts +23 -0
  39. package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
  40. package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
  41. package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
  42. package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
  43. package/dist/types/client/icons.d.ts +0 -10
  44. package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
  45. package/dist/types/client/nodes/HtmlNode.d.ts +1 -0
  46. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
  47. package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
  48. package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
  49. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
  50. package/dist/types/client/state/boards-store.d.ts +81 -9
  51. package/dist/types/client/state/canvas-store.d.ts +14 -1
  52. package/dist/types/client/state/context-chip-store.d.ts +22 -0
  53. package/dist/types/client/state/context-status-store.d.ts +35 -0
  54. package/dist/types/client/state/intent-bridge.d.ts +11 -8
  55. package/dist/types/client/state/knowledge-store.d.ts +22 -0
  56. package/dist/types/client/state/near-pin-store.d.ts +44 -1
  57. package/dist/types/client/state/presence-store.d.ts +4 -4
  58. package/dist/types/client/state/presentation.d.ts +28 -0
  59. package/dist/types/client/state/relation-store.d.ts +10 -0
  60. package/dist/types/client/state/session-store.d.ts +41 -2
  61. package/dist/types/client/state/workspace-settings.d.ts +7 -0
  62. package/dist/types/client/types.d.ts +13 -1
  63. package/dist/types/client/utils/keyboard.d.ts +2 -0
  64. package/dist/types/client/utils/time-ago.d.ts +4 -0
  65. package/dist/types/mcp/context-uri-template.d.ts +3 -1
  66. package/dist/types/server/agent-presence.d.ts +10 -3
  67. package/dist/types/server/ax-context.d.ts +1 -1
  68. package/dist/types/server/canvas-db.d.ts +30 -7
  69. package/dist/types/server/canvas-operations.d.ts +24 -2
  70. package/dist/types/server/canvas-provenance.d.ts +22 -2
  71. package/dist/types/server/canvas-serialization.d.ts +2 -0
  72. package/dist/types/server/canvas-state.d.ts +93 -15
  73. package/dist/types/server/canvas-validation.d.ts +1 -1
  74. package/dist/types/server/context-brief.d.ts +94 -6
  75. package/dist/types/server/context-reads.d.ts +33 -7
  76. package/dist/types/server/edit-summary.d.ts +20 -0
  77. package/dist/types/server/index.d.ts +71 -10
  78. package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
  79. package/dist/types/server/lifecycle-status.d.ts +10 -0
  80. package/dist/types/server/memory-graph.d.ts +9 -0
  81. package/dist/types/server/mutation-history.d.ts +2 -0
  82. package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
  83. package/dist/types/server/operations/ops/boards.d.ts +2 -1
  84. package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
  85. package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
  86. package/dist/types/server/operations/ops/nodes.d.ts +5 -0
  87. package/dist/types/server/operations/ops/query.d.ts +23 -0
  88. package/dist/types/server/operations/registry.d.ts +1 -0
  89. package/dist/types/server/placement.d.ts +7 -0
  90. package/dist/types/server/scope-fence.d.ts +2 -1
  91. package/dist/types/server/spatial-analysis.d.ts +6 -3
  92. package/dist/types/server/tour-control.d.ts +32 -0
  93. package/dist/types/server/wiki-links.d.ts +14 -0
  94. package/dist/types/server/workbench-auth.d.ts +2 -0
  95. package/dist/types/shared/agent-context.d.ts +2 -0
  96. package/dist/types/shared/agent-presence.d.ts +15 -12
  97. package/dist/types/shared/auto-arrange.d.ts +1 -0
  98. package/dist/types/shared/boards.d.ts +4 -4
  99. package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
  100. package/dist/types/shared/knowledge-presets.d.ts +6 -0
  101. package/dist/types/shared/lifecycle-status.d.ts +24 -0
  102. package/dist/types/shared/memory-graph.d.ts +53 -0
  103. package/dist/types/shared/relations.d.ts +68 -0
  104. package/dist/types/shared/semantic-attention.d.ts +2 -0
  105. package/dist/types/shared/tour.d.ts +38 -0
  106. package/dist/types/shared/workspace-settings.d.ts +11 -0
  107. package/docs/RELEASE.md +21 -3
  108. package/docs/adr-001-bun-only-runtime.md +7 -1
  109. package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
  110. package/docs/api-reference.md +109 -0
  111. package/docs/ax-host-adapter-contract.md +1 -1
  112. package/docs/ax-state-contract.md +5 -4
  113. package/docs/cli.md +153 -15
  114. package/docs/demos/pmx-canvas-demo.mp4 +0 -0
  115. package/docs/design/AgentContext.dc.html +3 -3
  116. package/docs/design/BoardPins.dc.html +37 -22
  117. package/docs/design/Categories.dc.html +208 -0
  118. package/docs/design/CategoryEditor.dc.html +33 -0
  119. package/docs/design/Chrome.dc.html +55 -9
  120. package/docs/design/ContextChip.dc.html +224 -0
  121. package/docs/design/ContextChip600.dc.html +136 -0
  122. package/docs/design/EmptyState.dc.html +45 -0
  123. package/docs/design/Flows.dc.html +23 -1
  124. package/docs/design/HeaderMarks.dc.html +183 -0
  125. package/docs/design/Home.dc.html +19 -1
  126. package/docs/design/HomeGraph.dc.html +86 -202
  127. package/docs/design/HomeMap.dc.html +23 -1
  128. package/docs/design/HomeTeam.dc.html +32 -14
  129. package/docs/design/Lifecycle.dc.html +22 -0
  130. package/docs/design/Lifecycle600.dc.html +18 -0
  131. package/docs/design/LifecycleVariants.dc.html +157 -0
  132. package/docs/design/LinkAuthorship.dc.html +139 -0
  133. package/docs/design/LinksChanged.dc.html +203 -0
  134. package/docs/design/LinksOptions.dc.html +137 -0
  135. package/docs/design/LinksOptions600.dc.html +134 -0
  136. package/docs/design/NearPin.dc.html +9 -10
  137. package/docs/design/Pane600.dc.html +4 -3
  138. package/docs/design/Relations.dc.html +3 -2
  139. package/docs/design/Restore.dc.html +152 -0
  140. package/docs/design/SeeChange.dc.html +267 -0
  141. package/docs/design/Uses.dc.html +12 -6
  142. package/docs/design/canvas.json +146 -19
  143. package/docs/design.md +454 -18
  144. package/docs/environment.md +0 -1
  145. package/docs/evals/curation-effect.md +128 -0
  146. package/docs/http-api.md +465 -38
  147. package/docs/mcp.md +150 -19
  148. package/docs/node-types.md +58 -3
  149. package/docs/plans/design-gaps-2026-10-08.md +53 -0
  150. package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
  151. package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
  152. package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
  153. package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
  154. package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
  155. package/docs/product-context-vision-2026-09.md +17 -1
  156. package/docs/product-vision-2026-09.md +1082 -43
  157. package/docs/product-vision-review-2026-09.md +13 -1
  158. package/docs/screenshots/workbench-live-dark.png +0 -0
  159. package/docs/screenshots/workbench-live-light.png +0 -0
  160. package/docs/sdk.md +62 -2
  161. package/package.json +1 -1
  162. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  163. package/plugins/pmx-canvas/plugin.json +1 -1
  164. package/plugins/pmx-canvas/runtime.json +1 -1
  165. package/plugins/pmx-canvas/tools.json +601 -22
  166. package/skills/pmx-canvas/SKILL.md +234 -43
  167. package/skills/pmx-canvas/evals/evals.json +62 -0
  168. package/skills/pmx-canvas/references/ax-html-control-surface.md +6 -0
  169. package/skills/pmx-canvas/references/full-reference.md +120 -21
  170. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  171. package/skills/pmx-canvas/references/importing-context.md +132 -0
  172. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  173. package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
  174. package/skills/pmx-canvas-testing/SKILL.md +20 -0
  175. package/src/cli/agent.ts +2 -0
  176. package/src/cli/commands/ax.ts +12 -0
  177. package/src/cli/commands/boards.ts +87 -11
  178. package/src/cli/commands/edges.ts +28 -0
  179. package/src/cli/commands/groups.ts +7 -0
  180. package/src/cli/commands/nodes.ts +4 -2
  181. package/src/cli/commands/query.ts +34 -2
  182. package/src/cli/commands/record.ts +199 -48
  183. package/src/cli/index.ts +7 -1
  184. package/src/client/App.tsx +177 -70
  185. package/src/client/canvas/AttentionHistory.tsx +0 -2
  186. package/src/client/canvas/BarHint.tsx +40 -2
  187. package/src/client/canvas/BoardCopyDialog.tsx +1 -1
  188. package/src/client/canvas/BoardFolderDialog.tsx +7 -7
  189. package/src/client/canvas/BoardLinkDialog.tsx +138 -33
  190. package/src/client/canvas/BoardPinButton.tsx +33 -0
  191. package/src/client/canvas/BoardSwitcher.tsx +26 -40
  192. package/src/client/canvas/CanvasNode.tsx +360 -237
  193. package/src/client/canvas/CanvasViewport.tsx +45 -7
  194. package/src/client/canvas/CommandBar.tsx +29 -23
  195. package/src/client/canvas/CommandPalette.tsx +5 -12
  196. package/src/client/canvas/ConnectionBanner.tsx +3 -3
  197. package/src/client/canvas/ContextChip.tsx +296 -0
  198. package/src/client/canvas/ContextMenu.tsx +63 -5
  199. package/src/client/canvas/CrossBoardReferences.tsx +60 -0
  200. package/src/client/canvas/EdgeLayer.tsx +339 -76
  201. package/src/client/canvas/EmptyState.tsx +33 -18
  202. package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
  203. package/src/client/canvas/ExternalWriters.tsx +57 -42
  204. package/src/client/canvas/HomeGraphView.tsx +942 -0
  205. package/src/client/canvas/HomeMapView.tsx +641 -0
  206. package/src/client/canvas/HomeReviewView.tsx +167 -0
  207. package/src/client/canvas/HomeView.tsx +687 -160
  208. package/src/client/canvas/IntentLayer.tsx +19 -12
  209. package/src/client/canvas/KnowledgeDialog.tsx +488 -0
  210. package/src/client/canvas/LifecycleChrome.tsx +133 -0
  211. package/src/client/canvas/LifecycleFields.tsx +91 -0
  212. package/src/client/canvas/LifecyclePanel.tsx +533 -0
  213. package/src/client/canvas/MemoryInspector.tsx +104 -0
  214. package/src/client/canvas/Minimap.tsx +3 -3
  215. package/src/client/canvas/NearPinLayer.tsx +187 -0
  216. package/src/client/canvas/NodeContextMark.tsx +175 -97
  217. package/src/client/canvas/Presentation.tsx +23 -57
  218. package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
  219. package/src/client/canvas/RelationsPanel.tsx +82 -0
  220. package/src/client/canvas/SelectionBar.tsx +3 -6
  221. package/src/client/canvas/SessionPanel.tsx +90 -50
  222. package/src/client/canvas/SessionReceipt.tsx +311 -39
  223. package/src/client/canvas/ShortcutOverlay.tsx +1 -1
  224. package/src/client/canvas/SnapshotPanel.tsx +27 -36
  225. package/src/client/canvas/ToolRail.tsx +367 -192
  226. package/src/client/canvas/TopBar.tsx +354 -124
  227. package/src/client/canvas/canvas-area.ts +10 -3
  228. package/src/client/canvas/graph-geometry.ts +195 -0
  229. package/src/client/canvas/graph-model.ts +254 -0
  230. package/src/client/canvas/memory-layout.ts +106 -0
  231. package/src/client/canvas/use-chrome-height.ts +20 -0
  232. package/src/client/canvas/use-focus-trap.ts +31 -5
  233. package/src/client/canvas/use-memory-graph.ts +58 -0
  234. package/src/client/canvas/use-node-drag.ts +2 -1
  235. package/src/client/canvas/use-pan-zoom.ts +64 -7
  236. package/src/client/icons.tsx +0 -63
  237. package/src/client/mcp-app/style.css +0 -6
  238. package/src/client/nodes/AxStepControls.tsx +74 -50
  239. package/src/client/nodes/BoardNode.tsx +5 -1
  240. package/src/client/nodes/ContextNode.tsx +18 -11
  241. package/src/client/nodes/ExtAppFrame.tsx +0 -1
  242. package/src/client/nodes/FileNode.tsx +46 -42
  243. package/src/client/nodes/HtmlNode.tsx +44 -2
  244. package/src/client/nodes/ImageNode.tsx +1 -1
  245. package/src/client/nodes/InlineFormatBar.tsx +6 -9
  246. package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
  247. package/src/client/nodes/MarkdownNode.tsx +377 -109
  248. package/src/client/nodes/McpAppNode.tsx +50 -20
  249. package/src/client/nodes/MdFormatBar.tsx +21 -18
  250. package/src/client/nodes/PromptNode.tsx +1 -1
  251. package/src/client/nodes/ResponseNode.tsx +1 -1
  252. package/src/client/nodes/viewer-runtime-state.ts +54 -22
  253. package/src/client/state/attention-bridge.ts +64 -15
  254. package/src/client/state/boards-store.ts +193 -39
  255. package/src/client/state/canvas-store.ts +99 -12
  256. package/src/client/state/context-chip-store.ts +70 -0
  257. package/src/client/state/context-status-store.ts +53 -0
  258. package/src/client/state/intent-bridge.ts +51 -30
  259. package/src/client/state/knowledge-store.ts +43 -0
  260. package/src/client/state/near-pin-store.ts +115 -6
  261. package/src/client/state/presence-store.ts +7 -10
  262. package/src/client/state/presentation.ts +91 -0
  263. package/src/client/state/relation-store.ts +81 -0
  264. package/src/client/state/session-store.ts +144 -18
  265. package/src/client/state/sse-bridge.ts +206 -29
  266. package/src/client/state/workspace-settings.ts +11 -0
  267. package/src/client/theme/global.css +2759 -510
  268. package/src/client/types.ts +12 -0
  269. package/src/client/utils/keyboard.ts +10 -0
  270. package/src/client/utils/time-ago.ts +14 -0
  271. package/src/json-render/renderer/index.css +6 -1
  272. package/src/json-render/renderer/index.tsx +31 -1
  273. package/src/mcp/canvas-app.ts +12 -2
  274. package/src/mcp/context-uri-template.ts +7 -4
  275. package/src/mcp/server.ts +63 -41
  276. package/src/mcp/workbench-app.ts +39 -6
  277. package/src/server/agent-presence.ts +128 -28
  278. package/src/server/ax-context.ts +27 -13
  279. package/src/server/board-export.ts +12 -1
  280. package/src/server/canvas-db.ts +331 -42
  281. package/src/server/canvas-operations.ts +196 -84
  282. package/src/server/canvas-provenance.ts +53 -22
  283. package/src/server/canvas-serialization.ts +2 -0
  284. package/src/server/canvas-state.ts +950 -122
  285. package/src/server/canvas-validation.ts +9 -2
  286. package/src/server/context-brief.ts +345 -38
  287. package/src/server/context-reads.ts +179 -30
  288. package/src/server/demo-state.json +355 -24
  289. package/src/server/edit-summary.ts +74 -0
  290. package/src/server/html-surface.ts +8 -1
  291. package/src/server/index.ts +203 -13
  292. package/src/server/knowledge-lifecycle.ts +304 -0
  293. package/src/server/lifecycle-status.ts +232 -0
  294. package/src/server/memory-graph.ts +166 -0
  295. package/src/server/mutation-history.ts +18 -6
  296. package/src/server/operations/composites.ts +32 -11
  297. package/src/server/operations/http.ts +7 -2
  298. package/src/server/operations/index.ts +4 -0
  299. package/src/server/operations/mcp.ts +32 -1
  300. package/src/server/operations/ops/ax-presence.ts +1 -1
  301. package/src/server/operations/ops/ax-read.ts +57 -6
  302. package/src/server/operations/ops/ax-timeline.ts +28 -2
  303. package/src/server/operations/ops/backup.ts +5 -3
  304. package/src/server/operations/ops/batch.ts +35 -4
  305. package/src/server/operations/ops/boards.ts +188 -47
  306. package/src/server/operations/ops/canvas-wire.ts +31 -1
  307. package/src/server/operations/ops/edges.ts +36 -2
  308. package/src/server/operations/ops/groups.ts +6 -1
  309. package/src/server/operations/ops/json-render.ts +5 -1
  310. package/src/server/operations/ops/lifecycle.ts +262 -0
  311. package/src/server/operations/ops/memory-graph.ts +45 -0
  312. package/src/server/operations/ops/nodes.ts +107 -24
  313. package/src/server/operations/ops/query.ts +281 -68
  314. package/src/server/operations/ops/snapshots.ts +73 -1
  315. package/src/server/operations/ops/validate.ts +5 -1
  316. package/src/server/operations/ops/viewport.ts +97 -3
  317. package/src/server/operations/registry.ts +77 -26
  318. package/src/server/placement.ts +29 -0
  319. package/src/server/scope-fence.ts +78 -6
  320. package/src/server/server.ts +115 -16
  321. package/src/server/spatial-analysis.ts +48 -23
  322. package/src/server/tour-control.ts +82 -0
  323. package/src/server/wiki-links.ts +137 -0
  324. package/src/server/workbench-auth.ts +11 -0
  325. package/src/shared/agent-context.ts +3 -0
  326. package/src/shared/agent-presence.ts +15 -15
  327. package/src/shared/auto-arrange.ts +2 -0
  328. package/src/shared/boards.ts +4 -4
  329. package/src/shared/knowledge-lifecycle.ts +22 -0
  330. package/src/shared/knowledge-presets.ts +80 -0
  331. package/src/shared/lifecycle-status.ts +26 -0
  332. package/src/shared/memory-graph.ts +74 -0
  333. package/src/shared/relations.ts +98 -0
  334. package/src/shared/semantic-attention.ts +15 -1
  335. package/src/shared/tour.ts +23 -5
  336. package/src/shared/workspace-settings.ts +13 -0
  337. package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
  338. package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
  339. package/dist/types/client/canvas/kind-colors.d.ts +0 -3
  340. package/dist/types/server/agent-context.d.ts +0 -22
  341. package/src/client/canvas/ContextPinBar.tsx +0 -25
@@ -8,7 +8,9 @@
8
8
  import { Database } from 'bun:sqlite';
9
9
  import type { ActorAttribution } from './attribution.js';
10
10
  import { CONTEXT_READS_SCHEMA_SQL } from './context-reads.js';
11
- import { MIGRATED_SNAPSHOT_CATEGORY, normalizeBoardCategory } from '../shared/boards.js';
11
+ import { MIGRATED_SNAPSHOT_FOLDER, normalizeBoardFolder } from '../shared/boards.js';
12
+ import { wikiLinksFromData } from '../shared/memory-graph.js';
13
+ import { relationVocabularySchema, type RelationDefinition } from '../shared/relations.js';
12
14
  import { tourSchema, type Tour } from '../shared/tour.js';
13
15
  import { existsSync, mkdirSync } from 'node:fs';
14
16
  import { dirname } from 'node:path';
@@ -54,6 +56,16 @@ export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = 'ha
54
56
  }
55
57
 
56
58
  const SCHEMA_SQL = `
59
+ CREATE TABLE IF NOT EXISTS node_creation_requests (
60
+ request_id TEXT PRIMARY KEY, board_id TEXT NOT NULL, fingerprint TEXT NOT NULL, node TEXT NOT NULL
61
+ );
62
+ CREATE TABLE IF NOT EXISTS knowledge_categories (id TEXT PRIMARY KEY, value TEXT NOT NULL);
63
+ CREATE TABLE IF NOT EXISTS folder_properties (path TEXT PRIMARY KEY, value TEXT NOT NULL);
64
+ CREATE TABLE IF NOT EXISTS review_records (
65
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ board_id TEXT NOT NULL, node_id TEXT NOT NULL, value TEXT NOT NULL
67
+ );
68
+ CREATE INDEX IF NOT EXISTS review_records_target ON review_records (board_id, node_id, sequence);
57
69
  CREATE TABLE IF NOT EXISTS meta (
58
70
  key TEXT PRIMARY KEY,
59
71
  value TEXT NOT NULL
@@ -75,7 +87,7 @@ const SCHEMA_SQL = `
75
87
  viewport_y REAL NOT NULL DEFAULT 0,
76
88
  viewport_scale REAL NOT NULL DEFAULT 1,
77
89
  tour TEXT NOT NULL DEFAULT 'null',
78
- category TEXT,
90
+ folder TEXT,
79
91
  readme_node_id TEXT
80
92
  );
81
93
 
@@ -120,6 +132,13 @@ const SCHEMA_SQL = `
120
132
  PRIMARY KEY (board_id, id)
121
133
  );
122
134
 
135
+ -- Board pins (vision move 0a): the workspace's working set across boards. Not part of
136
+ -- any board's state, so snapshots and restores never touch them.
137
+ CREATE TABLE IF NOT EXISTS board_pins (
138
+ board_id TEXT PRIMARY KEY,
139
+ meta TEXT NOT NULL
140
+ );
141
+
123
142
  CREATE TABLE IF NOT EXISTS context_pins (
124
143
  board_id TEXT NOT NULL,
125
144
  node_id TEXT NOT NULL,
@@ -291,6 +310,14 @@ const SCHEMA_SQL = `
291
310
  );
292
311
  `;
293
312
 
313
+ type EdgeAttribution = Pick<CanvasEdge, 'changedBy' | 'changedAt' | 'createdBy' | 'createdAt' | 'sourceRevision'>;
314
+
315
+ /** A link's authorship (LinkAuthorship.dc.html) as one JSON column, like a node's attribution. */
316
+ function edgeAttribution(edge: CanvasEdge): string {
317
+ const { changedBy, changedAt, createdBy, createdAt, sourceRevision } = edge;
318
+ return JSON.stringify({ changedBy, changedAt, createdBy, createdAt, sourceRevision });
319
+ }
320
+
294
321
  /** Idempotently add a column to an existing table (no prior-migrations system in this repo). */
295
322
  function ensureColumn(db: Database, table: string, column: string, ddl: string): void {
296
323
  interface ColumnInfoRow {
@@ -404,7 +431,29 @@ export function openCanvasDb(dbPath: string, options: OpenCanvasDbOptions = {}):
404
431
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
405
432
 
406
433
  const db = new Database(dbPath);
407
- db.exec('PRAGMA journal_mode=WAL');
434
+ // Journal negotiation can return SQLITE_BUSY without invoking SQLite's busy
435
+ // handler (including WAL cleanup by a closing connection). Retry only this
436
+ // startup step within the same five-second budget, not arbitrary writes.
437
+ const journalDeadline = performance.now() + 5000;
438
+ while (true) {
439
+ try {
440
+ db.exec(`PRAGMA busy_timeout=${Math.max(1, Math.ceil(journalDeadline - performance.now()))}`);
441
+ db.exec('PRAGMA journal_mode=WAL');
442
+ break;
443
+ } catch (error) {
444
+ if (
445
+ !(error instanceof Error) ||
446
+ !('code' in error) ||
447
+ error.code !== 'SQLITE_BUSY' ||
448
+ performance.now() >= journalDeadline
449
+ ) {
450
+ db.close();
451
+ throw error;
452
+ }
453
+ Bun.sleepSync(10);
454
+ }
455
+ }
456
+ db.exec('PRAGMA busy_timeout=5000');
408
457
  // FULL, not NORMAL: under WAL, NORMAL lets a host/OS-level crash lose the most
409
458
  // recent commits (a clean process exit loses nothing either way). The reporter of
410
459
  // issue #22 declined FULL specifically because a save rewrote the whole board, so
@@ -412,7 +461,6 @@ export function openCanvasDb(dbPath: string, options: OpenCanvasDbOptions = {}):
412
461
  // typical save is a row or two — so that objection is gone and the durability is
413
462
  // worth the fsync.
414
463
  db.exec('PRAGMA synchronous=FULL');
415
- db.exec('PRAGMA busy_timeout=5000');
416
464
  if (needsBoardsMigration(db)) migrateToBoards(db, dbPath, options.migratedBoardName ?? 'Board');
417
465
  db.exec(SCHEMA_SQL);
418
466
  db.exec(CONTEXT_READS_SCHEMA_SQL);
@@ -423,14 +471,47 @@ export function openCanvasDb(dbPath: string, options: OpenCanvasDbOptions = {}):
423
471
  ensureColumn(db, 'ax_steering', 'target', 'target TEXT');
424
472
  ensureColumn(db, 'context_reads', 'board_id', 'board_id TEXT');
425
473
  ensureColumn(db, 'context_reads', 'read_nodes', "read_nodes TEXT NOT NULL DEFAULT '{}'");
474
+ ensureColumn(db, 'context_reads', 'seen_links', "seen_links TEXT NOT NULL DEFAULT '{}'");
426
475
  ensureColumn(db, 'context_pins', 'meta', 'meta TEXT');
427
476
  ensureColumn(db, 'snapshot_pins', 'meta', 'meta TEXT');
428
- ensureColumn(db, 'boards', 'category', 'category TEXT');
477
+ const boardColumns = db
478
+ .query<{ name: string }, []>('PRAGMA table_info(boards)')
479
+ .all()
480
+ .map((column) => column.name);
481
+ if (!boardColumns.includes('folder') || boardColumns.includes('category')) {
482
+ // Recheck under the writer lock; already-current databases need no write transaction.
483
+ db.transaction(() => {
484
+ if (
485
+ db
486
+ .query<{ name: string }, []>('PRAGMA table_info(boards)')
487
+ .all()
488
+ .some((column) => column.name === 'category')
489
+ ) {
490
+ db.exec('ALTER TABLE boards RENAME COLUMN category TO folder');
491
+ }
492
+ ensureColumn(db, 'boards', 'folder', 'folder TEXT');
493
+ }).immediate();
494
+ }
429
495
  ensureColumn(db, 'boards', 'readme_node_id', 'readme_node_id TEXT');
496
+ ensureColumn(db, 'boards', 'map_position', 'map_position TEXT');
497
+ ensureColumn(db, 'boards', 'previous_board_id', 'previous_board_id TEXT');
498
+ ensureColumn(db, 'edges', 'to_board_id', 'to_board_id TEXT');
499
+ ensureColumn(db, 'snapshot_edges', 'to_board_id', 'to_board_id TEXT');
500
+ for (const table of ['edges', 'snapshot_edges']) {
501
+ ensureColumn(db, table, 'kind', 'kind TEXT');
502
+ ensureColumn(db, table, 'reason', 'reason TEXT');
503
+ }
504
+ ensureColumn(db, 'boards', 'relation_vocabulary', 'relation_vocabulary TEXT');
430
505
  ensureColumn(db, 'nodes', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}' ");
431
506
  ensureColumn(db, 'nodes', 'content_revision', 'content_revision INTEGER NOT NULL DEFAULT 0');
432
507
  ensureColumn(db, 'snapshot_nodes', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}' ");
433
508
  ensureColumn(db, 'snapshot_nodes', 'content_revision', 'content_revision INTEGER NOT NULL DEFAULT 0');
509
+ ensureColumn(db, 'nodes', 'links_revision', 'links_revision INTEGER NOT NULL DEFAULT 0');
510
+ ensureColumn(db, 'snapshot_nodes', 'links_revision', 'links_revision INTEGER NOT NULL DEFAULT 0');
511
+ ensureColumn(db, 'edges', 'revision', 'revision INTEGER NOT NULL DEFAULT 0');
512
+ ensureColumn(db, 'edges', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}'");
513
+ ensureColumn(db, 'snapshot_edges', 'revision', 'revision INTEGER NOT NULL DEFAULT 0');
514
+ ensureColumn(db, 'snapshot_edges', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}'");
434
515
  ensureColumn(db, 'document_imports', 'committed_node_ids', 'committed_node_ids TEXT');
435
516
 
436
517
  // Set schema version if not present
@@ -551,11 +632,11 @@ function snapshotsToBoards(db: Database): void {
551
632
  );
552
633
  const boardId = createBoardId();
553
634
  db.run(
554
- 'INSERT INTO boards (id, name, category, created_at, viewport_x, viewport_y, viewport_scale, tour) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
635
+ 'INSERT INTO boards (id, name, folder, created_at, viewport_x, viewport_y, viewport_scale, tour) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
555
636
  [
556
637
  boardId,
557
638
  snapshot.name,
558
- MIGRATED_SNAPSHOT_CATEGORY,
639
+ MIGRATED_SNAPSHOT_FOLDER,
559
640
  snapshot.created_at,
560
641
  Number(meta.get('viewport_x') ?? 0),
561
642
  Number(meta.get('viewport_y') ?? 0),
@@ -713,19 +794,21 @@ export function saveStateToDB(db: Database, boardId: string, state: PersistedCan
713
794
 
714
795
  // Save nodes
715
796
  const upsertNode = db.prepare(
716
- `INSERT INTO nodes (board_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data, attribution, content_revision)
717
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
797
+ `INSERT INTO nodes (board_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data, attribution, content_revision, links_revision)
798
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
718
799
  ON CONFLICT(board_id, id) DO UPDATE SET
719
800
  type = excluded.type, pos_x = excluded.pos_x, pos_y = excluded.pos_y,
720
801
  width = excluded.width, height = excluded.height, z_index = excluded.z_index,
721
802
  collapsed = excluded.collapsed, pinned = excluded.pinned, data = excluded.data,
722
- attribution = excluded.attribution, content_revision = excluded.content_revision
803
+ attribution = excluded.attribution, content_revision = excluded.content_revision,
804
+ links_revision = excluded.links_revision
723
805
  WHERE type IS NOT excluded.type OR pos_x IS NOT excluded.pos_x
724
806
  OR pos_y IS NOT excluded.pos_y OR width IS NOT excluded.width
725
807
  OR height IS NOT excluded.height OR z_index IS NOT excluded.z_index
726
808
  OR collapsed IS NOT excluded.collapsed OR pinned IS NOT excluded.pinned
727
809
  OR data IS NOT excluded.data OR attribution IS NOT excluded.attribution
728
- OR content_revision IS NOT excluded.content_revision`,
810
+ OR content_revision IS NOT excluded.content_revision
811
+ OR links_revision IS NOT excluded.links_revision`,
729
812
  );
730
813
  for (const node of state.nodes) {
731
814
  upsertNode.run(
@@ -740,22 +823,32 @@ export function saveStateToDB(db: Database, boardId: string, state: PersistedCan
740
823
  node.collapsed ? 1 : 0,
741
824
  node.pinned ? 1 : 0,
742
825
  JSON.stringify(node.data),
743
- JSON.stringify({ createdBy: node.createdBy, lastEditedBy: node.lastEditedBy }),
826
+ JSON.stringify({
827
+ createdBy: node.createdBy,
828
+ lastEditedBy: node.lastEditedBy,
829
+ contentChangedAt: node.contentChangedAt,
830
+ freshnessStartedAt: node.freshnessStartedAt,
831
+ }),
744
832
  node.contentRevision ?? 0,
833
+ node.linksRevision ?? 0,
745
834
  );
746
835
  }
747
836
  deleteMissingRows(db, 'nodes', 'id', boardId, new Set(state.nodes.map((node) => node.id)));
748
837
 
749
838
  // Save edges
750
839
  const upsertEdge = db.prepare(
751
- `INSERT INTO edges (board_id, id, from_node, to_node, type, label, style, animated)
752
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
840
+ `INSERT INTO edges (board_id, id, from_node, to_node, type, label, style, animated, revision, attribution, to_board_id, kind, reason)
841
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
753
842
  ON CONFLICT(board_id, id) DO UPDATE SET
754
843
  from_node = excluded.from_node, to_node = excluded.to_node, type = excluded.type,
755
- label = excluded.label, style = excluded.style, animated = excluded.animated
844
+ label = excluded.label, style = excluded.style, animated = excluded.animated,
845
+ revision = excluded.revision, attribution = excluded.attribution, to_board_id = excluded.to_board_id,
846
+ kind = excluded.kind, reason = excluded.reason
756
847
  WHERE from_node IS NOT excluded.from_node OR to_node IS NOT excluded.to_node
757
848
  OR type IS NOT excluded.type OR label IS NOT excluded.label
758
- OR style IS NOT excluded.style OR animated IS NOT excluded.animated`,
849
+ OR style IS NOT excluded.style OR animated IS NOT excluded.animated
850
+ OR revision IS NOT excluded.revision OR attribution IS NOT excluded.attribution
851
+ OR to_board_id IS NOT excluded.to_board_id OR kind IS NOT excluded.kind OR reason IS NOT excluded.reason`,
759
852
  );
760
853
  for (const edge of state.edges) {
761
854
  upsertEdge.run(
@@ -767,6 +860,11 @@ export function saveStateToDB(db: Database, boardId: string, state: PersistedCan
767
860
  edge.label ?? null,
768
861
  edge.style ?? null,
769
862
  edge.animated ? 1 : 0,
863
+ edge.revision ?? 0,
864
+ edgeAttribution(edge),
865
+ edge.toBoardId ?? null,
866
+ edge.kind ?? null,
867
+ edge.reason ?? null,
770
868
  );
771
869
  }
772
870
  deleteMissingRows(db, 'edges', 'id', boardId, new Set(state.edges.map((edge) => edge.id)));
@@ -863,11 +961,12 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
863
961
  data: string;
864
962
  attribution: string;
865
963
  content_revision: number;
964
+ links_revision: number;
866
965
  }
867
966
  const nodeRows = db.query<NodeRow, [string]>('SELECT * FROM nodes WHERE board_id = ?').all(id);
868
967
  const nodes: CanvasNodeState[] = nodeRows.map((row) => {
869
968
  const attribution = JSON.parse(row.attribution || '{}') as Partial<
870
- Pick<CanvasNodeState, 'createdBy' | 'lastEditedBy'>
969
+ Pick<CanvasNodeState, 'createdBy' | 'lastEditedBy' | 'contentChangedAt' | 'freshnessStartedAt'>
871
970
  >;
872
971
  return {
873
972
  id: row.id,
@@ -880,7 +979,10 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
880
979
  data: JSON.parse(row.data) as Record<string, unknown>,
881
980
  createdBy: attribution.createdBy,
882
981
  lastEditedBy: attribution.lastEditedBy,
982
+ contentChangedAt: attribution.contentChangedAt,
983
+ freshnessStartedAt: attribution.freshnessStartedAt,
883
984
  contentRevision: row.content_revision,
985
+ linksRevision: row.links_revision,
884
986
  };
885
987
  });
886
988
 
@@ -889,20 +991,30 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
889
991
  id: string;
890
992
  from_node: string;
891
993
  to_node: string;
994
+ to_board_id: string | null;
892
995
  type: string;
996
+ kind: string | null;
997
+ reason: string | null;
893
998
  label: string | null;
894
999
  style: string | null;
895
1000
  animated: number;
1001
+ revision: number;
1002
+ attribution: string;
896
1003
  }
897
1004
  const edgeRows = db.query<EdgeRow, [string]>('SELECT * FROM edges WHERE board_id = ?').all(id);
898
1005
  const edges: CanvasEdge[] = edgeRows.map((row) => ({
899
1006
  id: row.id,
900
1007
  from: row.from_node,
901
1008
  to: row.to_node,
1009
+ ...(row.to_board_id ? { toBoardId: row.to_board_id } : {}),
902
1010
  type: row.type as CanvasEdge['type'],
1011
+ ...(row.kind ? { kind: row.kind } : {}),
1012
+ ...(row.reason ? { reason: row.reason } : {}),
903
1013
  ...(row.label ? { label: row.label } : {}),
904
1014
  ...(row.style ? { style: row.style as CanvasEdge['style'] } : {}),
905
1015
  ...(row.animated ? { animated: true } : {}),
1016
+ ...(row.revision ? { revision: row.revision } : {}),
1017
+ ...(JSON.parse(row.attribution || '{}') as EdgeAttribution),
906
1018
  }));
907
1019
 
908
1020
  // Load annotations
@@ -966,38 +1078,53 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
966
1078
  export interface CanvasBoard {
967
1079
  id: string;
968
1080
  name: string;
969
- /** A human-chosen shelf on Home ("Planning", "Research"); null is uncategorized. */
970
- category: string | null;
1081
+ /** A human-chosen folder on Home ("Planning", "Research"); null is unfiled. */
1082
+ folder: string | null;
971
1083
  createdAt: string;
972
1084
  lastOpenedAt: string | null;
973
1085
  nodeCount: number;
974
1086
  /** Markdown node used as this board's introduction. */
975
1087
  readmeNodeId: string | null;
1088
+ /** Human-dragged position in Home Map; null uses the generated folder layout. */
1089
+ mapPosition?: { x: number; y: number } | null;
1090
+ /** The source of a recurring/template board, separate from ordinary portals. */
1091
+ previousBoardId?: string | null;
1092
+ relationVocabulary?: RelationDefinition[];
1093
+ /** Pinned into the agent's working set (vision move 0a); null when not pinned. */
1094
+ pin: ContextPinMeta | null;
976
1095
  }
977
1096
 
978
1097
  interface BoardListRow {
979
1098
  id: string;
980
1099
  name: string;
981
- category: string | null;
1100
+ folder: string | null;
982
1101
  created_at: string;
983
1102
  last_opened_at: string | null;
984
1103
  node_count: number;
985
1104
  readme_node_id: string | null;
1105
+ map_position: string | null;
1106
+ previous_board_id: string | null;
1107
+ relation_vocabulary: string | null;
1108
+ pin_meta: string | null;
986
1109
  }
987
1110
 
988
- const BOARD_LIST_SQL = `SELECT b.id, b.name, b.category, b.created_at, b.last_opened_at, b.readme_node_id,
989
- (SELECT COUNT(*) FROM nodes n WHERE n.board_id = b.id) AS node_count
990
- FROM boards b`;
1111
+ const BOARD_LIST_SQL = `SELECT b.id, b.name, b.folder, b.created_at, b.last_opened_at, b.readme_node_id, b.map_position, b.previous_board_id, b.relation_vocabulary,
1112
+ (SELECT COUNT(*) FROM nodes n WHERE n.board_id = b.id) AS node_count, p.meta AS pin_meta
1113
+ FROM boards b LEFT JOIN board_pins p ON p.board_id = b.id`;
991
1114
 
992
1115
  function rowToBoard(row: BoardListRow): CanvasBoard {
993
1116
  return {
994
1117
  id: row.id,
995
1118
  name: row.name,
996
- category: row.category,
1119
+ folder: row.folder,
997
1120
  createdAt: row.created_at,
998
1121
  lastOpenedAt: row.last_opened_at,
999
1122
  nodeCount: row.node_count,
1000
1123
  readmeNodeId: row.readme_node_id,
1124
+ mapPosition: row.map_position ? (JSON.parse(row.map_position) as { x: number; y: number }) : null,
1125
+ previousBoardId: row.previous_board_id,
1126
+ relationVocabulary: row.relation_vocabulary ? JSON.parse(row.relation_vocabulary) : [],
1127
+ pin: row.pin_meta ? (JSON.parse(row.pin_meta) as ContextPinMeta) : null,
1001
1128
  };
1002
1129
  }
1003
1130
 
@@ -1014,27 +1141,53 @@ export function getBoardFromDB(db: Database, id: string): CanvasBoard | null {
1014
1141
  return row ? rowToBoard(row) : null;
1015
1142
  }
1016
1143
 
1017
- export function createBoardInDB(db: Database, name: string, category: string | null = null): CanvasBoard {
1018
- category = normalizeBoardCategory(category);
1144
+ export function createBoardInDB(db: Database, name: string, folder: string | null = null): CanvasBoard {
1145
+ folder = normalizeBoardFolder(folder);
1019
1146
  const id = createBoardId();
1020
1147
  const createdAt = new Date(Date.now()).toISOString();
1021
- db.run('INSERT INTO boards (id, name, category, created_at) VALUES (?, ?, ?, ?)', [id, name, category, createdAt]);
1022
- return { id, name, category, createdAt, lastOpenedAt: null, nodeCount: 0, readmeNodeId: null };
1148
+ db.run('INSERT INTO boards (id, name, folder, created_at) VALUES (?, ?, ?, ?)', [id, name, folder, createdAt]);
1149
+ return { id, name, folder, createdAt, lastOpenedAt: null, nodeCount: 0, readmeNodeId: null, pin: null };
1150
+ }
1151
+
1152
+ /** Pin a board into the working set, or unpin it with null. False when the board does not exist. */
1153
+ export function setBoardPinInDB(db: Database, id: string, meta: ContextPinMeta | null): boolean {
1154
+ if (!db.query<{ id: string }, [string]>('SELECT id FROM boards WHERE id = ?').get(id)) return false;
1155
+ if (meta) db.run('INSERT OR REPLACE INTO board_pins (board_id, meta) VALUES (?, ?)', [id, JSON.stringify(meta)]);
1156
+ else db.run('DELETE FROM board_pins WHERE board_id = ?', [id]);
1157
+ return true;
1023
1158
  }
1024
1159
 
1025
- /** Rename and/or re-shelve a board; `category: null` removes it from its category. */
1160
+ /** Rename and/or re-shelve a board; `folder: null` unfiles it. */
1026
1161
  export function updateBoardInDB(
1027
1162
  db: Database,
1028
1163
  id: string,
1029
- patch: { name?: string; category?: string | null; readmeNodeId?: string | null },
1164
+ patch: {
1165
+ name?: string;
1166
+ folder?: string | null;
1167
+ readmeNodeId?: string | null;
1168
+ mapPosition?: { x: number; y: number } | null;
1169
+ previousBoardId?: string | null;
1170
+ relationVocabulary?: RelationDefinition[];
1171
+ },
1030
1172
  ): boolean {
1031
- const category = patch.category === undefined ? undefined : normalizeBoardCategory(patch.category);
1173
+ const folder = patch.folder === undefined ? undefined : normalizeBoardFolder(patch.folder);
1174
+ const vocabulary =
1175
+ patch.relationVocabulary === undefined ? undefined : relationVocabularySchema.parse(patch.relationVocabulary);
1032
1176
  if (!db.query<{ id: string }, [string]>('SELECT id FROM boards WHERE id = ?').get(id)) return false;
1177
+ if (vocabulary !== undefined)
1178
+ db.run('UPDATE boards SET relation_vocabulary = ? WHERE id = ?', [JSON.stringify(vocabulary), id]);
1033
1179
  if (patch.name !== undefined) db.run('UPDATE boards SET name = ? WHERE id = ?', [patch.name, id]);
1034
- if (category !== undefined) db.run('UPDATE boards SET category = ? WHERE id = ?', [category, id]);
1180
+ if (folder !== undefined) db.run('UPDATE boards SET folder = ? WHERE id = ?', [folder, id]);
1035
1181
  if (patch.readmeNodeId !== undefined) {
1036
1182
  db.run('UPDATE boards SET readme_node_id = ? WHERE id = ?', [patch.readmeNodeId, id]);
1037
1183
  }
1184
+ if (patch.mapPosition !== undefined)
1185
+ db.run('UPDATE boards SET map_position = ? WHERE id = ?', [
1186
+ patch.mapPosition ? JSON.stringify(patch.mapPosition) : null,
1187
+ id,
1188
+ ]);
1189
+ if (patch.previousBoardId !== undefined)
1190
+ db.run('UPDATE boards SET previous_board_id = ? WHERE id = ?', [patch.previousBoardId, id]);
1038
1191
  return true;
1039
1192
  }
1040
1193
 
@@ -1042,22 +1195,133 @@ export function updateBoardInDB(
1042
1195
  export function createBoardWithStateInDB(
1043
1196
  db: Database,
1044
1197
  name: string,
1045
- category: string | null,
1198
+ folder: string | null,
1046
1199
  state: PersistedCanvasState,
1047
1200
  readmeNodeId: string | null,
1201
+ invalidateTarget?: (boardId: string, nodeId: string) => void,
1048
1202
  ): CanvasBoard {
1049
1203
  return db.transaction(() => {
1050
- const board = createBoardInDB(db, name, category);
1204
+ const board = createBoardInDB(db, name, folder);
1051
1205
  saveStateToDB(db, board.id, state);
1206
+ for (const edge of state.edges) {
1207
+ if (edge.toBoardId && !edge.id.startsWith('codegraph-') && !edge.id.startsWith('boardmap-'))
1208
+ (invalidateTarget ?? ((boardId, nodeId) => invalidateLinksInDB(db, boardId, nodeId)))(edge.toBoardId, edge.to);
1209
+ }
1210
+ for (const node of state.nodes) {
1211
+ for (const { target } of node.type === 'markdown' ? wikiLinksFromData(node.data) : []) {
1212
+ if (target?.cardId && target.boardId !== board.id)
1213
+ (invalidateTarget ?? ((boardId, nodeId) => invalidateLinksInDB(db, boardId, nodeId)))(
1214
+ target.boardId,
1215
+ target.cardId,
1216
+ );
1217
+ }
1218
+ }
1052
1219
  updateBoardInDB(db, board.id, { readmeNodeId });
1053
1220
  return { ...board, nodeCount: state.nodes.length, readmeNodeId };
1054
1221
  })();
1055
1222
  }
1056
1223
 
1224
+ /** Derived incoming-link metadata only: the target owns its clock and its body. */
1225
+ export function invalidateLinksInDB(db: Database, boardId: string, nodeId: string): number | undefined {
1226
+ return db.transaction(() => {
1227
+ const node = db
1228
+ .query<{ links_revision: number; content_revision: number }, [string, string]>(
1229
+ 'SELECT links_revision, content_revision FROM nodes WHERE board_id = ? AND id = ?',
1230
+ )
1231
+ .get(boardId, nodeId);
1232
+ if (!node || !getBoardFromDB(db, boardId)) return;
1233
+ const raw = db
1234
+ .query<{ value: string }, [string, string]>('SELECT value FROM ax_state WHERE board_id = ? AND key = ?')
1235
+ .get(boardId, 'revision')?.value;
1236
+ const state: PersistedRevisionState = raw ? JSON.parse(raw) : { revision: 0, floor: 0, tombstones: [] };
1237
+ state.revision = Math.max(state.revision, node.links_revision, node.content_revision) + 1;
1238
+ db.run('UPDATE nodes SET links_revision = ? WHERE board_id = ? AND id = ?', [state.revision, boardId, nodeId]);
1239
+ db.run(
1240
+ 'INSERT INTO ax_state (board_id, key, value) VALUES (?, ?, ?) ON CONFLICT(board_id, key) DO UPDATE SET value = excluded.value',
1241
+ [boardId, 'revision', JSON.stringify(state)],
1242
+ );
1243
+ return state.revision;
1244
+ })();
1245
+ }
1246
+
1247
+ /** A target's appearance/disappearance changes its incoming saved relations on source cards. */
1248
+ export function invalidateIncomingLinksInDB(
1249
+ db: Database,
1250
+ boardId: string,
1251
+ nodeId: string | null,
1252
+ invalidate: (boardId: string, nodeId: string) => void,
1253
+ ): void {
1254
+ const sources = new Set<string>();
1255
+ for (const edge of db
1256
+ .query<{ id: string; board_id: string; from_node: string }, [string, string, string | null, string | null]>(
1257
+ 'SELECT id, board_id, from_node FROM edges WHERE to_board_id = ? AND board_id <> ? AND (? IS NULL OR to_node = ?)',
1258
+ )
1259
+ .all(boardId, boardId, nodeId, nodeId)) {
1260
+ if (edge.id.startsWith('codegraph-') || edge.id.startsWith('boardmap-')) continue;
1261
+ const key = JSON.stringify([edge.board_id, edge.from_node]);
1262
+ if (sources.has(key)) continue;
1263
+ sources.add(key);
1264
+ invalidate(edge.board_id, edge.from_node);
1265
+ }
1266
+ for (const source of db
1267
+ .query<{ board_id: string; id: string }, [string, string, string | null, string | null]>(
1268
+ `SELECT DISTINCT n.board_id, n.id FROM nodes n, json_each(n.data, '$.wikiLinks') link
1269
+ WHERE n.type = 'markdown' AND n.board_id <> ?
1270
+ AND json_extract(link.value, '$.target.boardId') = ?
1271
+ AND (? IS NULL OR json_extract(link.value, '$.target.cardId') = ?)`,
1272
+ )
1273
+ .all(boardId, boardId, nodeId, nodeId)) {
1274
+ const key = JSON.stringify([source.board_id, source.id]);
1275
+ if (sources.has(key)) continue;
1276
+ sources.add(key);
1277
+ invalidate(source.board_id, source.id);
1278
+ }
1279
+ }
1280
+
1057
1281
  /** Deletes a board with its rows and its snapshots. */
1058
- export function deleteBoardFromDB(db: Database, id: string): boolean {
1282
+ export function deleteBoardFromDB(
1283
+ db: Database,
1284
+ id: string,
1285
+ invalidateTarget?: (boardId: string, nodeId: string) => void,
1286
+ ): boolean {
1059
1287
  const transaction = db.transaction(() => {
1060
- for (const table of ['nodes', 'edges', 'annotations', 'context_pins', 'ax_state']) {
1288
+ invalidateIncomingLinksInDB(
1289
+ db,
1290
+ id,
1291
+ null,
1292
+ invalidateTarget ?? ((boardId, nodeId) => invalidateLinksInDB(db, boardId, nodeId)),
1293
+ );
1294
+ for (const edge of db
1295
+ .query<{ id: string; to_board_id: string; to_node: string }, [string]>(
1296
+ 'SELECT id, to_board_id, to_node FROM edges WHERE board_id = ? AND to_board_id IS NOT NULL',
1297
+ )
1298
+ .all(id)) {
1299
+ if (edge.to_board_id !== id && !edge.id.startsWith('codegraph-') && !edge.id.startsWith('boardmap-'))
1300
+ (invalidateTarget ?? ((boardId, nodeId) => invalidateLinksInDB(db, boardId, nodeId)))(
1301
+ edge.to_board_id,
1302
+ edge.to_node,
1303
+ );
1304
+ }
1305
+ for (const node of db
1306
+ .query<{ data: string }, [string]>("SELECT data FROM nodes WHERE board_id = ? AND type = 'markdown'")
1307
+ .all(id)) {
1308
+ for (const { target } of wikiLinksFromData(JSON.parse(node.data))) {
1309
+ if (target?.cardId && target.boardId !== id)
1310
+ (invalidateTarget ?? ((boardId, nodeId) => invalidateLinksInDB(db, boardId, nodeId)))(
1311
+ target.boardId,
1312
+ target.cardId,
1313
+ );
1314
+ }
1315
+ }
1316
+ for (const table of [
1317
+ 'nodes',
1318
+ 'edges',
1319
+ 'annotations',
1320
+ 'context_pins',
1321
+ 'ax_state',
1322
+ 'board_pins',
1323
+ 'node_creation_requests',
1324
+ ]) {
1061
1325
  db.run(`DELETE FROM ${table} WHERE board_id = ?`, [id]);
1062
1326
  }
1063
1327
  const snapshotIds = db
@@ -1162,8 +1426,8 @@ export function saveSnapshotToDB(
1162
1426
 
1163
1427
  // Insert snapshot nodes
1164
1428
  const insertNode = db.prepare(
1165
- `INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data, attribution, content_revision)
1166
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1429
+ `INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, data, attribution, content_revision, links_revision)
1430
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1167
1431
  );
1168
1432
  for (const node of state.nodes) {
1169
1433
  insertNode.run(
@@ -1178,15 +1442,21 @@ export function saveSnapshotToDB(
1178
1442
  node.collapsed ? 1 : 0,
1179
1443
  node.pinned ? 1 : 0,
1180
1444
  JSON.stringify(node.data),
1181
- JSON.stringify({ createdBy: node.createdBy, lastEditedBy: node.lastEditedBy }),
1445
+ JSON.stringify({
1446
+ createdBy: node.createdBy,
1447
+ lastEditedBy: node.lastEditedBy,
1448
+ contentChangedAt: node.contentChangedAt,
1449
+ freshnessStartedAt: node.freshnessStartedAt,
1450
+ }),
1182
1451
  node.contentRevision ?? 0,
1452
+ node.linksRevision ?? 0,
1183
1453
  );
1184
1454
  }
1185
1455
 
1186
1456
  // Insert snapshot edges
1187
1457
  const insertEdge = db.prepare(
1188
- `INSERT INTO snapshot_edges (snapshot_id, id, from_node, to_node, type, label, style, animated)
1189
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1458
+ `INSERT INTO snapshot_edges (snapshot_id, id, from_node, to_node, type, label, style, animated, revision, attribution, to_board_id, kind, reason)
1459
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1190
1460
  );
1191
1461
  for (const edge of state.edges) {
1192
1462
  insertEdge.run(
@@ -1198,6 +1468,11 @@ export function saveSnapshotToDB(
1198
1468
  edge.label ?? null,
1199
1469
  edge.style ?? null,
1200
1470
  edge.animated ? 1 : 0,
1471
+ edge.revision ?? 0,
1472
+ edgeAttribution(edge),
1473
+ edge.toBoardId ?? null,
1474
+ edge.kind ?? null,
1475
+ edge.reason ?? null,
1201
1476
  );
1202
1477
  }
1203
1478
 
@@ -1303,13 +1578,14 @@ export function loadSnapshotFromDB(
1303
1578
  data: string;
1304
1579
  attribution: string;
1305
1580
  content_revision: number;
1581
+ links_revision: number;
1306
1582
  }
1307
1583
  const nodeRows = db
1308
1584
  .query<NodeRow, [string]>('SELECT * FROM snapshot_nodes WHERE snapshot_id = ?')
1309
1585
  .all(snapshotRow.id);
1310
1586
  const nodes: CanvasNodeState[] = nodeRows.map((row) => {
1311
1587
  const attribution = JSON.parse(row.attribution || '{}') as Partial<
1312
- Pick<CanvasNodeState, 'createdBy' | 'lastEditedBy'>
1588
+ Pick<CanvasNodeState, 'createdBy' | 'lastEditedBy' | 'contentChangedAt' | 'freshnessStartedAt'>
1313
1589
  >;
1314
1590
  return {
1315
1591
  id: row.id,
@@ -1322,7 +1598,10 @@ export function loadSnapshotFromDB(
1322
1598
  data: JSON.parse(row.data) as Record<string, unknown>,
1323
1599
  createdBy: attribution.createdBy,
1324
1600
  lastEditedBy: attribution.lastEditedBy,
1601
+ contentChangedAt: attribution.contentChangedAt,
1602
+ freshnessStartedAt: attribution.freshnessStartedAt,
1325
1603
  contentRevision: row.content_revision,
1604
+ linksRevision: row.links_revision,
1326
1605
  };
1327
1606
  });
1328
1607
 
@@ -1331,10 +1610,15 @@ export function loadSnapshotFromDB(
1331
1610
  id: string;
1332
1611
  from_node: string;
1333
1612
  to_node: string;
1613
+ to_board_id: string | null;
1334
1614
  type: string;
1615
+ kind: string | null;
1616
+ reason: string | null;
1335
1617
  label: string | null;
1336
1618
  style: string | null;
1337
1619
  animated: number;
1620
+ revision: number;
1621
+ attribution: string;
1338
1622
  }
1339
1623
  const edgeRows = db
1340
1624
  .query<EdgeRow, [string]>('SELECT * FROM snapshot_edges WHERE snapshot_id = ?')
@@ -1343,10 +1627,15 @@ export function loadSnapshotFromDB(
1343
1627
  id: row.id,
1344
1628
  from: row.from_node,
1345
1629
  to: row.to_node,
1630
+ ...(row.to_board_id ? { toBoardId: row.to_board_id } : {}),
1346
1631
  type: row.type as CanvasEdge['type'],
1632
+ ...(row.kind ? { kind: row.kind } : {}),
1633
+ ...(row.reason ? { reason: row.reason } : {}),
1347
1634
  ...(row.label ? { label: row.label } : {}),
1348
1635
  ...(row.style ? { style: row.style as CanvasEdge['style'] } : {}),
1349
1636
  ...(row.animated ? { animated: true } : {}),
1637
+ ...(row.revision ? { revision: row.revision } : {}),
1638
+ ...(JSON.parse(row.attribution || '{}') as EdgeAttribution),
1350
1639
  }));
1351
1640
 
1352
1641
  // Load snapshot annotations