@rsktash/beads-ui 0.1.50 → 0.10.3

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 (336) hide show
  1. package/README.md +85 -144
  2. package/bin/bd-web +94 -0
  3. package/dist/assets/index-BBVIMXaM.js +73 -0
  4. package/dist/assets/index-SUXI6Mzt.css +1 -0
  5. package/dist/bd-favicon-16.svg +6 -0
  6. package/dist/bd-favicon-32.svg +6 -0
  7. package/dist/bd-logo-512.svg +6 -0
  8. package/dist/favicon.ico +0 -0
  9. package/dist/icon-180.png +0 -0
  10. package/dist/icon-192.png +0 -0
  11. package/dist/icon-512.png +0 -0
  12. package/dist/index.html +8 -5
  13. package/package.json +39 -29
  14. package/server/auth.js +68 -87
  15. package/server/db.js +91 -134
  16. package/server/dsn.js +130 -0
  17. package/server/index.js +151 -87
  18. package/server/queries.js +247 -0
  19. package/server/routes/auth.js +27 -0
  20. package/server/routes/issues.js +120 -0
  21. package/server/routes/stream.js +111 -0
  22. package/server/types.js +83 -0
  23. package/app/protocol.js +0 -216
  24. package/bin/bd-grep +0 -121
  25. package/bin/bd-ui +0 -19
  26. package/dist/assets/abap-DsBKuouk.js +0 -1
  27. package/dist/assets/actionscript-3-D_z4Izcz.js +0 -1
  28. package/dist/assets/ada-727ZlQH0.js +0 -1
  29. package/dist/assets/andromeeda-C3khCPGq.js +0 -1
  30. package/dist/assets/angular-html-LfdN0zeE.js +0 -1
  31. package/dist/assets/angular-ts-CKsD7JZE.js +0 -1
  32. package/dist/assets/apache-Dn00JSTd.js +0 -1
  33. package/dist/assets/apex-COJ4H7py.js +0 -1
  34. package/dist/assets/apl-BBq3IX1j.js +0 -1
  35. package/dist/assets/applescript-Bu5BbsvL.js +0 -1
  36. package/dist/assets/ara-7O62HKoU.js +0 -1
  37. package/dist/assets/asciidoc-BPT9niGB.js +0 -1
  38. package/dist/assets/asm-Dhn9LcZ4.js +0 -1
  39. package/dist/assets/astro-CqkE3fuf.js +0 -1
  40. package/dist/assets/aurora-x-D-2ljcwZ.js +0 -1
  41. package/dist/assets/awk-eg146-Ew.js +0 -1
  42. package/dist/assets/ayu-dark-Cv9koXgw.js +0 -1
  43. package/dist/assets/ballerina-Du268qiB.js +0 -1
  44. package/dist/assets/bat-fje9CFhw.js +0 -1
  45. package/dist/assets/beancount-BwXTMy5W.js +0 -1
  46. package/dist/assets/berry-3xVqZejG.js +0 -1
  47. package/dist/assets/bibtex-xW4inM5L.js +0 -1
  48. package/dist/assets/bicep-DHo0CJ0O.js +0 -1
  49. package/dist/assets/blade-a8OxSdnT.js +0 -1
  50. package/dist/assets/bsl-Dgyn0ogV.js +0 -1
  51. package/dist/assets/c-C3t2pwGQ.js +0 -1
  52. package/dist/assets/cadence-DNquZEk8.js +0 -1
  53. package/dist/assets/cairo--RitsXJZ.js +0 -1
  54. package/dist/assets/catppuccin-frappe-CD_QflpE.js +0 -1
  55. package/dist/assets/catppuccin-latte-DRW-0cLl.js +0 -1
  56. package/dist/assets/catppuccin-macchiato-C-_shW-Y.js +0 -1
  57. package/dist/assets/catppuccin-mocha-LGGdnPYs.js +0 -1
  58. package/dist/assets/clarity-BHOwM8T6.js +0 -1
  59. package/dist/assets/clojure-DxSadP1t.js +0 -1
  60. package/dist/assets/cmake-DbXoA79R.js +0 -1
  61. package/dist/assets/cobol-PTqiYgYu.js +0 -1
  62. package/dist/assets/codeowners-Bp6g37R7.js +0 -1
  63. package/dist/assets/codeql-sacFqUAJ.js +0 -1
  64. package/dist/assets/coffee-dyiR41kL.js +0 -1
  65. package/dist/assets/common-lisp-C7gG9l05.js +0 -1
  66. package/dist/assets/coq-Dsg_Bt_b.js +0 -1
  67. package/dist/assets/cpp-BksuvNSY.js +0 -1
  68. package/dist/assets/crystal-DtDmRg-F.js +0 -1
  69. package/dist/assets/csharp-D9R-vmeu.js +0 -1
  70. package/dist/assets/css-BPhBrDlE.js +0 -1
  71. package/dist/assets/csv-B0qRVHPH.js +0 -1
  72. package/dist/assets/cue-DtFQj3wx.js +0 -1
  73. package/dist/assets/cypher-m2LEI-9-.js +0 -1
  74. package/dist/assets/d-BoXegm-a.js +0 -1
  75. package/dist/assets/dark-plus-C3mMm8J8.js +0 -1
  76. package/dist/assets/dart-B9wLZaAG.js +0 -1
  77. package/dist/assets/dax-ClGRhx96.js +0 -1
  78. package/dist/assets/desktop-DEIpsLCJ.js +0 -1
  79. package/dist/assets/diff-BgYniUM_.js +0 -1
  80. package/dist/assets/docker-COcR7UxN.js +0 -1
  81. package/dist/assets/dotenv-BjQB5zDj.js +0 -1
  82. package/dist/assets/dracula-BzJJZx-M.js +0 -1
  83. package/dist/assets/dracula-soft-BXkSAIEj.js +0 -1
  84. package/dist/assets/dream-maker-C-nORZOA.js +0 -1
  85. package/dist/assets/edge-D5gP-w-T.js +0 -1
  86. package/dist/assets/elixir-CLiX3zqd.js +0 -1
  87. package/dist/assets/elm-CmHSxxaM.js +0 -1
  88. package/dist/assets/emacs-lisp-BX77sIaO.js +0 -1
  89. package/dist/assets/erb-BYTLMnw6.js +0 -1
  90. package/dist/assets/erlang-B-DoSBHF.js +0 -1
  91. package/dist/assets/everforest-dark-BgDCqdQA.js +0 -1
  92. package/dist/assets/everforest-light-C8M2exoo.js +0 -1
  93. package/dist/assets/fennel-bCA53EVm.js +0 -1
  94. package/dist/assets/fish-w-ucz2PV.js +0 -1
  95. package/dist/assets/fluent-Dayu4EKP.js +0 -1
  96. package/dist/assets/fortran-fixed-form-TqA4NnZg.js +0 -1
  97. package/dist/assets/fortran-free-form-DKXYxT9g.js +0 -1
  98. package/dist/assets/fsharp-XplgxFYe.js +0 -1
  99. package/dist/assets/gdresource-BHYsBjWJ.js +0 -1
  100. package/dist/assets/gdscript-DfxzS6Rs.js +0 -1
  101. package/dist/assets/gdshader-SKMF96pI.js +0 -1
  102. package/dist/assets/genie-ajMbGru0.js +0 -1
  103. package/dist/assets/gherkin--30QC5Em.js +0 -1
  104. package/dist/assets/git-commit-i4q6IMui.js +0 -1
  105. package/dist/assets/git-rebase-B-v9cOL2.js +0 -1
  106. package/dist/assets/github-dark-DHJKELXO.js +0 -1
  107. package/dist/assets/github-dark-default-Cuk6v7N8.js +0 -1
  108. package/dist/assets/github-dark-dimmed-DH5Ifo-i.js +0 -1
  109. package/dist/assets/github-dark-high-contrast-E3gJ1_iC.js +0 -1
  110. package/dist/assets/github-light-DAi9KRSo.js +0 -1
  111. package/dist/assets/github-light-default-D7oLnXFd.js +0 -1
  112. package/dist/assets/github-light-high-contrast-BfjtVDDH.js +0 -1
  113. package/dist/assets/gleam-B430Bg39.js +0 -1
  114. package/dist/assets/glimmer-js-D-cwc0-E.js +0 -1
  115. package/dist/assets/glimmer-ts-pgjy16dm.js +0 -1
  116. package/dist/assets/glsl-DBO2IWDn.js +0 -1
  117. package/dist/assets/gnuplot-CM8KxXT1.js +0 -1
  118. package/dist/assets/go-B1SYOhNW.js +0 -1
  119. package/dist/assets/graphql-cDcHW_If.js +0 -1
  120. package/dist/assets/groovy-DkBy-JyN.js +0 -1
  121. package/dist/assets/hack-D1yCygmZ.js +0 -1
  122. package/dist/assets/haml-B2EZWmdv.js +0 -1
  123. package/dist/assets/handlebars-BQGss363.js +0 -1
  124. package/dist/assets/haskell-BILxekzW.js +0 -1
  125. package/dist/assets/haxe-C5wWYbrZ.js +0 -1
  126. package/dist/assets/hcl-HzYwdGDm.js +0 -1
  127. package/dist/assets/hjson-T-Tgc4AT.js +0 -1
  128. package/dist/assets/hlsl-ifBTmRxC.js +0 -1
  129. package/dist/assets/houston-DnULxvSX.js +0 -1
  130. package/dist/assets/html-C2L_23MC.js +0 -1
  131. package/dist/assets/html-derivative-CSfWNPLT.js +0 -1
  132. package/dist/assets/http-FRrOvY1W.js +0 -1
  133. package/dist/assets/hxml-TIA70rKU.js +0 -1
  134. package/dist/assets/hy-BMj5Y0dO.js +0 -1
  135. package/dist/assets/imba-bv_oIlVt.js +0 -1
  136. package/dist/assets/index-BuQ1xTdM.css +0 -1
  137. package/dist/assets/index-DclShbpd.js +0 -125
  138. package/dist/assets/ini-BjABl1g7.js +0 -1
  139. package/dist/assets/java-xI-RfyKK.js +0 -1
  140. package/dist/assets/javascript-ySlJ1b_l.js +0 -1
  141. package/dist/assets/jinja-DGy0s7-h.js +0 -1
  142. package/dist/assets/jison-BqZprYcd.js +0 -1
  143. package/dist/assets/json-BQoSv7ci.js +0 -1
  144. package/dist/assets/json5-w8dY5SsB.js +0 -1
  145. package/dist/assets/jsonc-TU54ms6u.js +0 -1
  146. package/dist/assets/jsonl-DREVFZK8.js +0 -1
  147. package/dist/assets/jsonnet-BfivnA6A.js +0 -1
  148. package/dist/assets/jssm-P4WzXJd0.js +0 -1
  149. package/dist/assets/jsx-BAng5TT0.js +0 -1
  150. package/dist/assets/julia-BBuGR-5E.js +0 -1
  151. package/dist/assets/kanagawa-dragon-CkXjmgJE.js +0 -1
  152. package/dist/assets/kanagawa-lotus-CfQXZHmo.js +0 -1
  153. package/dist/assets/kanagawa-wave-DWedfzmr.js +0 -1
  154. package/dist/assets/kotlin-B5lbUyaz.js +0 -1
  155. package/dist/assets/kusto-mebxcVVE.js +0 -1
  156. package/dist/assets/laserwave-DUszq2jm.js +0 -1
  157. package/dist/assets/latex-C-cWTeAZ.js +0 -1
  158. package/dist/assets/lean-XBlWyCtg.js +0 -1
  159. package/dist/assets/less-BfCpw3nA.js +0 -1
  160. package/dist/assets/light-plus-B7mTdjB0.js +0 -1
  161. package/dist/assets/liquid-D3W5UaiH.js +0 -1
  162. package/dist/assets/log-Cc5clBb7.js +0 -1
  163. package/dist/assets/logo-IuBKFhSY.js +0 -1
  164. package/dist/assets/lua-CvWAzNxB.js +0 -1
  165. package/dist/assets/luau-Du5NY7AG.js +0 -1
  166. package/dist/assets/make-Bvotw-X0.js +0 -1
  167. package/dist/assets/markdown-UIAJJxZW.js +0 -1
  168. package/dist/assets/marko-z0MBrx5-.js +0 -1
  169. package/dist/assets/material-theme-D5KoaKCx.js +0 -1
  170. package/dist/assets/material-theme-darker-BfHTSMKl.js +0 -1
  171. package/dist/assets/material-theme-lighter-B0m2ddpp.js +0 -1
  172. package/dist/assets/material-theme-ocean-CyktbL80.js +0 -1
  173. package/dist/assets/material-theme-palenight-Csfq5Kiy.js +0 -1
  174. package/dist/assets/matlab-D9-PGadD.js +0 -1
  175. package/dist/assets/mdc-DB_EDNY_.js +0 -1
  176. package/dist/assets/mdx-sdHcTMYB.js +0 -1
  177. package/dist/assets/mermaid-Ci6OQyBP.js +0 -1
  178. package/dist/assets/min-dark-CafNBF8u.js +0 -1
  179. package/dist/assets/min-light-CTRr51gU.js +0 -1
  180. package/dist/assets/mipsasm-BC5c_5Pe.js +0 -1
  181. package/dist/assets/mojo-Tz6hzZYG.js +0 -1
  182. package/dist/assets/monokai-D4h5O-jR.js +0 -1
  183. package/dist/assets/move-DB_GagMm.js +0 -1
  184. package/dist/assets/narrat-DLbgOhZU.js +0 -1
  185. package/dist/assets/nextflow-B0XVJmRM.js +0 -1
  186. package/dist/assets/nginx-D_VnBJ67.js +0 -1
  187. package/dist/assets/night-owl-C39BiMTA.js +0 -1
  188. package/dist/assets/nim-ZlGxZxc3.js +0 -1
  189. package/dist/assets/nix-shcSOmrb.js +0 -1
  190. package/dist/assets/nord-Ddv68eIx.js +0 -1
  191. package/dist/assets/nushell-D4Tzg5kh.js +0 -1
  192. package/dist/assets/objective-c-Deuh7S70.js +0 -1
  193. package/dist/assets/objective-cpp-BUEGK8hf.js +0 -1
  194. package/dist/assets/ocaml-BNioltXt.js +0 -1
  195. package/dist/assets/one-dark-pro-GBQ2dnAY.js +0 -1
  196. package/dist/assets/one-light-PoHY5YXO.js +0 -1
  197. package/dist/assets/pascal-JqZropPD.js +0 -1
  198. package/dist/assets/perl-CHQXSrWU.js +0 -1
  199. package/dist/assets/php-B5ebYQev.js +0 -1
  200. package/dist/assets/plastic-3e1v2bzS.js +0 -1
  201. package/dist/assets/plsql-LKU2TuZ1.js +0 -1
  202. package/dist/assets/po-BFLt1xDp.js +0 -1
  203. package/dist/assets/poimandres-CS3Unz2-.js +0 -1
  204. package/dist/assets/polar-DKykz6zU.js +0 -1
  205. package/dist/assets/postcss-B3ZDOciz.js +0 -1
  206. package/dist/assets/powerquery-CSHBycmS.js +0 -1
  207. package/dist/assets/powershell-BIEUsx6d.js +0 -1
  208. package/dist/assets/prisma-B48N-Iqd.js +0 -1
  209. package/dist/assets/prolog-BY-TUvya.js +0 -1
  210. package/dist/assets/proto-zocC4JxJ.js +0 -1
  211. package/dist/assets/pug-CM9l7STV.js +0 -1
  212. package/dist/assets/puppet-Cza_XSSt.js +0 -1
  213. package/dist/assets/purescript-Bg-kzb6g.js +0 -1
  214. package/dist/assets/python-DhUJRlN_.js +0 -1
  215. package/dist/assets/qml-D8XfuvdV.js +0 -1
  216. package/dist/assets/qmldir-C8lEn-DE.js +0 -1
  217. package/dist/assets/qss-DhMKtDLN.js +0 -1
  218. package/dist/assets/r-CwjWoCRV.js +0 -1
  219. package/dist/assets/racket-CzouJOBO.js +0 -1
  220. package/dist/assets/raku-B1bQXN8T.js +0 -1
  221. package/dist/assets/razor-CNLDkMZG.js +0 -1
  222. package/dist/assets/red-bN70gL4F.js +0 -1
  223. package/dist/assets/reg-5LuOXUq_.js +0 -1
  224. package/dist/assets/regexp-DWJ3fJO_.js +0 -1
  225. package/dist/assets/rel-DJlmqQ1C.js +0 -1
  226. package/dist/assets/riscv-QhoSD0DR.js +0 -1
  227. package/dist/assets/rose-pine-CmCqftbK.js +0 -1
  228. package/dist/assets/rose-pine-dawn-Ds-gbosJ.js +0 -1
  229. package/dist/assets/rose-pine-moon-CjDtw9vr.js +0 -1
  230. package/dist/assets/rst-4NLicBqY.js +0 -1
  231. package/dist/assets/ruby-DeZ3UC14.js +0 -1
  232. package/dist/assets/rust-Be6lgOlo.js +0 -1
  233. package/dist/assets/sas-BmTFh92c.js +0 -1
  234. package/dist/assets/sass-BJ4Li9vH.js +0 -1
  235. package/dist/assets/scala-DQVVAn-B.js +0 -1
  236. package/dist/assets/scheme-BJGe-b2p.js +0 -1
  237. package/dist/assets/scss-C31hgJw-.js +0 -1
  238. package/dist/assets/sdbl-BLhTXw86.js +0 -1
  239. package/dist/assets/shaderlab-B7qAK45m.js +0 -1
  240. package/dist/assets/shellscript-atvbtKCR.js +0 -1
  241. package/dist/assets/shellsession-C_rIy8kc.js +0 -1
  242. package/dist/assets/slack-dark-BthQWCQV.js +0 -1
  243. package/dist/assets/slack-ochin-DqwNpetd.js +0 -1
  244. package/dist/assets/smalltalk-DkLiglaE.js +0 -1
  245. package/dist/assets/snazzy-light-Bw305WKR.js +0 -1
  246. package/dist/assets/solarized-dark-DXbdFlpD.js +0 -1
  247. package/dist/assets/solarized-light-L9t79GZl.js +0 -1
  248. package/dist/assets/solidity-C1w2a3ep.js +0 -1
  249. package/dist/assets/soy-C-lX7w71.js +0 -1
  250. package/dist/assets/sparql-bYkjHRlG.js +0 -1
  251. package/dist/assets/splunk-Cf8iN4DR.js +0 -1
  252. package/dist/assets/sql-COK4E0Yg.js +0 -1
  253. package/dist/assets/ssh-config-BknIz3MU.js +0 -1
  254. package/dist/assets/stata-DorPZHa4.js +0 -1
  255. package/dist/assets/stylus-BeQkCIfX.js +0 -1
  256. package/dist/assets/svelte-MSaWC3Je.js +0 -1
  257. package/dist/assets/swift-BSxZ-RaX.js +0 -1
  258. package/dist/assets/synthwave-84-CbfX1IO0.js +0 -1
  259. package/dist/assets/system-verilog-C7L56vO4.js +0 -1
  260. package/dist/assets/systemd-CUnW07Te.js +0 -1
  261. package/dist/assets/talonscript-C1XDQQGZ.js +0 -1
  262. package/dist/assets/tasl-CQjiPCtT.js +0 -1
  263. package/dist/assets/tcl-DQ1-QYvQ.js +0 -1
  264. package/dist/assets/templ-dwX3ZSMB.js +0 -1
  265. package/dist/assets/terraform-BbSNqyBO.js +0 -1
  266. package/dist/assets/tex-rYs2v40G.js +0 -1
  267. package/dist/assets/tokyo-night-DBQeEorK.js +0 -1
  268. package/dist/assets/toml-CB2ApiWb.js +0 -1
  269. package/dist/assets/ts-tags-CipyTH0X.js +0 -1
  270. package/dist/assets/tsv-B_m7g4N7.js +0 -1
  271. package/dist/assets/tsx-B6W0miNI.js +0 -1
  272. package/dist/assets/turtle-BMR_PYu6.js +0 -1
  273. package/dist/assets/twig-NC5TFiHP.js +0 -1
  274. package/dist/assets/typescript-Dj6nwHGl.js +0 -1
  275. package/dist/assets/typespec-BpWG_bgh.js +0 -1
  276. package/dist/assets/typst-BVUVsWT6.js +0 -1
  277. package/dist/assets/v-CAQ2eGtk.js +0 -1
  278. package/dist/assets/vala-BFOHcciG.js +0 -1
  279. package/dist/assets/vb-CdO5JTpU.js +0 -1
  280. package/dist/assets/verilog-CJaU5se_.js +0 -1
  281. package/dist/assets/vesper-BEBZ7ncR.js +0 -1
  282. package/dist/assets/vhdl-DYoNaHQp.js +0 -1
  283. package/dist/assets/viml-m4uW47V2.js +0 -1
  284. package/dist/assets/vitesse-black-Bkuqu6BP.js +0 -1
  285. package/dist/assets/vitesse-dark-D0r3Knsf.js +0 -1
  286. package/dist/assets/vitesse-light-CVO1_9PV.js +0 -1
  287. package/dist/assets/vue-BuYVFjOK.js +0 -1
  288. package/dist/assets/vue-html-xdeiXROB.js +0 -1
  289. package/dist/assets/vyper-nyqBNV6O.js +0 -1
  290. package/dist/assets/wasm-C6j12Q_x.js +0 -1
  291. package/dist/assets/wasm-CG6Dc4jp.js +0 -1
  292. package/dist/assets/wenyan-7A4Fjokl.js +0 -1
  293. package/dist/assets/wgsl-CB0Krxn9.js +0 -1
  294. package/dist/assets/wikitext-DCE3LsBG.js +0 -1
  295. package/dist/assets/wolfram-C3FkfJm5.js +0 -1
  296. package/dist/assets/xml-e3z08dGr.js +0 -1
  297. package/dist/assets/xsl-Dd0NUgwM.js +0 -1
  298. package/dist/assets/yaml-CVw76BM1.js +0 -1
  299. package/dist/assets/zenscript-HnGAYVZD.js +0 -1
  300. package/dist/assets/zig-BVz_zdnA.js +0 -1
  301. package/server/app.js +0 -165
  302. package/server/app.test.js +0 -30
  303. package/server/bd.js +0 -227
  304. package/server/bd.test.js +0 -194
  305. package/server/cli/cli.test.js +0 -207
  306. package/server/cli/commands.integration.test.js +0 -148
  307. package/server/cli/commands.js +0 -285
  308. package/server/cli/commands.unit.test.js +0 -408
  309. package/server/cli/daemon.js +0 -340
  310. package/server/cli/daemon.test.js +0 -31
  311. package/server/cli/index.js +0 -135
  312. package/server/cli/open.js +0 -178
  313. package/server/cli/open.test.js +0 -26
  314. package/server/cli/usage.js +0 -49
  315. package/server/config.js +0 -36
  316. package/server/db.test.js +0 -169
  317. package/server/dolt-pool.js +0 -313
  318. package/server/dolt-queries.js +0 -764
  319. package/server/list-adapters.js +0 -421
  320. package/server/list-adapters.test.js +0 -208
  321. package/server/logging.js +0 -23
  322. package/server/registry-watcher.js +0 -200
  323. package/server/subscriptions.js +0 -299
  324. package/server/subscriptions.test.js +0 -128
  325. package/server/validators.js +0 -124
  326. package/server/watcher.js +0 -139
  327. package/server/watcher.test.js +0 -120
  328. package/server/ws.comments.test.js +0 -262
  329. package/server/ws.delete.test.js +0 -119
  330. package/server/ws.js +0 -1329
  331. package/server/ws.labels.test.js +0 -95
  332. package/server/ws.list-refresh.coalesce.test.js +0 -95
  333. package/server/ws.list-subscriptions.test.js +0 -403
  334. package/server/ws.mutation-window.test.js +0 -147
  335. package/server/ws.mutations.test.js +0 -389
  336. package/server/ws.test.js +0 -52
