harper 5.3.2 → 5.3.4

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 (261) hide show
  1. package/bin/cliOperations.ts +3 -3
  2. package/bin/copyDb.ts +4 -3
  3. package/components/DESIGN.md +12 -0
  4. package/components/packageComponent.ts +42 -17
  5. package/config/DESIGN.md +4 -0
  6. package/config/configUtils.ts +8 -8
  7. package/config/parseConfigFile.ts +11 -7
  8. package/dist/bin/cliOperations.js +3 -3
  9. package/dist/bin/cliOperations.js.map +1 -1
  10. package/dist/bin/copyDb.js +3 -3
  11. package/dist/bin/copyDb.js.map +1 -1
  12. package/dist/components/packageComponent.d.ts +9 -7
  13. package/dist/components/packageComponent.js +41 -16
  14. package/dist/components/packageComponent.js.map +1 -1
  15. package/dist/config/configUtils.js +4 -3
  16. package/dist/config/configUtils.js.map +1 -1
  17. package/dist/config/parseConfigFile.d.ts +1 -0
  18. package/dist/config/parseConfigFile.js +7 -3
  19. package/dist/config/parseConfigFile.js.map +1 -1
  20. package/dist/resources/DatabaseTransaction.d.ts +80 -11
  21. package/dist/resources/DatabaseTransaction.js +1100 -281
  22. package/dist/resources/DatabaseTransaction.js.map +1 -1
  23. package/dist/resources/LMDBTransaction.d.ts +1 -1
  24. package/dist/resources/LMDBTransaction.js +4 -3
  25. package/dist/resources/LMDBTransaction.js.map +1 -1
  26. package/dist/resources/Resource.js +40 -14
  27. package/dist/resources/Resource.js.map +1 -1
  28. package/dist/resources/RocksTransactionLogStore.js +1 -0
  29. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  30. package/dist/resources/Table.js +97 -34
  31. package/dist/resources/Table.js.map +1 -1
  32. package/dist/resources/analytics/write.js +34 -16
  33. package/dist/resources/analytics/write.js.map +1 -1
  34. package/dist/resources/databases.d.ts +5 -0
  35. package/dist/resources/databases.js +38 -4
  36. package/dist/resources/databases.js.map +1 -1
  37. package/dist/resources/recordLock.d.ts +9 -4
  38. package/dist/resources/recordLock.js +79 -19
  39. package/dist/resources/recordLock.js.map +1 -1
  40. package/dist/server/DurableSubscriptionsSession.d.ts +2 -0
  41. package/dist/server/DurableSubscriptionsSession.js +24 -24
  42. package/dist/server/DurableSubscriptionsSession.js.map +1 -1
  43. package/dist/server/mqtt.js +59 -29
  44. package/dist/server/mqtt.js.map +1 -1
  45. package/dist/server/serverHelpers/NodeAdapterResponse.js +18 -0
  46. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -1
  47. package/dist/server/serverHelpers/contentTypes.js +8 -2
  48. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  49. package/dist/server/serverHelpers/sharedMessageEncoding.js +3 -2
  50. package/dist/server/serverHelpers/sharedMessageEncoding.js.map +1 -1
  51. package/dist/utility/npmUtilities.js +18 -7
  52. package/dist/utility/npmUtilities.js.map +1 -1
  53. package/package.json +1 -1
  54. package/resources/DESIGN.md +46 -10
  55. package/resources/DatabaseTransaction.ts +1268 -279
  56. package/resources/LMDBTransaction.ts +4 -3
  57. package/resources/Resource.ts +39 -14
  58. package/resources/RocksTransactionLogStore.ts +1 -0
  59. package/resources/Table.ts +120 -41
  60. package/resources/analytics/DESIGN.md +17 -0
  61. package/resources/analytics/write.ts +28 -15
  62. package/resources/databases.ts +40 -4
  63. package/resources/record-locks.md +34 -17
  64. package/resources/recordLock.ts +89 -22
  65. package/server/DESIGN.md +9 -1
  66. package/server/DurableSubscriptionsSession.ts +22 -23
  67. package/server/mqtt.ts +54 -29
  68. package/server/serverHelpers/NodeAdapterResponse.ts +19 -1
  69. package/server/serverHelpers/contentTypes.ts +8 -1
  70. package/server/serverHelpers/sharedMessageEncoding.ts +3 -2
  71. package/studio/web/assets/Chat-DjZNWwwA.js +2100 -0
  72. package/studio/web/assets/FloatingChat-BWfKK_RX.js +23 -0
  73. package/studio/web/assets/SearchDialog-CdlNxWef.js +1 -0
  74. package/studio/web/assets/{abnfDiagram-O67JEVCF-raXE027J.js → abnfDiagram-YUKMZFIV-DPR2lFRk.js} +1 -1
  75. package/studio/web/assets/{apiToken-DNrfij0E.js → apiToken-DyI8IwU3.js} +1 -1
  76. package/studio/web/assets/applications-DgGopKJq.js +296 -0
  77. package/studio/web/assets/architecture-WOLXFQ4H-DtJHIK0_.js +1 -0
  78. package/studio/web/assets/{architectureDiagram-NJMV4G6O-C8_Eet7w.js → architectureDiagram-47P4ROYG-MDymp2dh.js} +3 -3
  79. package/studio/web/assets/authStore-DE1HFvVP.js +3 -0
  80. package/studio/web/assets/{blockDiagram-BEXU5L5S-DR6S5-1D.js → blockDiagram-E7TT5TSB-O5qRgZmd.js} +3 -3
  81. package/studio/web/assets/button-C37w80Uk.js +21 -0
  82. package/studio/web/assets/{c4Diagram-YGBWAQC7-BAfZ1G01.js → c4Diagram-CRA5TL53-BdPORGHo.js} +2 -2
  83. package/studio/web/assets/channel-yCStoq0_.js +1 -0
  84. package/studio/web/assets/{chevron-up-CFxbMGv2.js → chevron-up-DozwrIz-.js} +1 -1
  85. package/studio/web/assets/chunk-2BW5OAIV-DJ-iXJAw.js +10 -0
  86. package/studio/web/assets/{chunk-ZIGJFQKS-Cs8fUn7y.js → chunk-3YJQHVM4-BOgacqS1.js} +2 -2
  87. package/studio/web/assets/{chunk-7PRAP22T-KyqX6x3f.js → chunk-4EA7E6EY-C-7xqUBl.js} +1 -1
  88. package/studio/web/assets/{chunk-GWA4HPMP-DsejB6gA.js → chunk-7TKQ45FW-C3XA1bIN.js} +1 -1
  89. package/studio/web/assets/{chunk-GTNCS2PH-DGHLXyow.js → chunk-AW2ZBBNX-BdvgD1UW.js} +3 -3
  90. package/studio/web/assets/{chunk-5DYCD2WN-BceBfIHT.js → chunk-DBDB3WZW-CKKxIc6F.js} +1 -1
  91. package/studio/web/assets/{chunk-MBY4JIJT-BwSGweQP.js → chunk-E2ZNV5FY-bVefNHvz.js} +3 -3
  92. package/studio/web/assets/chunk-EU5HNXII-BHv2kWp4.js +213 -0
  93. package/studio/web/assets/chunk-FVRAUYC3-BoL_NO6b.js +2 -0
  94. package/studio/web/assets/{chunk-742MDFTN-CIkt8ijt.js → chunk-HJ2JQQFS-N_98oMRS.js} +1 -1
  95. package/studio/web/assets/{chunk-NETBCI7D-C8UnOw_f.js → chunk-HTAEGDNF-BQ1fSjPs.js} +1 -1
  96. package/studio/web/assets/{chunk-WEXAMYUT-BAIhTwyh.js → chunk-KQW6MTUR-qP0QiQAg.js} +1 -1
  97. package/studio/web/assets/chunk-NGNAAXSQ-DqoPQk1a.js +136 -0
  98. package/studio/web/assets/{chunk-UA2S7LBM-BHpEdH8D.js → chunk-NTY3LDVX-CSBFjlJg.js} +1 -1
  99. package/studio/web/assets/chunk-VPRB5NB3-BVHOe9Q-.js +129 -0
  100. package/studio/web/assets/{chunk-7INBJB4K-BpeAXyOi.js → chunk-XC4XBNZT-46uQwtF4.js} +10 -10
  101. package/studio/web/assets/{classDiagram-v2-NBCMYWYE-DmDPkt2u.js → classDiagram-v2-K4WV4PDN-Da0ps1Ui.js} +3 -3
  102. package/studio/web/assets/cn-DpF1PxBT.js +1 -0
  103. package/studio/web/assets/{createLucideIcon-fO5dL7D5.js → createLucideIcon-DLWRoWN-.js} +1 -1
  104. package/studio/web/assets/{cssMode-DBucrQdT.js → cssMode-4ddO86ey.js} +1 -1
  105. package/studio/web/assets/cynefin-EF2NZ3EQ-jQ2eCUaU.js +1 -0
  106. package/studio/web/assets/cynefinDiagram-3GCD6N5R-TNi5iS0E.js +62 -0
  107. package/studio/web/assets/dagre-W4DXFKR2-BnLHmA44.js +4 -0
  108. package/studio/web/assets/{diagram-22UHCM2B-DJCvcaWP.js → diagram-2UJZ2QOL-r_jehhZR.js} +7 -7
  109. package/studio/web/assets/{diagram-3UASUU5V-Du6VKWln.js → diagram-OVF4WLC6-BSUXGVh8.js} +11 -11
  110. package/studio/web/assets/{diagram-CDSNMT55-l5Ga0u-W.js → diagram-PFPMY2P6-BrVedycw.js} +3 -3
  111. package/studio/web/assets/{diagram-MLGK6HIB-CNQY47LA.js → diagram-UMYRVEAY-CBIaakBB.js} +1 -1
  112. package/studio/web/assets/diagram-YEKJPTXX-DFmmDXCl.js +41 -0
  113. package/studio/web/assets/diagram-ZIFT7M5P-CQfOWxOi.js +3 -0
  114. package/studio/web/assets/dist-9lpzt2Hn.js +11 -0
  115. package/studio/web/assets/{ebnfDiagram-ZINNZB2B-5OnkTpfi.js → ebnfDiagram-VR2GEFS7-FXA3Xe6e.js} +1 -1
  116. package/studio/web/assets/{editor-CHpQCuBq.js → editor-DATuu7_v.js} +1 -1
  117. package/studio/web/assets/elk-IJKZMXRS-CC4gfVRU.js +27 -0
  118. package/studio/web/assets/{erDiagram-OPXOYQCR-CQlCX84D.js → erDiagram-O2IAPWRE-C-BkDTPl.js} +2 -2
  119. package/studio/web/assets/errorText-8xV8WZBt.js +1 -0
  120. package/studio/web/assets/eventmodeling-K75KTNOO-D0dJ_PbR.js +1 -0
  121. package/studio/web/assets/flowDiagram-OXPTDLAJ-C1LEc_Hf.js +1 -0
  122. package/studio/web/assets/{ganttDiagram-FUAMR5RP-bloX3Voy.js → ganttDiagram-R7TSEDQI-85F44BIo.js} +4 -4
  123. package/studio/web/assets/{getAnalytics-C0FHjtdQ.js → getAnalytics-B1xpd4kG.js} +1 -1
  124. package/studio/web/assets/gitGraph-VSP46ZUC-BBmqrmjK.js +1 -0
  125. package/studio/web/assets/gitGraphDiagram-XJZIOB7I-CFY6iMwg.js +106 -0
  126. package/studio/web/assets/graphlib-DS17s2tU.js +1 -0
  127. package/studio/web/assets/{html-B1iA8q38.js → html-c4OKnEF1.js} +1 -1
  128. package/studio/web/assets/{htmlMode-CrL3YO5Y.js → htmlMode-yXjqUsW9.js} +1 -1
  129. package/studio/web/assets/index-CDnn0-PN.css +1 -0
  130. package/studio/web/assets/index-CIK9Nhgo.js +833 -0
  131. package/studio/web/assets/index.lazy-DQpKL_QQ.js +1 -0
  132. package/studio/web/assets/info-OHQRW6UA-Bs2aEco7.js +1 -0
  133. package/studio/web/assets/{infoDiagram-VRGFBTTK-B0J1132p.js → infoDiagram-5W2HQ5XZ-d6yG1yfZ.js} +1 -1
  134. package/studio/web/assets/invalidateEntityQueries-BkWXiqjz.js +1 -0
  135. package/studio/web/assets/{ishikawaDiagram-OU5B5YK6-BNk0tuQx.js → ishikawaDiagram-K3B6WC7H-CSYz-WRA.js} +2 -2
  136. package/studio/web/assets/{javascript-CXgQMkoa.js → javascript-CP1WPkLu.js} +1 -1
  137. package/studio/web/assets/{journeyDiagram-ZHPQQLJL-Be79RMQM.js → journeyDiagram-COXZFDF6-BDj6Wqcj.js} +3 -3
  138. package/studio/web/assets/{jsonMode-DycqRKOv.js → jsonMode-BWsIHqbb.js} +1 -1
  139. package/studio/web/assets/{kanban-definition-PNTS6WVX-4uuoQP9W.js → kanban-definition-P3RFRI5V-C2Bjfbp-.js} +6 -6
  140. package/studio/web/assets/{languageServices-HYHV1wDJ.js → languageServices-Ca72n4Ql.js} +1 -1
  141. package/studio/web/assets/{lspLanguageFeatures-CH_6SYiA.js → lspLanguageFeatures-BQdsSDeh.js} +1 -1
  142. package/studio/web/assets/{mermaid-parser.core-V1UYMeXV.js → mermaid-parser.core-Ch_T61iL.js} +6 -6
  143. package/studio/web/assets/mermaid.core-BOAgKzo5.js +44 -0
  144. package/studio/web/assets/{mindmap-definition-NLK3R4M7-C70Ctm0J.js → mindmap-definition-LZFPQGKD-DzOapzMM.js} +3 -3
  145. package/studio/web/assets/notifications-BYgO5-yk.js +1 -0
  146. package/studio/web/assets/notifications-DsGk-TnW.js +1 -0
  147. package/studio/web/assets/operations-Dy69AlTr.js +4 -0
  148. package/studio/web/assets/packet-JDAUHWVQ-CGHB_EIE.js +1 -0
  149. package/studio/web/assets/{pegDiagram-GJSIUBJH-d6z1cc7W.js → pegDiagram-BGZESJAR-ADzOIKrz.js} +1 -1
  150. package/studio/web/assets/pie-XZMESJXO-Bijz5Y2o.js +1 -0
  151. package/studio/web/assets/pieDiagram-CAPJLFHJ-VM7fvbwO.js +39 -0
  152. package/studio/web/assets/profile-DzQvoYBF.js +1 -0
  153. package/studio/web/assets/{quadrantDiagram-O4NWA36T-CIF5Qkou.js → quadrantDiagram-GDMTTRAM-Bmza84_j.js} +3 -3
  154. package/studio/web/assets/radar-ABXABTNO-IaC8pB63.js +1 -0
  155. package/studio/web/assets/railroad-I3PHUGI6-CCrSUqeq.js +1 -0
  156. package/studio/web/assets/railroad-abnf-LNEFI6M7-BhobVp-t.js +1 -0
  157. package/studio/web/assets/railroad-ebnf-DWYD2UWJ-OHhGUTEh.js +1 -0
  158. package/studio/web/assets/railroad-peg-JR7BVNR7-FupC3Jcn.js +1 -0
  159. package/studio/web/assets/{railroadDiagram-XR7U4H2S-y3Jv2U6N.js → railroadDiagram-SM67HX2B-CgFGOVI5.js} +1 -1
  160. package/studio/web/assets/regions-Sv_IN7XA.js +1 -0
  161. package/studio/web/assets/{register-DCN4QUii.js → register-AM2_79FO.js} +3 -3
  162. package/studio/web/assets/request-BJj8dbdU.js +2 -0
  163. package/studio/web/assets/{requirementDiagram-PLB6GJNP-ChINZEmu.js → requirementDiagram-X7JNWC4A-BHoQgkoT.js} +4 -4
  164. package/studio/web/assets/restartGate-D_e93lFE.js +1 -0
  165. package/studio/web/assets/restartUnconfirmed-BqcDZ__Y.js +1 -0
  166. package/studio/web/assets/sankeyDiagram-UM26HJRW-8tFu_oUe.js +40 -0
  167. package/studio/web/assets/sequenceDiagram-ZO4K6R2Y-jryb1rwT.js +169 -0
  168. package/studio/web/assets/setComponentFile-BCnAnxjR.js +1 -0
  169. package/studio/web/assets/setup-Ca4RpPzm.js +2 -0
  170. package/studio/web/assets/{stateDiagram-v2-GCMORJYK-Bs8RIb9K.js → stateDiagram-v2-TBUQTH76-CnWE5yFn.js} +2 -2
  171. package/studio/web/assets/status-D05eZ5pQ.js +61 -0
  172. package/studio/web/assets/{swimlanes-2SLR337P-B52xrf6t.js → swimlanes-N4OXWK64-CBlFzDJn.js} +1 -1
  173. package/studio/web/assets/swimlanesDiagram-K3J5GTZL-DvFLtUPi.js +8 -0
  174. package/studio/web/assets/{timeline-definition-EJHVYXUP-DJSITpQX.js → timeline-definition-YOQAKGHF-CFWqHrqI.js} +4 -4
  175. package/studio/web/assets/{toggleHighContrast-CfAp6Sxb.js → toggleHighContrast-JRkLfyfb.js} +1 -1
  176. package/studio/web/assets/treeView-D4JQ5SDB-CpV9zuVb.js +1 -0
  177. package/studio/web/assets/treemap-SAJKECNS-Bc94Tqiq.js +1 -0
  178. package/studio/web/assets/{tsMode-HZR0bfq4.js → tsMode-C_-eSRVR.js} +1 -1
  179. package/studio/web/assets/{typescript-UMJSfZyF.js → typescript-BYXigM_n.js} +1 -1
  180. package/studio/web/assets/{useEntityRestURL-B379CS-h.js → useEntityRestURL-HHmjsRm9.js} +1 -1
  181. package/studio/web/assets/usecaseDiagram-VIAY4XPW-Brf2xP3S.js +328 -0
  182. package/studio/web/assets/{vendor-core-6LhfJlRC.js → vendor-core-PejxhUWQ.js} +1 -1
  183. package/studio/web/assets/vendor-datadog-GjET_Cbn.js +6 -0
  184. package/studio/web/assets/{vendor-react-BOliS0BL.js → vendor-react-DSIx92Ld.js} +1 -1
  185. package/studio/web/assets/vendor-tanstack-C5Y-nrd2.js +2 -0
  186. package/studio/web/assets/vendor-ui-BBWHOEoQ.js +41 -0
  187. package/studio/web/assets/{vennDiagram-UO4OBE2U-DZTsDA4k.js → vennDiagram-BLWOH2XV-XxOyJPq_.js} +3 -3
  188. package/studio/web/assets/wardley-7MLQ67FV-DRW0zuE4.js +1 -0
  189. package/studio/web/assets/{wardleyDiagram-VNRHLVJA-qu_f7Tvl.js → wardleyDiagram-YMQ3BMBF-B3WskJ9W.js} +3 -3
  190. package/studio/web/assets/wasAReleasedBeforeB-D5grAaGM.js +1 -0
  191. package/studio/web/assets/{workers-Bn_0_L1R.js → workers-DY0ka4Y7.js} +1 -1
  192. package/studio/web/assets/{xml-CG2N-1r1.js → xml-CdGINpmN.js} +1 -1
  193. package/studio/web/assets/xychartDiagram-TAQBALBS-D5NV4Env.js +7 -0
  194. package/studio/web/assets/{yaml-68pda6Av.js → yaml-DYeF07_w.js} +1 -1
  195. package/studio/web/index.html +17 -15
  196. package/utility/npmUtilities.ts +17 -7
  197. package/studio/web/assets/Chat-CrxPD4ph.js +0 -2294
  198. package/studio/web/assets/FloatingChat-EXzoUQ-9.js +0 -23
  199. package/studio/web/assets/SearchDialog-7qRL8Yqx.js +0 -1
  200. package/studio/web/assets/_basePickBy-QJXUw_pr.js +0 -1
  201. package/studio/web/assets/_baseUniq-DR4vVjyC.js +0 -1
  202. package/studio/web/assets/alertDialog-BbWD9Xxa.js +0 -1
  203. package/studio/web/assets/applications-CdqjiSQe.js +0 -296
  204. package/studio/web/assets/architecture-7GRP2DOG-DDmz3hpI.js +0 -1
  205. package/studio/web/assets/arrow-up-D2g_57vT.js +0 -1
  206. package/studio/web/assets/authStore-CJCaADw9.js +0 -3
  207. package/studio/web/assets/button-DWwfgGWI.js +0 -21
  208. package/studio/web/assets/channel-D0AkPjJ8.js +0 -1
  209. package/studio/web/assets/chunk-3FUC2YCW-DuRp-j19.js +0 -2
  210. package/studio/web/assets/chunk-7M6MHVWA-h6WlaTzO.js +0 -213
  211. package/studio/web/assets/chunk-FOHPRMQF-DzxwRta7.js +0 -161
  212. package/studio/web/assets/chunk-O7XYJQB3-CBS3nZYV.js +0 -128
  213. package/studio/web/assets/chunk-Z7XXMR3K-DSHbImdf.js +0 -10
  214. package/studio/web/assets/cn-6T8VranJ.js +0 -1
  215. package/studio/web/assets/cynefin-OW5HDTMX-DIvbXLSj.js +0 -1
  216. package/studio/web/assets/cynefinDiagram-VND7K2PF-BdtaXiCE.js +0 -62
  217. package/studio/web/assets/dagre-6A5THRUB-C7RUWHyz.js +0 -4
  218. package/studio/web/assets/diagram-ATOU4E4O-dxRfeG4B.js +0 -3
  219. package/studio/web/assets/diagram-MPIPVDR6-7prveD1B.js +0 -41
  220. package/studio/web/assets/dist-BrRuMUzt.js +0 -11
  221. package/studio/web/assets/download-Cn6tPG4L.js +0 -1
  222. package/studio/web/assets/elk-276RUBZZ-DxDQPjQT.js +0 -27
  223. package/studio/web/assets/errorText-NpBmvk9I.js +0 -1
  224. package/studio/web/assets/eventmodeling-NTZA5JFV-DONQf5aG.js +0 -1
  225. package/studio/web/assets/flowDiagram-KWPJA3E3-BoZ8_QR1.js +0 -1
  226. package/studio/web/assets/gitGraph-4MIJSDKK-BrXWmJt2.js +0 -1
  227. package/studio/web/assets/gitGraphDiagram-X574FWY7-C2KnnZeY.js +0 -106
  228. package/studio/web/assets/graphlib-C9Tl7QrK.js +0 -1
  229. package/studio/web/assets/index-QLZw6xrU.css +0 -1
  230. package/studio/web/assets/index-qCLyhyah.js +0 -828
  231. package/studio/web/assets/index.lazy-DCx-DxIV.js +0 -2
  232. package/studio/web/assets/info-A6RAGUB7-BhzBHe4U.js +0 -1
  233. package/studio/web/assets/mermaid.core-1PX6R49U.js +0 -44
  234. package/studio/web/assets/notifications-BSObVIY1.js +0 -1
  235. package/studio/web/assets/notifications-DFLXk68b.js +0 -1
  236. package/studio/web/assets/packet-AYTQ26CC-DWbXHx5g.js +0 -1
  237. package/studio/web/assets/pie-WAS4IAKB-DjoTm3yr.js +0 -1
  238. package/studio/web/assets/pieDiagram-5QR66LMP-Dz3RHpDA.js +0 -39
  239. package/studio/web/assets/profile-TBjzOMIy.js +0 -1
  240. package/studio/web/assets/radar-RG4KPBEZ-CVBgjCOj.js +0 -1
  241. package/studio/web/assets/railroad-74A4TZTK-BdhXw10Q.js +0 -1
  242. package/studio/web/assets/railroad-abnf-HS5TGJTU-BcoQPN39.js +0 -1
  243. package/studio/web/assets/railroad-ebnf-LZEXJU2U-B1Lke2JN.js +0 -1
  244. package/studio/web/assets/railroad-peg-WCYAUIDC-D-oJFkiV.js +0 -1
  245. package/studio/web/assets/regions-C4an4H7e.js +0 -1
  246. package/studio/web/assets/restartGate-nHAFLDh-.js +0 -1
  247. package/studio/web/assets/sankeyDiagram-IPEJSGJF-D9AyLce2.js +0 -40
  248. package/studio/web/assets/sequenceDiagram-PO4LG4MO-hfk4aIEm.js +0 -169
  249. package/studio/web/assets/setComponentFile-BrqOM1Zm.js +0 -1
  250. package/studio/web/assets/setup-0v2MCm-E.js +0 -2
  251. package/studio/web/assets/status-DdezofUu.js +0 -61
  252. package/studio/web/assets/swimlanesDiagram-TC7HE7FX-DfN7BAP5.js +0 -8
  253. package/studio/web/assets/tabs-BCTQzoQK.js +0 -1
  254. package/studio/web/assets/treeView-Q6P3EWNA-C-epdxRo.js +0 -1
  255. package/studio/web/assets/treemap-WGGIJYW6-DOVyp_zw.js +0 -1
  256. package/studio/web/assets/usecaseDiagram-POWQR4AR-B6252cbq.js +0 -328
  257. package/studio/web/assets/vendor-datadog-ozKf-pts.js +0 -6
  258. package/studio/web/assets/vendor-tanstack-Bntfka-M.js +0 -2
  259. package/studio/web/assets/vendor-ui-9EYYDgoN.js +0 -41
  260. package/studio/web/assets/wardley-WFR3VGLG-Cfqoe6z4.js +0 -1
  261. package/studio/web/assets/xychartDiagram-PMCCYNJV-UrOKRsmR.js +0 -7
