@anchrd/intel 0.59.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 (364) hide show
  1. package/README.md +496 -0
  2. package/bin/intel.mjs +134 -0
  3. package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
  4. package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
  5. package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
  6. package/dist/adapters/cloudflare/cloudflare.js +416 -0
  7. package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
  8. package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
  9. package/dist/adapters/content/content.d.ts +3 -0
  10. package/dist/adapters/content/content.js +128 -0
  11. package/dist/adapters/content/content.types.d.ts +38 -0
  12. package/dist/adapters/content/content.types.js +1 -0
  13. package/dist/adapters/db/db-audit.d.ts +62 -0
  14. package/dist/adapters/db/db-audit.js +150 -0
  15. package/dist/adapters/db/db-boards.d.ts +6 -0
  16. package/dist/adapters/db/db-boards.js +324 -0
  17. package/dist/adapters/db/db-feed.d.ts +30 -0
  18. package/dist/adapters/db/db-feed.js +276 -0
  19. package/dist/adapters/db/db-flows.d.ts +42 -0
  20. package/dist/adapters/db/db-flows.js +1311 -0
  21. package/dist/adapters/db/db-grants.d.ts +133 -0
  22. package/dist/adapters/db/db-grants.js +269 -0
  23. package/dist/adapters/db/db-indexing.d.ts +3 -0
  24. package/dist/adapters/db/db-indexing.js +200 -0
  25. package/dist/adapters/db/db-oauth.d.ts +3 -0
  26. package/dist/adapters/db/db-oauth.js +26 -0
  27. package/dist/adapters/db/db-prompts.d.ts +46 -0
  28. package/dist/adapters/db/db-prompts.js +144 -0
  29. package/dist/adapters/db/db.d.ts +6 -0
  30. package/dist/adapters/db/db.js +1687 -0
  31. package/dist/adapters/db/db.types.d.ts +16 -0
  32. package/dist/adapters/db/db.types.js +1 -0
  33. package/dist/adapters/document-converter/document-converter.d.ts +2 -0
  34. package/dist/adapters/document-converter/document-converter.js +26 -0
  35. package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
  36. package/dist/adapters/document-converter/document-converter.types.js +1 -0
  37. package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
  38. package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
  39. package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
  40. package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
  41. package/dist/adapters/index-queue/index-queue.d.ts +10 -0
  42. package/dist/adapters/index-queue/index-queue.js +12 -0
  43. package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
  44. package/dist/adapters/index-queue/index-queue.types.js +1 -0
  45. package/dist/adapters/json-schema/json-schema.d.ts +2 -0
  46. package/dist/adapters/json-schema/json-schema.js +77 -0
  47. package/dist/adapters/openid/openid.d.ts +5 -0
  48. package/dist/adapters/openid/openid.js +301 -0
  49. package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
  50. package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
  51. package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
  52. package/dist/adapters/remote-tools/remote-tools.js +129 -0
  53. package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
  54. package/dist/adapters/semantic-index/semantic-index.js +139 -0
  55. package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
  56. package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
  57. package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
  58. package/dist/adapters/session-cookie/session-cookie.js +87 -0
  59. package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
  60. package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
  61. package/dist/audit/audit.d.ts +2 -0
  62. package/dist/audit/audit.js +87 -0
  63. package/dist/audit/audit.types.d.ts +23 -0
  64. package/dist/audit/audit.types.js +1 -0
  65. package/dist/auth/auth.d.ts +2 -0
  66. package/dist/auth/auth.js +284 -0
  67. package/dist/auth/auth.types.d.ts +108 -0
  68. package/dist/auth/auth.types.js +1 -0
  69. package/dist/boards/boards.d.ts +44 -0
  70. package/dist/boards/boards.js +447 -0
  71. package/dist/boards/boards.types.d.ts +117 -0
  72. package/dist/boards/boards.types.js +1 -0
  73. package/dist/build/build.d.ts +2 -0
  74. package/dist/build/build.js +192 -0
  75. package/dist/build/build.types.d.ts +17 -0
  76. package/dist/build/build.types.js +1 -0
  77. package/dist/bundle/bundle.d.ts +4 -0
  78. package/dist/bundle/bundle.js +1194 -0
  79. package/dist/bundle/bundle.types.d.ts +33 -0
  80. package/dist/bundle/bundle.types.js +1 -0
  81. package/dist/cli/cli.d.ts +4 -0
  82. package/dist/cli/cli.js +262 -0
  83. package/dist/cli/cli.types.d.ts +14 -0
  84. package/dist/cli/cli.types.js +1 -0
  85. package/dist/contract/audit.d.ts +50 -0
  86. package/dist/contract/audit.js +72 -0
  87. package/dist/contract/board.d.ts +150 -0
  88. package/dist/contract/board.js +262 -0
  89. package/dist/contract/bundle.d.ts +147 -0
  90. package/dist/contract/bundle.js +139 -0
  91. package/dist/contract/contract.d.ts +112 -0
  92. package/dist/contract/contract.js +143 -0
  93. package/dist/contract/feed.d.ts +126 -0
  94. package/dist/contract/feed.js +149 -0
  95. package/dist/contract/flow-run.d.ts +346 -0
  96. package/dist/contract/flow-run.js +194 -0
  97. package/dist/contract/flow.d.ts +1057 -0
  98. package/dist/contract/flow.js +552 -0
  99. package/dist/contract/node.d.ts +551 -0
  100. package/dist/contract/node.js +456 -0
  101. package/dist/contract/share.d.ts +216 -0
  102. package/dist/contract/share.js +134 -0
  103. package/dist/contract/table.d.ts +171 -0
  104. package/dist/contract/table.js +117 -0
  105. package/dist/contract/tool.d.ts +122 -0
  106. package/dist/contract/tool.js +172 -0
  107. package/dist/feed/feed.d.ts +2 -0
  108. package/dist/feed/feed.js +77 -0
  109. package/dist/feed/feed.types.d.ts +23 -0
  110. package/dist/feed/feed.types.js +1 -0
  111. package/dist/flows/flows.d.ts +41 -0
  112. package/dist/flows/flows.js +2216 -0
  113. package/dist/flows/flows.types.d.ts +339 -0
  114. package/dist/flows/flows.types.js +1 -0
  115. package/dist/http/http.d.ts +9 -0
  116. package/dist/http/http.js +874 -0
  117. package/dist/http/http.types.d.ts +22 -0
  118. package/dist/http/http.types.js +1 -0
  119. package/dist/indexing/indexing.d.ts +6 -0
  120. package/dist/indexing/indexing.js +239 -0
  121. package/dist/indexing/indexing.types.d.ts +11 -0
  122. package/dist/indexing/indexing.types.js +1 -0
  123. package/dist/intel/intel.d.ts +3 -0
  124. package/dist/intel/intel.js +194 -0
  125. package/dist/intel/intel.types.d.ts +24 -0
  126. package/dist/intel/intel.types.js +1 -0
  127. package/dist/mcp/mcp.d.ts +2 -0
  128. package/dist/mcp/mcp.js +1292 -0
  129. package/dist/mcp/mcp.types.d.ts +25 -0
  130. package/dist/mcp/mcp.types.js +1 -0
  131. package/dist/nodes/document-links/document-links.d.ts +20 -0
  132. package/dist/nodes/document-links/document-links.js +57 -0
  133. package/dist/nodes/nodes.d.ts +2 -0
  134. package/dist/nodes/nodes.js +1667 -0
  135. package/dist/nodes/nodes.types.d.ts +511 -0
  136. package/dist/nodes/nodes.types.js +1 -0
  137. package/dist/prepare/prepare.d.ts +4 -0
  138. package/dist/prepare/prepare.js +16 -0
  139. package/dist/prepare/prepare.types.d.ts +9 -0
  140. package/dist/prepare/prepare.types.js +1 -0
  141. package/dist/prompts/prompts.d.ts +2 -0
  142. package/dist/prompts/prompts.js +65 -0
  143. package/dist/prompts/prompts.types.d.ts +71 -0
  144. package/dist/prompts/prompts.types.js +1 -0
  145. package/dist/shared/callers/callers.d.ts +28 -0
  146. package/dist/shared/callers/callers.js +30 -0
  147. package/dist/shared/csv/csv.d.ts +13 -0
  148. package/dist/shared/csv/csv.js +85 -0
  149. package/dist/shared/document-text/document-text.d.ts +21 -0
  150. package/dist/shared/document-text/document-text.js +31 -0
  151. package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
  152. package/dist/shared/gate-authorization/gate-authorization.js +26 -0
  153. package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
  154. package/dist/shared/grant-expiry/grant-expiry.js +26 -0
  155. package/dist/shared/intel-error/intel-error.d.ts +22 -0
  156. package/dist/shared/intel-error/intel-error.js +27 -0
  157. package/dist/shared/plain-title/plain-title.d.ts +16 -0
  158. package/dist/shared/plain-title/plain-title.js +45 -0
  159. package/dist/shared/problem-details/problem-details.d.ts +30 -0
  160. package/dist/shared/problem-details/problem-details.js +18 -0
  161. package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
  162. package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
  163. package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
  164. package/dist/shared/safe-return-path/safe-return-path.js +11 -0
  165. package/dist/shared/sha256/sha256.d.ts +2 -0
  166. package/dist/shared/sha256/sha256.js +14 -0
  167. package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
  168. package/dist/tools/tool-servers/tool-servers.js +177 -0
  169. package/dist/tools/tools.d.ts +2 -0
  170. package/dist/tools/tools.js +207 -0
  171. package/dist/tools/tools.types.d.ts +56 -0
  172. package/dist/tools/tools.types.js +1 -0
  173. package/examples/branding/favicon.svg +5 -0
  174. package/examples/branding/logo.svg +5 -0
  175. package/examples/branding/theme.css +16 -0
  176. package/examples/dev.vars.example +7 -0
  177. package/examples/intel.json +9 -0
  178. package/migrations/0000_intel_foundation.sql +203 -0
  179. package/migrations/0001_portal_native_tools.sql +21 -0
  180. package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
  181. package/migrations/0003_folder_permissions.sql +211 -0
  182. package/migrations/0004_subflow_runs.sql +14 -0
  183. package/migrations/0005_flow_node_cleanup.sql +28 -0
  184. package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
  185. package/migrations/0006_links_are_written_in_the_text.sql +20 -0
  186. package/migrations/0007_no_node_waits.sql +69 -0
  187. package/migrations/0008_three_layers.sql +130 -0
  188. package/migrations/0009_no_context_policy.sql +48 -0
  189. package/migrations/0010_no_run_waits_a_year.sql +27 -0
  190. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  191. package/migrations/0012_table_snapshots.sql +29 -0
  192. package/migrations/0017_a_vector_per_card.sql +38 -0
  193. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  194. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  195. package/migrations/0020_cascade_purge_replay.sql +11 -0
  196. package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
  197. package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
  198. package/migrations/0023_a_board_and_its_tasks.sql +116 -0
  199. package/migrations/0024_the_archive_is_a_column.sql +33 -0
  200. package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
  201. package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
  202. package/migrations/0027_the_runs_of_every_flow.sql +27 -0
  203. package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
  204. package/package.json +118 -0
  205. package/src/contract/CLAUDE.md +152 -0
  206. package/src/contract/README.md +102 -0
  207. package/src/contract/audit.ts +85 -0
  208. package/src/contract/board.ts +306 -0
  209. package/src/contract/bundle.ts +157 -0
  210. package/src/contract/contract.ts +161 -0
  211. package/src/contract/feed.ts +165 -0
  212. package/src/contract/flow-run.ts +241 -0
  213. package/src/contract/flow.ts +675 -0
  214. package/src/contract/node.ts +583 -0
  215. package/src/contract/share.ts +196 -0
  216. package/src/contract/table.ts +178 -0
  217. package/src/contract/tool.ts +202 -0
  218. package/ui/README.md +110 -0
  219. package/ui/components.json +21 -0
  220. package/ui/index.html +13 -0
  221. package/ui/package.json +11 -0
  222. package/ui/scripts/lint-tokens.mjs +187 -0
  223. package/ui/src/access-summary/access-summary.tsx +385 -0
  224. package/ui/src/app/action-slot/action-slot.tsx +27 -0
  225. package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
  226. package/ui/src/app/app-tree/app-tree.tsx +1175 -0
  227. package/ui/src/app/app.tsx +87 -0
  228. package/ui/src/app/header-search/header-search.tsx +294 -0
  229. package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
  230. package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
  231. package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  232. package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  233. package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
  234. package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
  235. package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
  236. package/ui/src/app/tree-move/tree-move.tsx +427 -0
  237. package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
  238. package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
  239. package/ui/src/app/user-footer/user-footer.tsx +188 -0
  240. package/ui/src/app/view-menu/view-menu.tsx +115 -0
  241. package/ui/src/app-root/app-root.tsx +109 -0
  242. package/ui/src/app-root/app-root.types.ts +27 -0
  243. package/ui/src/archive/archive.tsx +417 -0
  244. package/ui/src/attachment-detail/attachment-detail.ts +52 -0
  245. package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
  246. package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
  247. package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
  248. package/ui/src/board/board-assignee/board-assignee.ts +48 -0
  249. package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
  250. package/ui/src/board/board-chip/board-chip.tsx +46 -0
  251. package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
  252. package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
  253. package/ui/src/board/board-data/board-data.ts +195 -0
  254. package/ui/src/board/board-data/board-data.types.ts +38 -0
  255. package/ui/src/board/board-dates/board-dates.ts +40 -0
  256. package/ui/src/board/board-dates/board-dates.tsx +105 -0
  257. package/ui/src/board/board-kanban/board-kanban.ts +162 -0
  258. package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
  259. package/ui/src/board/board-open-task/board-open-task.ts +32 -0
  260. package/ui/src/board/board-panel/board-panel.tsx +259 -0
  261. package/ui/src/board/board-settings/board-settings.tsx +226 -0
  262. package/ui/src/board/board-status/board-status.tsx +60 -0
  263. package/ui/src/board/board-stripes/board-stripes.ts +128 -0
  264. package/ui/src/board/board-table/board-table.ts +53 -0
  265. package/ui/src/board/board-table/board-table.tsx +610 -0
  266. package/ui/src/board/board-task/board-task.ts +108 -0
  267. package/ui/src/board/board-task/board-task.tsx +335 -0
  268. package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
  269. package/ui/src/branding/branding.tsx +17 -0
  270. package/ui/src/branding/custom-logo.ts +1 -0
  271. package/ui/src/branding/favicon.default.svg +5 -0
  272. package/ui/src/branding/favicon.svg +5 -0
  273. package/ui/src/components/ui/avatar.tsx +39 -0
  274. package/ui/src/components/ui/button.tsx +64 -0
  275. package/ui/src/components/ui/collapsible.tsx +20 -0
  276. package/ui/src/components/ui/command.tsx +160 -0
  277. package/ui/src/components/ui/dialog.tsx +143 -0
  278. package/ui/src/components/ui/dropdown-menu.tsx +187 -0
  279. package/ui/src/components/ui/input.tsx +21 -0
  280. package/ui/src/components/ui/popover.tsx +41 -0
  281. package/ui/src/components/ui/select.tsx +163 -0
  282. package/ui/src/components/ui/separator.tsx +26 -0
  283. package/ui/src/components/ui/sheet.tsx +136 -0
  284. package/ui/src/components/ui/sidebar.tsx +693 -0
  285. package/ui/src/components/ui/skeleton.tsx +13 -0
  286. package/ui/src/components/ui/switch.tsx +25 -0
  287. package/ui/src/components/ui/table.tsx +82 -0
  288. package/ui/src/components/ui/tabs.tsx +52 -0
  289. package/ui/src/components/ui/tooltip.tsx +51 -0
  290. package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
  291. package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
  292. package/ui/src/data/query-client.ts +18 -0
  293. package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
  294. package/ui/src/data/request-refusal/request-refusal.ts +51 -0
  295. package/ui/src/data/sign-in/sign-in.ts +97 -0
  296. package/ui/src/data/sign-in/sign-in.types.ts +15 -0
  297. package/ui/src/document-link/document-link.tsx +114 -0
  298. package/ui/src/document-markdown/document-markdown.ts +195 -0
  299. package/ui/src/editor-schema/editor-schema.ts +23 -0
  300. package/ui/src/entry-picker/entry-picker.tsx +238 -0
  301. package/ui/src/feed/feed.tsx +344 -0
  302. package/ui/src/file-preview/file-preview-view.tsx +496 -0
  303. package/ui/src/file-preview/file-preview.tsx +28 -0
  304. package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
  305. package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
  306. package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  307. package/ui/src/file-preview/word-file-preview.tsx +5 -0
  308. package/ui/src/flow-runs/flow-runs.tsx +238 -0
  309. package/ui/src/flows/flows.tsx +1646 -0
  310. package/ui/src/flows/node-icon/node-icon.ts +34 -0
  311. package/ui/src/flows/node-palette/node-palette.tsx +214 -0
  312. package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
  313. package/ui/src/folder-contents/folder-contents.tsx +321 -0
  314. package/ui/src/folder-index/folder-index.ts +55 -0
  315. package/ui/src/frontmatter/frontmatter.tsx +193 -0
  316. package/ui/src/graph-pane/graph-pane.tsx +53 -0
  317. package/ui/src/hooks/use-mobile.ts +19 -0
  318. package/ui/src/i18n/custom.json +4 -0
  319. package/ui/src/i18n/de.json +609 -0
  320. package/ui/src/i18n/en.json +609 -0
  321. package/ui/src/i18n/es.json +609 -0
  322. package/ui/src/i18n/i18n-context.tsx +65 -0
  323. package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  324. package/ui/src/i18n/i18n.ts +32 -0
  325. package/ui/src/i18n/i18n.types.ts +49 -0
  326. package/ui/src/kind-icon.ts +97 -0
  327. package/ui/src/lib/utils.ts +6 -0
  328. package/ui/src/main.tsx +68 -0
  329. package/ui/src/modal/modal.tsx +53 -0
  330. package/ui/src/node-details/node-details.tsx +545 -0
  331. package/ui/src/node-editor/node-editor.tsx +304 -0
  332. package/ui/src/node-graph/graph-notice.tsx +21 -0
  333. package/ui/src/node-graph/node-graph.ts +214 -0
  334. package/ui/src/node-graph/node-graph.tsx +295 -0
  335. package/ui/src/node-import/node-import.tsx +418 -0
  336. package/ui/src/node-save-error.ts +46 -0
  337. package/ui/src/node-source/node-source.tsx +128 -0
  338. package/ui/src/node-table/node-table.tsx +107 -0
  339. package/ui/src/nodes/nodes.tsx +240 -0
  340. package/ui/src/rename-resource/rename-resource.ts +80 -0
  341. package/ui/src/resolved-names/resolved-names.tsx +67 -0
  342. package/ui/src/resource-error.ts +33 -0
  343. package/ui/src/resource-menu/resource-menu.tsx +1156 -0
  344. package/ui/src/router/router-context.ts +6 -0
  345. package/ui/src/router/router.tsx +67 -0
  346. package/ui/src/router/router.types.ts +8 -0
  347. package/ui/src/router/selection-search.ts +88 -0
  348. package/ui/src/save-button/save-button.tsx +94 -0
  349. package/ui/src/section-hint/section-hint.tsx +40 -0
  350. package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
  351. package/ui/src/styles.css +189 -0
  352. package/ui/src/table-columns/table-columns.tsx +379 -0
  353. package/ui/src/theme/custom.css +1 -0
  354. package/ui/src/theme/theme-context.tsx +89 -0
  355. package/ui/src/theme/theme.ts +70 -0
  356. package/ui/src/time/relative-time.tsx +41 -0
  357. package/ui/src/time/time-context.tsx +78 -0
  358. package/ui/src/time/time.ts +165 -0
  359. package/ui/src/title-row/title-row.tsx +185 -0
  360. package/ui/src/tools/tools.tsx +546 -0
  361. package/ui/src/user-name/user-name.ts +61 -0
  362. package/ui/src/verbatim/verbatim.tsx +68 -0
  363. package/ui/tsconfig.json +17 -0
  364. package/ui/vite.config.ts +129 -0
