@rsktash/beads-ui 0.1.51 → 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-CNDeKQGk.js +0 -125
  137. package/dist/assets/index-oO5WB2l2.css +0 -1
  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 -781
  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
@@ -0,0 +1,247 @@
1
+ // SQL used by the read-only Hono routes. Schema is owned by the Go CLI; we
2
+ // only SELECT + the occasional mutation. Both engines accept the same ANSI
3
+ // SQL we use here (LIKE patterns, IN clauses, parameterised values).
4
+ //
5
+ // Placeholders are `?` style. The pg adapter rewrites them to $N at runtime.
6
+
7
+ import { rowToComment, rowToDependency, rowToIssue } from './types.js';
8
+
9
+ export async function getConfigValue(db, key) {
10
+ const r = await db.one('SELECT value FROM config WHERE key = ?', [key]);
11
+ return r ? String(r.value) : '';
12
+ }
13
+
14
+ // Computed columns shared by list/ready/detail. Keeps the JSON shape stable
15
+ // across endpoints (parent_id, parent_title, total_children, closed_children,
16
+ // blocked_by_count, comment_count).
17
+ const ENRICHED_COMPUTED = `
18
+ (SELECT depends_on_id FROM dependencies d
19
+ WHERE d.issue_id = i.id AND d.type = 'parent-child' LIMIT 1) AS parent_id,
20
+ (SELECT p.title FROM dependencies d JOIN issues p ON p.id = d.depends_on_id
21
+ WHERE d.issue_id = i.id AND d.type = 'parent-child' LIMIT 1) AS parent_title,
22
+ (SELECT COUNT(*) FROM dependencies d
23
+ WHERE d.depends_on_id = i.id AND d.type = 'parent-child') AS total_children,
24
+ (SELECT COUNT(*) FROM dependencies d JOIN issues c ON c.id = d.issue_id
25
+ WHERE d.depends_on_id = i.id AND d.type = 'parent-child'
26
+ AND c.status = 'closed') AS closed_children,
27
+ (SELECT COUNT(*) FROM dependencies d JOIN issues b ON b.id = d.depends_on_id
28
+ WHERE d.issue_id = i.id AND d.type = 'blocks'
29
+ AND b.status NOT IN ('closed', 'pinned')) AS blocked_by_count,
30
+ (SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comment_count
31
+ `;
32
+
33
+ // Full row + enrichment — used by getIssue (detail page wants everything).
34
+ const ENRICHED_FULL = `i.*, ${ENRICHED_COMPUTED}`;
35
+
36
+ // Slim row + enrichment — used by listIssues and readyIssues. Drops the heavy
37
+ // markdown bodies (description/design/acceptance_criteria/notes) and the JSON
38
+ // blobs (metadata/payload) which the board, list, and search dialog never
39
+ // render. On epic-heavy projects this cuts the polled response by 50–80%.
40
+ // rowToIssue (server/types.js) defaults missing fields to '' so the public
41
+ // JSON shape stays the same — clients still see those keys, just empty.
42
+ const ENRICHED_SLIM = `
43
+ i.id, i.title, i.status, i.priority, i.issue_type,
44
+ i.assignee, i.estimated_minutes, i.content_hash,
45
+ i.created_at, i.created_by, i.owner,
46
+ i.updated_at, i.started_at, i.closed_at, i.due_at, i.defer_until,
47
+ i.closed_by_session, i.close_reason,
48
+ i.external_ref, i.spec_id, i.source_repo, i.source_system,
49
+ i.sender, i.ephemeral, i.pinned, i.is_template,
50
+ i.wisp_type, i.mol_type, i.role_type, i.event_kind,
51
+ i.actor, i.target,
52
+ ${ENRICHED_COMPUTED}
53
+ `;
54
+
55
+ export async function listIssues(db, filters = {}, limit = 0) {
56
+ const where = [];
57
+ const args = [];
58
+ if (filters.status) {
59
+ where.push('i.status = ?');
60
+ args.push(filters.status);
61
+ }
62
+ if (filters.status_in) {
63
+ const list = String(filters.status_in)
64
+ .split(',')
65
+ .map((s) => s.trim())
66
+ .filter(Boolean);
67
+ if (list.length) {
68
+ where.push(`i.status IN (${list.map(() => '?').join(',')})`);
69
+ args.push(...list);
70
+ }
71
+ }
72
+ if (filters.type) {
73
+ where.push('i.issue_type = ?');
74
+ args.push(filters.type);
75
+ }
76
+ if (filters.assignee) {
77
+ where.push('i.assignee = ?');
78
+ args.push(filters.assignee);
79
+ }
80
+ if (filters.priority !== undefined && filters.priority !== null && filters.priority !== '') {
81
+ where.push('i.priority = ?');
82
+ args.push(Number(filters.priority));
83
+ }
84
+ let sql = `SELECT ${ENRICHED_SLIM} FROM issues i`;
85
+ if (where.length) sql += ' WHERE ' + where.join(' AND ');
86
+ // ORDER BY: priority is irrelevant once an issue is closed — caller can ask
87
+ // for closed_at_desc to get a "recently finished" feed. Default keeps
88
+ // priority primary with updated_at DESC as tiebreaker so stale work sinks.
89
+ // NULLS LAST handles postgres (defaults to NULLS FIRST on DESC); sqlite
90
+ // already places NULLs last by default but accepts the keyword.
91
+ if (filters.order === 'closed_at_desc') {
92
+ sql += ' ORDER BY i.closed_at DESC NULLS LAST, i.created_at DESC';
93
+ } else {
94
+ sql += ' ORDER BY i.priority ASC, i.updated_at DESC NULLS LAST';
95
+ }
96
+ if (limit > 0) sql += ` LIMIT ${Number(limit) | 0}`;
97
+ const rows = await db.all(sql, args);
98
+ return rows.map(rowToIssue);
99
+ }
100
+
101
+ export async function getIssue(db, id) {
102
+ const sql = `SELECT ${ENRICHED_FULL} FROM issues i WHERE i.id = ?`;
103
+ const r = await db.one(sql, [id]);
104
+ return r ? rowToIssue(r) : null;
105
+ }
106
+
107
+ export async function listLabels(db, issueId) {
108
+ const rows = await db.all(
109
+ 'SELECT label FROM labels WHERE issue_id = ? ORDER BY label',
110
+ [issueId],
111
+ );
112
+ return rows.map((r) => r.label);
113
+ }
114
+
115
+ // listBlockedBy returns up to `limit` issue ids/titles that currently block
116
+ // the given issue. Used by the IssueCard "blocked by" badges.
117
+ export async function listBlockedBy(db, issueId, limit = 5) {
118
+ const rows = await db.all(
119
+ `SELECT b.id, b.title FROM dependencies d
120
+ JOIN issues b ON b.id = d.depends_on_id
121
+ WHERE d.issue_id = ? AND d.type = 'blocks'
122
+ AND b.status NOT IN ('closed', 'pinned')
123
+ ORDER BY b.created_at
124
+ LIMIT ${Number(limit) | 0}`,
125
+ [issueId],
126
+ );
127
+ return rows;
128
+ }
129
+
130
+ // listBlocks returns up to `limit` issue ids/titles currently blocked BY the
131
+ // given issue (i.e., rows where this issue is depends_on_id, type='blocks').
132
+ // Mirror of listBlockedBy with reversed direction; powers the "Blocks" card
133
+ // in the issue-detail sidebar.
134
+ export async function listBlocks(db, issueId, limit = 10) {
135
+ const rows = await db.all(
136
+ `SELECT b.id, b.title FROM dependencies d
137
+ JOIN issues b ON b.id = d.issue_id
138
+ WHERE d.depends_on_id = ? AND d.type = 'blocks'
139
+ AND b.status NOT IN ('closed', 'pinned')
140
+ ORDER BY b.created_at
141
+ LIMIT ${Number(limit) | 0}`,
142
+ [issueId],
143
+ );
144
+ return rows;
145
+ }
146
+
147
+ // listChildren returns the parent-child children of an issue (where THIS
148
+ // issue is the depends_on_id, type='parent-child'). Used by the metadata
149
+ // sidebar's Children card.
150
+ export async function listChildren(db, parentId) {
151
+ const rows = await db.all(
152
+ `SELECT c.id, c.title, c.status, c.priority, c.issue_type
153
+ FROM dependencies d
154
+ JOIN issues c ON c.id = d.issue_id
155
+ WHERE d.depends_on_id = ? AND d.type = 'parent-child'
156
+ ORDER BY c.priority ASC, c.created_at ASC`,
157
+ [parentId],
158
+ );
159
+ return rows;
160
+ }
161
+
162
+ export async function listDependencies(db, issueId) {
163
+ const rows = await db.all(
164
+ 'SELECT * FROM dependencies WHERE issue_id = ? OR depends_on_id = ? ORDER BY created_at',
165
+ [issueId, issueId],
166
+ );
167
+ return rows.map(rowToDependency);
168
+ }
169
+
170
+ export async function listComments(db, issueId) {
171
+ const rows = await db.all(
172
+ 'SELECT * FROM comments WHERE issue_id = ? ORDER BY created_at',
173
+ [issueId],
174
+ );
175
+ return rows.map(rowToComment);
176
+ }
177
+
178
+ export async function readyIssues(db) {
179
+ const sql = `
180
+ SELECT ${ENRICHED_SLIM} FROM issues i
181
+ WHERE i.status = 'open'
182
+ AND i.ephemeral = 0
183
+ AND i.is_template = 0
184
+ AND (i.defer_until IS NULL OR i.defer_until <= ?)
185
+ AND NOT EXISTS (
186
+ SELECT 1 FROM dependencies d
187
+ JOIN issues blocker ON blocker.id = d.depends_on_id
188
+ WHERE d.issue_id = i.id
189
+ AND d.type = 'blocks'
190
+ AND blocker.status NOT IN ('closed', 'pinned')
191
+ )
192
+ ORDER BY i.priority ASC, i.updated_at DESC NULLS LAST`;
193
+ const now = new Date().toISOString();
194
+ const rows = await db.all(sql, [now]);
195
+ return rows.map(rowToIssue);
196
+ }
197
+
198
+ // ---------- mutations (subset: comments + labels only) ----------
199
+
200
+ export async function addComment(db, { id, issueId, author, text }) {
201
+ const now = new Date().toISOString();
202
+ await db.exec(
203
+ `INSERT INTO comments (id, issue_id, author, text, created_at)
204
+ VALUES (?, ?, ?, ?, ?)`,
205
+ [id, issueId, author, text, now],
206
+ );
207
+ return { id, issue_id: issueId, author, text, created_at: now };
208
+ }
209
+
210
+ export async function deleteComment(db, commentId) {
211
+ await db.exec('DELETE FROM comments WHERE id = ?', [commentId]);
212
+ }
213
+
214
+ export async function addLabel(db, issueId, label) {
215
+ // idempotent — re-adding the same (issue, label) is a no-op
216
+ try {
217
+ await db.exec(
218
+ 'INSERT INTO labels (issue_id, label) VALUES (?, ?)',
219
+ [issueId, label],
220
+ );
221
+ } catch (err) {
222
+ if (!/UNIQUE|duplicate/i.test(String(err?.message))) throw err;
223
+ }
224
+ }
225
+
226
+ export async function removeLabel(db, issueId, label) {
227
+ await db.exec(
228
+ 'DELETE FROM labels WHERE issue_id = ? AND label = ?',
229
+ [issueId, label],
230
+ );
231
+ }
232
+
233
+ export async function listProjects(db) {
234
+ if (db.driver !== 'postgres') return [];
235
+ const sql = `
236
+ SELECT s.schema_name AS prefix
237
+ FROM information_schema.schemata s
238
+ WHERE s.schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public')
239
+ AND s.schema_name NOT LIKE 'pg_%'
240
+ AND EXISTS (
241
+ SELECT 1 FROM information_schema.tables t
242
+ WHERE t.table_schema = s.schema_name AND t.table_name = 'config'
243
+ )
244
+ ORDER BY s.schema_name`;
245
+ const rows = await db.all(sql);
246
+ return rows.map((r) => ({ prefix: r.prefix }));
247
+ }
@@ -0,0 +1,27 @@
1
+ import { Hono } from 'hono';
2
+ import { login, logout } from '../auth.js';
3
+
4
+ export function authRouter(auth) {
5
+ const r = new Hono();
6
+
7
+ r.post('/login', async (c) => {
8
+ if (!auth.enabled) return c.json({ error: 'auth disabled' }, 400);
9
+ const body = await c.req.json().catch(() => ({}));
10
+ const { username, password } = body;
11
+ if (!username || !password) {
12
+ return c.json({ error: 'username and password are required' }, 400);
13
+ }
14
+ const result = login(auth, username, password);
15
+ if (!result) return c.json({ error: 'invalid credentials' }, 401);
16
+ return c.json(result);
17
+ });
18
+
19
+ r.post('/logout', async (c) => {
20
+ if (!auth.enabled) return c.json({ ok: true });
21
+ const token = c.get('token');
22
+ if (token) logout(auth, token);
23
+ return c.json({ ok: true });
24
+ });
25
+
26
+ return r;
27
+ }
@@ -0,0 +1,120 @@
1
+ import { Hono } from 'hono';
2
+ import { etag } from 'hono/etag';
3
+ import { randomUUID } from 'node:crypto';
4
+ import {
5
+ addComment,
6
+ addLabel,
7
+ deleteComment,
8
+ getIssue,
9
+ listBlockedBy,
10
+ listBlocks,
11
+ listChildren,
12
+ listComments,
13
+ listDependencies,
14
+ listIssues,
15
+ listLabels,
16
+ readyIssues,
17
+ removeLabel,
18
+ } from '../queries.js';
19
+
20
+ // Reads the per-request project db from c.get('db'). Set by the project-
21
+ // scope middleware in index.js. No db is captured at registration time so
22
+ // the same router serves every project.
23
+ export function issuesRouter() {
24
+ const r = new Hono();
25
+
26
+ // ETag for GET responses: the board and detail pages poll every 5s, so
27
+ // most refetches return identical bodies. The middleware hashes the
28
+ // serialized response and replies 304 with an empty body when the
29
+ // client's If-None-Match header matches — saves bandwidth and the
30
+ // client-side parse/render. Skipped for mutations.
31
+ //
32
+ // Cache-Control "private, no-cache" tells the browser to keep the body
33
+ // in the per-origin disk cache and always revalidate via If-None-Match.
34
+ // Without this header the browser may not cache JSON responses at all,
35
+ // which would mean ETag is set but never sent back — neutering the 304
36
+ // path. "private" prevents shared caches from storing the data.
37
+ const etagMiddleware = etag();
38
+ r.use('/*', async (c, next) => {
39
+ if (c.req.method !== 'GET') return next();
40
+ await etagMiddleware(c, next);
41
+ if (c.res.status === 200 || c.res.status === 304) {
42
+ c.res.headers.set('Cache-Control', 'private, no-cache');
43
+ }
44
+ });
45
+
46
+ r.get('/', async (c) => {
47
+ const db = c.get('db');
48
+ const q = c.req.query();
49
+ const limit = q.limit ? Number(q.limit) : 0;
50
+ const issues = await listIssues(db, q, limit);
51
+ return c.json({ issues });
52
+ });
53
+
54
+ r.get('/ready', async (c) => {
55
+ const db = c.get('db');
56
+ const issues = await readyIssues(db);
57
+ return c.json({ issues });
58
+ });
59
+
60
+ r.get('/:id', async (c) => {
61
+ const db = c.get('db');
62
+ const id = c.req.param('id');
63
+ const issue = await getIssue(db, id);
64
+ if (!issue) return c.json({ error: 'not found' }, 404);
65
+ const [labels, deps, comments, blockedBy, blocks, children] = await Promise.all([
66
+ listLabels(db, id),
67
+ listDependencies(db, id),
68
+ listComments(db, id),
69
+ listBlockedBy(db, id, 5),
70
+ listBlocks(db, id, 10),
71
+ listChildren(db, id),
72
+ ]);
73
+ return c.json({
74
+ issue, labels, dependencies: deps, comments,
75
+ blocked_by: blockedBy,
76
+ blocks,
77
+ children,
78
+ });
79
+ });
80
+
81
+ r.post('/:id/comments', async (c) => {
82
+ const db = c.get('db');
83
+ const issueId = c.req.param('id');
84
+ const body = await c.req.json().catch(() => ({}));
85
+ const text = String(body.text || '').trim();
86
+ if (!text) return c.json({ error: 'text is required' }, 400);
87
+ const user = c.get('user') || { username: 'anon' };
88
+ const comment = await addComment(db, {
89
+ id: randomUUID(),
90
+ issueId,
91
+ author: user.username,
92
+ text,
93
+ });
94
+ return c.json({ comment });
95
+ });
96
+
97
+ r.delete('/:id/comments/:commentId', async (c) => {
98
+ const db = c.get('db');
99
+ await deleteComment(db, c.req.param('commentId'));
100
+ return c.json({ ok: true });
101
+ });
102
+
103
+ r.post('/:id/labels', async (c) => {
104
+ const db = c.get('db');
105
+ const issueId = c.req.param('id');
106
+ const body = await c.req.json().catch(() => ({}));
107
+ const label = String(body.label || '').trim();
108
+ if (!label) return c.json({ error: 'label is required' }, 400);
109
+ await addLabel(db, issueId, label);
110
+ return c.json({ ok: true, label });
111
+ });
112
+
113
+ r.delete('/:id/labels/:label', async (c) => {
114
+ const db = c.get('db');
115
+ await removeLabel(db, c.req.param('id'), c.req.param('label'));
116
+ return c.json({ ok: true });
117
+ });
118
+
119
+ return r;
120
+ }
@@ -0,0 +1,111 @@
1
+ // SSE: per-project change stream. Server polls a cheap "fingerprint" query
2
+ // every WATCH_MS and broadcasts a `tick` event when it changes. Clients open
3
+ // an EventSource and invalidate their cached queries on tick.
4
+ //
5
+ // Why poll-and-broadcast: bd is mutated by the CLI, agents, and the web
6
+ // server. Postgres LISTEN/NOTIFY would catch all of them but only on pg, and
7
+ // requires triggers. A 1.5s SQL fingerprint (max(updated_at), count(*)) is
8
+ // cheap enough for the polling rate we want, works on both engines, and
9
+ // scales: N connected browsers per project share one watcher.
10
+ //
11
+ // Fingerprint covers issues, dependencies, comments, and labels — anything
12
+ // the board / list / detail page renders.
13
+
14
+ const WATCH_MS = 1500; // server poll cadence
15
+ const HEARTBEAT_MS = 25000; // SSE ping to keep proxies from killing the stream
16
+
17
+ // project prefix -> { db, hash, timer, subs:Set<{enqueue,close}> }
18
+ const watchers = new Map();
19
+
20
+ // stamp normalises whatever the driver returns for a timestamp column
21
+ // (string from sqlite, Date from pg) into a stable string for hashing.
22
+ const stamp = (v) => {
23
+ if (v == null) return '';
24
+ if (v instanceof Date) return v.toISOString();
25
+ return String(v);
26
+ };
27
+
28
+ async function fingerprint(db) {
29
+ // Cheap "did anything change?" probe: max-of-mutable-timestamp + row count
30
+ // for each table the UI cares about. Portable across sqlite/postgres.
31
+ const i = await db.one(`SELECT MAX(updated_at) AS m, COUNT(*) AS c FROM issues`);
32
+ const d = await db.one(`SELECT MAX(created_at) AS m, COUNT(*) AS c FROM dependencies`);
33
+ const cm = await db.one(`SELECT MAX(created_at) AS m, COUNT(*) AS c FROM comments`);
34
+ const l = await db.one(`SELECT COUNT(*) AS c FROM labels`);
35
+ return [
36
+ `i:${stamp(i?.m)}/${i?.c ?? 0}`,
37
+ `d:${stamp(d?.m)}/${d?.c ?? 0}`,
38
+ `c:${stamp(cm?.m)}/${cm?.c ?? 0}`,
39
+ `l:${l?.c ?? 0}`,
40
+ ].join('|');
41
+ }
42
+
43
+ function startWatcher(prefix, db) {
44
+ const w = { db, hash: '', subs: new Set(), timer: null };
45
+ watchers.set(prefix, w);
46
+ const tick = async () => {
47
+ if (w.subs.size === 0) {
48
+ // No subscribers — stop polling and let the watcher idle out.
49
+ clearInterval(w.timer);
50
+ watchers.delete(prefix);
51
+ return;
52
+ }
53
+ let next;
54
+ try { next = await fingerprint(db); } catch (err) {
55
+ // Don't kill the watcher on transient db errors.
56
+ if (process.env.DEBUG) console.error('stream fingerprint failed:', err.message);
57
+ return;
58
+ }
59
+ if (next !== w.hash) {
60
+ w.hash = next;
61
+ const payload = `event: tick\ndata: ${next}\n\n`;
62
+ for (const sub of w.subs) {
63
+ try { sub.enqueue(payload); } catch {}
64
+ }
65
+ }
66
+ };
67
+ w.timer = setInterval(tick, WATCH_MS);
68
+ // prime hash so the first real change emits, but the initial open doesn't
69
+ fingerprint(db).then((h) => { w.hash = h; }).catch(() => {});
70
+ return w;
71
+ }
72
+
73
+ export function streamHandler(c) {
74
+ const prefix = c.get('project');
75
+ const db = c.get('db');
76
+ if (!prefix || !db) return c.json({ error: 'no project' }, 400);
77
+
78
+ const w = watchers.get(prefix) || startWatcher(prefix, db);
79
+
80
+ const stream = new ReadableStream({
81
+ start(controller) {
82
+ const enc = new TextEncoder();
83
+ const sub = {
84
+ enqueue: (s) => controller.enqueue(enc.encode(s)),
85
+ close: () => { try { controller.close(); } catch {} },
86
+ };
87
+ w.subs.add(sub);
88
+ // Initial hello + heartbeat loop.
89
+ sub.enqueue(`event: hello\ndata: ${w.hash}\n\n`);
90
+ const hb = setInterval(() => {
91
+ try { sub.enqueue(`: ping\n\n`); } catch {}
92
+ }, HEARTBEAT_MS);
93
+ // Cleanup when the client disconnects.
94
+ const onClose = () => {
95
+ clearInterval(hb);
96
+ w.subs.delete(sub);
97
+ sub.close();
98
+ };
99
+ c.req.raw.signal?.addEventListener('abort', onClose);
100
+ },
101
+ });
102
+
103
+ return new Response(stream, {
104
+ headers: {
105
+ 'Content-Type': 'text/event-stream; charset=utf-8',
106
+ 'Cache-Control': 'no-cache, no-transform',
107
+ 'Connection': 'keep-alive',
108
+ 'X-Accel-Buffering': 'no', // disable nginx buffering
109
+ },
110
+ });
111
+ }
@@ -0,0 +1,83 @@
1
+ // Row-shape helpers shared by the route handlers. Matches the public types
2
+ // in the Go side (beads.Issue, beads.Dependency, beads.Comment).
3
+
4
+ export function rowToIssue(r) {
5
+ if (!r) return null;
6
+ return {
7
+ id: r.id,
8
+ content_hash: r.content_hash || '',
9
+ title: r.title,
10
+ description: r.description || '',
11
+ design: r.design || '',
12
+ acceptance_criteria: r.acceptance_criteria || '',
13
+ notes: r.notes || '',
14
+ status: r.status,
15
+ priority: Number(r.priority),
16
+ issue_type: r.issue_type,
17
+ assignee: r.assignee || '',
18
+ estimated_minutes: Number(r.estimated_minutes || 0),
19
+ created_at: toISO(r.created_at),
20
+ created_by: r.created_by || '',
21
+ owner: r.owner || '',
22
+ updated_at: toISO(r.updated_at),
23
+ started_at: toISO(r.started_at),
24
+ closed_at: toISO(r.closed_at),
25
+ closed_by_session: r.closed_by_session || '',
26
+ external_ref: r.external_ref || '',
27
+ spec_id: r.spec_id || '',
28
+ metadata: r.metadata || '{}',
29
+ source_repo: r.source_repo || '',
30
+ source_system: r.source_system || '',
31
+ close_reason: r.close_reason || '',
32
+ sender: r.sender || '',
33
+ ephemeral: !!Number(r.ephemeral),
34
+ pinned: !!Number(r.pinned),
35
+ is_template: !!Number(r.is_template),
36
+ wisp_type: r.wisp_type || '',
37
+ mol_type: r.mol_type || '',
38
+ role_type: r.role_type || '',
39
+ event_kind: r.event_kind || '',
40
+ actor: r.actor || '',
41
+ target: r.target || '',
42
+ payload: r.payload || '',
43
+ due_at: toISO(r.due_at),
44
+ defer_until: toISO(r.defer_until),
45
+ // Enriched fields populated by ENRICHED SELECT (queries.js). Optional —
46
+ // detail/list endpoints set them; raw inserts won't have them.
47
+ parent_id: r.parent_id || '',
48
+ parent_title: r.parent_title || '',
49
+ total_children: r.total_children != null ? Number(r.total_children) : 0,
50
+ closed_children: r.closed_children != null ? Number(r.closed_children) : 0,
51
+ blocked_by_count: r.blocked_by_count != null ? Number(r.blocked_by_count) : 0,
52
+ comment_count: r.comment_count != null ? Number(r.comment_count) : 0,
53
+ };
54
+ }
55
+
56
+ export function rowToDependency(r) {
57
+ return {
58
+ issue_id: r.issue_id,
59
+ depends_on_id: r.depends_on_id,
60
+ type: r.type,
61
+ created_at: toISO(r.created_at),
62
+ created_by: r.created_by || '',
63
+ metadata: r.metadata || '{}',
64
+ thread_id: r.thread_id || '',
65
+ };
66
+ }
67
+
68
+ export function rowToComment(r) {
69
+ return {
70
+ id: r.id,
71
+ issue_id: r.issue_id,
72
+ author: r.author,
73
+ text: r.text,
74
+ created_at: toISO(r.created_at),
75
+ };
76
+ }
77
+
78
+ function toISO(v) {
79
+ if (!v) return null;
80
+ if (v instanceof Date) return v.toISOString();
81
+ // sqlite returns 'YYYY-MM-DD HH:MM:SS' or ISO already; postgres returns Date
82
+ return new Date(v).toISOString();
83
+ }