@@ -37,14 +37,24 @@ Consequences that shape the code:
37
37
  no-op. The key is `lockAttemptKey(tableId, id)` = `[LOCK_KEY_PREFIX, tableId, ...id]`, distinct from
38
38
  `getFromSource`'s bare-id single-flight lock.
39
39
  - **`RecordLockHandle`** (`recordLock.ts`) carries `store`, `key`, `keyId`, `acquiredAt`, `expiresAt`,
40
- `hold`, `released`, and `expired`. `release()` is synchronous: it sets `released`, clears the lease
41
- timer, and calls `store.unlock(key)`. A lease timer sets `expired = true` then calls `store.unlock()`
42
- on fire; any write staged through an expired or released handle throws 409 in
43
- `DatabaseTransaction.save()` before the write reaches the store — from `lockNotHeldError()`, which
44
- names the actual cause, since an expired lease and a handle already released (by `unlock()` or by
45
- the commit) send a caller after different bugs. `acquireRecordKey` loops
46
- `tryLock` → await wake → retry until acquired or `waitMs` elapsed (then 423). The contender wait
47
- timer uses `.unref()` so it does not prevent process exit.
40
+ `hold`, `released`, and `expired`. `release()` sets `released` and clears the lease timer; a lease
41
+ timer sets `expired = true` and `released = true` on fire. When no native commit is holding the
42
+ handle, both immediately call `store.unlock(key)` and emit `onRelease` (and `release()` returns `true`).
43
+ While a submitted native `RocksTransaction` is in flight (`retainForCommit(holdDeadlineMono)` called at
44
+ first native submission in `DatabaseTransaction.performCommit()`), `store.unlock(key)` and `onRelease`
45
+ are deferred (and `release()` returns `false`) until `releaseCommitHold()` releases the last commit
46
+ hold — at local commit landing (immediately before `releaseRecordLocks()`), in `commitOutcome`'s
47
+ rejection handler on any unlogged give-up, or at the commit-conflict budget boundary via
48
+ `noteOverdueLoggedCommit` or the handle's own hold-deadline timer when a native commit never settles
49
+ (harper#3236) — so a logged commit that retries past its lease cannot be overtaken by a same-node
50
+ contender or emit an early `LOCK_RELEASE` while within its conflict budget. `isExpired()` flips
51
+ immediately when the timer fires or `revokeLease()`/`release()` runs (while `isLeaseExpired()` reflects
52
+ only lease expiry or `revokeLease()`, not `release()`), so any new write staged through an expired or
53
+ released handle (and any unlogged conflict retry) throws 409 in `DatabaseTransaction.save()` or
54
+ `performCommit()` — from `lockNotHeldError()`, which names the actual cause, since an expired lease and
55
+ a handle already released (by `unlock()` or by the commit) send a caller after different bugs.
56
+ `acquireRecordKey` loops `tryLock` → await wake → retry until acquired or `waitMs` elapsed (then 423).
57
+ The contender wait timer uses `.unref()` so it does not prevent process exit.
48
58
  - **Re-entrancy is per-transaction.** `DatabaseTransaction.recordLocks` is a lazily allocated
49
59
  `Map<store, Map<keyId, handle>>` (O(1) lookup). `registerRecordLock`, `recordLockFor`, and
50
60
  `unregisterRecordLock` manage it. `lock()` consults it before calling `tryLock`; a re-entrant call
@@ -53,7 +63,8 @@ Consequences that shape the code:
53
63
  - **Release.** Every handle — scoped or hold — is registered in `link.recordLocks` and kept on the
54
64
  locked instance as `#lockHandle`; every commit or abort calls `releaseRecordLocks()`, which calls
55
65
  `handle.release()` on each non-hold handle, so a `{ hold: true }` handle outlives the transaction.
56
- `unlock()` calls `handle.release()` directly (synchronous, returns false if already released).
66
+ `unlock()` calls `handle.release()` directly (synchronous; returns false if already released, or
67
+ while a commit hold defers the native unlock until the in-flight commit settles).
57
68
  `unlock()` also accepts scoped handles: it calls `release()` early and unregisters the handle so the transaction commit does
58
69
  not release it again. After any `unlock()` call `#lockWritable` is cleared so writes through the
59
70
  instance are no longer accepted. In an explicit transaction with no staged writes and no open
@@ -1066,9 +1077,12 @@ feature that has not yet been measured.
1066
1077
  >
1067
1078
  > **§2's exclusion invariant is wider than what (1) and (2) leave.** It requires a successor to
1068
1079
  > exclude every predecessor capability that can still admit _or commit_; what ships excludes
1069
- > admission, and the commit half holds only up to the pre-submission expiry fence
1070
- > (the `hasLeaseProtectedWrite` fence in `DatabaseTransaction.performCommit()`) — a native commit that clears that fence and settles
1071
- > afterwards is limitation (2)'s third route.
1080
+ > admission, and the commit half holds up to the pre-submission expiry fence
1081
+ > (the `hasLeaseProtectedWrite` fence in `DatabaseTransaction.performCommit()`) plus, on the local
1082
+ > node and for clean recall drain, `KeyLockHandle`'s per-handle commit hold (`retainForCommit()` /
1083
+ > `releaseCommitHold()`, harper#3236) up to `commitConflictBudget()` — a native commit that clears the
1084
+ > pre-submission fence and settles past `commitConflictBudget()` (or after the home's own monotonic
1085
+ > delegation lease expires on a peer) is limitation (2)'s third route.
1072
1086
  >
1073
1087
  > **There is no caller-side mitigation for (2) — the obvious candidate is unreachable and, where it
1074
1088
  > is reachable, makes things worse.** `X-Replicate-To` / `confirm=` is meant to be super-user only —
@@ -1306,11 +1320,14 @@ drain and caps, §§5/6/8, plus the three inherited substrate defects in §11) a
1306
1320
  machine is gone, and the home ring, the delegation table, recall with §6 steps 1, 2 and 4, ordered