@@ -0,0 +1,1687 @@
1
+ import { descendantsCte, flowInSubtree, flowInSubtreeBindings, grantColumns as grantColumnsFor, grantInForce, mapGrant, nodeVerbBindings, nodeVerbQuery, principalColumns, subtreeBindings, subtreeCte, } from "./db-grants.js";
2
+ import { promptNameHolder } from "./db-prompts.js";
3
+ const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
4
+ size, segment, created_by, created_at`;
5
+ const grantColumns = grantColumnsFor("node_id");
6
+ const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
7
+ link.origin, link.label, link.created_by, link.created_at`;
8
+ // The first row of each node, in the order they arrive. A chunked kind — one whose index holds
9
+ // one row per task (#285) — answers a query once per matching card; a citation names a node, so the
10
+ // best of them is the one that gets to speak for it.
11
+ function dedupedByNode(rows) {
12
+ const seen = new Set();
13
+ return rows.filter((row) => {
14
+ if (seen.has(row.node_id))
15
+ return false;
16
+ seen.add(row.node_id);
17
+ return true;
18
+ });
19
+ }
20
+ const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description,
21
+ n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at, n.prompt_name`;
22
+ function mapNode(row) {
23
+ return {
24
+ id: row.id,
25
+ parentId: row.parent_id,
26
+ kind: row.kind,
27
+ title: row.title,
28
+ description: row.description,
29
+ ownerId: row.owner_id,
30
+ currentVersionId: row.current_version_id,
31
+ createdAt: row.created_at,
32
+ updatedAt: row.updated_at,
33
+ archivedAt: row.archived_at,
34
+ promptName: row.prompt_name,
35
+ };
36
+ }
37
+ function mapVersion(row) {
38
+ return {
39
+ id: row.id,
40
+ nodeId: row.node_id,
41
+ sequence: row.sequence,
42
+ contentKey: row.content_key,
43
+ mediaType: row.media_type,
44
+ contentHash: row.content_hash,
45
+ size: row.size,
46
+ segment: row.segment,
47
+ createdBy: row.created_by,
48
+ createdAt: row.created_at,
49
+ };
50
+ }
51
+ function mapLink(row) {
52
+ return {
53
+ id: row.id,
54
+ sourceNodeId: row.source_node_id,
55
+ targetNodeId: row.target_node_id,
56
+ relation: row.relation,
57
+ origin: row.origin,
58
+ label: row.label,
59
+ createdBy: row.created_by,
60
+ createdAt: row.created_at,
61
+ };
62
+ }
63
+ function toFtsQuery(query) {
64
+ return query
65
+ .split(/\s+/)
66
+ .filter(Boolean)
67
+ .map((token) => `"${token.replaceAll('"', '""')}"`)
68
+ .join(" AND ");
69
+ }
70
+ export function createNodeRepository(deps) {
71
+ // Reading is one verb among four; the tree walk itself lives in db-grants.ts so Flows asks the
72
+ // same question of the same table.
73
+ const visibleCte = subtreeCte;
74
+ const readBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
75
+ /**
76
+ * What a LEVEL query binds: the walk over `nodes`, then the flow half of `has_children`, in the
77
+ * order the two appear in the statement.
78
+ *
79
+ * ⚠️ One clock reading for both halves. Two calls to `deps.now()` inside one statement would
80
+ * measure the same `expires_at` against two different moments, and a grant that expires between
81
+ * them would be honoured on one side of the OR and refused on the other.
82
+ */
83
+ const levelBindings = (actor) => {
84
+ const now = deps.now().toISOString();
85
+ return [...subtreeBindings(actor, "read", now), ...flowInSubtreeBindings(actor, "read", now)];
86
+ };
87
+ /**
88
+ * The optional folder cut a search carries (#126), in the three places one statement needs it:
89
+ * the extra CTE, the extra join, and the one binding between the actor's and the query's. All
90
+ * three are absent together when there is no scope, so an unscoped search runs the exact
91
+ * statement it ran before.
92
+ *
93
+ * ⚠️ The join is added *beside* `allowed`, never instead of it. `descendants` is seeded from a
94
+ * caller-supplied id and knows nothing about grants; on its own it would answer "everything under
95
+ * this folder" to anyone who names the folder.
96
+ */
97
+ const scopeCte = (scopeId) => scopeId === undefined ? "" : `,\n${descendantsCte}`;
98
+ const scopeJoin = (scopeId) => scopeId === undefined ? "" : "\n JOIN descendants ON descendants.id = n.id";
99
+ const scopeBindings = (scopeId) => (scopeId === undefined ? [] : [scopeId]);
100
+ /**
101
+ * Which rows belong to the level being asked for.
102
+ *
103
+ * ⚠️ At the ROOT this is not `parent_id IS NULL` but "the top of every region this actor may
104
+ * read" (#429). A grant on a nested folder has no entry point otherwise: the way to it leads
105
+ * through a folder the recipient may not see, so the level it would appear in can never be
106
+ * opened, and the share stays usable only for whoever passes the URL around by hand. It is the
107
+ * same seed `listVisibleSubtree` already starts a whole-installation export from — written once
108
+ * more rather than differently.
109
+ *
110
+ * ⚠️ It says nothing about the way there. A row carries its own id and its own title, both of
111
+ * which `getVisible` already answers to this actor; the ancestors it hangs under are never read,
112
+ * named or counted, so the title of an unreadable parent folder stays where it was.
113
+ *
114
+ * ⚠️ Both `?` take the SAME value, and the second is not redundant: SQLite binds by position, so
115
+ * asking "was the root asked for" needs its own placeholder rather than a second reading of the
116
+ * first.
117
+ */
118
+ const levelPredicate = `(
119
+ n.parent_id IS ?
120
+ OR (? IS NULL AND n.parent_id NOT IN (SELECT id FROM allowed))
121
+ )`;
122
+ /**
123
+ * One statement for the level the tree reads and for the bounded read the relation graph makes,
124
+ * so the visibility predicate cannot drift between them.
125
+ *
126
+ * ⚠️ In the bounded form `LIMIT` follows the `WHERE`, so the cut falls among the rows this actor
127
+ * may see and never before them, and `COUNT(*) OVER ()` counts those same rows alone (#30).
128
+ */
129
+ /**
130
+ * The kinds the FOLDER TREE does not show. A task belongs to its board and is reached there
131
+ * (D66, #376); the tree would otherwise flood with three hundred cards, which is the objection
132
+ * that sank the node-per-task idea the first time it was proposed.
133
+ *
134
+ * ⚠️ It is a cut in the STATEMENT, not a filter in the interface. Filtered later, the rows have
135
+ * already crossed the wire — titles of other people's tasks inside an answer that was not
136
+ * supposed to carry them.
137
+ *
138
+ * ⚠️ It says nothing about reachability. A task keeps its own address, its own grants and its own
139
+ * place in search and in the relation graph; only this one level query looks away. Hiding it
140
+ * everywhere would make a link to a task a dead end.
141
+ */
142
+ const notInTree = (alias) => `${alias}.kind <> 'task'`;
143
+ /**
144
+ * The chevron column, and it is drawn ONLY for the tree (#777). The bounded read the relation
145
+ * graph makes discards it, and since #777 it is no longer free: computing it costs a second
146
+ * correlated `EXISTS` per row and six bindings that statement has no other use for. Leaving it
147
+ * out there is not a divergence in the visibility predicate, which is what the two forms share;
148
+ * it is one column the caller never reads.
149
+ */
150
+ const chevronColumn = `
151
+ -- Whether this row has children THIS actor may see (#59), asked of the same allowed set one
152
+ -- level down. A second rule written here would drift from the one above it, and the drift
153
+ -- would show as a chevron that opens onto nothing.
154
+ --
155
+ -- ⚠️ The same cut here as below, and it is the SAME rule read twice rather
156
+ -- than a second one: a board's children are all tasks, so with them hidden it has none this
157
+ -- actor can see, and the chevron falls away by itself (D66, #376). Leaving it out here would
158
+ -- draw a chevron that opens onto an empty level — exactly the drift the comment above warns
159
+ -- about, arriving through the door it was written for.
160
+ --
161
+ -- ⚠️ And the SECOND half of the level, because the tree is shared: a folder holds nodes and
162
+ -- flows side by side (ADR-0004 §1), and a flow is not a node. Asking only the nodes table
163
+ -- left a folder whose only children are flows answering "empty": no chevron, no way down,
164
+ -- while clicking the folder listed both kinds perfectly well (#777). It is the same root as
165
+ -- #457, where a count over nodes alone held such a folder for empty on the delete path; that
166
+ -- one was fixed, this one was not.
167
+ --
168
+ -- ⚠️ flowInSubtree rather than a hand-written condition, for the reason stated above it: it
169
+ -- is the very predicate db-flows.ts draws the level with, so the chevron and the level it
170
+ -- opens onto cannot answer differently. Its folder clause is already satisfied here (the
171
+ -- flow's parent is n, and n is joined to allowed), so today it can only widen the answer,
172
+ -- never narrow it, which is the only direction a chevron may drift in at all.
173
+ (EXISTS (
174
+ SELECT 1 FROM nodes child
175
+ JOIN allowed AS allowed_child ON allowed_child.id = child.id
176
+ WHERE child.parent_id = n.id AND child.archived_at IS NULL AND ${notInTree("child")}
177
+ ) OR EXISTS (
178
+ SELECT 1 FROM flows flow
179
+ WHERE flow.parent_id = n.id AND flow.archived_at IS NULL AND ${flowInSubtree}
180
+ )) AS has_children`;
181
+ const visibleChildren = (bounded) => `${visibleCte}
182
+ SELECT ${nodeColumns}${bounded ? ", COUNT(*) OVER () AS total" : `,${chevronColumn}`}
183
+ FROM nodes n
184
+ JOIN allowed ON allowed.id = n.id
185
+ WHERE ${levelPredicate} AND (? = 1 OR n.archived_at IS NULL) AND ${notInTree("n")}
186
+ ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, lower(n.title), n.id${bounded ? " LIMIT ?" : ""}`;
187
+ /**
188
+ * Everything archived that this actor may see, wherever it sits (#113).
189
+ *
190
+ * ⚠️ No `parent_id` at all, and that is the point rather than an omission: somebody looking for
191
+ * what they archived does not know which folder it was in. The visibility predicate is the same
192
+ * `allowed` set every other read uses, so the archive shows no more than the tree would have.
193
+ *
194
+ * `has_children` is answered as 0 without asking: an archived row is not expandable — its
195
+ * children are reached by restoring it, not by opening it here.
196
+ */
197
+ const archivedEverywhere = `${visibleCte}
198
+ SELECT ${nodeColumns}, 0 AS has_children
199
+ FROM nodes n
200
+ JOIN allowed ON allowed.id = n.id
201
+ WHERE n.archived_at IS NOT NULL
202
+ ORDER BY n.archived_at DESC, lower(n.title), n.id`;
203
+ return {
204
+ async listVisible(actor, input) {
205
+ const result = await deps.db
206
+ .prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
207
+ .bind(
208
+ // ⚠️ The archive statement answers `has_children` as a literal 0, so it carries no flow
209
+ // bindings; the level statement carries both halves. Same branch, two bind lists.
210
+ ...(input.archivedOnly
211
+ ? readBindings(actor)
212
+ : [
213
+ ...levelBindings(actor),
214
+ input.parentId,
215
+ input.parentId,
216
+ input.includeArchived ? 1 : 0,
217
+ ]))
218
+ .all();
219
+ const rows = result.results ?? [];
220
+ return {
221
+ items: rows.map(mapNode),
222
+ withChildren: rows.filter((row) => row.has_children === 1).map((row) => row.id),
223
+ };
224
+ },
225
+ async listVisibleBounded(actor, input) {
226
+ const result = await deps.db
227
+ .prepare(visibleChildren(true))
228
+ // ⚠️ `readBindings`, not `levelBindings`: this form carries no chevron column, so the
229
+ // flow half's six bindings have no placeholder to land in. D1 answers a miscount with
230
+ // `Wrong number of parameter bindings`, so the two cannot silently go out of step.
231
+ .bind(...readBindings(actor), input.parentId, input.parentId, 0, input.limit)
232
+ .all();
233
+ const rows = result.results ?? [];
234
+ return { items: rows.map(mapNode), total: rows[0]?.total ?? 0 };
235
+ },
236
+ async getVisible(actor, nodeId) {
237
+ const row = await deps.db
238
+ .prepare(`${visibleCte}
239
+ SELECT ${nodeColumns}
240
+ FROM nodes n
241
+ JOIN allowed ON allowed.id = n.id
242
+ WHERE n.id = ?`)
243
+ .bind(...readBindings(actor), nodeId)
244
+ .first();
245
+ return row ? mapNode(row) : null;
246
+ },
247
+ /**
248
+ * The readable subtree as one statement (#136): the `allowed` walk says what the actor may
249
+ * read, and `walk` says what hangs together under the chosen root. Both joins apply at every
250
+ * step, so the walk neither descends into an archived node nor through an unreadable one — a
251
+ * child the actor may read under a folder they may not stays out, because the bundle would have
252
+ * no path to file it under.
253
+ *
254
+ * ⚠️ `UNION`, never `UNION ALL`, for the reason `nodeVerbQuery` gives: a cycle in `parent_id`
255
+ * can reach the table and this walks downwards into it.
256
+ */
257
+ async listVisibleSubtree(actor, rootId) {
258
+ const seed = rootId === null
259
+ ? // "Everything this actor may read": the top of every readable region, not only the top
260
+ // of the tree. A reader who holds a grant on one nested folder has that folder as the
261
+ // top of what they see, and their whole-installation export starts there.
262
+ "(seed.parent_id IS NULL OR seed.parent_id NOT IN (SELECT id FROM allowed))"
263
+ : // The root node itself is the seed; whether it is a folder is the service's question.
264
+ "seed.id = ?";
265
+ const result = await deps.db
266
+ .prepare(`${visibleCte},
267
+ walk(id) AS (
268
+ SELECT seed.id FROM nodes seed
269
+ JOIN allowed ON allowed.id = seed.id
270
+ WHERE ${seed} AND seed.archived_at IS NULL
271
+ UNION
272
+ SELECT child.id FROM nodes child
273
+ JOIN walk ON child.parent_id = walk.id
274
+ JOIN allowed AS allowed_child ON allowed_child.id = child.id
275
+ WHERE child.archived_at IS NULL
276
+ )
277
+ SELECT ${nodeColumns},
278
+ version.id AS version_id, version.media_type, version.content_key
279
+ FROM nodes n
280
+ JOIN walk ON walk.id = n.id
281
+ LEFT JOIN node_versions version ON version.id = n.current_version_id`)
282
+ .bind(...readBindings(actor), ...(rootId === null ? [] : [rootId]))
283
+ .all();
284
+ return (result.results ?? []).map((row) => ({
285
+ node: mapNode(row),
286
+ version: row.version_id === null || row.media_type === null || row.content_key === null
287
+ ? null
288
+ : { id: row.version_id, mediaType: row.media_type, contentKey: row.content_key },
289
+ }));
290
+ },
291
+ /**
292
+ * One bundle import as one transaction (#137). Every table takes its rows from a single JSON
293
+ * parameter through `json_each`, so the statement count stays constant however large the
294
+ * bundle is — the same reason `idList` exists in db-flows — and `db.batch` makes the whole
295
+ * import land or refuse as one.
296
+ *
297
+ * ⚠️ `nodes` arrive parents before children: `parent_id REFERENCES nodes(id)` is checked as
298
+ * each row lands, and a child before its parent would abort the batch that is otherwise valid.
299
+ * The service owns that order; this only writes what it is handed.
300
+ */
301
+ async importTree(input) {
302
+ const statements = [
303
+ deps.db
304
+ .prepare(`INSERT INTO nodes (
305
+ id, parent_id, kind, title, description, owner_id,
306
+ current_version_id, created_at, updated_at, archived_at
307
+ ) SELECT
308
+ json_extract(value, '$.id'), json_extract(value, '$.parentId'),
309
+ json_extract(value, '$.kind'), json_extract(value, '$.title'),
310
+ json_extract(value, '$.description'),
311
+ json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
312
+ json_extract(value, '$.createdAt'), json_extract(value, '$.updatedAt'), NULL
313
+ FROM json_each(?)`)
314
+ .bind(JSON.stringify(input.nodes)),
315
+ deps.db
316
+ .prepare(`INSERT INTO node_versions (
317
+ id, node_id, sequence, content_key, media_type, content_hash, size, segment,
318
+ created_by, created_at
319
+ ) SELECT
320
+ json_extract(value, '$.id'), json_extract(value, '$.nodeId'),
321
+ json_extract(value, '$.sequence'), json_extract(value, '$.contentKey'),
322
+ json_extract(value, '$.mediaType'), json_extract(value, '$.contentHash'),
323
+ json_extract(value, '$.size'), json_extract(value, '$.segment'),
324
+ json_extract(value, '$.createdBy'), json_extract(value, '$.createdAt')
325
+ FROM json_each(?)`)
326
+ .bind(JSON.stringify(input.versions)),
327
+ deps.db
328
+ .prepare(`INSERT INTO node_links (
329
+ id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
330
+ ) SELECT
331
+ json_extract(value, '$.id'), json_extract(value, '$.sourceNodeId'),
332
+ json_extract(value, '$.targetNodeId'), 'references', 'text', NULL, ?, ?
333
+ FROM json_each(?)`)
334
+ .bind(input.actorId, input.occurredAt, JSON.stringify(input.links)),
335
+ deps.db
336
+ .prepare(`INSERT INTO flows (
337
+ id, parent_id, title, description, owner_id, current_version_id,
338
+ published_version_id, created_at, updated_at, archived_at
339
+ ) SELECT
340
+ json_extract(value, '$.id'), json_extract(value, '$.parentId'),
341
+ json_extract(value, '$.title'), json_extract(value, '$.description'),
342
+ json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
343
+ json_extract(value, '$.publishedVersionId'), json_extract(value, '$.createdAt'),
344
+ json_extract(value, '$.updatedAt'), NULL
345
+ FROM json_each(?)`)
346
+ .bind(JSON.stringify(input.flows)),
347
+ deps.db
348
+ .prepare(`INSERT INTO flow_versions (
349
+ id, flow_id, sequence, graph_json, created_by, created_at
350
+ ) SELECT
351
+ json_extract(value, '$.id'), json_extract(value, '$.flowId'),
352
+ -- json_extract of an object answers with its JSON text, which is exactly what the
353
+ -- graph_json column stores.
354
+ json_extract(value, '$.sequence'), json_extract(value, '$.graph'),
355
+ json_extract(value, '$.createdBy'), json_extract(value, '$.createdAt')
356
+ FROM json_each(?)`)
357
+ .bind(JSON.stringify(input.flowVersions)),
358
+ // ⚠️ The idempotency row stores the AUDIT id, unlike its siblings that store the resource:
359
+ // one import creates many resources, and the collective audit event is where the summary a
360
+ // replay answers with is kept.
361
+ deps.db
362
+ .prepare(`INSERT INTO idempotency_keys (
363
+ actor_id, operation, idempotency_key, resource_id, created_at
364
+ ) VALUES (?, 'node.import', ?, ?, ?)`)
365
+ .bind(input.actorId, input.idempotencyKey, input.auditId, input.occurredAt),
366
+ deps.db
367
+ .prepare(`INSERT INTO audit_events (
368
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
369
+ ) VALUES (?, ?, 'node.import', 'node', ?, ?, ?)`)
370
+ .bind(input.auditId, input.actorId, input.auditResourceId, JSON.stringify(input.metadata), input.occurredAt),
371
+ ];
372
+ try {
373
+ await deps.db.batch(statements);
374
+ return "created";
375
+ }
376
+ catch (error) {
377
+ const replayed = await this.findImportReplay(input.actorId, input.idempotencyKey);
378
+ if (replayed !== null)
379
+ return "replayed";
380
+ throw error;
381
+ }
382
+ },
383
+ /**
384
+ * What an earlier write with this key recorded about itself (#285).
385
+ *
386
+ * ⚠️ The idempotency row points at the VERSION, and the audit event points at the node, so the
387
+ * two are joined through the version id the snapshot writer puts into the metadata. That
388
+ * metadata is where a snapshot says which positions it touched and how many it removed —
389
+ * facts a replay cannot recompute from the stored document, because the document is the state
390
+ * AFTER the write. Without this, repeating a delete would answer with a count nobody measured.
391
+ */
392
+ async findSnapshotMetadata(actorId, operation, idempotencyKey) {
393
+ const row = await deps.db
394
+ .prepare(`SELECT audit.metadata_json AS metadata_json
395
+ FROM idempotency_keys keys
396
+ JOIN audit_events audit
397
+ ON audit.action = keys.operation
398
+ AND json_extract(audit.metadata_json, '$.versionId') = keys.resource_id
399
+ WHERE keys.actor_id = ? AND keys.operation = ? AND keys.idempotency_key = ?`)
400
+ .bind(actorId, operation, idempotencyKey)
401
+ .first();
402
+ if (!row)
403
+ return null;
404
+ const parsed = JSON.parse(row.metadata_json);
405
+ return typeof parsed === "object" && parsed !== null
406
+ ? parsed
407
+ : null;
408
+ },
409
+ async findImportReplay(actorId, idempotencyKey) {
410
+ const row = await deps.db
411
+ .prepare(`SELECT audit.metadata_json AS metadata_json
412
+ FROM idempotency_keys keys
413
+ JOIN audit_events audit ON audit.id = keys.resource_id
414
+ WHERE keys.actor_id = ? AND keys.operation = 'node.import'
415
+ AND keys.idempotency_key = ?`)
416
+ .bind(actorId, idempotencyKey)
417
+ .first();
418
+ if (!row)
419
+ return null;
420
+ const parsed = JSON.parse(row.metadata_json);
421
+ return typeof parsed === "object" && parsed !== null
422
+ ? parsed
423
+ : null;
424
+ },
425
+ async can(actor, nodeId, verb) {
426
+ const row = await deps.db
427
+ .prepare(nodeVerbQuery)
428
+ .bind(...nodeVerbBindings(nodeId, actor, verb, deps.now().toISOString()))
429
+ .first();
430
+ return row?.allowed === 1;
431
+ },
432
+ async findIdempotentNode(actorId, operation, idempotencyKey) {
433
+ const row = await deps.db
434
+ .prepare(`SELECT resource_id FROM idempotency_keys
435
+ WHERE actor_id = ? AND operation = ? AND idempotency_key = ?`)
436
+ .bind(actorId, operation, idempotencyKey)
437
+ .first();
438
+ return row?.resource_id ?? null;
439
+ },
440
+ async findIdempotentRevocation(actorId, idempotencyKey) {
441
+ const row = await deps.db
442
+ .prepare(`SELECT resource_id FROM idempotency_keys
443
+ WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
444
+ .bind(actorId, idempotencyKey)
445
+ .first();
446
+ if (!row)
447
+ return null;
448
+ return row.resource_id.startsWith("1:");
449
+ },
450
+ async insertNode(input) {
451
+ const node = input.node;
452
+ try {
453
+ await deps.db.batch([
454
+ deps.db
455
+ .prepare(`INSERT INTO nodes (
456
+ id, parent_id, kind, title, description, owner_id,
457
+ current_version_id, created_at, updated_at, archived_at
458
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
459
+ .bind(node.id, node.parentId, node.kind, node.title, node.description, node.ownerId, node.currentVersionId, node.createdAt, node.updatedAt, node.archivedAt),
460
+ deps.db
461
+ .prepare(`INSERT INTO idempotency_keys (
462
+ actor_id, operation, idempotency_key, resource_id, created_at
463
+ ) VALUES (?, 'node.create', ?, ?, ?)`)
464
+ .bind(input.actorId, input.idempotencyKey, node.id, node.createdAt),
465
+ deps.db
466
+ .prepare(`INSERT INTO audit_events (
467
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
468
+ ) VALUES (?, ?, 'node.create', 'node', ?, ?, ?)`)
469
+ .bind(input.auditId, input.actorId, node.id, JSON.stringify({ kind: node.kind, parentId: node.parentId }), node.createdAt),
470
+ ]);
471
+ }
472
+ catch (error) {
473
+ const replayedKey = await deps.db
474
+ .prepare(`SELECT resource_id FROM idempotency_keys
475
+ WHERE actor_id = ? AND operation = 'node.create' AND idempotency_key = ?`)
476
+ .bind(input.actorId, input.idempotencyKey)
477
+ .first();
478
+ if (replayedKey) {
479
+ const replayed = await deps.db
480
+ .prepare(`${visibleCte}
481
+ SELECT ${nodeColumns}
482
+ FROM nodes n
483
+ JOIN allowed ON allowed.id = n.id
484
+ WHERE n.id = ?`)
485
+ .bind(...readBindings({ id: input.actorId, email: "" }), replayedKey.resource_id)
486
+ .first();
487
+ if (replayed)
488
+ return mapNode(replayed);
489
+ }
490
+ throw error;
491
+ }
492
+ return node;
493
+ },
494
+ async getVersion(versionId) {
495
+ const row = await deps.db
496
+ .prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
497
+ .bind(versionId)
498
+ .first();
499
+ return row ? mapVersion(row) : null;
500
+ },
501
+ // From the newest snapshot on (#135). `COALESCE(..., 0)` keeps a table from before snapshots
502
+ // readable in full: without any snapshot row every segment still counts.
503
+ async listVersionContentKeys(nodeId) {
504
+ const result = await deps.db
505
+ .prepare(`SELECT content_key FROM node_versions
506
+ WHERE node_id = ? AND sequence >= COALESCE((
507
+ SELECT MAX(sequence) FROM node_versions
508
+ WHERE node_id = ? AND segment = 'snapshot'
509
+ ), 0)
510
+ ORDER BY sequence`)
511
+ .bind(nodeId, nodeId)
512
+ .all();
513
+ return (result.results ?? []).map((row) => row.content_key);
514
+ },
515
+ async tableHeaderContentKey(nodeId) {
516
+ const row = await deps.db
517
+ .prepare(`SELECT content_key FROM node_versions
518
+ WHERE node_id = ? AND segment = 'snapshot'
519
+ ORDER BY sequence DESC LIMIT 1`)
520
+ .bind(nodeId)
521
+ .first();
522
+ return row?.content_key ?? null;
523
+ },
524
+ async listVersions(nodeId) {
525
+ const result = await deps.db
526
+ .prepare(`SELECT ${versionColumns}
527
+ FROM node_versions
528
+ WHERE node_id = ?
529
+ ORDER BY sequence DESC`)
530
+ .bind(nodeId)
531
+ .all();
532
+ return (result.results ?? []).map(mapVersion);
533
+ },
534
+ /**
535
+ * ⚠️ **The prompt name is the one field here whose write can fail for a reason that is not a
536
+ * conflict, and its guard sits ON the statement rather than in front of it** (#775). A
537
+ * `SELECT` beforehand decides on a state that may be gone by the time the `UPDATE` lands; the
538
+ * condition below is evaluated inside the same transaction as the write it guards.
539
+ *
540
+ * ⚠️ **And it is on every statement of the batch, not only the first** — `destructive.md`,
541
+ * `anchrd/intel#457`. A statement matching zero rows is not an error and the batch commits
542
+ * anyway, so the idempotency row and the audit row carry the same `prompt_name IS ?` the
543
+ * `UPDATE` carries. Without it a refused rename would still be booked as done, and the replay
544
+ * path would hand the caller back a node it never wrote.
545
+ *
546
+ * ⚠️ `other.id <> ?` is what lets a node keep its OWN name. Writing the name it already holds
547
+ * is the everyday case — every `node_update` that changes a title sends the current prompt
548
+ * name along — and without the exclusion the node would be refused for colliding with itself.
549
+ */
550
+ async updateNode(input) {
551
+ const node = input.node;
552
+ try {
553
+ await deps.db.batch([
554
+ deps.db
555
+ .prepare(`WITH RECURSIVE descendants(id) AS (
556
+ SELECT id FROM nodes WHERE id = ?
557
+ UNION ALL
558
+ SELECT child.id
559
+ FROM nodes child
560
+ JOIN descendants parent ON child.parent_id = parent.id
561
+ )
562
+ UPDATE nodes
563
+ SET parent_id = ?, title = ?, description = ?, prompt_name = ?, updated_at = ?
564
+ WHERE id = ? AND updated_at = ?
565
+ AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))
566
+ AND (
567
+ ? IS NULL
568
+ OR (
569
+ NOT EXISTS (
570
+ SELECT 1 FROM nodes other WHERE other.prompt_name = ? AND other.id <> ?
571
+ )
572
+ AND NOT EXISTS (SELECT 1 FROM flows holder WHERE holder.prompt_name = ?)
573
+ )
574
+ )`)
575
+ .bind(node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId, node.promptName, node.promptName, node.id, node.promptName),
576
+ deps.db
577
+ .prepare(`INSERT INTO idempotency_keys (
578
+ actor_id, operation, idempotency_key, resource_id, created_at
579
+ ) SELECT ?, 'node.update', ?, ?, ?
580
+ WHERE EXISTS (
581
+ SELECT 1 FROM nodes
582
+ WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
583
+ AND prompt_name IS ? AND updated_at = ?
584
+ )`)
585
+ .bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt),
586
+ deps.db
587
+ .prepare(`INSERT INTO audit_events (
588
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
589
+ ) SELECT ?, ?, 'node.update', 'node', ?, ?, ?
590
+ WHERE EXISTS (
591
+ SELECT 1 FROM idempotency_keys
592
+ WHERE actor_id = ? AND operation = 'node.update'
593
+ AND idempotency_key = ? AND resource_id = ?
594
+ )`)
595
+ .bind(input.auditId, input.actorId, node.id, JSON.stringify({
596
+ parentId: node.parentId,
597
+ title: node.title,
598
+ }), node.updatedAt, input.actorId, input.idempotencyKey, node.id),
599
+ ]);
600
+ }
601
+ catch (error) {
602
+ const replayed = await deps.db
603
+ .prepare(`SELECT resource_id FROM idempotency_keys
604
+ WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
605
+ .bind(input.actorId, input.idempotencyKey)
606
+ .first();
607
+ if (!replayed)
608
+ throw error;
609
+ }
610
+ const replayed = await deps.db
611
+ .prepare(`SELECT resource_id FROM idempotency_keys
612
+ WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
613
+ .bind(input.actorId, input.idempotencyKey)
614
+ .first();
615
+ if (replayed) {
616
+ const row = await deps.db
617
+ .prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
618
+ .bind(replayed.resource_id)
619
+ .first();
620
+ if (row)
621
+ return mapNode(row);
622
+ }
623
+ if (node.parentId !== null) {
624
+ const cycle = await deps.db
625
+ .prepare(`WITH RECURSIVE descendants(id) AS (
626
+ SELECT id FROM nodes WHERE id = ?
627
+ UNION ALL
628
+ SELECT child.id
629
+ FROM nodes child
630
+ JOIN descendants parent ON child.parent_id = parent.id
631
+ )
632
+ SELECT 1 AS found FROM descendants WHERE id = ? LIMIT 1`)
633
+ .bind(node.id, node.parentId)
634
+ .first();
635
+ if (cycle?.found === 1)
636
+ return "cycle";
637
+ }
638
+ /**
639
+ * ⚠️ **Which of the two refusals it was, asked only once the write has already failed.** The
640
+ * condition on the statement is what makes the guard hold; this read is what makes the answer
641
+ * sayable. Doing it the other way round — deciding here and writing unguarded — is the shape
642
+ * that loses the race it was written for.
643
+ *
644
+ * ⚠️ It is asked of BOTH tables, because the name is one catalogue across the two (#775). A
645
+ * check against `nodes` alone answers "free" for a name a published flow holds, and the write
646
+ * would then keep failing with a message that says the node was changed by another editor.
647
+ */
648
+ if (node.promptName !== null) {
649
+ const taken = await deps.db
650
+ .prepare(`SELECT 1 AS found FROM nodes WHERE prompt_name = ? AND id <> ?
651
+ UNION ALL
652
+ SELECT 1 AS found FROM flows WHERE prompt_name = ?
653
+ LIMIT 1`)
654
+ .bind(node.promptName, node.id, node.promptName)
655
+ .first();
656
+ if (taken?.found === 1)
657
+ return "prompt_name_taken";
658
+ }
659
+ return "conflict";
660
+ },
661
+ async promptNameHolder(actor, name) {
662
+ // One question, one implementation, shared with the flow repository — see `db-prompts.ts`.
663
+ return await promptNameHolder(deps, actor, name);
664
+ },
665
+ async archiveNode(input) {
666
+ const archiving = input.archivedAt !== null;
667
+ /**
668
+ * ⚠️ **The operation a node falls with, read BEFORE anything moves** (D71, #733).
669
+ *
670
+ * Archiving takes the whole subtree, so a restore needs to know which nodes fell TOGETHER —
671
+ * anything already archived before keeps its own older operation and stays archived. On the
672
+ * way in that is the new id; on the way back it is the one the root already carries, and it
673
+ * has to be read here because the first statement below clears it.
674
+ *
675
+ * ⚠️ `NULL` is not a group. A node archived before this migration carries `NULL`, and
676
+ * restoring it must bring back that node alone — matching on `NULL` would revive every node
677
+ * ever archived before the migration ran.
678
+ */
679
+ const group = archiving
680
+ ? input.operationId
681
+ : ((await deps.db
682
+ .prepare("SELECT archived_with FROM nodes WHERE id = ?")
683
+ .bind(input.nodeId)
684
+ .first())?.archived_with ?? null);
685
+ const subtree = `WITH RECURSIVE tree(id) AS (
686
+ SELECT id FROM nodes WHERE id = ?
687
+ UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
688
+ )`;
689
+ /**
690
+ * ⚠️ **Every cascading statement carries the same guard, not just the first** (#457). A
691
+ * `D1.batch` is one transaction, but a statement matching zero rows is not an error — so
692
+ * without `EXISTS` on the root the children would fall while the root stayed put, taken out
693
+ * by the `updated_at` guard on statement one, and the caller would be told about a conflict
694
+ * that had already half happened.
695
+ */
696
+ const rootMoved = archiving
697
+ ? "EXISTS (SELECT 1 FROM nodes WHERE id = ? AND archived_at = ? AND archived_with = ?)"
698
+ : "EXISTS (SELECT 1 FROM nodes WHERE id = ? AND archived_at IS NULL)";
699
+ const cascade = archiving
700
+ ? [
701
+ // The subtree, minus what was already archived: those keep their own operation.
702
+ deps.db
703
+ .prepare(`${subtree}
704
+ UPDATE nodes SET archived_at = ?, archived_with = ?, updated_at = ?
705
+ WHERE id IN (SELECT id FROM tree) AND id != ? AND archived_at IS NULL
706
+ AND ${rootMoved}`)
707
+ .bind(input.nodeId, input.archivedAt, input.operationId, input.updatedAt, input.nodeId, input.nodeId, input.archivedAt, input.operationId),
708
+ // Flows hang in the same tree by `parent_id` and carry their own `archived_at`.
709
+ deps.db
710
+ .prepare(`${subtree}
711
+ UPDATE flows SET archived_at = ?, archived_with = ?, updated_at = ?
712
+ WHERE parent_id IN (SELECT id FROM tree) AND archived_at IS NULL
713
+ AND ${rootMoved}`)
714
+ .bind(input.nodeId, input.archivedAt, input.operationId, input.updatedAt, input.nodeId, input.archivedAt, input.operationId),
715
+ ]
716
+ : group === null
717
+ ? []
718
+ : [
719
+ /**
720
+ * ⚠️ **The group AND the subtree, not the group alone** (found in review of #738).
721
+ * Matching on the operation by itself made restoring a leaf bring back its former
722
+ * parent and every sibling that fell with it: one card pulled out of a board's
723
+ * archive column put the whole card tree back. The walk starts at the node being
724
+ * restored, so a leaf restores itself and a folder restores what hangs under it.
725
+ */
726
+ deps.db
727
+ .prepare(`${subtree}
728
+ UPDATE nodes SET archived_at = NULL, archived_with = NULL, updated_at = ?
729
+ WHERE id IN (SELECT id FROM tree) AND id != ? AND archived_with = ?
730
+ AND ${rootMoved}`)
731
+ .bind(input.nodeId, input.updatedAt, input.nodeId, group, input.nodeId),
732
+ deps.db
733
+ .prepare(`${subtree}
734
+ UPDATE flows SET archived_at = NULL, archived_with = NULL, updated_at = ?
735
+ WHERE parent_id IN (SELECT id FROM tree) AND archived_with = ?
736
+ AND ${rootMoved}`)
737
+ .bind(input.nodeId, input.updatedAt, group, input.nodeId),
738
+ ];
739
+ // How many rows this operation moves besides the root, read before it moves them: the journal
740
+ // line is written inside the same batch and cannot count what it is doing.
741
+ const cascadeSize = archiving
742
+ ? ((await deps.db
743
+ .prepare(`WITH RECURSIVE tree(id) AS (
744
+ SELECT id FROM nodes WHERE id = ?
745
+ UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
746
+ )
747
+ SELECT (SELECT COUNT(*) FROM nodes WHERE id IN (SELECT id FROM tree) AND id != ?
748
+ AND archived_at IS NULL)
749
+ + (SELECT COUNT(*) FROM flows WHERE parent_id IN (SELECT id FROM tree)
750
+ AND archived_at IS NULL) AS total`)
751
+ .bind(input.nodeId, input.nodeId)
752
+ .first())?.total ?? 0)
753
+ : group === null
754
+ ? 0
755
+ : ((await deps.db
756
+ .prepare(`WITH RECURSIVE tree(id) AS (
757
+ SELECT id FROM nodes WHERE id = ?
758
+ UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
759
+ )
760
+ SELECT (SELECT COUNT(*) FROM nodes WHERE id IN (SELECT id FROM tree) AND id != ?
761
+ AND archived_with = ?)
762
+ + (SELECT COUNT(*) FROM flows WHERE parent_id IN (SELECT id FROM tree)
763
+ AND archived_with = ?) AS total`)
764
+ .bind(input.nodeId, input.nodeId, group, group)
765
+ .first())?.total ?? 0);
766
+ try {
767
+ await deps.db.batch([
768
+ deps.db
769
+ .prepare(`UPDATE nodes
770
+ SET archived_at = ?, archived_with = ?, updated_at = ?
771
+ WHERE id = ? AND updated_at = ?`)
772
+ .bind(input.archivedAt, archiving ? input.operationId : null, input.updatedAt, input.nodeId, input.baseUpdatedAt),
773
+ ...cascade,
774
+ deps.db
775
+ .prepare(`INSERT INTO idempotency_keys (
776
+ actor_id, operation, idempotency_key, resource_id, created_at
777
+ ) SELECT ?, 'node.archive', ?, ?, ?
778
+ WHERE EXISTS (
779
+ SELECT 1 FROM nodes
780
+ WHERE id = ? AND archived_at IS ? AND updated_at = ?
781
+ )`)
782
+ .bind(input.actorId, input.idempotencyKey, input.nodeId, input.updatedAt, input.nodeId, input.archivedAt, input.updatedAt),
783
+ deps.db
784
+ .prepare(`INSERT INTO audit_events (
785
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
786
+ ) SELECT ?, ?, 'node.archive', 'node', ?, ?, ?
787
+ WHERE EXISTS (
788
+ SELECT 1 FROM idempotency_keys
789
+ WHERE actor_id = ? AND operation = 'node.archive'
790
+ AND idempotency_key = ? AND resource_id = ?
791
+ )`)
792
+ .bind(input.auditId, input.actorId, input.nodeId,
793
+ /**
794
+ * ⚠️ **The operation and the SIZE of what moved** (D71, #733). One `node.archive` now
795
+ * moves a whole subtree, and the change journal (#747) reads this table: without the
796
+ * count, archiving a folder makes N documents disappear from every list behind a
797
+ * single line that names only the folder.
798
+ *
799
+ * ⚠️ What this row does NOT carry is WHICH nodes fell. After a restore the column is
800
+ * cleared, so that list is gone; if it is ever needed, it needs its own row per node
801
+ * and that is a decision, not a detail.
802
+ */
803
+ JSON.stringify({
804
+ archived: input.archivedAt !== null,
805
+ operation: group,
806
+ cascaded: cascadeSize,
807
+ }), input.updatedAt, input.actorId, input.idempotencyKey, input.nodeId),
808
+ ]);
809
+ }
810
+ catch (error) {
811
+ const replayed = await deps.db
812
+ .prepare(`SELECT resource_id FROM idempotency_keys
813
+ WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
814
+ .bind(input.actorId, input.idempotencyKey)
815
+ .first();
816
+ if (!replayed)
817
+ throw error;
818
+ }
819
+ const replayed = await deps.db
820
+ .prepare(`SELECT resource_id FROM idempotency_keys
821
+ WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
822
+ .bind(input.actorId, input.idempotencyKey)
823
+ .first();
824
+ if (!replayed)
825
+ return "conflict";
826
+ const row = await deps.db
827
+ .prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
828
+ .bind(replayed.resource_id)
829
+ .first();
830
+ return row ? mapNode(row) : "conflict";
831
+ },
832
+ async parentArchivedAt(nodeId) {
833
+ // ⚠️ **No visibility predicate, and that is the whole point of this query** (found in review
834
+ // of #738). A grant can sit on a single node (ADR-0004 §2), so whoever may restore a child may
835
+ // well not see the folder above it. Asked through `getVisible`, that folder answers `null`,
836
+ // the guard reads "not archived", and a live node ends up under an archived one — where the
837
+ // purge takes it unnamed. It is the same lesson `countLiveChildren` carried before D71
838
+ // replaced it, and the same mistake was made again in its place.
839
+ //
840
+ // It leaks one timestamp about a node the caller cannot open. The alternative leaks the node
841
+ // itself, permanently.
842
+ const row = await deps.db
843
+ .prepare("SELECT archived_at, kind FROM nodes WHERE id = ?")
844
+ .bind(nodeId)
845
+ .first();
846
+ return row ? { archivedAt: row.archived_at, kind: row.kind } : null;
847
+ },
848
+ async versionIdsInSubtree(nodeId) {
849
+ // ⚠️ No visibility predicate and no archived filter, for the reason `hasAnyChild` gives: this
850
+ // answers what the INDEX has to be told about, not what somebody may look at. A node whose
851
+ // vectors stay behind pays for itself out of everybody else's search results (#348).
852
+ const rows = await deps.db
853
+ .prepare(`WITH RECURSIVE tree(id) AS (
854
+ SELECT id FROM nodes WHERE id = ?
855
+ UNION SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
856
+ )
857
+ SELECT n.current_version_id AS version_id FROM nodes n
858
+ JOIN tree ON tree.id = n.id
859
+ WHERE n.current_version_id IS NOT NULL`)
860
+ .bind(nodeId)
861
+ .all();
862
+ return (rows.results ?? []).map((row) => row.version_id);
863
+ },
864
+ async hasAnyChild(nodeId) {
865
+ // ⚠️ Without a visibility predicate and without an archived filter, both on purpose: this
866
+ // answers whether the row can be deleted at all, not what somebody may look at (#457).
867
+ //
868
+ // ⚠️ BOTH tables, and that is not defensive breadth: knowledge and flows share ONE tree
869
+ // (ADR-0004), `flows.parent_id` references `nodes(id)` since migration 0002, and a folder whose
870
+ // only content is a flow answered "empty" while it was not. Purging it left the flow with a
871
+ // parent that no longer exists — invisible in the tree, unreachable, unmovable.
872
+ const row = await deps.db
873
+ .prepare(`SELECT 1 AS found FROM nodes WHERE parent_id = ?
874
+ UNION ALL
875
+ SELECT 1 AS found FROM flows WHERE parent_id = ?
876
+ LIMIT 1`)
877
+ .bind(nodeId, nodeId)
878
+ .first();
879
+ return row !== null;
880
+ },
881
+ // The names this node's vectors carry in the external index. They are read BEFORE the row falls,
882
+ // because `node_vectors` is the only thing that knows them — and once it is gone, an orphan in
883
+ // Vectorize can never be named again (#457).
884
+ async listVectorKeys(nodeId) {
885
+ const rows = await deps.db
886
+ .prepare("SELECT chunk_key FROM node_vectors WHERE node_id = ?")
887
+ .bind(nodeId)
888
+ .all();
889
+ return (rows.results ?? []).map((row) => row.chunk_key);
890
+ },
891
+ async countInboundLinks(nodeId) {
892
+ const row = await deps.db
893
+ .prepare(`WITH RECURSIVE tree(id) AS (
894
+ SELECT id FROM nodes WHERE id = ?
895
+ UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
896
+ ) SELECT COUNT(*) AS n FROM node_links
897
+ WHERE target_node_id IN (SELECT id FROM tree)
898
+ AND source_node_id NOT IN (SELECT id FROM tree)`)
899
+ .bind(nodeId)
900
+ .first();
901
+ return row?.n ?? 0;
902
+ },
903
+ async inspectPurgeTree(nodeId) {
904
+ const tree = `WITH RECURSIVE tree(id) AS (
905
+ SELECT id FROM nodes WHERE id = ?
906
+ UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
907
+ )`;
908
+ const nodes = await deps.db
909
+ .prepare(`${tree} SELECT n.id, n.kind, n.title FROM nodes n JOIN tree ON tree.id = n.id`)
910
+ .bind(nodeId)
911
+ .all();
912
+ const nodeIds = (nodes.results ?? []).map((row) => row.id);
913
+ const flows = await deps.db
914
+ .prepare(`${tree} SELECT f.id, f.title FROM flows f WHERE f.parent_id IN (SELECT id FROM tree)`)
915
+ .bind(nodeId)
916
+ .all();
917
+ const keys = await deps.db
918
+ .prepare(`${tree} SELECT v.content_key FROM node_versions v WHERE v.node_id IN (SELECT id FROM tree)`)
919
+ .bind(nodeId)
920
+ .all();
921
+ const vectors = await deps.db
922
+ .prepare(`${tree} SELECT v.node_id, v.chunk_key FROM node_vectors v WHERE v.node_id IN (SELECT id FROM tree)`)
923
+ .bind(nodeId)
924
+ .all();
925
+ const grouped = new Map();
926
+ for (const row of vectors.results ?? []) {
927
+ const current = grouped.get(row.node_id) ?? [];
928
+ current.push(row.chunk_key);
929
+ grouped.set(row.node_id, current);
930
+ }
931
+ const counts = { flow: flows.results?.length ?? 0 };
932
+ for (const row of nodes.results ?? [])
933
+ counts[row.kind] = (counts[row.kind] ?? 0) + 1;
934
+ return {
935
+ nodeIds,
936
+ // The names the preview shows, capped there rather than here: a caller that wants the whole
937
+ // tree has `nodeIds`, and the cap belongs where the sentence is written (#733).
938
+ /**
939
+ * ⚠️ **Flows FIRST, and the order is the finding rather than a preference** (second review
940
+ * of #738). The preview names the first fifty, so with flows appended a folder holding
941
+ * fifty documents and one flow named every document and hid the flow — the one thing the
942
+ * cascade had to be widened for, and the one a reader is least likely to expect in there.
943
+ */
944
+ named: [
945
+ ...(flows.results ?? []).map((row) => ({
946
+ id: row.id,
947
+ kind: "flow",
948
+ title: row.title,
949
+ })),
950
+ ...(nodes.results ?? []).map((row) => ({
951
+ id: row.id,
952
+ kind: row.kind,
953
+ title: row.title,
954
+ })),
955
+ ],
956
+ flowIds: (flows.results ?? []).map((row) => row.id),
957
+ contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))],
958
+ vectorKeys: nodeIds.map((id) => ({ nodeId: id, keys: grouped.get(id) ?? [] })),
959
+ counts,
960
+ };
961
+ },
962
+ async findPurgeReplay(actorId, idempotencyKey) {
963
+ const row = await deps.db
964
+ .prepare(`SELECT title, content_keys_json FROM node_purge_receipts
965
+ WHERE actor_id = ? AND idempotency_key = ?`)
966
+ .bind(actorId, idempotencyKey)
967
+ .first();
968
+ return row ? { title: row.title, contentKeys: JSON.parse(row.content_keys_json) } : null;
969
+ },
970
+ async completePurgeReplay(actorId, idempotencyKey, completedAt) {
971
+ await deps.db
972
+ .prepare(`UPDATE node_purge_receipts SET completed_at = ?
973
+ WHERE actor_id = ? AND idempotency_key = ?`)
974
+ .bind(completedAt, actorId, idempotencyKey)
975
+ .run();
976
+ },
977
+ async purgeNode(input) {
978
+ // The keys BEFORE anything falls: after the batch there is no version row left to read them
979
+ // from, and an object nobody can name is an object nobody can delete.
980
+ const keys = await deps.db
981
+ .prepare(`WITH RECURSIVE tree(id) AS (
982
+ SELECT id FROM nodes WHERE id = ?
983
+ UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
984
+ ) SELECT content_key FROM node_versions WHERE node_id IN (SELECT id FROM tree)`)
985
+ .bind(input.nodeId)
986
+ .all();
987
+ // ⚠️ The guard belongs on EVERY statement, not only on the last one (found in review). A
988
+ // `D1.batch` is one implicit transaction, but a final statement that changes ZERO rows is not
989
+ // an error — the transaction commits. With the condition only at the end, a restore between
990
+ // the service's check and this batch deleted every version, grant, link and index row of a
991
+ // node that was alive again, left its row standing, and answered `409 update_conflict`: the
992
+ // reader was told nothing had happened while the content was already gone. The audit row
993
+ // carried the same condition, so it was not even booked.
994
+ const alive = "EXISTS (SELECT 1 FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL)";
995
+ const tree = `WITH RECURSIVE tree(id) AS (
996
+ SELECT id FROM nodes WHERE id = ?
997
+ UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
998
+ )`;
999
+ const treeFlows = `${tree}, tree_flows(id) AS (
1000
+ SELECT id FROM flows WHERE parent_id IN (SELECT id FROM tree)
1001
+ )`;
1002
+ const deleted = await deps.db.batch([
1003
+ // ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
1004
+ // `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
1005
+ // not book one.
1006
+ //
1007
+ // ⚠️ **`parentId` travels beside the title, and it is what makes this row READABLE
1008
+ // afterwards** (#645). Visibility everywhere else walks `nodes` from the row itself, and
1009
+ // after this batch that row is gone — so until now the event was unreachable for everybody,
1010
+ // admin included. The surviving FOLDER answers instead. It is written here rather than
1011
+ // derived later for the same reason the title is: after the delete there is nothing left to
1012
+ // look either of them up from (`destructive.md`).
1013
+ deps.db
1014
+ .prepare(`INSERT INTO audit_events (
1015
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
1016
+ ) SELECT ?, ?, 'node.purge', 'node', n.id,
1017
+ json_patch(?, json_object('title', n.title, 'kind', n.kind, 'parentId', n.parent_id)), ?
1018
+ FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
1019
+ .bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
1020
+ deps.db
1021
+ .prepare(`INSERT INTO node_purge_receipts (
1022
+ actor_id, idempotency_key, node_id, title, content_keys_json, completed_at
1023
+ ) SELECT ?, ?, n.id, n.title, ?, NULL FROM nodes n
1024
+ WHERE n.id = ? AND n.archived_at IS NOT NULL`)
1025
+ .bind(input.actorId, input.idempotencyKey, JSON.stringify([...new Set((keys.results ?? []).map((row) => row.content_key))]), input.nodeId),
1026
+ // The derived indexes go with their source. ⚠️ A hit on a deleted document is not a blemish
1027
+ // but a leak: the title stands in the result and the content is gone.
1028
+ deps.db
1029
+ .prepare(`${tree} DELETE FROM node_fts WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
1030
+ .bind(input.nodeId, input.nodeId),
1031
+ deps.db
1032
+ .prepare(`${tree} DELETE FROM node_vectors WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
1033
+ .bind(input.nodeId, input.nodeId),
1034
+ deps.db
1035
+ .prepare(`${tree} DELETE FROM node_index_state
1036
+ WHERE version_id IN (SELECT id FROM node_versions WHERE node_id IN (SELECT id FROM tree)) AND ${alive}`)
1037
+ .bind(input.nodeId, input.nodeId),
1038
+ // Both directions. Outgoing links belong to this node; incoming ones would otherwise point
1039
+ // at a row that is gone, and a foreign key would refuse the delete anyway.
1040
+ deps.db
1041
+ .prepare(`${tree} DELETE FROM node_links
1042
+ WHERE (source_node_id IN (SELECT id FROM tree) OR target_node_id IN (SELECT id FROM tree)) AND ${alive}`)
1043
+ .bind(input.nodeId, input.nodeId),
1044
+ // A permission on a node that does not exist is a dangling permission.
1045
+ deps.db
1046
+ .prepare(`${tree} DELETE FROM node_grants WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
1047
+ .bind(input.nodeId, input.nodeId),
1048
+ deps.db
1049
+ .prepare(`${treeFlows} UPDATE flows SET published_version_id = NULL
1050
+ WHERE id NOT IN (SELECT id FROM tree_flows) AND EXISTS (
1051
+ SELECT 1 FROM flow_versions fv, json_tree(fv.graph_json) graph
1052
+ WHERE fv.id IN (flows.current_version_id, flows.published_version_id)
1053
+ AND ((graph.key = 'resourceId' AND graph.value IN (SELECT id FROM tree))
1054
+ OR (graph.key = 'flowId' AND graph.value IN (SELECT id FROM tree_flows)))
1055
+ ) AND ${alive}`)
1056
+ .bind(input.nodeId, input.nodeId),
1057
+ deps.db
1058
+ .prepare(`${treeFlows} DELETE FROM flow_run_steps
1059
+ WHERE run_id IN (SELECT id FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows)) AND ${alive}`)
1060
+ .bind(input.nodeId, input.nodeId),
1061
+ deps.db
1062
+ .prepare(`${treeFlows} DELETE FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
1063
+ .bind(input.nodeId, input.nodeId),
1064
+ deps.db
1065
+ .prepare(`${treeFlows} DELETE FROM flow_versions WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
1066
+ .bind(input.nodeId, input.nodeId),
1067
+ deps.db
1068
+ .prepare(`${treeFlows} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree_flows) AND ${alive}`)
1069
+ .bind(input.nodeId, input.nodeId),
1070
+ deps.db
1071
+ .prepare(`${treeFlows} DELETE FROM flows WHERE id IN (SELECT id FROM tree_flows) AND ${alive}`)
1072
+ .bind(input.nodeId, input.nodeId),
1073
+ deps.db
1074
+ .prepare(`${tree} DELETE FROM node_versions WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
1075
+ .bind(input.nodeId, input.nodeId),
1076
+ // ⚠️ The replay keys of every earlier write to this node. Left behind, a later write reusing
1077
+ // one of them would be answered with a resource id that no longer resolves.
1078
+ deps.db
1079
+ .prepare(`${tree} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree) AND ${alive}`)
1080
+ .bind(input.nodeId, input.nodeId),
1081
+ // LAST — and its count is what the caller reads as the verdict.
1082
+ deps.db
1083
+ .prepare(`${tree} DELETE FROM nodes WHERE id IN (SELECT id FROM tree) AND ${alive}`)
1084
+ .bind(input.nodeId, input.nodeId),
1085
+ ]);
1086
+ const removed = deleted.at(-1)?.meta?.changes ?? 0;
1087
+ if (removed === 0)
1088
+ return "missing";
1089
+ return { contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))] };
1090
+ },
1091
+ async appendVersion(input) {
1092
+ const version = input.version;
1093
+ await deps.db.batch([
1094
+ deps.db
1095
+ .prepare(`INSERT INTO node_versions (
1096
+ id, node_id, sequence, content_key, media_type, content_hash, size, segment,
1097
+ created_by, created_at
1098
+ ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1099
+ FROM nodes
1100
+ WHERE id = ? AND current_version_id IS ?`)
1101
+ .bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size, version.segment, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
1102
+ deps.db
1103
+ .prepare(`UPDATE nodes
1104
+ SET current_version_id = ?, updated_at = ?
1105
+ WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1106
+ .bind(version.id, version.createdAt, version.nodeId, version.id),
1107
+ deps.db
1108
+ .prepare(`INSERT INTO idempotency_keys (
1109
+ actor_id, operation, idempotency_key, resource_id, created_at
1110
+ ) SELECT ?, 'node.save', ?, ?, ?
1111
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1112
+ .bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
1113
+ deps.db
1114
+ .prepare(`INSERT INTO node_index_state (
1115
+ version_id, status, attempt_count, last_error, updated_at
1116
+ ) SELECT ?, 'pending', 0, NULL, ?
1117
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1118
+ .bind(version.id, version.createdAt, version.id),
1119
+ deps.db
1120
+ .prepare(`INSERT INTO audit_events (
1121
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
1122
+ ) SELECT ?, ?, 'node.save', 'node', ?, ?, ?
1123
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1124
+ .bind(input.auditId, input.actorId, version.nodeId, JSON.stringify({ versionId: version.id, sequence: version.sequence }), version.createdAt, version.id),
1125
+ ]);
1126
+ const row = await deps.db
1127
+ .prepare("SELECT current_version_id FROM nodes WHERE id = ?")
1128
+ .bind(version.nodeId)
1129
+ .first();
1130
+ return row?.current_version_id === version.id ? "saved" : "conflict";
1131
+ },
1132
+ /**
1133
+ * One appended segment of a table (#40).
1134
+ *
1135
+ * ⚠️ The sequence is computed inside the INSERT, never read out and sent back in. Two appends
1136
+ * that arrive together would otherwise both read the same `MAX(sequence)` and the second would
1137
+ * lose to `UNIQUE (node_id, sequence)` — the very loss the ticket forbids.
1138
+ *
1139
+ * ⚠️ `current_version_id` only ever moves forward. It names the newest segment, and indexing
1140
+ * keys off it; letting a slower append pull it back would leave the index describing a table
1141
+ * that is missing its last rows.
1142
+ */
1143
+ async appendTableVersion(input) {
1144
+ const version = input.version;
1145
+ const stored = async () => await deps.db
1146
+ .prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
1147
+ .bind(version.id)
1148
+ .first();
1149
+ try {
1150
+ await deps.db.batch([
1151
+ deps.db
1152
+ .prepare(`INSERT INTO node_versions (
1153
+ id, node_id, sequence, content_key, media_type, content_hash, size, segment,
1154
+ created_by, created_at
1155
+ ) SELECT ?, ?, COALESCE(MAX(sequence), 0) + 1, ?, ?, ?, ?, ?, ?, ?
1156
+ FROM node_versions WHERE node_id = ?`)
1157
+ .bind(version.id, version.nodeId, version.contentKey, version.mediaType, version.contentHash, version.size, version.segment, version.createdBy, version.createdAt, version.nodeId),
1158
+ deps.db
1159
+ .prepare(`UPDATE nodes
1160
+ SET current_version_id = ?, updated_at = ?
1161
+ WHERE id = ?
1162
+ AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)
1163
+ AND (
1164
+ current_version_id IS NULL
1165
+ OR COALESCE((
1166
+ SELECT sequence FROM node_versions
1167
+ WHERE id = nodes.current_version_id
1168
+ ), 0) < (SELECT sequence FROM node_versions WHERE id = ?)
1169
+ )`)
1170
+ .bind(version.id, version.createdAt, version.nodeId, version.id, version.id),
1171
+ deps.db
1172
+ .prepare(`INSERT INTO idempotency_keys (
1173
+ actor_id, operation, idempotency_key, resource_id, created_at
1174
+ ) SELECT ?, 'node.append', ?, ?, ?
1175
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1176
+ .bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
1177
+ deps.db
1178
+ .prepare(`INSERT INTO node_index_state (
1179
+ version_id, status, attempt_count, last_error, updated_at
1180
+ ) SELECT ?, 'pending', 0, NULL, ?
1181
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1182
+ .bind(version.id, version.createdAt, version.id),
1183
+ deps.db
1184
+ .prepare(`INSERT INTO audit_events (
1185
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
1186
+ ) SELECT ?, ?, 'node.append', 'node', ?, json_object(
1187
+ 'versionId', ?, 'size', ?
1188
+ ), ?
1189
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1190
+ .bind(input.auditId, input.actorId, version.nodeId, version.id, version.size, version.createdAt, version.id),
1191
+ ]);
1192
+ }
1193
+ catch (error) {
1194
+ // The same key twice at the same moment: the first write owns the row, and this call
1195
+ // answers with it rather than with a failure the caller cannot act on.
1196
+ const replayed = await deps.db
1197
+ .prepare(`SELECT resource_id FROM idempotency_keys
1198
+ WHERE actor_id = ? AND operation = 'node.append' AND idempotency_key = ?`)
1199
+ .bind(input.actorId, input.idempotencyKey)
1200
+ .first();
1201
+ const row = replayed
1202
+ ? await deps.db
1203
+ .prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
1204
+ .bind(replayed.resource_id)
1205
+ .first()
1206
+ : null;
1207
+ if (row)
1208
+ return mapVersion(row);
1209
+ throw error;
1210
+ }
1211
+ const row = await stored();
1212
+ if (!row)
1213
+ throw new Error("Node table append did not store a version");
1214
+ return mapVersion(row);
1215
+ },
1216
+ /**
1217
+ * One snapshot — the whole readable state after a mutation — written only against the state it
1218
+ * replaces (#135, #285).
1219
+ *
1220
+ * ⚠️ The shape of `appendVersion`, not of the append above it: the base check sits inside the
1221
+ * INSERT's own WHERE, so a segment that landed between the service's read and this batch makes
1222
+ * the INSERT store nothing and every guarded statement after it a no-op. The service reads the
1223
+ * answer the same way `save` does — by asking whether the node now stands on the new version.
1224
+ *
1225
+ * The idempotency row carries the mutation's own operation, so a replayed update can never be
1226
+ * confused with a delete that reused the key, and the audit trail names what was done.
1227
+ */
1228
+ async appendSnapshotVersion(input) {
1229
+ const version = input.version;
1230
+ await deps.db.batch([
1231
+ deps.db
1232
+ .prepare(`INSERT INTO node_versions (
1233
+ id, node_id, sequence, content_key, media_type, content_hash, size, segment,
1234
+ created_by, created_at
1235
+ ) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1236
+ FROM nodes
1237
+ WHERE id = ? AND current_version_id IS ?`)
1238
+ .bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size,
1239
+ // ⚠️ Taken from the version rather than written in as `'snapshot'`. A table's mutation
1240
+ // marks a snapshot, because reading a table starts at the newest one and everything
1241
+ // before it is history (#135). A snapshot version carries the whole document by
1242
+ // construction, so it marks nothing — the same `null` a document's version carries, and
1243
+ // for the same reason (#285).
1244
+ version.segment, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
1245
+ deps.db
1246
+ .prepare(`UPDATE nodes
1247
+ SET current_version_id = ?, updated_at = ?
1248
+ WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1249
+ .bind(version.id, version.createdAt, version.nodeId, version.id),
1250
+ deps.db
1251
+ .prepare(`INSERT INTO idempotency_keys (
1252
+ actor_id, operation, idempotency_key, resource_id, created_at
1253
+ ) SELECT ?, ?, ?, ?, ?
1254
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1255
+ .bind(input.actorId, input.operation, input.idempotencyKey, version.id, version.createdAt, version.id),
1256
+ deps.db
1257
+ .prepare(`INSERT INTO node_index_state (
1258
+ version_id, status, attempt_count, last_error, updated_at
1259
+ ) SELECT ?, 'pending', 0, NULL, ?
1260
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1261
+ .bind(version.id, version.createdAt, version.id),
1262
+ deps.db
1263
+ .prepare(`INSERT INTO audit_events (
1264
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
1265
+ ) SELECT ?, ?, ?, 'node', ?, ?, ?
1266
+ WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
1267
+ .bind(input.auditId, input.actorId, input.operation, version.nodeId, JSON.stringify({ versionId: version.id, ...input.metadata }), version.createdAt, version.id),
1268
+ ]);
1269
+ const row = await deps.db
1270
+ .prepare("SELECT current_version_id FROM nodes WHERE id = ?")
1271
+ .bind(version.nodeId)
1272
+ .first();
1273
+ return row?.current_version_id === version.id ? "saved" : "conflict";
1274
+ },
1275
+ async listGrants(resourceId) {
1276
+ const result = await deps.db
1277
+ .prepare(`SELECT ${grantColumns} FROM node_grants
1278
+ WHERE node_id = ?
1279
+ ORDER BY principal_type, principal_id, verb`)
1280
+ .bind(resourceId)
1281
+ .all();
1282
+ return (result.results ?? []).map(mapGrant);
1283
+ },
1284
+ async listEffectiveAccess(resourceId) {
1285
+ // Owners and grants come from ONE statement, hence one D1 snapshot. Two parallel SELECTs are
1286
+ // not atomic: a grant mutation between their snapshots can produce a principal list that
1287
+ // never existed as a whole.
1288
+ const row = await deps.db
1289
+ .prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
1290
+ SELECT id, parent_id, owner_id FROM nodes WHERE id = ?
1291
+ UNION
1292
+ SELECT parent.id, parent.parent_id, parent.owner_id
1293
+ FROM nodes parent
1294
+ JOIN ancestors child ON child.parent_id = parent.id
1295
+ )
1296
+ SELECT
1297
+ (SELECT json_group_array(owner_id)
1298
+ FROM (SELECT DISTINCT owner_id FROM ancestors ORDER BY owner_id)) AS owner_ids_json,
1299
+ (SELECT json_group_array(json_object(
1300
+ 'id', id, 'resource_id', node_id, 'principal_type', principal_type,
1301
+ 'principal_id', principal_id, 'verb', verb, 'expires_at', expires_at,
1302
+ 'created_by', created_by, 'created_at', created_at
1303
+ ))
1304
+ FROM (
1305
+ SELECT grant_row.id, grant_row.node_id, grant_row.principal_type,
1306
+ grant_row.principal_id, grant_row.verb, grant_row.expires_at,
1307
+ grant_row.created_by, grant_row.created_at
1308
+ FROM node_grants grant_row
1309
+ JOIN ancestors ON ancestors.id = grant_row.node_id
1310
+ WHERE ${grantInForce}
1311
+ ORDER BY grant_row.principal_type, grant_row.principal_id, grant_row.verb
1312
+ )) AS grants_json`)
1313
+ .bind(resourceId, deps.now().toISOString())
1314
+ .first();
1315
+ return {
1316
+ ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
1317
+ items: JSON.parse(row?.grants_json ?? "[]").map(mapGrant),
1318
+ };
1319
+ },
1320
+ // ⚠️ `UNION`, never `UNION ALL`: a ring in `parent_id` must end the recursion rather than the
1321
+ // database, the same reason every other walk of this tree gives.
1322
+ async organizationExecuteReaches(nodeId, exceptGrantId) {
1323
+ const row = await deps.db
1324
+ .prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
1325
+ SELECT id, parent_id FROM nodes WHERE id = ?
1326
+ UNION
1327
+ SELECT parent.id, parent.parent_id
1328
+ FROM nodes parent
1329
+ JOIN ancestors child ON child.parent_id = parent.id
1330
+ )
1331
+ SELECT 1 AS reaches
1332
+ FROM node_grants grant_row
1333
+ JOIN ancestors ON ancestors.id = grant_row.node_id
1334
+ WHERE grant_row.id <> ?
1335
+ AND grant_row.principal_type = 'organization'
1336
+ AND grant_row.verb = 'execute'
1337
+ AND ${grantInForce}
1338
+ LIMIT 1`)
1339
+ .bind(nodeId, exceptGrantId, deps.now().toISOString())
1340
+ .first();
1341
+ return row?.reaches === 1;
1342
+ },
1343
+ async listLinksVisible(actor, nodeId) {
1344
+ const result = await deps.db
1345
+ .prepare(`${visibleCte}
1346
+ SELECT ${linkColumns}
1347
+ FROM node_links link
1348
+ JOIN allowed source_access ON source_access.id = link.source_node_id
1349
+ JOIN allowed target_access ON target_access.id = link.target_node_id
1350
+ WHERE link.source_node_id = ? OR link.target_node_id = ?
1351
+ ORDER BY link.created_at, link.id`)
1352
+ .bind(...readBindings(actor), nodeId, nodeId)
1353
+ .all();
1354
+ return (result.results ?? []).map(mapLink);
1355
+ },
1356
+ /**
1357
+ * The titles of the nodes among these that this actor may see (#41).
1358
+ *
1359
+ * ⚠️ The same `allowed` walk as every other read, joined rather than filtered afterwards, and
1360
+ * archived nodes are out. An unreachable or deleted target contributes no row at all — no
1361
+ * placeholder, no id echoed back — because a row is already the statement "something is here".
1362
+ */
1363
+ async resolveVisibleTitles(actor, nodeIds) {
1364
+ const uniqueIds = [...new Set(nodeIds)].slice(0, 200);
1365
+ if (uniqueIds.length === 0)
1366
+ return [];
1367
+ const rows = [];
1368
+ for (let offset = 0; offset < uniqueIds.length; offset += 96) {
1369
+ const ids = uniqueIds.slice(offset, offset + 96);
1370
+ const placeholders = ids.map(() => "?").join(", ");
1371
+ const result = await deps.db
1372
+ .prepare(`${visibleCte}
1373
+ SELECT n.id, n.title
1374
+ FROM nodes n
1375
+ JOIN allowed ON allowed.id = n.id
1376
+ WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
1377
+ .bind(...readBindings(actor), ...ids)
1378
+ .all();
1379
+ rows.push(...(result.results ?? []));
1380
+ }
1381
+ return rows.map((row) => ({ nodeId: row.id, title: row.title }));
1382
+ },
1383
+ /**
1384
+ * The `text` links of one document, replaced wholesale by what the saved text says (#41).
1385
+ *
1386
+ * ⚠️ `origin = 'text'` in the delete, and nowhere else. Links made in the removed dialog are
1387
+ * `manual`; saving a document must not take away a relationship somebody entered before the
1388
+ * text became the place to write one.
1389
+ *
1390
+ * The whole set is written in one batch, so a save never leaves the graph half-rewritten. There
1391
+ * is no idempotency row: the operation is a replacement, and replaying it writes the same set.
1392
+ */
1393
+ async replaceTextLinks(input) {
1394
+ await deps.db.batch([
1395
+ deps.db
1396
+ .prepare("DELETE FROM node_links WHERE source_node_id = ? AND origin = 'text'")
1397
+ .bind(input.sourceNodeId),
1398
+ ...input.links.map((link) => deps.db
1399
+ .prepare(`INSERT INTO node_links (
1400
+ id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
1401
+ ) VALUES (?, ?, ?, 'references', 'text', NULL, ?, ?)
1402
+ ON CONFLICT (source_node_id, target_node_id, relation) DO NOTHING`)
1403
+ .bind(link.id, input.sourceNodeId, link.targetNodeId, input.actorId, input.occurredAt)),
1404
+ deps.db
1405
+ .prepare(`INSERT INTO audit_events (
1406
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
1407
+ ) VALUES (?, ?, 'node.link', 'node', ?, ?, ?)`)
1408
+ .bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ targetNodeIds: input.links.map((link) => link.targetNodeId) }), input.occurredAt),
1409
+ ]);
1410
+ },
1411
+ async graphVisible(actor, input) {
1412
+ const nodeResult = await deps.db
1413
+ .prepare(`${visibleCte}
1414
+ SELECT ${nodeColumns}
1415
+ FROM nodes n
1416
+ JOIN allowed ON allowed.id = n.id
1417
+ WHERE n.archived_at IS NULL
1418
+ ORDER BY lower(n.title), n.id
1419
+ LIMIT ?`)
1420
+ .bind(...readBindings(actor), input.limit)
1421
+ .all();
1422
+ const nodes = (nodeResult.results ?? []).map(mapNode);
1423
+ if (nodes.length === 0)
1424
+ return { nodes, links: [] };
1425
+ const linkResult = await deps.db
1426
+ .prepare(`${visibleCte}, graph_nodes(id) AS (
1427
+ SELECT n.id FROM nodes n
1428
+ JOIN allowed ON allowed.id = n.id
1429
+ WHERE n.archived_at IS NULL
1430
+ ORDER BY lower(n.title), n.id
1431
+ LIMIT ?
1432
+ )
1433
+ SELECT ${linkColumns}
1434
+ FROM node_links link
1435
+ JOIN graph_nodes source_node ON source_node.id = link.source_node_id
1436
+ JOIN graph_nodes target_node ON target_node.id = link.target_node_id
1437
+ ORDER BY link.created_at, link.id`)
1438
+ .bind(...readBindings(actor), input.limit)
1439
+ .all();
1440
+ return { nodes, links: (linkResult.results ?? []).map(mapLink) };
1441
+ },
1442
+ /**
1443
+ * The same `allowed` set every other read joins against, narrowed to attachments (#773).
1444
+ *
1445
+ * ⚠️ Ordered and paged by `n.id` rather than by title: the id is the cursor, and a cursor has to
1446
+ * be unique or a page boundary falling between two identical titles silently drops one of them.
1447
+ * `graphVisible` above orders by title because it draws a picture and takes the whole set.
1448
+ */
1449
+ async listVisibleAttachments(actor, input) {
1450
+ const result = await deps.db
1451
+ .prepare(`${visibleCte}
1452
+ SELECT ${nodeColumns}
1453
+ FROM nodes n
1454
+ JOIN allowed ON allowed.id = n.id
1455
+ WHERE n.archived_at IS NULL
1456
+ AND n.kind = 'attachment'
1457
+ AND (? IS NULL OR n.id > ?)
1458
+ ORDER BY n.id
1459
+ LIMIT ?`)
1460
+ // ⚠️ Positional throughout, and the cursor is bound TWICE. Numbered parameters (?1, ?2)
1461
+ // would count from the first placeholder of the whole statement — and `visibleCte` above
1462
+ // has already spent several of them.
1463
+ .bind(...readBindings(actor), input.after, input.after, input.limit)
1464
+ .all();
1465
+ return (result.results ?? []).map(mapNode);
1466
+ },
1467
+ async setGrant(input) {
1468
+ const grant = input.grant;
1469
+ const { type: principalType, id: principalId } = principalColumns(grant.principal);
1470
+ try {
1471
+ await deps.db.batch([
1472
+ deps.db
1473
+ // The verb is part of the key, so re-granting the same verb only refreshes its expiry
1474
+ // and never turns one verb into another.
1475
+ .prepare(`INSERT INTO node_grants (
1476
+ id, node_id, principal_type, principal_id, verb,
1477
+ expires_at, created_by, created_at
1478
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1479
+ ON CONFLICT (node_id, principal_type, principal_id, verb)
1480
+ DO UPDATE SET expires_at = excluded.expires_at`)
1481
+ .bind(grant.id, grant.resourceId, principalType, principalId, grant.verb, grant.expiresAt, grant.createdBy, grant.createdAt),
1482
+ deps.db
1483
+ .prepare(`INSERT INTO idempotency_keys (
1484
+ actor_id, operation, idempotency_key, resource_id, created_at
1485
+ ) SELECT ?, 'node.share', ?, id, ? FROM node_grants
1486
+ WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
1487
+ .bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId, grant.verb),
1488
+ deps.db
1489
+ .prepare(`INSERT INTO audit_events (
1490
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
1491
+ ) VALUES (?, ?, 'node.share', 'node', ?, ?, ?)`)
1492
+ .bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
1493
+ ]);
1494
+ const stored = await deps.db
1495
+ .prepare(`SELECT ${grantColumns} FROM node_grants
1496
+ WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
1497
+ .bind(grant.resourceId, principalType, principalId, grant.verb)
1498
+ .first();
1499
+ if (!stored)
1500
+ throw new Error("Node grant disappeared after upsert");
1501
+ return mapGrant(stored);
1502
+ }
1503
+ catch (error) {
1504
+ const replayed = await deps.db
1505
+ .prepare(`SELECT resource_id FROM idempotency_keys
1506
+ WHERE actor_id = ? AND operation = 'node.share' AND idempotency_key = ?`)
1507
+ .bind(input.actorId, input.idempotencyKey)
1508
+ .first();
1509
+ if (replayed) {
1510
+ const row = await deps.db
1511
+ .prepare(`SELECT ${grantColumns} FROM node_grants WHERE id = ?`)
1512
+ .bind(replayed.resource_id)
1513
+ .first();
1514
+ if (row)
1515
+ return mapGrant(row);
1516
+ }
1517
+ throw error;
1518
+ }
1519
+ },
1520
+ async revokeGrant(input) {
1521
+ try {
1522
+ await deps.db.batch([
1523
+ deps.db
1524
+ .prepare(`INSERT INTO idempotency_keys (
1525
+ actor_id, operation, idempotency_key, resource_id, created_at
1526
+ ) SELECT ?, 'node.revoke', ?,
1527
+ (CASE WHEN EXISTS (
1528
+ SELECT 1 FROM node_grants WHERE id = ? AND node_id = ?
1529
+ ) THEN '1:' ELSE '0:' END) || ?, ?`)
1530
+ .bind(input.actorId, input.idempotencyKey, input.grantId, input.resourceId, input.grantId, input.occurredAt),
1531
+ deps.db
1532
+ .prepare("DELETE FROM node_grants WHERE id = ? AND node_id = ?")
1533
+ .bind(input.grantId, input.resourceId),
1534
+ deps.db
1535
+ .prepare(`INSERT INTO audit_events (
1536
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
1537
+ ) SELECT ?, ?, 'node.revoke', 'node', ?,
1538
+ json_object(
1539
+ 'grantId', ?,
1540
+ 'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
1541
+ ), ?
1542
+ FROM idempotency_keys
1543
+ WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
1544
+ .bind(input.auditId, input.actorId, input.resourceId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
1545
+ ]);
1546
+ }
1547
+ catch (error) {
1548
+ const replayed = await this.findIdempotentRevocation(input.actorId, input.idempotencyKey);
1549
+ if (replayed === null)
1550
+ throw error;
1551
+ return replayed;
1552
+ }
1553
+ return (await this.findIdempotentRevocation(input.actorId, input.idempotencyKey)) ?? false;
1554
+ },
1555
+ async searchVisible(actor, input) {
1556
+ const query = toFtsQuery(input.query);
1557
+ if (!query)
1558
+ return [];
1559
+ const result = await deps.db
1560
+ .prepare(`${visibleCte}${scopeCte(input.scopeId)}
1561
+ SELECT n.id AS node_id, v.id AS version_id, n.title,
1562
+ snippet(node_fts, 3, '', '', ' … ', 32) AS passage,
1563
+ n.updated_at AS freshness, bm25(node_fts, 0.0, 0.0, 4.0, 1.0) AS rank
1564
+ FROM node_fts
1565
+ JOIN nodes n ON n.id = node_fts.node_id
1566
+ JOIN node_versions v ON v.id = node_fts.version_id
1567
+ JOIN allowed ON allowed.id = n.id${scopeJoin(input.scopeId)}
1568
+ WHERE node_fts MATCH ?
1569
+ AND n.current_version_id = v.id
1570
+ AND n.archived_at IS NULL
1571
+ ORDER BY rank, n.updated_at DESC
1572
+ LIMIT ?`)
1573
+ .bind(...readBindings(actor), ...scopeBindings(input.scopeId), query, input.limit)
1574
+ .all();
1575
+ // ⚠️ One citation per node, and the FIRST is the one kept. The index is CHUNKED — a node
1576
+ // may hold several rows (#285) — so a query matching three of them arrives here three times;
1577
+ // the rows come back in rank order, so the survivor is the best-matching card and its own
1578
+ // passage is what the reader is shown. Without this, one busy node could fill a whole page
1579
+ // of results and push every other node off it.
1580
+ return dedupedByNode(result.results ?? []).map((row) => ({
1581
+ nodeId: row.node_id,
1582
+ versionId: row.version_id,
1583
+ title: row.title,
1584
+ passage: row.passage,
1585
+ source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
1586
+ freshness: row.freshness,
1587
+ score: 1 / (1 + Math.abs(row.rank)),
1588
+ match: "lexical",
1589
+ }));
1590
+ },
1591
+ async hydrateVisibleCitations(actor, hits, scopeId) {
1592
+ // One entry per node: the caller has already picked the best-scoring chunk of each, so a
1593
+ // node that answered with four passages arrives as the one passage that answered best.
1594
+ const unique = [...new Map(hits.map((hit) => [hit.nodeId, hit])).values()].slice(0, 100);
1595
+ if (unique.length === 0)
1596
+ return [];
1597
+ const rows = [];
1598
+ // Two bindings per hit now instead of one, so the batch is halved and then some: the actor's
1599
+ // predicate and the optional scope are bound in front of them, and D1 counts every one of
1600
+ // them against the same per-statement ceiling.
1601
+ for (let offset = 0; offset < unique.length; offset += 40) {
1602
+ const batch = unique.slice(offset, offset + 40);
1603
+ const pairs = batch.map(() => "(?, ?)").join(", ");
1604
+ const result = await deps.db
1605
+ .prepare(`${visibleCte}${scopeCte(scopeId)},
1606
+ hits(node_id, chunk_key) AS (VALUES ${pairs})
1607
+ SELECT n.id AS node_id, v.id AS version_id, n.title,
1608
+ -- ⚠️ The card that answered, and only otherwise the first passage of the node
1609
+ -- (anchrd/intel#301). Every vector written before #301 is named by the bare node id
1610
+ -- and has no row here, so the fallback is not defensive coding but the exact
1611
+ -- behaviour those vectors had — a board found through one of them still answers,
1612
+ -- with the passage it always answered with, until the next indexing pass.
1613
+ COALESCE(vector.passage, substr(node_fts.content, 1, 480)) AS passage,
1614
+ n.updated_at AS freshness
1615
+ FROM hits
1616
+ JOIN nodes n ON n.id = hits.node_id
1617
+ JOIN node_versions v ON v.id = n.current_version_id
1618
+ JOIN node_fts ON node_fts.version_id = v.id
1619
+ LEFT JOIN node_vectors vector
1620
+ ON vector.node_id = hits.node_id AND vector.chunk_key = hits.chunk_key
1621
+ JOIN allowed ON allowed.id = n.id${scopeJoin(scopeId)}
1622
+ WHERE n.archived_at IS NULL`)
1623
+ .bind(...readBindings(actor), ...scopeBindings(scopeId), ...batch.flatMap((hit) => [hit.nodeId, hit.chunkKey]))
1624
+ .all();
1625
+ rows.push(...(result.results ?? []));
1626
+ }
1627
+ const order = new Map(unique.map((hit, index) => [hit.nodeId, index]));
1628
+ // The same one-per-node rule as the lexical half above, for the same reason: a citation names
1629
+ // a NODE, and a board holds one full-text row per task (#285), so the join above still
1630
+ // produces one row per card even though only one of them was asked about.
1631
+ const deduped = dedupedByNode(rows);
1632
+ deduped.sort((left, right) => (order.get(left.node_id) ?? 0) - (order.get(right.node_id) ?? 0));
1633
+ return deduped.map((row) => ({
1634
+ nodeId: row.node_id,
1635
+ versionId: row.version_id,
1636
+ title: row.title,
1637
+ passage: row.passage,
1638
+ source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
1639
+ freshness: row.freshness,
1640
+ score: 0,
1641
+ match: "semantic",
1642
+ }));
1643
+ },
1644
+ async invalidateVectors() {
1645
+ /**
1646
+ * ⚠️ The fingerprints are cleared and the ROWS are kept, which is not a detail. A record row
1647
+ * says two things at once: "this chunk was embedded from this text" and "this vector exists".
1648
+ * Only the first is stale after a rebuild is asked for; deleting the row would throw the
1649
+ * second away, and then a card removed between this call and that board's pass would leave a
1650
+ * vector nothing could ever name again — matching questions and answering with a card that is
1651
+ * not on the board.
1652
+ *
1653
+ * The empty string is a value no digest produces, so every chunk compares as changed and the
1654
+ * next pass embeds all of them. `node_fts` is deliberately not touched: its rows are
1655
+ * overwritten by the pass that rewrites them, and emptying it would take the lexical half of
1656
+ * the installation offline for as long as the queue needs to work through the tree.
1657
+ */
1658
+ await deps.db.prepare("UPDATE node_vectors SET fingerprint = ''").run();
1659
+ },
1660
+ /**
1661
+ * ⚠️ Archived nodes are IN this walk since anchrd/intel#348, and the omission of
1662
+ * `archived_at IS NULL` is the point. The pass an archived node gets is a purge, not an index —
1663
+ * `getTarget` refuses it and `archivedNodeId` says why — so including them is what makes
1664
+ * `reindex` the way back for a vector that is in the index with nothing left to name it.
1665
+ *
1666
+ * Three of those exist and none of them has another repair: everything archived before #348,
1667
+ * which nothing ever swept; a node whose `archive` wrote its row and then failed to reach the
1668
+ * queue; and a pass that upserted vectors in the moment somebody archived the node underneath
1669
+ * it. Without this line an administrator's rebuild walked straight past all three, and the only
1670
+ * remaining answer would have been to delete the whole Vectorize index by hand.
1671
+ *
1672
+ * It does mean `queued` counts them. That is still what the number says — versions handed to the
1673
+ * queue — and a rebuild that silently skipped part of the tree is the failure `reindex` exists
1674
+ * to prevent.
1675
+ */
1676
+ async listCurrentVersionIds(input) {
1677
+ const result = await deps.db
1678
+ .prepare(`SELECT current_version_id AS id FROM nodes
1679
+ WHERE current_version_id IS NOT NULL
1680
+ AND (? IS NULL OR current_version_id > ?)
1681
+ ORDER BY id LIMIT ?`)
1682
+ .bind(input.after, input.after, input.limit)
1683
+ .all();
1684
+ return (result.results ?? []).map((row) => row.id);
1685
+ },
1686
+ };
1687
+ }