package/server/ws.js DELETED
@@ -1,1329 +0,0 @@
1
- /**
2
- * @import { Server } from 'node:http'
3
- * @import { RawData, WebSocket } from 'ws'
4
- * @import { MessageType } from '../app/protocol.js'
5
- */
6
- import path from 'node:path';
7
- import { WebSocketServer } from 'ws';
8
- import { isRequest, makeError, makeOk } from '../app/protocol.js';
9
- import { isAuthEnabled, verifyToken } from './auth.js';
10
- import { getGitUserName, runBd, runBdJson } from './bd.js';
11
- import { resolveWorkspaceDatabase } from './db.js';
12
- import { rebindDoltServer } from './dolt-pool.js';
13
- import {
14
- addComment,
15
- addDependency,
16
- addLabel,
17
- deleteIssue,
18
- isDoltPoolReady,
19
- queryComments,
20
- queryIssueDetail,
21
- removeDependency,
22
- removeLabel,
23
- updateIssueField
24
- } from './dolt-queries.js';
25
- import {
26
- enrichIssueDetailParentContext,
27
- fetchListForSubscription
28
- } from './list-adapters.js';
29
- import { debug } from './logging.js';
30
- import { getAvailableWorkspaces } from './registry-watcher.js';
31
- import { keyOf, registry } from './subscriptions.js';
32
- import { validateSubscribeListPayload } from './validators.js';
33
-
34
- const log = debug('ws');
35
-
36
- /**
37
- * Run a mutation via SQL (with detail refetch) or bd CLI fallback, send the
38
- * result to the client, and trigger a subscription refresh.
39
- *
40
- * Covers the common pattern: mutate → fetch updated issue → respond → refresh.
41
- *
42
- * @param {WebSocket} ws
43
- * @param {any} req
44
- * @param {string} detailId - Issue ID to fetch after mutation
45
- * @param {() => Promise<{ ok: boolean, error?: { code: string, message: string } }>} sqlMutateFn
46
- * @param {string[]} bdArgs - Arguments for the bd CLI fallback
47
- */
48
- async function mutateAndRespond(ws, req, detailId, sqlMutateFn, bdArgs) {
49
- if (isDoltPoolReady()) {
50
- const upd = await sqlMutateFn();
51
- if (!upd.ok) {
52
- ws.send(JSON.stringify(makeError(req, upd.error.code, upd.error.message)));
53
- return;
54
- }
55
- const detail = await queryIssueDetail(detailId);
56
- if (!detail.ok) {
57
- ws.send(JSON.stringify(makeError(req, detail.error.code, detail.error.message)));
58
- return;
59
- }
60
- ws.send(JSON.stringify(makeOk(req, detail.item)));
61
- } else {
62
- const res = await runBd(bdArgs);
63
- if (res.code !== 0) {
64
- ws.send(JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed')));
65
- return;
66
- }
67
- const shown = await runBdJson(['show', detailId, '--json']);
68
- if (shown.code !== 0) {
69
- ws.send(JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed')));
70
- return;
71
- }
72
- const detail =
73
- shown.stdoutJson && typeof shown.stdoutJson === 'object' && !Array.isArray(shown.stdoutJson)
74
- ? await enrichIssueDetailParentContext(
75
- /** @type {Record<string, unknown>} */ (shown.stdoutJson),
76
- { cwd: CURRENT_WORKSPACE?.root_dir }
77
- )
78
- : shown.stdoutJson;
79
- ws.send(JSON.stringify(makeOk(req, detail)));
80
- }
81
- try { triggerMutationRefreshOnce(); } catch { /* ignore */ }
82
- }
83
-
84
- /**
85
- * Debounced refresh scheduling for active list subscriptions.
86
- * A trailing window coalesces rapid change bursts into a single refresh run.
87
- */
88
- /** @type {ReturnType<typeof setTimeout> | null} */
89
- let REFRESH_TIMER = null;
90
- let REFRESH_DEBOUNCE_MS = 75;
91
-
92
- /**
93
- * Mutation refresh window gate. When active, watcher-driven list refresh
94
- * scheduling is suppressed. The gate resolves either when a watcher event
95
- * arrives (via scheduleListRefresh) or when a timeout elapses, at which
96
- * point a single refresh pass over all active list subscriptions is run.
97
- */
98
- /**
99
- * @typedef {Object} MutationGate
100
- * @property {boolean} resolved
101
- * @property {(reason: 'watcher'|'timeout') => void} resolve
102
- * @property {ReturnType<typeof setTimeout>} timer
103
- */
104
- /** @type {MutationGate | null} */
105
- let MUTATION_GATE = null;
106
-
107
- /**
108
- * Start a mutation window gate if not already active. The gate resolves on the
109
- * next watcher event or after `timeout_ms`, then triggers a single refresh run
110
- * across all active list subscriptions. Watcher-driven refresh scheduling is
111
- * suppressed during the window.
112
- *
113
- * Fire-and-forget; callers should not await this.
114
- *
115
- * @param {number} [timeout_ms]
116
- */
117
- function triggerMutationRefreshOnce(timeout_ms = 500) {
118
- if (MUTATION_GATE) {
119
- return;
120
- }
121
- /** @type {(r: 'watcher'|'timeout') => void} */
122
- let doResolve = () => {};
123
- const p = new Promise((resolve) => {
124
- doResolve = resolve;
125
- });
126
- MUTATION_GATE = {
127
- resolved: false,
128
- resolve: (reason) => {
129
- if (!MUTATION_GATE || MUTATION_GATE.resolved) {
130
- return;
131
- }
132
- MUTATION_GATE.resolved = true;
133
- try {
134
- doResolve(reason);
135
- } catch {
136
- // ignore resolve errors
137
- }
138
- },
139
- timer: setTimeout(() => {
140
- try {
141
- MUTATION_GATE?.resolve('timeout');
142
- } catch {
143
- // ignore
144
- }
145
- }, timeout_ms)
146
- };
147
- MUTATION_GATE.timer.unref?.();
148
-
149
- // After resolution, run a single refresh across active subs and clear gate
150
- void p.then(async () => {
151
- log('mutation window resolved → refresh active subs');
152
- try {
153
- await refreshAllActiveListSubscriptions();
154
- } catch {
155
- // ignore refresh errors
156
- } finally {
157
- try {
158
- if (MUTATION_GATE?.timer) {
159
- clearTimeout(MUTATION_GATE.timer);
160
- }
161
- } catch {
162
- // ignore
163
- }
164
- MUTATION_GATE = null;
165
- }
166
- });
167
- }
168
-
169
- /**
170
- * Collect unique active list subscription specs across all connected clients.
171
- *
172
- * @returns {Array<{ type: string, params?: Record<string,string|number|boolean> }>}
173
- */
174
- function collectActiveListSpecs() {
175
- /** @type {Array<{ type: string, params?: Record<string,string|number|boolean> }>} */
176
- const specs = [];
177
- /** @type {Set<string>} */
178
- const seen = new Set();
179
- const wss = CURRENT_WSS;
180
- if (!wss) {
181
- return specs;
182
- }
183
- for (const ws of wss.clients) {
184
- if (ws.readyState !== ws.OPEN) {
185
- continue;
186
- }
187
- const s = ensureSubs(/** @type {any} */ (ws));
188
- if (!s.list_subs) {
189
- continue;
190
- }
191
- for (const { key, spec } of s.list_subs.values()) {
192
- if (!seen.has(key)) {
193
- seen.add(key);
194
- specs.push(spec);
195
- }
196
- }
197
- }
198
- return specs;
199
- }
200
-
201
- /**
202
- * Run refresh for all active list subscription specs and publish deltas.
203
- */
204
- async function refreshAllActiveListSubscriptions() {
205
- const specs = collectActiveListSpecs();
206
- // Run refreshes concurrently; locking is handled per key in the registry
207
- await Promise.all(
208
- specs.map(async (spec) => {
209
- try {
210
- await refreshAndPublish(spec);
211
- } catch {
212
- // ignore refresh errors per spec
213
- }
214
- })
215
- );
216
- }
217
-
218
- /**
219
- * Schedule a coalesced refresh of all active list subscriptions.
220
- */
221
- export function scheduleListRefresh() {
222
- // Suppress watcher-driven refreshes during an active mutation gate; resolve gate once
223
- if (MUTATION_GATE) {
224
- try {
225
- MUTATION_GATE.resolve('watcher');
226
- } catch {
227
- // ignore
228
- }
229
- return;
230
- }
231
- if (REFRESH_TIMER) {
232
- clearTimeout(REFRESH_TIMER);
233
- }
234
- REFRESH_TIMER = setTimeout(() => {
235
- REFRESH_TIMER = null;
236
- // Fire and forget; callers don't await scheduling
237
- void refreshAllActiveListSubscriptions();
238
- }, REFRESH_DEBOUNCE_MS);
239
- REFRESH_TIMER.unref?.();
240
- }
241
-
242
- /**
243
- * @typedef {{
244
- * show_id?: string | null,
245
- * list_subs?: Map<string, { key: string, spec: { type: string, params?: Record<string, string | number | boolean> } }>,
246
- * list_revisions?: Map<string, number>
247
- * }} ConnectionSubs
248
- */
249
-
250
- /** @type {WeakMap<WebSocket, any>} */
251
- const SUBS = new WeakMap();
252
-
253
- /** @type {WebSocketServer | null} */
254
- let CURRENT_WSS = null;
255
-
256
- /**
257
- * Current workspace configuration.
258
- *
259
- * @type {{ root_dir: string, db_path: string } | null}
260
- */
261
- let CURRENT_WORKSPACE = null;
262
-
263
- /**
264
- * Reference to the database watcher for rebinding on workspace change.
265
- *
266
- * @type {{ rebind: (opts?: { root_dir?: string }) => void, path: string } | null}
267
- */
268
- let DB_WATCHER = null;
269
-
270
- /**
271
- * Get or initialize the subscription state for a socket.
272
- *
273
- * @param {WebSocket} ws
274
- * @returns {any}
275
- */
276
- function ensureSubs(ws) {
277
- let s = SUBS.get(ws);
278
- if (!s) {
279
- s = {
280
- show_id: null,
281
- list_subs: new Map(),
282
- list_revisions: new Map()
283
- };
284
- SUBS.set(ws, s);
285
- }
286
- return s;
287
- }
288
-
289
- /**
290
- * Get next monotonically increasing revision for a subscription key on this connection.
291
- *
292
- * @param {WebSocket} ws
293
- * @param {string} key
294
- */
295
- /**
296
- * @param {WebSocket} ws
297
- * @param {string} key
298
- */
299
- function nextListRevision(ws, key) {
300
- const s = ensureSubs(ws);
301
- const m = s.list_revisions || new Map();
302
- s.list_revisions = m;
303
- const prev = m.get(key) || 0;
304
- const next = prev + 1;
305
- m.set(key, next);
306
- return next;
307
- }
308
-
309
- /**
310
- * Emit per-subscription envelopes to a specific client id on a socket.
311
- * Helpers for snapshot / upsert / delete.
312
- */
313
- /**
314
- * @param {WebSocket} ws
315
- * @param {string} client_id
316
- * @param {string} key
317
- * @param {Array<Record<string, unknown>>} issues
318
- * @param {number} [total] - Total count for paginated queries
319
- */
320
- function emitSubscriptionSnapshot(ws, client_id, key, issues, total) {
321
- const revision = nextListRevision(ws, key);
322
- const payload = {
323
- type: /** @type {const} */ ('snapshot'),
324
- id: client_id,
325
- revision,
326
- issues,
327
- ...(typeof total === 'number' ? { total } : {})
328
- };
329
- const msg = JSON.stringify({
330
- id: `evt-${Date.now()}`,
331
- ok: true,
332
- type: /** @type {MessageType} */ ('snapshot'),
333
- payload
334
- });
335
- try {
336
- ws.send(msg);
337
- } catch (err) {
338
- log('emit snapshot send failed key=%s id=%s: %o', key, client_id, err);
339
- }
340
- }
341
-
342
- /**
343
- * @param {WebSocket} ws
344
- * @param {string} client_id
345
- * @param {string} key
346
- * @param {Record<string, unknown>} issue
347
- */
348
- function emitSubscriptionUpsert(ws, client_id, key, issue) {
349
- const revision = nextListRevision(ws, key);
350
- const payload = {
351
- type: 'upsert',
352
- id: client_id,
353
- revision,
354
- issue
355
- };
356
- const msg = JSON.stringify({
357
- id: `evt-${Date.now()}`,
358
- ok: true,
359
- type: /** @type {MessageType} */ ('upsert'),
360
- payload
361
- });
362
- try {
363
- ws.send(msg);
364
- } catch (err) {
365
- log('emit upsert send failed key=%s id=%s: %o', key, client_id, err);
366
- }
367
- }
368
-
369
- /**
370
- * @param {WebSocket} ws
371
- * @param {string} client_id
372
- * @param {string} key
373
- * @param {string} issue_id
374
- */
375
- function emitSubscriptionDelete(ws, client_id, key, issue_id) {
376
- const revision = nextListRevision(ws, key);
377
- const payload = {
378
- type: 'delete',
379
- id: client_id,
380
- revision,
381
- issue_id
382
- };
383
- const msg = JSON.stringify({
384
- id: `evt-${Date.now()}`,
385
- ok: true,
386
- type: /** @type {MessageType} */ ('delete'),
387
- payload
388
- });
389
- try {
390
- ws.send(msg);
391
- } catch (err) {
392
- log('emit delete send failed key=%s id=%s: %o', key, client_id, err);
393
- }
394
- }
395
-
396
- // issues-changed removed in v2: detail and lists are pushed via subscriptions
397
-
398
- /**
399
- * Refresh a subscription spec: fetch via adapter, apply to registry and emit
400
- * per-subscription full-issue envelopes to subscribers. Serialized per key.
401
- *
402
- * @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
403
- */
404
- async function refreshAndPublish(spec) {
405
- const key = keyOf(spec);
406
- await registry.withKeyLock(key, async () => {
407
- const res = await fetchListForSubscription(spec, {
408
- cwd: CURRENT_WORKSPACE?.root_dir
409
- });
410
- if (!res.ok) {
411
- log('refresh failed for %s: %s %o', key, res.error.message, res.error);
412
- return;
413
- }
414
- const items = applyClosedIssuesFilter(spec, res.items);
415
- const prev_size = registry.get(key)?.itemsById.size || 0;
416
- const delta = registry.applyItems(key, items);
417
- const entry = registry.get(key);
418
- if (!entry || entry.subscribers.size === 0) {
419
- return;
420
- }
421
- /** @type {Map<string, any>} */
422
- const by_id = new Map();
423
- for (const it of items) {
424
- if (it && typeof it.id === 'string') {
425
- by_id.set(it.id, it);
426
- }
427
- }
428
- for (const ws of entry.subscribers) {
429
- if (ws.readyState !== ws.OPEN) continue;
430
- const s = ensureSubs(ws);
431
- const subs = s.list_subs || new Map();
432
- /** @type {string[]} */
433
- const client_ids = [];
434
- for (const [cid, v] of subs.entries()) {
435
- if (v.key === key) client_ids.push(cid);
436
- }
437
- if (client_ids.length === 0) continue;
438
- if (prev_size === 0) {
439
- for (const cid of client_ids) {
440
- emitSubscriptionSnapshot(ws, cid, key, items);
441
- }
442
- continue;
443
- }
444
- for (const cid of client_ids) {
445
- for (const id of [...delta.added, ...delta.updated]) {
446
- const issue = by_id.get(id);
447
- if (issue) {
448
- emitSubscriptionUpsert(ws, cid, key, issue);
449
- }
450
- }
451
- for (const id of delta.removed) {
452
- emitSubscriptionDelete(ws, cid, key, id);
453
- }
454
- }
455
- }
456
- });
457
- }
458
-
459
- /**
460
- * Apply pre-diff filtering for closed-issues lists based on spec.params.since (epoch ms).
461
- *
462
- * @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
463
- * @param {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
464
- */
465
- function applyClosedIssuesFilter(spec, items) {
466
- if (String(spec.type) !== 'closed-issues') {
467
- return items;
468
- }
469
- const p = spec.params || {};
470
- const since = typeof p.since === 'number' ? p.since : 0;
471
- if (!Number.isFinite(since) || since <= 0) {
472
- return items;
473
- }
474
- /** @type {typeof items} */
475
- const out = [];
476
- for (const it of items) {
477
- const ca = it.closed_at;
478
- if (typeof ca === 'number' && Number.isFinite(ca) && ca >= since) {
479
- out.push(it);
480
- }
481
- }
482
- return out;
483
- }
484
-
485
- /**
486
- * Attach a WebSocket server to an existing HTTP server.
487
- *
488
- * @param {Server} http_server
489
- * @param {{ path?: string, heartbeat_ms?: number, refresh_debounce_ms?: number, root_dir?: string, watcher?: { rebind: (opts?: { root_dir?: string }) => void, path: string } }} [options]
490
- * @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, scheduleListRefresh: () => void, setWorkspace: (root_dir: string) => { changed: boolean, workspace: { root_dir: string, db_path: string } } }}
491
- */
492
- export function attachWsServer(http_server, options = {}) {
493
- const ws_path = options.path || '/ws';
494
-
495
- // Initialize workspace state
496
- const initial_root = options.root_dir || process.cwd();
497
- const initial_db = resolveWorkspaceDatabase({ cwd: initial_root });
498
- CURRENT_WORKSPACE = {
499
- root_dir: initial_root,
500
- db_path: initial_db.path
501
- };
502
-
503
- if (options.watcher) {
504
- DB_WATCHER = options.watcher;
505
- }
506
- const heartbeat_ms = options.heartbeat_ms ?? 30000;
507
- if (typeof options.refresh_debounce_ms === 'number') {
508
- const n = options.refresh_debounce_ms;
509
- if (Number.isFinite(n) && n >= 0) {
510
- REFRESH_DEBOUNCE_MS = n;
511
- }
512
- }
513
-
514
- const wss = new WebSocketServer({
515
- server: http_server,
516
- path: ws_path,
517
- verifyClient: (info, cb) => {
518
- if (!isAuthEnabled()) return cb(true);
519
- const url = new URL(info.req.url, 'http://localhost');
520
- const token = url.searchParams.get('token');
521
- if (token && verifyToken(token)) return cb(true);
522
- cb(false, 401, 'Unauthorized');
523
- }
524
- });
525
- CURRENT_WSS = wss;
526
-
527
- // Heartbeat: track if client answered the last ping
528
- wss.on('connection', (ws) => {
529
- log('client connected');
530
- // @ts-expect-error add marker property
531
- ws.isAlive = true;
532
-
533
- // Initialize subscription state for this connection
534
- ensureSubs(ws);
535
-
536
- ws.on('pong', () => {
537
- // @ts-expect-error marker
538
- ws.isAlive = true;
539
- });
540
-
541
- ws.on('message', (data) => {
542
- handleMessage(ws, data);
543
- });
544
-
545
- ws.on('close', () => {
546
- try {
547
- registry.onDisconnect(ws);
548
- } catch {
549
- // ignore cleanup errors
550
- }
551
- });
552
- });
553
-
554
- const interval = setInterval(() => {
555
- for (const ws of wss.clients) {
556
- // @ts-expect-error marker
557
- if (ws.isAlive === false) {
558
- ws.terminate();
559
- continue;
560
- }
561
- // @ts-expect-error marker
562
- ws.isAlive = false;
563
- ws.ping();
564
- }
565
- }, heartbeat_ms);
566
-
567
- interval.unref?.();
568
-
569
- wss.on('close', () => {
570
- clearInterval(interval);
571
- });
572
-
573
- /**
574
- * Broadcast a server-initiated event to all open clients.
575
- *
576
- * @param {MessageType} type
577
- * @param {unknown} [payload]
578
- */
579
- function broadcast(type, payload) {
580
- const msg = JSON.stringify({
581
- id: `evt-${Date.now()}`,
582
- ok: true,
583
- type,
584
- payload
585
- });
586
- for (const ws of wss.clients) {
587
- if (ws.readyState === ws.OPEN) {
588
- ws.send(msg);
589
- }
590
- }
591
- }
592
-
593
- /**
594
- * Change the current workspace and rebind the database watcher.
595
- *
596
- * @param {string} new_root_dir - Absolute path to the new workspace root.
597
- * @returns {{ changed: boolean, workspace: { root_dir: string, db_path: string } }}
598
- */
599
- function setWorkspace(new_root_dir) {
600
- const resolved_root = path.resolve(new_root_dir);
601
- const new_db = resolveWorkspaceDatabase({ cwd: resolved_root });
602
- const old_path = CURRENT_WORKSPACE?.db_path || '';
603
-
604
- CURRENT_WORKSPACE = {
605
- root_dir: resolved_root,
606
- db_path: new_db.path
607
- };
608
-
609
- const changed = new_db.path !== old_path;
610
-
611
- if (changed) {
612
- log('workspace changed: %s → %s', old_path, new_db.path);
613
-
614
- // Rebind the database watcher to the new workspace
615
- if (DB_WATCHER) {
616
- DB_WATCHER.rebind({ root_dir: resolved_root });
617
- }
618
-
619
- // Clear existing registry entries
620
- registry.clear();
621
-
622
- // Broadcast workspace-changed event to all clients
623
- broadcast('workspace-changed', CURRENT_WORKSPACE);
624
-
625
- // Rebind the Dolt SQL pool THEN refresh subscriptions
626
- rebindDoltServer(resolved_root).then((pool) => {
627
- log('Dolt pool rebound for %s: %s', resolved_root, pool ? 'connected' : 'unavailable');
628
- scheduleListRefresh();
629
- }).catch((err) => {
630
- log('rebindDoltServer error: %o', err);
631
- scheduleListRefresh();
632
- });
633
- }
634
-
635
- return { changed, workspace: CURRENT_WORKSPACE };
636
- }
637
-
638
- return {
639
- wss,
640
- broadcast,
641
- scheduleListRefresh,
642
- setWorkspace
643
- // v2: list subscription refresh handles updates
644
- };
645
- }
646
-
647
- /**
648
- * Handle an incoming message frame and respond to the same socket.
649
- *
650
- * @param {WebSocket} ws
651
- * @param {RawData} data
652
- */
653
- export async function handleMessage(ws, data) {
654
- /** @type {unknown} */
655
- let json;
656
- try {
657
- json = JSON.parse(data.toString());
658
- } catch {
659
- const reply = {
660
- id: 'unknown',
661
- ok: false,
662
- type: 'bad-json',
663
- error: { code: 'bad_json', message: 'Invalid JSON' }
664
- };
665
- ws.send(JSON.stringify(reply));
666
- return;
667
- }
668
-
669
- if (!isRequest(json)) {
670
- log('invalid request');
671
- const reply = {
672
- id: 'unknown',
673
- ok: false,
674
- type: 'bad-request',
675
- error: { code: 'bad_request', message: 'Invalid request envelope' }
676
- };
677
- ws.send(JSON.stringify(reply));
678
- return;
679
- }
680
-
681
- const req = json;
682
-
683
- // Dispatch known types here as we implement them. For now, only a ping utility.
684
- if (req.type === /** @type {MessageType} */ ('ping')) {
685
- ws.send(JSON.stringify(makeOk(req, { ts: Date.now() })));
686
- return;
687
- }
688
-
689
- // subscribe-list: payload { id: string, type: string, params?: object }
690
- if (req.type === 'subscribe-list') {
691
- const payload_id = /** @type {any} */ (req.payload)?.id || '';
692
- log('subscribe-list %s', payload_id);
693
- const validation = validateSubscribeListPayload(
694
- /** @type {any} */ (req.payload || {})
695
- );
696
- if (!validation.ok) {
697
- ws.send(
698
- JSON.stringify(makeError(req, validation.code, validation.message))
699
- );
700
- return;
701
- }
702
- const client_id = validation.id;
703
- const spec = validation.spec;
704
- const key = keyOf(spec);
705
-
706
- /**
707
- * Reply with an error and avoid attaching the subscription when
708
- * initialization fails.
709
- *
710
- * @param {string} code
711
- * @param {string} message
712
- * @param {Record<string, unknown>|undefined} details
713
- */
714
- const replyWithError = (code, message, details = undefined) => {
715
- ws.send(JSON.stringify(makeError(req, code, message, details)));
716
- };
717
-
718
- /** @type {Awaited<ReturnType<typeof fetchListForSubscription>> | null} */
719
- let initial = null;
720
- try {
721
- initial = await fetchListForSubscription(spec, {
722
- cwd: CURRENT_WORKSPACE?.root_dir
723
- });
724
- } catch (err) {
725
- log('subscribe-list snapshot error for %s: %o', key, err);
726
- const message =
727
- (err && /** @type {any} */ (err).message) || 'Failed to load list';
728
- replyWithError('bd_error', String(message), { key });
729
- return;
730
- }
731
-
732
- if (!initial.ok) {
733
- log(
734
- 'initial snapshot failed for %s: %s %o',
735
- key,
736
- initial.error.message,
737
- initial.error
738
- );
739
- const details = { ...(initial.error.details || {}), key };
740
- replyWithError(initial.error.code, initial.error.message, details);
741
- return;
742
- }
743
-
744
- const s = ensureSubs(ws);
745
- const { key: attached_key } = registry.attach(spec, ws);
746
- s.list_subs?.set(client_id, { key: attached_key, spec });
747
-
748
- try {
749
- await registry.withKeyLock(attached_key, async () => {
750
- const items = applyClosedIssuesFilter(
751
- spec,
752
- initial ? initial.items : []
753
- );
754
- void registry.applyItems(attached_key, items);
755
- emitSubscriptionSnapshot(ws, client_id, attached_key, items, initial?.total);
756
- });
757
- } catch (err) {
758
- log('subscribe-list snapshot error for %s: %o', attached_key, err);
759
- s.list_subs?.delete(client_id);
760
- try {
761
- registry.detach(spec, ws);
762
- } catch {
763
- // ignore detach errors
764
- }
765
- replyWithError('bd_error', 'Failed to publish snapshot', { key });
766
- return;
767
- }
768
-
769
- ws.send(JSON.stringify(makeOk(req, { id: client_id, key: attached_key })));
770
- return;
771
- }
772
-
773
- // unsubscribe-list: payload { id: string }
774
- if (req.type === 'unsubscribe-list') {
775
- log('unsubscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
776
- const { id: client_id } = /** @type {any} */ (req.payload || {});
777
- if (typeof client_id !== 'string' || client_id.length === 0) {
778
- ws.send(
779
- JSON.stringify(
780
- makeError(req, 'bad_request', 'payload.id must be a non-empty string')
781
- )
782
- );
783
- return;
784
- }
785
- const s = ensureSubs(ws);
786
- const sub = s.list_subs?.get(client_id) || null;
787
- let removed = false;
788
- if (sub) {
789
- try {
790
- removed = registry.detach(sub.spec, ws);
791
- } catch {
792
- removed = false;
793
- }
794
- s.list_subs?.delete(client_id);
795
- }
796
- ws.send(
797
- JSON.stringify(
798
- makeOk(req, {
799
- id: client_id,
800
- unsubscribed: removed
801
- })
802
- )
803
- );
804
- return;
805
- }
806
-
807
- // Removed: subscribe-updates and subscribe-issues. No-ops in v2.
808
-
809
- // list-issues and epic-status were removed in favor of push-only subscriptions
810
-
811
- // Removed: show-issue. Details flow is push-only via `subscribe-list { type: 'issue-detail' }`.
812
-
813
- // type updates are not exposed via UI; no handler
814
-
815
- // update-assignee
816
- if (req.type === 'update-assignee') {
817
- const { id, assignee } = /** @type {any} */ (req.payload || {});
818
- if (
819
- typeof id !== 'string' ||
820
- id.length === 0 ||
821
- typeof assignee !== 'string'
822
- ) {
823
- ws.send(
824
- JSON.stringify(
825
- makeError(
826
- req,
827
- 'bad_request',
828
- 'payload requires { id: string, assignee: string }'
829
- )
830
- )
831
- );
832
- return;
833
- }
834
- await mutateAndRespond(
835
- ws, req, id,
836
- () => updateIssueField(id, 'assignee', assignee || null),
837
- ['update', id, '--assignee', assignee]
838
- );
839
- return;
840
- }
841
-
842
- // update-status
843
- if (req.type === 'update-status') {
844
- log('update-status');
845
- const { id, status } = /** @type {any} */ (req.payload);
846
- const allowed = new Set(['open', 'in_progress', 'closed']);
847
- if (
848
- typeof id !== 'string' ||
849
- id.length === 0 ||
850
- typeof status !== 'string' ||
851
- !allowed.has(status)
852
- ) {
853
- ws.send(
854
- JSON.stringify(
855
- makeError(
856
- req,
857
- 'bad_request',
858
- "payload requires { id: string, status: 'open'|'in_progress'|'closed' }"
859
- )
860
- )
861
- );
862
- return;
863
- }
864
- await mutateAndRespond(
865
- ws, req, id,
866
- () => updateIssueField(id, 'status', status),
867
- ['update', id, '--status', status]
868
- );
869
- return;
870
- }
871
-
872
- // update-priority
873
- if (req.type === 'update-priority') {
874
- log('update-priority');
875
- const { id, priority } = /** @type {any} */ (req.payload);
876
- if (
877
- typeof id !== 'string' ||
878
- id.length === 0 ||
879
- typeof priority !== 'number' ||
880
- priority < 0 ||
881
- priority > 4
882
- ) {
883
- ws.send(
884
- JSON.stringify(
885
- makeError(
886
- req,
887
- 'bad_request',
888
- 'payload requires { id: string, priority: 0..4 }'
889
- )
890
- )
891
- );
892
- return;
893
- }
894
- await mutateAndRespond(
895
- ws, req, id,
896
- () => updateIssueField(id, 'priority', priority),
897
- ['update', id, '--priority', String(priority)]
898
- );
899
- return;
900
- }
901
-
902
- // edit-text
903
- if (req.type === 'edit-text') {
904
- log('edit-text');
905
- const { id, field, value } = /** @type {any} */ (req.payload);
906
- if (
907
- typeof id !== 'string' ||
908
- id.length === 0 ||
909
- (field !== 'title' &&
910
- field !== 'description' &&
911
- field !== 'acceptance' &&
912
- field !== 'notes' &&
913
- field !== 'design') ||
914
- typeof value !== 'string'
915
- ) {
916
- ws.send(
917
- JSON.stringify(
918
- makeError(
919
- req,
920
- 'bad_request',
921
- "payload requires { id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }"
922
- )
923
- )
924
- );
925
- return;
926
- }
927
- const SQL_FIELD_MAP = /** @type {Record<string, string>} */ ({
928
- title: 'title',
929
- description: 'description',
930
- acceptance: 'acceptance_criteria',
931
- notes: 'notes',
932
- design: 'design'
933
- });
934
- const BD_FLAG_MAP = /** @type {Record<string, string>} */ ({
935
- title: '--title',
936
- description: '--description',
937
- acceptance: '--acceptance-criteria',
938
- notes: '--notes',
939
- design: '--design'
940
- });
941
-
942
- await mutateAndRespond(
943
- ws, req, id,
944
- () => updateIssueField(id, SQL_FIELD_MAP[field], value),
945
- ['update', id, BD_FLAG_MAP[field], value]
946
- );
947
- return;
948
- }
949
-
950
- // create-issue
951
- if (req.type === 'create-issue') {
952
- log('create-issue');
953
- const { title, type, priority, description } = /** @type {any} */ (
954
- req.payload || {}
955
- );
956
- if (typeof title !== 'string' || title.length === 0) {
957
- ws.send(
958
- JSON.stringify(
959
- makeError(
960
- req,
961
- 'bad_request',
962
- 'payload requires { title: string, ... }'
963
- )
964
- )
965
- );
966
- return;
967
- }
968
- const args = ['create', title];
969
- if (
970
- typeof type === 'string' &&
971
- (type === 'bug' ||
972
- type === 'feature' ||
973
- type === 'task' ||
974
- type === 'epic' ||
975
- type === 'chore')
976
- ) {
977
- args.push('-t', type);
978
- }
979
- if (typeof priority === 'number' && priority >= 0 && priority <= 4) {
980
- args.push('-p', String(priority));
981
- }
982
- if (typeof description === 'string' && description.length > 0) {
983
- args.push('-d', description);
984
- }
985
- const res = await runBd(args);
986
- if (res.code !== 0) {
987
- ws.send(
988
- JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
989
- );
990
- return;
991
- }
992
- // Reply with a minimal ack
993
- ws.send(JSON.stringify(makeOk(req, { created: true })));
994
- // Refresh active subscriptions once (watcher or timeout)
995
- try {
996
- triggerMutationRefreshOnce();
997
- } catch {
998
- // ignore
999
- }
1000
- return;
1001
- }
1002
-
1003
- // dep-add: payload { a: string, b: string, view_id?: string }
1004
- if (req.type === 'dep-add') {
1005
- const { a, b, view_id } = /** @type {any} */ (req.payload || {});
1006
- if (
1007
- typeof a !== 'string' ||
1008
- a.length === 0 ||
1009
- typeof b !== 'string' ||
1010
- b.length === 0
1011
- ) {
1012
- ws.send(
1013
- JSON.stringify(
1014
- makeError(
1015
- req,
1016
- 'bad_request',
1017
- 'payload requires { a: string, b: string }'
1018
- )
1019
- )
1020
- );
1021
- return;
1022
- }
1023
- const id = typeof view_id === 'string' && view_id.length > 0 ? view_id : a;
1024
- await mutateAndRespond(
1025
- ws, req, id,
1026
- () => addDependency(a, b),
1027
- ['dep', 'add', a, b]
1028
- );
1029
- return;
1030
- }
1031
-
1032
- // dep-remove: payload { a: string, b: string, view_id?: string }
1033
- if (req.type === 'dep-remove') {
1034
- const { a, b, view_id } = /** @type {any} */ (req.payload || {});
1035
- if (
1036
- typeof a !== 'string' ||
1037
- a.length === 0 ||
1038
- typeof b !== 'string' ||
1039
- b.length === 0
1040
- ) {
1041
- ws.send(
1042
- JSON.stringify(
1043
- makeError(
1044
- req,
1045
- 'bad_request',
1046
- 'payload requires { a: string, b: string }'
1047
- )
1048
- )
1049
- );
1050
- return;
1051
- }
1052
- const id = typeof view_id === 'string' && view_id.length > 0 ? view_id : a;
1053
- await mutateAndRespond(
1054
- ws, req, id,
1055
- () => removeDependency(a, b),
1056
- ['dep', 'remove', a, b]
1057
- );
1058
- return;
1059
- }
1060
-
1061
- // label-add: payload { id: string, label: string }
1062
- if (req.type === 'label-add') {
1063
- const { id, label } = /** @type {any} */ (req.payload || {});
1064
- if (
1065
- typeof id !== 'string' ||
1066
- id.length === 0 ||
1067
- typeof label !== 'string' ||
1068
- label.trim().length === 0
1069
- ) {
1070
- ws.send(
1071
- JSON.stringify(
1072
- makeError(
1073
- req,
1074
- 'bad_request',
1075
- 'payload requires { id: string, label: non-empty string }'
1076
- )
1077
- )
1078
- );
1079
- return;
1080
- }
1081
- await mutateAndRespond(
1082
- ws, req, id,
1083
- () => addLabel(id, label.trim()),
1084
- ['label', 'add', id, label.trim()]
1085
- );
1086
- return;
1087
- }
1088
-
1089
- // label-remove: payload { id: string, label: string }
1090
- if (req.type === 'label-remove') {
1091
- const { id, label } = /** @type {any} */ (req.payload || {});
1092
- if (
1093
- typeof id !== 'string' ||
1094
- id.length === 0 ||
1095
- typeof label !== 'string' ||
1096
- label.trim().length === 0
1097
- ) {
1098
- ws.send(
1099
- JSON.stringify(
1100
- makeError(
1101
- req,
1102
- 'bad_request',
1103
- 'payload requires { id: string, label: non-empty string }'
1104
- )
1105
- )
1106
- );
1107
- return;
1108
- }
1109
- await mutateAndRespond(
1110
- ws, req, id,
1111
- () => removeLabel(id, label.trim()),
1112
- ['label', 'remove', id, label.trim()]
1113
- );
1114
- return;
1115
- }
1116
-
1117
- // get-comments: payload { id: string }
1118
- if (req.type === 'get-comments') {
1119
- const { id } = /** @type {any} */ (req.payload || {});
1120
- if (typeof id !== 'string' || id.length === 0) {
1121
- ws.send(
1122
- JSON.stringify(
1123
- makeError(req, 'bad_request', 'payload requires { id: string }')
1124
- )
1125
- );
1126
- return;
1127
- }
1128
- if (isDoltPoolReady()) {
1129
- const res = await queryComments(id);
1130
- if (!res.ok) {
1131
- ws.send(JSON.stringify(makeError(req, res.error.code, res.error.message)));
1132
- return;
1133
- }
1134
- ws.send(JSON.stringify(makeOk(req, res.items)));
1135
- } else {
1136
- const res = await runBdJson(['comments', id, '--json']);
1137
- if (res.code !== 0) {
1138
- ws.send(JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed')));
1139
- return;
1140
- }
1141
- ws.send(JSON.stringify(makeOk(req, res.stdoutJson || [])));
1142
- }
1143
- return;
1144
- }
1145
-
1146
- // add-comment: payload { id: string, text: string }
1147
- if (req.type === 'add-comment') {
1148
- const { id, text } = /** @type {any} */ (req.payload || {});
1149
- if (
1150
- typeof id !== 'string' ||
1151
- id.length === 0 ||
1152
- typeof text !== 'string' ||
1153
- text.trim().length === 0
1154
- ) {
1155
- ws.send(
1156
- JSON.stringify(
1157
- makeError(
1158
- req,
1159
- 'bad_request',
1160
- 'payload requires { id: string, text: non-empty string }'
1161
- )
1162
- )
1163
- );
1164
- return;
1165
- }
1166
-
1167
- const author = await getGitUserName();
1168
-
1169
- if (isDoltPoolReady()) {
1170
- const upd = await addComment(id, text.trim(), author || 'anonymous');
1171
- if (!upd.ok) {
1172
- ws.send(JSON.stringify(makeError(req, upd.error.code, upd.error.message)));
1173
- return;
1174
- }
1175
- const res = await queryComments(id);
1176
- if (!res.ok) {
1177
- ws.send(JSON.stringify(makeError(req, res.error.code, res.error.message)));
1178
- return;
1179
- }
1180
- ws.send(JSON.stringify(makeOk(req, res.items)));
1181
- try { triggerMutationRefreshOnce(); } catch { /* ignore */ }
1182
- } else {
1183
- const args = ['comment', id, text.trim()];
1184
- if (author) {
1185
- args.push('--author', author);
1186
- }
1187
-
1188
- const res = await runBd(args);
1189
- if (res.code !== 0) {
1190
- ws.send(JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed')));
1191
- return;
1192
- }
1193
-
1194
- const comments = await runBdJson(['comments', id, '--json']);
1195
- if (comments.code !== 0) {
1196
- ws.send(JSON.stringify(makeError(req, 'bd_error', comments.stderr || 'bd failed')));
1197
- return;
1198
- }
1199
- ws.send(JSON.stringify(makeOk(req, comments.stdoutJson || [])));
1200
- try { triggerMutationRefreshOnce(); } catch { /* ignore */ }
1201
- }
1202
- return;
1203
- }
1204
-
1205
- // delete-issue: payload { id: string }
1206
- if (req.type === 'delete-issue') {
1207
- const { id } = /** @type {any} */ (req.payload || {});
1208
- if (typeof id !== 'string' || id.length === 0) {
1209
- ws.send(
1210
- JSON.stringify(
1211
- makeError(req, 'bad_request', 'payload requires { id: string }')
1212
- )
1213
- );
1214
- return;
1215
- }
1216
- if (isDoltPoolReady()) {
1217
- const upd = await deleteIssue(id);
1218
- if (!upd.ok) {
1219
- ws.send(JSON.stringify(makeError(req, upd.error.code, upd.error.message)));
1220
- return;
1221
- }
1222
- ws.send(JSON.stringify(makeOk(req, { deleted: true, id })));
1223
- } else {
1224
- const res = await runBd(['delete', id, '--force']);
1225
- if (res.code !== 0) {
1226
- ws.send(JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd delete failed')));
1227
- return;
1228
- }
1229
- ws.send(JSON.stringify(makeOk(req, { deleted: true, id })));
1230
- }
1231
- try {
1232
- triggerMutationRefreshOnce();
1233
- } catch {
1234
- // ignore
1235
- }
1236
- return;
1237
- }
1238
-
1239
- // list-workspaces: returns all available workspaces from the registry
1240
- if (req.type === 'list-workspaces') {
1241
- log('list-workspaces');
1242
- const workspaces = getAvailableWorkspaces();
1243
- ws.send(
1244
- JSON.stringify(
1245
- makeOk(req, {
1246
- workspaces,
1247
- current: CURRENT_WORKSPACE
1248
- })
1249
- )
1250
- );
1251
- return;
1252
- }
1253
-
1254
- // get-workspace: returns the current workspace
1255
- if (req.type === 'get-workspace') {
1256
- log('get-workspace');
1257
- ws.send(JSON.stringify(makeOk(req, CURRENT_WORKSPACE)));
1258
- return;
1259
- }
1260
-
1261
- // set-workspace: payload { path: string }
1262
- if (req.type === 'set-workspace') {
1263
- log('set-workspace');
1264
- const { path: workspace_path } = /** @type {any} */ (req.payload || {});
1265
- if (typeof workspace_path !== 'string' || workspace_path.length === 0) {
1266
- ws.send(
1267
- JSON.stringify(
1268
- makeError(
1269
- req,
1270
- 'bad_request',
1271
- 'payload requires { path: string } (absolute workspace path)'
1272
- )
1273
- )
1274
- );
1275
- return;
1276
- }
1277
-
1278
- // Resolve and validate the path
1279
- const resolved = path.resolve(workspace_path);
1280
-
1281
- // Update workspace (this will rebind watcher, clear registry, broadcast change)
1282
- const new_db = resolveWorkspaceDatabase({ cwd: resolved });
1283
- const old_path = CURRENT_WORKSPACE?.db_path || '';
1284
-
1285
- CURRENT_WORKSPACE = {
1286
- root_dir: resolved,
1287
- db_path: new_db.path
1288
- };
1289
-
1290
- const changed = new_db.path !== old_path;
1291
-
1292
- if (changed) {
1293
- log(
1294
- 'workspace changed via set-workspace: %s → %s',
1295
- old_path,
1296
- new_db.path
1297
- );
1298
-
1299
- // Rebind the database watcher
1300
- if (DB_WATCHER) {
1301
- DB_WATCHER.rebind({ root_dir: resolved });
1302
- }
1303
-
1304
- // Clear existing registry entries
1305
- registry.clear();
1306
-
1307
- // Schedule refresh of all active list subscriptions
1308
- scheduleListRefresh();
1309
- }
1310
-
1311
- ws.send(
1312
- JSON.stringify(
1313
- makeOk(req, {
1314
- changed,
1315
- workspace: CURRENT_WORKSPACE
1316
- })
1317
- )
1318
- );
1319
- return;
1320
- }
1321
-
1322
- // Unknown type
1323
- const err = makeError(
1324
- req,
1325
- 'unknown_type',
1326
- `Unknown message type: ${req.type}`
1327
- );
1328
- ws.send(JSON.stringify(err));
1329
- }