1307
1321
  fencing tokens, the caps, the §4.3 restart quarantine, the generation monotonicity floor and the
1308
1322
  transport-swap grant fence are in place, with all three inherited defects fixed. **§6 step 3 —
1309
- settlement — is not implemented**: a recall revokes capability and then writes the release without
1310
- waiting for a native commit already submitted to complete. That is exactly limitation (2)'s third
1311
- route in §10, which the contract already states, so the gap is disclosed rather than hidden — but §6
1312
- must not be read as fully implemented. Closing it means hanging the release hook off
1313
- logical-transaction settlement rather than off the last admission unlocking. harper-pro#822 supplies
1323
+ settlement — is bounded by the commit-conflict budget (`retainForCommit(holdDeadlineMono)` /
1324
+ `releaseCommitHold()`, harper#3236)**: a submitted native `RocksTransaction` retains a commit hold
1325
+ on each write's `KeyLockHandle` until local commit landing (or `commitOutcome` rejection), so
1326
+ `revokeLease()` defers `store.unlock(key)` and `onRelease` (`LOCK_RELEASE` emission) while the
1327
+ commit is within `commitConflictBudget()`. Past `commitConflictBudget()`, `KeyLockHandle`'s
1328
+ `#holdTimer` (as well as `noteOverdueLoggedCommit` between retry attempts) releases the hold so a
1329
+ wedged conflict cannot pin the key forever, and a peer's own monotonic delegation lease (§5.2) can
1330
+ still expire first — which is limitation (2)'s third route in §10. harper-pro#822 supplies
1314
1331
  the operator-agreed home map and `establishLockFreshness()`; the feature is opt-in
1315
1332
  (`replication.recordLocks: true`). A `lockRelease` that reaches a non-coordinating thread is handed
1316
1333
  to the transport's `relayControlEntry` (harper-pro#977). Without the hook, a thread that has a
@@ -52,10 +52,11 @@ export interface ResolvedRecordLockOptions {
52
52
 
53
53
  /**
54
54
  * The handle returned by `lock()` and held by each gated write until its transaction completes.
55
- * `release()` is synchronous: it calls `store.unlock(key)` which is ownerless and fires any
56
- * waiting callbacks cross-thread. The `expired` flag is set in the holder thread by the lease
57
- * timer, so a stale holder cannot accidentally call unlock after its lease already fired and
58
- * released the key to a new holder.
55
+ * `release()` is synchronous: it calls `store.unlock(key)` (or defers it while a commit hold
56
+ * acquired via `retainForCommit` is still active), which is ownerless and fires any waiting
57
+ * callbacks cross-thread. The lease timer sets `expired` in the holder thread when it fires
58
+ * (while a commit hold is active, only the native unlock is deferred), so a stale holder cannot
59
+ * accidentally call unlock after its lease already fired and released the key to a new holder.
59
60
  */
60
61
  export interface RecordLockHandle {
61
62
  store: any;
@@ -107,7 +108,11 @@ export interface RecordLockHandle {
107
108
  * version has been noted; otherwise advances by the minimum monotonic step from the recorded floor.
108
109
  */
109
110
  nextHolderVersion(): number;
111
+ /** True when this call returned the native key; false if already released, or while a commit hold defers the unlock. */
110
112
  release(): boolean;
113
+ /** Defer `store.unlock(key)` until an in-flight native commit settles or `holdDeadlineMono` expires. */
114
+ retainForCommit?(holdDeadlineMono?: number): void;
115
+ releaseCommitHold?(): void;
111
116
  /**
112
117
  * Flip this handle to hold mode and reset its lease timer. Used during scoped→hold upgrades
113
118
  * so all instances that reference this handle object remain valid (no retire + replace).
@@ -192,6 +197,10 @@ class KeyLockHandle implements RecordLockHandle {
192
197
  // an NTP step nor a manual clock change can extend a lease past what peers assumed.
193
198
  #deadlineMono: number | undefined;
194
199
  #onRelease: (() => void) | undefined;
200
+ #commitHolds = 0;
201
+ #deferredUnlock = false;
202
+ #holdDeadlineMono: number | undefined;
203
+ #holdTimer: ReturnType<typeof setTimeout> | undefined;
195
204
 
196
205
  constructor(store: any, key: any[], keyId: unknown, lease?: number, hold = false, acquiredAt?: number) {
197
206
  this.store = store;
@@ -238,8 +247,9 @@ class KeyLockHandle implements RecordLockHandle {
238
247
  // bound and the key may belong to someone else.
239
248
  #leaseLapsed(): boolean {
240
249
  if (this.#deadlineMono === undefined || performance.now() < this.#deadlineMono) return false;
241
- // Finish the expiry rather than only reporting it: the native key must actually go back so a
242
- // stalled holder does not keep a key every participant has already written off.
250
+ // Finish the expiry rather than only reporting it: once no in-flight commit holds the handle,
251
+ // the native key must go back so a stalled holder does not keep a key every participant has
252
+ // already written off.
243
253
  if (!this.released) this.#onLeaseExpire();
244
254
  return true;
245
255
  }
@@ -259,8 +269,8 @@ class KeyLockHandle implements RecordLockHandle {
259
269
  // no-op in the one state it was written for.
260
270
  this.expired = true;
261
271
  if (this.released) {
262
- // The native key is already back and `onRelease` already fired; only the fence flag and the
263
- // lease timer are left to settle.
272
+ // `release()` already ran (returning the native key or deferring the unlock onto an active
273
+ // commit hold); only the fence flag and the lease timer are left to settle.
264
274
  clearTimeout(this.#timer);
265
275
  return;
266
276
  }
@@ -294,22 +304,84 @@ class KeyLockHandle implements RecordLockHandle {
294
304
  return next;
295
305
  }
296
306
 
297
- #onLeaseExpire() {
298
- if (this.released) return;
299
- this.released = true;
300
- this.expired = true;
301
- clearTimeout(this.#timer);
307
+ retainForCommit(holdDeadlineMono?: number): void {
308
+ if (this.released && !this.#deferredUnlock) return;
309
+ this.#commitHolds++;
310
+ if (holdDeadlineMono !== undefined && Number.isFinite(holdDeadlineMono)) {
311
+ this.#holdDeadlineMono = Math.max(this.#holdDeadlineMono ?? holdDeadlineMono, holdDeadlineMono);
312
+ if (this.#deferredUnlock) this.#armHoldTimer();
313
+ }
314
+ }
315
+
316
+ releaseCommitHold(): void {
317
+ if (this.#commitHolds <= 0) return;
318
+ if (--this.#commitHolds === 0) {
319
+ clearTimeout(this.#holdTimer);
320
+ this.#holdTimer = undefined;
321
+ this.#holdDeadlineMono = undefined;
322
+ if (this.#deferredUnlock) {
323
+ this.#deferredUnlock = false;
324
+ this.#unlockAndEmitRelease('record lock deferred release failed (store may have been dropped)');
325
+ }
326
+ }
327
+ }
328
+
329
+ // True when the native key went back now; false when the unlock waits on the commit hold.
330
+ #deferOrFlushUnlock(warningMessage: string): boolean {
331
+ if (this.#holdDeadlineMono !== undefined && performance.now() >= this.#holdDeadlineMono) {
332
+ this.#commitHolds = 0;
333
+ this.#deferredUnlock = false;
334
+ this.#holdDeadlineMono = undefined;
335
+ clearTimeout(this.#holdTimer);
336
+ this.#holdTimer = undefined;
337
+ this.#unlockAndEmitRelease(warningMessage);
338
+ return true;
339
+ }
340
+ this.#deferredUnlock = true;
341
+ this.#armHoldTimer();
342
+ return false;
343
+ }
344
+
345
+ #armHoldTimer() {
346
+ clearTimeout(this.#holdTimer);
347
+ this.#holdTimer = undefined;
348
+ if (!this.#deferredUnlock || this.#holdDeadlineMono === undefined) return;
349
+ const remaining = Math.max(0, Math.ceil(this.#holdDeadlineMono - performance.now()));
350
+ this.#holdTimer = setTimeout(() => {
351
+ this.#holdTimer = undefined;
352
+ this.#commitHolds = 0;
353
+ this.#holdDeadlineMono = undefined;
354
+ if (this.#deferredUnlock) {
355
+ this.#deferredUnlock = false;
356
+ this.#unlockAndEmitRelease('record lock commit hold timer failed (store may have been dropped)');
357
+ }
358
+ }, remaining).unref();
359
+ }
360
+
361
+ #unlockAndEmitRelease(warningMessage: string) {
302
362
  try {
303
363
  this.store.unlock(this.key);
304
364
  } catch (err) {
305
365
  if (!warnedLeaseTimerStores.has(this.store)) {
306
366
  warnedLeaseTimerStores.add(this.store);
307
- harperLogger.warn?.('record lock lease timer failed (store may have been dropped)', err);
367
+ harperLogger.warn?.(warningMessage, err);
308
368
  }
309
369
  }
310
370
  this.#emitRelease();
311
371
  }
312
372
 
373
+ #onLeaseExpire() {
374
+ if (this.released) return;
375
+ this.released = true;
376
+ this.expired = true;
377
+ clearTimeout(this.#timer);
378
+ if (this.#commitHolds > 0) {
379
+ this.#deferOrFlushUnlock('record lock lease timer failed (store may have been dropped)');
380
+ return;
381
+ }
382
+ this.#unlockAndEmitRelease('record lock lease timer failed (store may have been dropped)');
383
+ }
384
+
313
385
  // The cluster release is a durability optimization, not the safety mechanism — peers expire the
314
386
  // hold on their own lease bound — so neither a throw nor a rejected promise may escape here. This
315
387
  // runs from a lease timer and from post-commit cleanup, where an escaping rejection would take
@@ -331,15 +403,10 @@ class KeyLockHandle implements RecordLockHandle {
331
403
  if (this.released) return false;
332
404
  this.released = true;
333
405
  clearTimeout(this.#timer);
334
- try {
335
- this.store.unlock(this.key);
336
- } catch (err) {
337
- if (!warnedLeaseTimerStores.has(this.store)) {
338
- warnedLeaseTimerStores.add(this.store);
339
- harperLogger.warn?.('record lock release failed (store may have been dropped)', err);
340
- }
406
+ if (this.#commitHolds > 0) {
407
+ return this.#deferOrFlushUnlock('record lock release failed (store may have been dropped)');
341
408
  }
342
- this.#emitRelease();
409
+ this.#unlockAndEmitRelease('record lock release failed (store may have been dropped)');
343
410
  return true;
344
411
  }
345
412
 
package/server/DESIGN.md CHANGED
@@ -73,6 +73,11 @@ A request entering `http.ts` does **not** go through Fastify unless no Harper ha
73
73
  > all. Only mutating an object _without_ changing its version can serve stale bytes, and that is
74
74
  > indistinguishable from re-sending the same message.
75
75
 
76
+ > **Pass-through payloads are `Buffer | string`.** A message that carries its own `{ contentType, data }`
77
+ > is delivered as its `data`, but binary data leaves `serializeMessage` as a `Buffer`: on a worker other
78
+ > than the publisher's, the record is decoded from the transaction log and `data` is a plain `Uint8Array`,
79
+ > which mqtt-packet cannot encode. Pinned by `integrationTests/mqtt/opaque-payload-across-workers.test.ts`.
80
+
76
81
  ### Threads
77
82
 
78
83
  | File | Purpose |
@@ -353,11 +358,13 @@ The `@table(cacheControl:)` value is persisted on the primary-key attribute (lik
353
358
 
354
359
  A persisted QoS 1/2 entry is `{ qos, topic, startTime, databaseGeneration? }`, and the record carries the `incarnation` of the connection that owns it (harper#2448). A QoS 0 entry is `{ qos, topic }`: it is session state with no position, resumed live with retain handling 2 and no acknowledgement tracking.
355
360
 
356
- - **Positions are certified, never clocks.** A new subscription starts at its registration watermark (`registeredThrough`), bound to the database generation when one exists, and otherwise at `getNextMonotonicTime()` unbound until the first certified position replaces it. A SUBSCRIBE that replaces a QoS 1/2 topic's subscription continues that topic's position through the checked replay, since replacing must lose nothing; a pruned-history refusal there resets the session as it would at reconnect. A topic's position is `min(subscription.progress(), previousKey of its oldest unacknowledged delivery)`; a transaction's messages share a key, so one unacked message holds the position before its whole transaction, and while a message is still queued the position stays before the last delivered transaction, which that message may share. Live events arrive in commit order, and a RocksDB transaction takes its key before it commits, so a transaction can arrive after a higher key. Its previousKey is then the position just below its own key, and while such a late delivery is unacknowledged the position takes the lowest previousKey of all of them rather than the oldest's (harper#3101). Scan deliveries (`fromScan`) stay out of this ordering: they are retained state, which a resumed session does not resend.
361
+ - **Positions are certified, never clocks.** A new subscription starts at its registration watermark (`registeredThrough`), bound to the database generation when one exists, and otherwise at `getNextMonotonicTime()` unbound until the first certified position replaces it. A SUBSCRIBE that replaces a QoS 1/2 topic's subscription continues that topic's position through the checked replay, since replacing must lose nothing; a pruned-history refusal there resets the session as it would at reconnect. A topic's position is `min(subscription.progress(), previousKey of its oldest unacknowledged delivery)`; a transaction's messages share a key, so one unacked message holds the position before its whole transaction, and while a message is still queued the position stays before the last delivered transaction, which that message may share. Live events arrive in commit order, and a RocksDB transaction takes its key before it commits, so a transaction can arrive after a higher key. Its previousKey is then the position just below its own key, and while such a late delivery is unacknowledged the position takes the lowest previousKey of all of them rather than the oldest's (harper#3101). Scan deliveries (`fromScan`) stay out of this ordering: they are retained state, which a resumed session does not resend: `resumeTopics` subscribes with retain handling 2 (`addSubscription` omits current values for that or an explicit `omitCurrent`), since a record topic whose saved position covers its current version would otherwise send that version again.
357
362
  - **Checkpoints:** coalesced after acknowledgements, on an unref'd timer (`min(auditRetention / 10, 1h)`), and at disconnect, where positions are taken before subscriptions end because `close()` clears a queue and would read as idle. Nothing past a resumed position is saved until that subscription's `resumeVerified` resolves true. Out-of-order acks are not persisted: a partly acked transaction is redelivered. Legacy `acks` lists are ignored.
358
363
  - **Before CONNACK,** a collection entry bound to its database's current generation is checked against the floor (a record entry's own walk decides after CONNACK); a failure deletes the record and reports the session as not present. The connection then writes its incarnation (`takeOver`), and every later write first checks the stored incarnation: a connection that finds another's stops writing and closes (0x8E), and one resumed from a record never creates one. Any newer authenticated connection for the client id on the same thread, a clean start included, closes the older durable connection the same way before it reads or deletes the record, once that connection has saved its positions, and waits for that save to settle; a connection that has already closed counts until its last save settles. A save that failed leaves the record at the last one that landed, so the newer connection resumes from there and may redeliver, never skip. Simultaneous CONNECTs for one client id take their turn through a per-thread claim, so each sees the one before it; one that gives up waiting keeps its place until the one before it finishes. The CONNECT's pending session is assigned before it waits, so packets sent right behind the CONNECT wait with it rather than being refused; the wait is bounded (10 s, then CONNACK 0x88, or 0x03 on v3.1.1), and a CONNECT whose client closes while it waits takes nothing over. A session that has ended, by a disconnect or by yielding to a takeover, saves nothing after its final save, so a SUBSCRIBE still in flight cannot write over the record a newer connection claimed.
359
364
  - **Subscription changes are serial.** Packets are handled concurrently, so a durable session applies SUBSCRIBE, UNSUBSCRIBE and `resume()` one at a time: a topic's state belongs to one subscription. A SUBSCRIBE of any QoS is saved before SUBACK. A failed SUBSCRIBE leaves its topic unsubscribed and saves that. It ends the subscription it started, and drops the state of the one it replaced, which replacing ended. An UNSUBSCRIBE whose save fails is reported failed again on a retry until the removal is saved, and any failed save starts the checkpoint timer, so a session with only QoS 0 topics retries it too.
365
+ - **A delivery that cannot be sent ends the connection, not just the session.** The listener's `catch` in `mqtt.ts` aborts the transport (`destroy`/`terminate`, as for a parse error) and `onClose` ends the session, so the client sees the drop and reconnects. Ending only the session, as before, left the client connected and receiving nothing. An abort rather than a graceful end, because teardown on a graceful TCP end waits for the peer to close its side. A delivery that fails every time therefore repeats on each reconnect (a durable QoS≥1 session replays the unacknowledged message).
360
366
  - **Wills are kept per connection,** keyed `[clientId, incarnation]`: a connection closing after a takeover publishes and deletes only its own will (MQTT 5 §3.1.4 publishes a taken-over client's will), never the will of the connection that replaced it.
367
+ - **Only a will's publish is authorized** (`settleWill`, harper#1874). The `hdb_session_will` row is the session's bookkeeping: it is read and deleted on contexts of its own, not the session's `authorize: true` context, which spends its one-shot check on the first call (roles are not granted system tables, so a non-super user's will never published and its row leaked). The publish then runs on a fresh session context, as the connection's user or a scoped will's embedded role, and the row is deleted after any outcome, a refused or failed publish included, so a will that fails for a transient reason is lost: the boot sweep, the only retry path, would replay a kept row only at the next restart, with no liveness check (harper#3034). The publish commits before the delete, so a crash between them leaves the row for that sweep and the will may be published twice (by design; untested). `unitTests/server/sessionWill.test.js` and `integrationTests/mqtt/will-authorization.test.ts` enforce that only the publish is authorized and that the row is deleted after a publish, a refusal and a clean DISCONNECT.
361
368
  - **One terminal path** (`subscriptionFailed`): a refused resume subscribe, a final `Error` value (observed through the subscription's `close` as well as the consumer loop, which may be parked on socket back-pressure), or an iterator failure. Only a 410 (pruned history) discards the session — writers fenced, siblings ended, a v5 DISCONNECT (0x83, the client-error message when problem information is allowed) and a socket close that never waits on storage, then a deletion only if the record still carries this incarnation. Anything else, a 409 from a restored or copied database included, saves positions and closes the connection so the client resumes.
362
369
  - **Only the current generation is checked.** A position bound to another generation, another node's (`hdb_durable_session` replicates, and each node mints its own generations) or this database's before a restore or copy, resumes best-effort: it is not checked before CONNACK, replays unchecked from where it is, and binds again at the first certified position. Until a live write gives it a watermark, that replay saves no position, so a reconnect on a quiet database replays it again.
363
370
  - **Not covered:** concurrent connections for one client id on different threads, together with crash replay of a failed conditional write (harper#2940); late commits below a saved position, including a late transaction still queued behind the message being sent, which the session cannot see (harper#2928); a replay from another generation, which is unchecked.
@@ -647,6 +654,7 @@ The SQL and job paths are additive rather than exclusive: `verifyPermsAST` valid
647
654
  - **Headers commit exactly once, through `this.writeHead`.** `write()`, `end()`, `flushHeaders()` and `_implicitHeader()` all reach `this.writeHead(this.statusCode)` by property lookup, so a `writeHead` that `on-headers` replaced on the instance runs its listeners (the ones that set `Content-Encoding` and remove `Content-Length`) before the promise resolves. After commit, `setHeader`/`appendHeader`/`removeHeader` and a second `writeHead` throw `ERR_HTTP_HEADERS_SENT` as Node's do; `_header` (which `compression` ≤ 1.7 tests instead of `headersSent`) and `finished` (which `on-finished` tests) derive from that state.
648
655
  - **The adapter owns the `'error'` listener.** A `destroy(err)` right after `writeHead()` emits before the awaiting caller can attach one; the error stays in the stream's `errored` state for `pipeline()`, `finished()` or async iteration. Client disconnect (`Request.signal`) destroys the response without an error after headers (a plain premature close, which `pipeBodyToResponse` treats as routine) and rejects the promise with the abort reason before them; a handler that throws or rejects before ending the response destroys it, and one that fails after `end()` is logged at warn.
649
656
  - **Header names are case-insensitive on removal too.** `Headers.delete` lowercases like `set`/`get`/`has`; the inherited `Map.delete` silently left `send`'s `Content-Length` on a gzip body (truncated transfers).
657
+ - **Every piped chunk reaches `write()` by property lookup.** Node 26.11's `Readable.pipe` fast path (nodejs/node#66182) calls `_write()` directly when pipe's listener is a byte-mode source's only `'data'` listener, so `compression`'s instance patch of `res.write` was skipped for most chunks of `send(...).pipe(res)`: raw bytes inside the gzip stream, or a stall once `compression` had routed pipe's `'drain'` to its idle zlib stream. On runtimes that have the fast path (`'writeKnownBuffer' in Writable`, a static it added) the response adds an inert `'data'` listener to each byte-mode core `Readable` on `'pipe'`, which fails the fast path's single-listener check, and removes it on `'unpipe'` through a listener prepended in the constructor: pipe's own cleanup resumes a source awaiting `'drain'` while any `'data'` listener remains, which would leave an unpiped source flowing into nothing. Object-mode and decoded sources, and legacy `Stream.prototype.pipe` (which never emits `'unpipe'`), are skipped. Remove it once every supported Node 26 release dispatches pipe through an overridden `write()`.
650
658
  - **Express is not a target.** `express`'s `app.handle()` replaces the response's prototype with one rooted at `http.ServerResponse.prototype`, which no Writable-derived response survives, and would do the same to the request `Proxy`'s target, Harper's real `IncomingMessage`. Middleware that duck-types the response (Next.js, `compression`, `send`, `serve-static`, `finalhandler`, h3, fastify) is the supported surface.
651
659
 
652
660
  ## `manageThreads` has two different `workerCount`s (`server/threads/manageThreads.js`)
@@ -254,13 +254,13 @@ class SubscriptionsSession {
254
254
  if (path.indexOf('.') > -1) throw new Error('Dots are not allowed in topic names');
255
255
  // might be faster to somehow modify existing subscription and re-get the retained record, but this should work for now
256
256
  const existingSubscription = this.subscriptions.find((subscription) => subscription.topic === topic);
257
- let omitCurrent;
257
+ let omitCurrent = subscriptionRequest.omitCurrent === true;
258
258
  if (existingSubscription) {
259
- omitCurrent = retainHandling > 0;
259
+ omitCurrent ||= retainHandling > 0;
260
260
  existingSubscription.end();
261
261
  this.subscriptions.splice(this.subscriptions.indexOf(existingSubscription), 1);
262
262
  } else {
263
- omitCurrent = retainHandling === 2;
263
+ omitCurrent ||= retainHandling === 2;
264
264
  }
265
265
  if (startTime) trace('Resuming subscription from', topic, 'from', startTime);
266
266
  const entry = resources.getMatch(path, 'mqtt');
@@ -484,22 +484,7 @@ class SubscriptionsSession {
484
484
  const willId = this.willId;
485
485
  this.willId = undefined;
486
486
  if (willId) {
487
- const context = this.createContext();
488
- transaction(context, async () => {
489
- try {
490
- if (!clientTerminated) {
491
- const will = await getLastWill().get(willId);
492
- if (will && !isWillFromExpiredScopedToken(will)) {
493
- // A scoped will authorizes under its own embedded role, never the disconnecting
494
- // session's user (which may be a later same-clientId reconnect).
495
- const willContext = will.user?._scopedToken ? { ...context, user: will.user } : context;
496
- await publishMessage(will, will.data, willContext);
497
- }
498
- }
499
- } finally {
500
- await getLastWill().delete(willId);
501
- }
502
- }).catch((error) => {
487
+ this.settleWill(willId, !clientTerminated).catch((error) => {
503
488
  warn(`Error publishing MQTT will for ${this.sessionId}`, error);
504
489
  });
505
490
  }
@@ -509,6 +494,23 @@ class SubscriptionsSession {
509
494
  }
510
495
  this.subscriptions = [];
511
496
  }
497
+ /** The will row is session bookkeeping on contexts of its own; only the publish runs as the session (server/DESIGN.md). */
498
+ async settleWill(willId: [string, string], publish: boolean) {
499
+ try {
500
+ if (publish) {
501
+ const will = await getLastWill().get(willId, {});
502
+ if (will && !isWillFromExpiredScopedToken(will)) {
503
+ const context = this.createContext();
504
+ // A scoped will authorizes under its own embedded role, never the disconnecting
505
+ // session's user (which may be a later same-clientId reconnect).
506
+ if (will.user?._scopedToken) context.user = will.user;
507
+ await publishMessage(will, will.data, context);
508
+ }
509
+ }
510
+ } finally {
511
+ await getLastWill().delete(willId, {});
512
+ }
513
+ }
512
514
  receivedPacket() {
513
515
  if (this.keepalive) {
514
516
  clearTimeout(this.keepaliveTimer);
@@ -670,11 +672,8 @@ export class DurableSubscriptionsSession extends SubscriptionsSession {
670
672
  const { qos, topic, startTime, databaseGeneration } = state.entry;
671
673
  const durable = qos > 0;
672
674
  try {
673
- // retain handling, not omitCurrent, decides whether a subscription starts with current values
674
675
  await this.resumeSubscription(
675
- durable
676
- ? { omitCurrent: true, topic, qos, startTime, databaseGeneration, reportProgress: true }
677
- : { topic, qos, rh: 2 },
676
+ durable ? { topic, qos, rh: 2, startTime, databaseGeneration, reportProgress: true } : { topic, qos, rh: 2 },
678
677
  durable
679
678
  );
680
679
  } catch (error) {
package/server/mqtt.ts CHANGED
@@ -206,6 +206,19 @@ const SERVER_SHUTTING_DOWN = 0x8b; // MQTT v5 DISCONNECT reason code
206
206
  const REASON_STRING_LIMIT = 256; // bytes
207
207
  /** Fixed header, message id and reason code, plus the property identifier and its length prefix. */
208
208
  const ACK_PACKET_OVERHEAD = 16;
209
+ /** `AccessViolation` is 401 for a request with no user and 403 for a user lacking permission; an MQTT client sees both as "Not authorized". */
210
+ const isNotAuthorized = (error: any) => error?.statusCode === 401 || error?.statusCode === 403;
211
+ /** Only failures this layer maps to a code of their own are safe to describe: any other carries an internal message. */
212
+ const hasClientSafeMessage = (error: any) => isNotAuthorized(error) || error?.statusCode === 404;
213
+ /** REASON_STRING_LIMIT counts encoded bytes, and a character must not be cut in half. */
214
+ function boundedReasonString(message: unknown): string {
215
+ const text = String(message);
216
+ if (Buffer.byteLength(text) <= REASON_STRING_LIMIT) return text;
217
+ const bytes = Buffer.from(text, 'utf8');
218
+ let end = REASON_STRING_LIMIT;
219
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
220
+ return bytes.subarray(0, end).toString('utf8');
221
+ }
209
222
 
210
223
  /**
211
224
  * Every live MQTT connection on this worker, so a shutdown can announce itself: a bare socket close
@@ -316,6 +329,8 @@ function onSocket(socket, send, request, user, mqttSettings) {
316
329
  }
317
330
  }
318
331
  let disconnected;
332
+ // set by a failed delivery, so sibling subscriptions stop writing before the aborted socket's 'close' arrives
333
+ let aborted = false;
319
334
  numberOfConnections++;
320
335
  const connection = {
321
336
  protocolVersion: () => mqttOptions.protocolVersion,
@@ -327,17 +342,14 @@ function onSocket(socket, send, request, user, mqttSettings) {
327
342
  function closeConnection(error?: Error) {
328
343
  try {
329
344
  if (mqttOptions.protocolVersion >= 5) {
330
- const packet: any = { cmd: 'disconnect', reasonCode: error ? 0x83 : 0x8e };
345
+ const packet: any = { cmd: 'disconnect', reasonCode: error ? (isNotAuthorized(error) ? 0x87 : 0x83) : 0x8e };
331
346
  // as for PUBACK: only failures this layer describes itself are safe to hand a client
332
347
  const describable =
333
348
  error instanceof DatabaseGenerationChangedError ||
334
349
  error instanceof ResumeHistoryUnavailableError ||
335
- (error as any)?.statusCode === 403 ||
336
- (error as any)?.statusCode === 404;
350
+ hasClientSafeMessage(error);
337
351
  const reasonString =
338
- describable && error.message && sendProblemInformation
339
- ? Buffer.from(String(error.message), 'utf8').subarray(0, REASON_STRING_LIMIT).toString('utf8')
340
- : undefined;
352
+ describable && error.message && sendProblemInformation ? boundedReasonString(error.message) : undefined;
341
353
  if (
342
354
  reasonString &&
343
355
  (!maximumPacketSize || maximumPacketSize >= Buffer.byteLength(reasonString) + ACK_PACKET_OVERHEAD)
@@ -547,7 +559,14 @@ function onSocket(socket, send, request, user, mqttSettings) {
547
559
  });
548
560
  const listener = async (topic, message, messageId, subscription, version) => {
549
561
  try {
550
- if (disconnected) throw new Error('Session disconnected while trying to send message to', topic);
562
+ if (disconnected || aborted) {
563
+ // a delivery already past the subscription when the client left or a failed delivery aborted
564
+ // the connection; onClose ends the session
565
+ mqttLog.debug?.(
566
+ `Session ${session?.sessionId} ${aborted ? 'aborted' : 'disconnected'} while trying to send message to ${topic}`
567
+ );
568
+ return false;
569
+ }
551
570
  const slashIndex = topic.indexOf('/', 1);
552
571
  const generalTopic = slashIndex > 0 ? topic.slice(0, slashIndex) : topic;
553
572
  const qos = subscription.qos || 0;
@@ -555,11 +574,12 @@ function onSocket(socket, send, request, user, mqttSettings) {
555
574
  // payload is encoded once per (message, content type) and reused across all of them.
556
575
  const encoding = getSharedMessageEncoding(message, request, version);
557
576
  const encoded = encoding.payload;
577
+ let payload: Buffer | string;
558
578
  // only pay for a microtask when the serialization is genuinely still pending
559
- const payload =
560
- typeof (encoded as any)?.then === 'function'
561
- ? await resolveSharedPayload(encoding, message, request, version)
562
- : (encoded as Buffer | string);
579
+ if (typeof (encoded as any)?.then === 'function') {
580
+ payload = await resolveSharedPayload(encoding, message, request, version);
581
+ if (disconnected || aborted) return false;
582
+ } else payload = encoded as Buffer | string;
563
583
  if (qos > 0) {
564
584
  // mqtt-packet requires a numeric message identifier once qos is non-zero
565
585
  const packetData: any = {
@@ -595,9 +615,11 @@ function onSocket(socket, send, request, user, mqttSettings) {
595
615
  return !rawSocket.closed;
596
616
  } catch (error) {
597
617
  mqttLog.error?.(error);
598
- session?.disconnect(false);
599
- mqttSettings.sessions.delete(session);
600
- releaseClientSession(session);
618
+ // Ending only the session would leave the client connected and receiving nothing. An abort,
619
+ // unlike a graceful end, closes without waiting on the peer, and onClose ends the session.
620
+ aborted = true;
621
+ if (socket.destroy) socket.destroy();
622
+ else socket.terminate();
601
623
  return false;
602
624
  }
603
625
  };
@@ -606,6 +628,8 @@ function onSocket(socket, send, request, user, mqttSettings) {
606
628
  break;
607
629
  case 'subscribe':
608
630
  const granted = [];
631
+ // a SUBACK carries one reason string for the whole packet, so the first refusal's is the one sent
632
+ let refusalMessage: string | undefined;
609
633
  for (const subscription of packet.subscriptions) {
610
634
  let grantedQos;
611
635
  try {
@@ -624,21 +648,27 @@ function onSocket(socket, send, request, user, mqttSettings) {
624
648
  grantedQos =
625
649
  mqttOptions.protocolVersion < 5
626
650
  ? 0x80 // the only error code in v3.1.1
627
- : error.statusCode === 403
651
+ : isNotAuthorized(error)
628
652
  ? 0x87 // unauthorized
629
653
  : error.statusCode === 404
630
654
  ? 0x8f // invalid topic
631
655
  : 0x80; // generic failure
656
+ if (mqttOptions.protocolVersion >= 5 && error.message && hasClientSafeMessage(error))
657
+ refusalMessage ??= error.message;
632
658
  }
633
659
  granted.push(grantedQos);
634
660
  }
635
661
  await session.committed;
636
- generateAndSendPacket({
637
- // Send a subscription acknowledgment
638
- cmd: 'suback',
639
- granted,
640
- messageId: packet.messageId,
641
- });
662
+ const subackPacket: any = { cmd: 'suback', granted, messageId: packet.messageId };
663
+ if (refusalMessage !== undefined && sendProblemInformation)
664
+ subackPacket.properties = { reasonString: boundedReasonString(refusalMessage) };
665
+ let suback = generate(subackPacket, mqttOptions);
666
+ // the result vector grows with the filter count, so only the encoded packet says whether the string fits
667
+ if (subackPacket.properties && maximumPacketSize && suback.length > maximumPacketSize) {
668
+ delete subackPacket.properties;
669
+ suback = generate(subackPacket, mqttOptions);
670
+ }
671
+ sendPacket(suback, 'suback');
642
672
  break;
643
673
  case 'unsubscribe': {
644
674
  const granted = [];
@@ -691,23 +721,18 @@ function onSocket(socket, send, request, user, mqttSettings) {
691
721
  reasonCode:
692
722
  mqttOptions.protocolVersion < 5
693
723
  ? 0x80 // the only error code in v3.1.1
694
- : error?.statusCode === 403
724
+ : isNotAuthorized(error)
695
725
  ? 0x87 // not authorized
696
726
  : error?.statusCode === 404
697
727
  ? 0x90 // topic name invalid
698
728
  : 0x80, // unspecified error
699
729
  };
700
- // Only errors this layer maps to a code of their own are safe to describe: any other
701
- // failure carries an internal message that a client must not be handed. Both limits
702
- // are in encoded bytes, so measure and trim the string in bytes too.
703
730
  const describable =
704
731
  mqttOptions.protocolVersion >= 5 &&
705
732
  sendProblemInformation &&
706
733
  error?.message &&
707
- (error.statusCode === 403 || error.statusCode === 404);
708
- const reasonString = describable
709
- ? Buffer.from(String(error.message), 'utf8').subarray(0, REASON_STRING_LIMIT).toString('utf8')
710
- : undefined;
734
+ hasClientSafeMessage(error);
735
+ const reasonString = describable ? boundedReasonString(error.message) : undefined;
711
736
  if (
712
737
  reasonString &&
713
738
  (!maximumPacketSize || maximumPacketSize >= Buffer.byteLength(reasonString) + ACK_PACKET_OVERHEAD)
@@ -6,7 +6,7 @@ import type {
6
6
  ServerResponse as NodeServerResponse,
7
7
  } from 'node:http';
8
8
  import type { Socket } from 'node:net';
9
- import { PassThrough } from 'node:stream';
9
+ import { PassThrough, Readable, Writable } from 'node:stream';
10
10
  import { Headers as ResponseHeaders, applyWriteHeadHeaders } from './Headers.ts';
11
11
 
12
12
  export interface AdaptedResponse {
@@ -29,6 +29,20 @@ class UnsupportedResponseMethodError extends Error {
29
29
  }
30
30
 
31
31
  const ignoreError = () => {};
32
+ const ignoreChunk = () => {};
33
+
34
+ // Node 26.11's pipe fast path (nodejs/node#66182) skips write() overrides such as compression's while pipe's listener
35
+ // is a byte-mode source's only 'data' listener; see server/DESIGN.md
36
+ const pipeSkipsWrite = 'writeKnownBuffer' in Writable;
37
+ function guardPipeSource(source: Readable) {
38
+ // object mode, a decoder, and legacy or userland pipe() never take the fast path; legacy pipe() never emits 'unpipe'
39
+ if (source instanceof Readable && !source.readableObjectMode && !source.readableEncoding)
40
+ source.on('data', ignoreChunk);
41
+ }
42
+ // must run before pipe's own unpipe cleanup, which resumes a source awaiting 'drain' while any 'data' listener remains
43
+ function releasePipeSource(source: unknown) {
44
+ if (source instanceof Readable) source.removeListener('data', ignoreChunk);
45
+ }
32
46
 
33
47
  export class NodeAdapterResponse extends PassThrough implements NodeServerResponse {
34
48
  statusCode = 200;
@@ -60,6 +74,10 @@ export class NodeAdapterResponse extends PassThrough implements NodeServerRespon
60
74
  this.#resolve = resolve;
61
75
  this.#reject = reject;
62
76
  this.on('error', ignoreError);
77
+ if (pipeSkipsWrite) {
78
+ this.on('pipe', guardPipeSource);
79
+ this.prependListener('unpipe', releasePipeSource);
80
+ }
63
81
  if (typeof nodeResponse?.on === 'function') {
64
82
  const forward = (...args: any[]) => this.emit('timeout', ...args);
65
83
  let forwarding = false;
@@ -536,7 +536,14 @@ export function serializeMessage(
536
536
  request?: Request,
537
537
  inAsyncContinuation?: boolean
538
538
  ): Buffer | string | Promise<Buffer | string> {
539
- if (message?.contentType != null && message.data != null) return message.data;
539
+ if (message?.contentType != null && message.data != null) {
540
+ const data = message.data;
541
+ // a record read on another thread decodes binary as a plain Uint8Array view, which protocol
542
+ // encoders (mqtt-packet) reject; wrap the same bytes rather than copy them
543
+ return ArrayBuffer.isView(data) && !Buffer.isBuffer(data)
544
+ ? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
545
+ : data;
546
+ }
540
547
  asyncSerializations = inAsyncContinuation ? undefined : [];
541
548
  try {
542
549
  const serialized: Buffer | string = getMessageSerializer(request)(message);
@@ -162,8 +162,9 @@ function fill(encoding: SharedMessageEncoding, message: any, request: any) {
162
162
  const payload = serializeMessage(message, request);
163
163
  encoding.payload = payload;
164
164
  encoding.failed = false;
165
- // A pass-through payload IS the message's own data, already reachable from the WeakMap key, so
166
- // caching it retains nothing extra and must not be billed against the budget.
165
+ // A pass-through payload IS the message's own data (or a Buffer over the same memory), already
166
+ // reachable from the WeakMap key, so caching it retains nothing extra and must not be billed
167
+ // against the budget.
167
168
  if (encoding.serializer === PASS_THROUGH) return;
168
169
  if (typeof (payload as any)?.then === 'function')
169
170
  // collapse an async serialization in place so later subscribers, and frame sharing, see the