@harperfast/harper 5.3.0-beta.4 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/bin/backup.ts +6 -1
  2. package/components/Application.ts +486 -115
  3. package/components/DESIGN.md +109 -21
  4. package/components/deploymentProvenance.ts +28 -0
  5. package/components/operations.js +14 -7
  6. package/components/packageComponent.ts +5 -1
  7. package/config-root.schema.json +1 -1
  8. package/dataLayer/DESIGN.md +35 -7
  9. package/dataLayer/blobBackup.ts +71 -30
  10. package/dataLayer/harperBridge/ResourceBridge.ts +16 -2
  11. package/dataLayer/rocksdbBackup.ts +88 -34
  12. package/dataLayer/schemaDescribe.ts +45 -1
  13. package/dist/bin/backup.js +1 -1
  14. package/dist/bin/backup.js.map +1 -1
  15. package/dist/components/Application.d.ts +11 -21
  16. package/dist/components/Application.js +436 -105
  17. package/dist/components/Application.js.map +1 -1
  18. package/dist/components/deploymentProvenance.d.ts +4 -0
  19. package/dist/components/deploymentProvenance.js +32 -0
  20. package/dist/components/deploymentProvenance.js.map +1 -0
  21. package/dist/components/operations.js +14 -7
  22. package/dist/components/operations.js.map +1 -1
  23. package/dist/components/packageComponent.js +6 -1
  24. package/dist/components/packageComponent.js.map +1 -1
  25. package/dist/dataLayer/blobBackup.d.ts +30 -3
  26. package/dist/dataLayer/blobBackup.js +73 -36
  27. package/dist/dataLayer/blobBackup.js.map +1 -1
  28. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +1 -0
  29. package/dist/dataLayer/harperBridge/ResourceBridge.js +15 -3
  30. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  31. package/dist/dataLayer/rocksdbBackup.d.ts +4 -1
  32. package/dist/dataLayer/rocksdbBackup.js +74 -36
  33. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  34. package/dist/dataLayer/schemaDescribe.js +45 -1
  35. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  36. package/dist/resources/DatabaseTransaction.d.ts +1 -0
  37. package/dist/resources/DatabaseTransaction.js +33 -8
  38. package/dist/resources/DatabaseTransaction.js.map +1 -1
  39. package/dist/resources/ResourceInterface.d.ts +7 -3
  40. package/dist/resources/ResourceInterface.js +2 -0
  41. package/dist/resources/ResourceInterface.js.map +1 -1
  42. package/dist/resources/Table.d.ts +151 -445
  43. package/dist/resources/Table.js +209 -8
  44. package/dist/resources/Table.js.map +1 -1
  45. package/dist/resources/analytics/write.d.ts +11 -0
  46. package/dist/resources/analytics/write.js +3 -0
  47. package/dist/resources/analytics/write.js.map +1 -1
  48. package/dist/resources/blob.d.ts +30 -0
  49. package/dist/resources/blob.js +189 -1
  50. package/dist/resources/blob.js.map +1 -1
  51. package/dist/resources/databases.d.ts +1 -0
  52. package/dist/resources/databases.js +80 -15
  53. package/dist/resources/databases.js.map +1 -1
  54. package/dist/resources/derivedIndexRuntime.d.ts +13 -0
  55. package/dist/resources/derivedIndexRuntime.js +57 -5
  56. package/dist/resources/derivedIndexRuntime.js.map +1 -1
  57. package/dist/resources/derivedIndexes.d.ts +1 -0
  58. package/dist/resources/derivedIndexes.js +116 -15
  59. package/dist/resources/derivedIndexes.js.map +1 -1
  60. package/dist/resources/fullTextSchema.d.ts +5 -3
  61. package/dist/resources/fullTextSchema.js +48 -5
  62. package/dist/resources/fullTextSchema.js.map +1 -1
  63. package/dist/resources/fullTextSchemaLifecycle.d.ts +2 -0
  64. package/dist/resources/fullTextSchemaLifecycle.js +33 -0
  65. package/dist/resources/fullTextSchemaLifecycle.js.map +1 -1
  66. package/dist/resources/graphql.js +30 -12
  67. package/dist/resources/graphql.js.map +1 -1
  68. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +21 -1
  69. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +114 -5
  70. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  71. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +11 -2
  72. package/dist/resources/indexes/fullTextDerivedIndex.js +187 -24
  73. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -1
  74. package/dist/resources/indexes/fullTextNativeBinding.d.ts +99 -1
  75. package/dist/resources/indexes/fullTextNativeBinding.js +35 -1
  76. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
  77. package/dist/resources/indexes/fullTextQueryIndex.d.ts +58 -0
  78. package/dist/resources/indexes/fullTextQueryIndex.js +943 -0
  79. package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -0
  80. package/dist/resources/indexes/fullTextQueryProtocol.d.ts +6 -0
  81. package/dist/resources/indexes/fullTextQueryProtocol.js +32 -0
  82. package/dist/resources/indexes/fullTextQueryProtocol.js.map +1 -0
  83. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +4 -1
  84. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +30 -7
  85. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -1
  86. package/dist/resources/search.d.ts +41 -0
  87. package/dist/resources/search.js +468 -26
  88. package/dist/resources/search.js.map +1 -1
  89. package/dist/resources/transactionBroadcast.js +101 -50
  90. package/dist/resources/transactionBroadcast.js.map +1 -1
  91. package/dist/resources/typeParity.d.ts +9 -0
  92. package/dist/resources/typeParity.js +3 -0
  93. package/dist/resources/typeParity.js.map +1 -0
  94. package/dist/server/REST.js.map +1 -1
  95. package/dist/server/itc/serverHandlers.js +48 -8
  96. package/dist/server/itc/serverHandlers.js.map +1 -1
  97. package/dist/server/serverHelpers/contentTypes.d.ts +7 -0
  98. package/dist/server/serverHelpers/contentTypes.js +36 -23
  99. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  100. package/dist/server/serverHelpers/uwsServer.js +28 -5
  101. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  102. package/dist/server/threads/itc.js +19 -0
  103. package/dist/server/threads/itc.js.map +1 -1
  104. package/dist/server/threads/manageThreads.d.ts +3 -0
  105. package/dist/server/threads/manageThreads.js +90 -23
  106. package/dist/server/threads/manageThreads.js.map +1 -1
  107. package/dist/utility/operation_authorization.js +25 -7
  108. package/dist/utility/operation_authorization.js.map +1 -1
  109. package/dist/utility/signalling.js +3 -0
  110. package/dist/utility/signalling.js.map +1 -1
  111. package/dist/validation/searchValidator.js +22 -2
  112. package/dist/validation/searchValidator.js.map +1 -1
  113. package/npm-shrinkwrap.json +22 -22
  114. package/package.json +2 -4
  115. package/resources/DESIGN.md +19 -2
  116. package/resources/DatabaseTransaction.ts +40 -10
  117. package/resources/FULL_TEXT.md +97 -0
  118. package/resources/ResourceInterface.ts +8 -2
  119. package/resources/Table.ts +767 -17
  120. package/resources/analytics/write.ts +3 -3
  121. package/resources/blob.ts +183 -1
  122. package/resources/databases.ts +126 -14
  123. package/resources/derivedIndexRuntime.ts +79 -6
  124. package/resources/derivedIndexes.ts +142 -18
  125. package/resources/fullTextSchema.ts +55 -9
  126. package/resources/fullTextSchemaLifecycle.ts +45 -0
  127. package/resources/graphql.ts +33 -12
  128. package/resources/indexes/DESIGN.md +167 -15
  129. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +125 -5
  130. package/resources/indexes/fullTextDerivedIndex.ts +216 -32
  131. package/resources/indexes/fullTextNativeBinding.ts +133 -3
  132. package/resources/indexes/fullTextQueryIndex.ts +1120 -0
  133. package/resources/indexes/fullTextQueryProtocol.ts +35 -0
  134. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +28 -8
  135. package/resources/search.ts +513 -26
  136. package/resources/transactionBroadcast.ts +86 -47
  137. package/resources/typeParity.ts +20 -0
  138. package/schema.graphql +18 -5
  139. package/server/DESIGN.md +18 -0
  140. package/server/REST.ts +1 -1
  141. package/server/itc/serverHandlers.js +61 -8
  142. package/server/serverHelpers/contentTypes.ts +31 -26
  143. package/server/serverHelpers/uwsServer.ts +35 -7
  144. package/server/threads/itc.js +21 -0
  145. package/server/threads/manageThreads.js +95 -21
  146. package/studio/web/assets/{Chat-Bq60AO-g.js → Chat-CNEKMllJ.js} +13 -13
  147. package/studio/web/assets/FloatingChat-CEnWhHeR.js +23 -0
  148. package/studio/web/assets/SearchDialog-CwhJdEdY.js +1 -0
  149. package/studio/web/assets/{abnfDiagram-O67JEVCF-DpPZWMOf.js → abnfDiagram-O67JEVCF-raXE027J.js} +1 -1
  150. package/studio/web/assets/alertDialog-BbWD9Xxa.js +1 -0
  151. package/studio/web/assets/apiToken-DC5vsaaO.js +1 -0
  152. package/studio/web/assets/applications-DQZxDy0x.js +296 -0
  153. package/studio/web/assets/architecture-7GRP2DOG-DDmz3hpI.js +1 -0
  154. package/studio/web/assets/{architectureDiagram-NJMV4G6O-OmgLraID.js → architectureDiagram-NJMV4G6O-C8_Eet7w.js} +1 -1
  155. package/studio/web/assets/arrow-up-D2g_57vT.js +1 -0
  156. package/studio/web/assets/authStore-CJCaADw9.js +3 -0
  157. package/studio/web/assets/{blockDiagram-BEXU5L5S-Cv7CD3Ai.js → blockDiagram-BEXU5L5S-DR6S5-1D.js} +1 -1
  158. package/studio/web/assets/{button-MFP9n8Ck.js → button-DWwfgGWI.js} +2 -2
  159. package/studio/web/assets/{c4Diagram-YGBWAQC7-Ds1vKYnN.js → c4Diagram-YGBWAQC7-BAfZ1G01.js} +1 -1
  160. package/studio/web/assets/channel-D0AkPjJ8.js +1 -0
  161. package/studio/web/assets/{chevron-up-CFe8x5Bp.js → chevron-up-CFxbMGv2.js} +1 -1
  162. package/studio/web/assets/{chunk-3FUC2YCW-DpnBPH8B.js → chunk-3FUC2YCW-DuRp-j19.js} +1 -1
  163. package/studio/web/assets/{chunk-5DYCD2WN-LLLCQSzg.js → chunk-5DYCD2WN-BceBfIHT.js} +1 -1
  164. package/studio/web/assets/{chunk-742MDFTN-BL8J_pIO.js → chunk-742MDFTN-CIkt8ijt.js} +1 -1
  165. package/studio/web/assets/{chunk-7INBJB4K-C-hJHiiK.js → chunk-7INBJB4K-BpeAXyOi.js} +1 -1
  166. package/studio/web/assets/{chunk-7M6MHVWA-C6ZqkFbA.js → chunk-7M6MHVWA-h6WlaTzO.js} +1 -1
  167. package/studio/web/assets/{chunk-7PRAP22T-CUXM8BG6.js → chunk-7PRAP22T-KyqX6x3f.js} +1 -1
  168. package/studio/web/assets/{chunk-GTNCS2PH-DJ2BFvS1.js → chunk-GTNCS2PH-DGHLXyow.js} +1 -1
  169. package/studio/web/assets/{chunk-GWA4HPMP-C42bSqLL.js → chunk-GWA4HPMP-DsejB6gA.js} +1 -1
  170. package/studio/web/assets/{chunk-MBY4JIJT-C-5jz6Lo.js → chunk-MBY4JIJT-BwSGweQP.js} +1 -1
  171. package/studio/web/assets/{chunk-NETBCI7D-D-bzlN4r.js → chunk-NETBCI7D-C8UnOw_f.js} +1 -1
  172. package/studio/web/assets/{chunk-O7XYJQB3-ep14zA23.js → chunk-O7XYJQB3-CBS3nZYV.js} +1 -1
  173. package/studio/web/assets/{chunk-UA2S7LBM-BMRWRHED.js → chunk-UA2S7LBM-BHpEdH8D.js} +1 -1
  174. package/studio/web/assets/{chunk-WEXAMYUT-DdLYVdX1.js → chunk-WEXAMYUT-BAIhTwyh.js} +1 -1
  175. package/studio/web/assets/{chunk-Z7XXMR3K-BmKEDjcP.js → chunk-Z7XXMR3K-DSHbImdf.js} +1 -1
  176. package/studio/web/assets/{chunk-ZIGJFQKS-DLZgdNxA.js → chunk-ZIGJFQKS-Cs8fUn7y.js} +1 -1
  177. package/studio/web/assets/{classDiagram-v2-NBCMYWYE-DOI5vgKT.js → classDiagram-v2-NBCMYWYE-DmDPkt2u.js} +1 -1
  178. package/studio/web/assets/cn-6T8VranJ.js +1 -0
  179. package/studio/web/assets/{codicon-Brq4_Ui5.ttf → codicon-DF1abBS2.ttf} +0 -0
  180. package/studio/web/assets/{createLucideIcon-bziYxJli.js → createLucideIcon-fO5dL7D5.js} +1 -1
  181. package/studio/web/assets/{css.worker-l1psR_wG.js → css.worker-DoT-OOzx.js} +36 -36
  182. package/studio/web/assets/{cssMode-DJyc9mHj.js → cssMode-Dz2DxF4e.js} +1 -1
  183. package/studio/web/assets/{cynefin-OW5HDTMX-BSW-DB7S.js → cynefin-OW5HDTMX-DIvbXLSj.js} +1 -1
  184. package/studio/web/assets/{cynefinDiagram-VND7K2PF-CbAHEwxt.js → cynefinDiagram-VND7K2PF-BdtaXiCE.js} +1 -1
  185. package/studio/web/assets/{dagre-6A5THRUB-Csxewgs4.js → dagre-6A5THRUB-C7RUWHyz.js} +1 -1
  186. package/studio/web/assets/{diagram-22UHCM2B-a0rt8tY4.js → diagram-22UHCM2B-DJCvcaWP.js} +1 -1
  187. package/studio/web/assets/{diagram-3UASUU5V-lEj2SVNh.js → diagram-3UASUU5V-Du6VKWln.js} +1 -1
  188. package/studio/web/assets/{diagram-ATOU4E4O-HcrAeB-q.js → diagram-ATOU4E4O-dxRfeG4B.js} +1 -1
  189. package/studio/web/assets/{diagram-CDSNMT55-CYxiuyEx.js → diagram-CDSNMT55-l5Ga0u-W.js} +1 -1
  190. package/studio/web/assets/{diagram-MLGK6HIB-ChbCJTgJ.js → diagram-MLGK6HIB-CNQY47LA.js} +1 -1
  191. package/studio/web/assets/{diagram-MPIPVDR6-DaX9Y-N3.js → diagram-MPIPVDR6-7prveD1B.js} +1 -1
  192. package/studio/web/assets/{dist-Cz2Y6-6G.js → dist-BrRuMUzt.js} +1 -1
  193. package/studio/web/assets/{download-CtostjYn.js → download-Cn6tPG4L.js} +1 -1
  194. package/studio/web/assets/{ebnfDiagram-ZINNZB2B-CwAsENB1.js → ebnfDiagram-ZINNZB2B-5OnkTpfi.js} +1 -1
  195. package/studio/web/assets/{editor-DDIsQfj6.js → editor-B3mjd3tz.js} +1 -1
  196. package/studio/web/assets/editor.worker-Yd1NR0QT.js +33 -0
  197. package/studio/web/assets/{elk-276RUBZZ-Hexg9E4D.js → elk-276RUBZZ-DxDQPjQT.js} +1 -1
  198. package/studio/web/assets/{erDiagram-OPXOYQCR-CeC-wwO1.js → erDiagram-OPXOYQCR-CQlCX84D.js} +1 -1
  199. package/studio/web/assets/eventmodeling-NTZA5JFV-DONQf5aG.js +1 -0
  200. package/studio/web/assets/flowDiagram-KWPJA3E3-BoZ8_QR1.js +1 -0
  201. package/studio/web/assets/{ganttDiagram-FUAMR5RP-C2_Hhl1-.js → ganttDiagram-FUAMR5RP-bloX3Voy.js} +1 -1
  202. package/studio/web/assets/{getAnalytics-CjthMQqF.js → getAnalytics-C0FHjtdQ.js} +1 -1
  203. package/studio/web/assets/{gitGraph-4MIJSDKK-xIk5Vuew.js → gitGraph-4MIJSDKK-BrXWmJt2.js} +1 -1
  204. package/studio/web/assets/{gitGraphDiagram-X574FWY7-CMxJ34up.js → gitGraphDiagram-X574FWY7-C2KnnZeY.js} +1 -1
  205. package/studio/web/assets/{html-g1BJHxLy.js → html-BsRGM7tp.js} +1 -1
  206. package/studio/web/assets/{html.worker-DdwM4IrA.js → html.worker-B1dwY0Ay.js} +27 -27
  207. package/studio/web/assets/{htmlMode-Vh5IglCQ.js → htmlMode-tUKJaA3o.js} +1 -1
  208. package/studio/web/assets/index-72cEH474.css +1 -0
  209. package/studio/web/assets/index-BuqFjN5K.js +828 -0
  210. package/studio/web/assets/index.lazy-CcpfQFY_.js +2 -0
  211. package/studio/web/assets/{info-A6RAGUB7-o49TEbj-.js → info-A6RAGUB7-BhzBHe4U.js} +1 -1
  212. package/studio/web/assets/{infoDiagram-VRGFBTTK-Cjhh7yzZ.js → infoDiagram-VRGFBTTK-B0J1132p.js} +1 -1
  213. package/studio/web/assets/{ishikawaDiagram-OU5B5YK6-C63aXRmh.js → ishikawaDiagram-OU5B5YK6-BNk0tuQx.js} +1 -1
  214. package/studio/web/assets/{javascript-5VIolqf7.js → javascript-BZ_uRGHl.js} +1 -1
  215. package/studio/web/assets/{journeyDiagram-ZHPQQLJL-D_o0P5Mu.js → journeyDiagram-ZHPQQLJL-Be79RMQM.js} +1 -1
  216. package/studio/web/assets/json.worker-BQqXGI3X.js +65 -0
  217. package/studio/web/assets/{jsonMode-DG4zQraI.js → jsonMode-DgB-aFMj.js} +1 -1
  218. package/studio/web/assets/{kanban-definition-PNTS6WVX-CTI9SSxY.js → kanban-definition-PNTS6WVX-4uuoQP9W.js} +1 -1
  219. package/studio/web/assets/{languageServices-t5FBpX-i.js → languageServices-BleuBw17.js} +1 -1
  220. package/studio/web/assets/localStorageKeys-etzFY_xV.js +1 -0
  221. package/studio/web/assets/{lspLanguageFeatures-DgVnNPh-.js → lspLanguageFeatures-ByVYLSsQ.js} +1 -1
  222. package/studio/web/assets/{mermaid-parser.core-C2lcH_jP.js → mermaid-parser.core-V1UYMeXV.js} +3 -3
  223. package/studio/web/assets/{mermaid.core-BG5bjfIG.js → mermaid.core-1PX6R49U.js} +4 -4
  224. package/studio/web/assets/{mindmap-definition-NLK3R4M7-BK7ho6JD.js → mindmap-definition-NLK3R4M7-C70Ctm0J.js} +1 -1
  225. package/studio/web/assets/notifications-D1Nf-Edn.js +1 -0
  226. package/studio/web/assets/notifications-TuzN0IMi.js +1 -0
  227. package/studio/web/assets/{packet-AYTQ26CC-BHPy6KBV.js → packet-AYTQ26CC-DWbXHx5g.js} +1 -1
  228. package/studio/web/assets/{pegDiagram-GJSIUBJH-DzjwG3jO.js → pegDiagram-GJSIUBJH-d6z1cc7W.js} +1 -1
  229. package/studio/web/assets/{pie-WAS4IAKB-D0cP-DTU.js → pie-WAS4IAKB-DjoTm3yr.js} +1 -1
  230. package/studio/web/assets/{pieDiagram-5QR66LMP-C_hOP9Lv.js → pieDiagram-5QR66LMP-Dz3RHpDA.js} +1 -1
  231. package/studio/web/assets/profile-D7C7Lupz.js +1 -0
  232. package/studio/web/assets/{quadrantDiagram-O4NWA36T-CZW8PbV1.js → quadrantDiagram-O4NWA36T-CIF5Qkou.js} +1 -1
  233. package/studio/web/assets/{radar-RG4KPBEZ-C8fk0U81.js → radar-RG4KPBEZ-CVBgjCOj.js} +1 -1
  234. package/studio/web/assets/{railroad-74A4TZTK-B60FWm17.js → railroad-74A4TZTK-BdhXw10Q.js} +1 -1
  235. package/studio/web/assets/railroad-abnf-HS5TGJTU-BcoQPN39.js +1 -0
  236. package/studio/web/assets/railroad-ebnf-LZEXJU2U-B1Lke2JN.js +1 -0
  237. package/studio/web/assets/railroad-peg-WCYAUIDC-D-oJFkiV.js +1 -0
  238. package/studio/web/assets/{railroadDiagram-XR7U4H2S-DyyNmgbU.js → railroadDiagram-XR7U4H2S-y3Jv2U6N.js} +1 -1
  239. package/studio/web/assets/regions-nGQCm-v8.js +1 -0
  240. package/studio/web/assets/register-C3x9Ze6P.js +909 -0
  241. package/studio/web/assets/register-DjpsE8to.css +1 -0
  242. package/studio/web/assets/{requirementDiagram-PLB6GJNP-C4H-mLKu.js → requirementDiagram-PLB6GJNP-ChINZEmu.js} +1 -1
  243. package/studio/web/assets/restartGate-nHAFLDh-.js +1 -0
  244. package/studio/web/assets/{sankeyDiagram-IPEJSGJF-DHDyga4c.js → sankeyDiagram-IPEJSGJF-D9AyLce2.js} +1 -1
  245. package/studio/web/assets/{sequenceDiagram-PO4LG4MO-CdNcHXRI.js → sequenceDiagram-PO4LG4MO-hfk4aIEm.js} +1 -1
  246. package/studio/web/assets/{setComponentFile-z4-qJCbA.js → setComponentFile-DpjrRqeG.js} +1 -1
  247. package/studio/web/assets/{setup-CS50uJ4x.js → setup-CGiKLZ9e.js} +2 -2
  248. package/studio/web/assets/{stateDiagram-v2-GCMORJYK-CpKuXq15.js → stateDiagram-v2-GCMORJYK-Bs8RIb9K.js} +1 -1
  249. package/studio/web/assets/status-CGJwrNPH.js +61 -0
  250. package/studio/web/assets/{swimlanes-2SLR337P-NRHa2D8I.js → swimlanes-2SLR337P-B52xrf6t.js} +1 -1
  251. package/studio/web/assets/swimlanesDiagram-TC7HE7FX-DfN7BAP5.js +8 -0
  252. package/studio/web/assets/tabs-BCTQzoQK.js +1 -0
  253. package/studio/web/assets/{timeline-definition-EJHVYXUP-D39Y-xpq.js → timeline-definition-EJHVYXUP-DJSITpQX.js} +1 -1
  254. package/studio/web/assets/toggleHighContrast-C-Ne6P7T.js +51 -0
  255. package/studio/web/assets/toggleHighContrast-n7clI0si.css +1 -0
  256. package/studio/web/assets/{treeView-Q6P3EWNA-CRWR9OqD.js → treeView-Q6P3EWNA-C-epdxRo.js} +1 -1
  257. package/studio/web/assets/{treemap-WGGIJYW6-ZfXyJgLO.js → treemap-WGGIJYW6-DOVyp_zw.js} +1 -1
  258. package/studio/web/assets/{ts.worker-DDMUrpqh.js → ts.worker-Clm12uY6.js} +131 -131
  259. package/studio/web/assets/{tsMode-D83Gg3cq.js → tsMode-CSreYTys.js} +1 -1
  260. package/studio/web/assets/{typescript-Dfp-GrNq.js → typescript-MnxBk89W.js} +1 -1
  261. package/studio/web/assets/{useEntityRestURL-BKetlINf.js → useEntityRestURL-TNrh77-s.js} +1 -1
  262. package/studio/web/assets/{usecaseDiagram-POWQR4AR-jf-zIDZ_.js → usecaseDiagram-POWQR4AR-B6252cbq.js} +1 -1
  263. package/studio/web/assets/{vendor-core-BdkdbUek.js → vendor-core-6LhfJlRC.js} +1 -1
  264. package/studio/web/assets/vendor-datadog-ozKf-pts.js +6 -0
  265. package/studio/web/assets/{vendor-react-izWchTRL.js → vendor-react-BOliS0BL.js} +1 -1
  266. package/studio/web/assets/vendor-tanstack-Bntfka-M.js +2 -0
  267. package/studio/web/assets/vendor-ui-9EYYDgoN.js +41 -0
  268. package/studio/web/assets/{vennDiagram-UO4OBE2U-BKYAZUh_.js → vennDiagram-UO4OBE2U-DZTsDA4k.js} +1 -1
  269. package/studio/web/assets/{wardley-WFR3VGLG-BO7f36ci.js → wardley-WFR3VGLG-Cfqoe6z4.js} +1 -1
  270. package/studio/web/assets/{wardleyDiagram-VNRHLVJA-Djb0JDQc.js → wardleyDiagram-VNRHLVJA-qu_f7Tvl.js} +1 -1
  271. package/studio/web/assets/{workers-CCvmDLH3.js → workers-BfZlUNVI.js} +3 -3
  272. package/studio/web/assets/{xml-06pZp00U.js → xml-BHd6qGH7.js} +1 -1
  273. package/studio/web/assets/{xychartDiagram-PMCCYNJV-BQh9ubvb.js → xychartDiagram-PMCCYNJV-UrOKRsmR.js} +1 -1
  274. package/studio/web/assets/{yaml-iSnWMVlV.js → yaml-C53R8OQq.js} +1 -1
  275. package/studio/web/assets/{yaml.worker-DAK2o_H1.js → yaml.worker-D9JekGdp.js} +183 -183
  276. package/studio/web/index.html +15 -15
  277. package/utility/operation_authorization.ts +27 -7
  278. package/utility/signalling.ts +3 -0
  279. package/validation/searchValidator.ts +22 -13
  280. package/studio/web/assets/FloatingChat-D4560T0p.js +0 -23
  281. package/studio/web/assets/alertDialog-UrbiMgQt.js +0 -1
  282. package/studio/web/assets/apiToken-BEvbFPMc.js +0 -1
  283. package/studio/web/assets/applications-CSUVTH4E.js +0 -296
  284. package/studio/web/assets/architecture-7GRP2DOG-11l4Ny1l.js +0 -1
  285. package/studio/web/assets/authStore-5jSS5pTW.js +0 -3
  286. package/studio/web/assets/channel-BDtouPd6.js +0 -1
  287. package/studio/web/assets/dialog-DTepachC.js +0 -1
  288. package/studio/web/assets/editor.worker-CkkvdXvO.js +0 -33
  289. package/studio/web/assets/eventmodeling-NTZA5JFV-CFb03e0o.js +0 -1
  290. package/studio/web/assets/flowDiagram-KWPJA3E3-Cidi00_Y.js +0 -1
  291. package/studio/web/assets/index-CPoDzQTe.css +0 -1
  292. package/studio/web/assets/index-DGRAzvO4.js +0 -824
  293. package/studio/web/assets/index.lazy-vgSic7et.js +0 -2
  294. package/studio/web/assets/json.worker-CSi2hF-a.js +0 -65
  295. package/studio/web/assets/notifications-BeWcX04m.js +0 -1
  296. package/studio/web/assets/notifications-DUGWrWoO.js +0 -1
  297. package/studio/web/assets/profile-BvUEDBzW.js +0 -1
  298. package/studio/web/assets/railroad-abnf-HS5TGJTU-CLMVZvH0.js +0 -1
  299. package/studio/web/assets/railroad-ebnf-LZEXJU2U--tlwFoVB.js +0 -1
  300. package/studio/web/assets/railroad-peg-WCYAUIDC-Dpuslblm.js +0 -1
  301. package/studio/web/assets/regions-CB-_2m7M.js +0 -1
  302. package/studio/web/assets/register-1w1Zr3b2.js +0 -848
  303. package/studio/web/assets/register-jjEx9u7D.css +0 -1
  304. package/studio/web/assets/setLocalStorage-D_kflv4U.js +0 -1
  305. package/studio/web/assets/status-CI0wOdOE.js +0 -61
  306. package/studio/web/assets/swimlanesDiagram-TC7HE7FX-BBk4t4hO.js +0 -8
  307. package/studio/web/assets/tabs-CnQ_zenf.js +0 -1
  308. package/studio/web/assets/toggleHighContrast--ixms01u.css +0 -1
  309. package/studio/web/assets/toggleHighContrast-BsAE4jPe.js +0 -49
  310. package/studio/web/assets/useLocalStorage-BYoZF60G.js +0 -1
  311. package/studio/web/assets/vendor-datadog-fCQhBHGk.js +0 -6
  312. package/studio/web/assets/vendor-tanstack-CQ1aRjVa.js +0 -2
  313. package/studio/web/assets/vendor-ui-aE1tHcW7.js +0 -41
  314. package/studio/web/assets/x-vYntCxm4.js +0 -1
@@ -32,6 +32,11 @@ import {
32
32
  } from './gitCredentialServer.ts';
33
33
  import { getSecretDecryptor } from '../resources/secretDecryptor.ts';
34
34
  import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts';
35
+ import {
36
+ DEPLOYMENT_PROVENANCE_FILE,
37
+ formatDeploymentProvenance,
38
+ parseDeploymentProvenance,
39
+ } from './deploymentProvenance.ts';
35
40
 
36
41
  import { basename, dirname, extname, isAbsolute, join, relative, sep, win32 } from 'node:path';
37
42
  import {
@@ -52,6 +57,7 @@ import {
52
57
  rm,
53
58
  stat,
54
59
  symlink,
60
+ utimes,
55
61
  writeFile,
56
62
  } from 'node:fs/promises';
57
63
  import { spawn, type ChildProcess } from 'node:child_process';
@@ -60,7 +66,7 @@ import {
60
66
  type WindowsProcessTreeIdentity,
61
67
  } from '../server/threads/windowsProcessTree.ts';
62
68
  import { tmpdir } from 'node:os';
63
- import { randomUUID } from 'node:crypto';
69
+ import { createHash, randomUUID } from 'node:crypto';
64
70
  import { chmodSync, createReadStream, existsSync, lstatSync, renameSync } from 'node:fs';
65
71
  import { Readable } from 'node:stream';
66
72
  import { pipeline } from 'node:stream/promises';
@@ -1087,6 +1093,11 @@ const ACTIVATION_JOURNAL_VERSION = 2;
1087
1093
  const LEGACY_ACTIVATION_JOURNAL_VERSION = 1;
1088
1094
  const ARTIFACT_DESCRIPTOR_VERSION = 1;
1089
1095
  const DEFAULT_STAGING_RETENTION_MAX_COUNT = 5;
1096
+ // A digest of the component, not its name, follows the uuid in a claim's name: a long name would pass the filename
1097
+ // limit.
1098
+ const CLAIMING_PREFIX = '.claiming-';
1099
+ const CLAIMING_UUID_LENGTH = 36;
1100
+ const CLAIM_OWNER_DIGEST_LENGTH = 16;
1090
1101
 
1091
1102
  /** `deployment_stagingRetention_maxCount`; 0 keeps none. Only a number or numeric string counts, so `true`/`[]`/blank cannot become "keep nothing". */
1092
1103
  export function getStagingRetentionMaxCount(): number {
@@ -1103,6 +1114,10 @@ type DormantBuild = {
1103
1114
  completedAt: number;
1104
1115
  };
1105
1116
 
1117
+ function isDeploymentDirectoryEntry(entry: import('node:fs').Dirent): boolean {
1118
+ return entry.isDirectory() && !entry.name.startsWith('.');
1119
+ }
1120
+
1106
1121
  async function presentOrAbsent(path: string): Promise<import('node:fs').Stats | undefined> {
1107
1122
  return lstat(path).catch((error: NodeJS.ErrnoException) => {
1108
1123
  if (error?.code === 'ENOENT') return undefined;
@@ -1131,20 +1146,24 @@ async function dormantBuildAt(deploymentDirPath: string, owner: string): Promise
1131
1146
  * cannot hold or miss a slot. Never throws: a failure must neither fail a component closed nor replace a
1132
1147
  * deploy's own error.
1133
1148
  *
1134
- * `pinnedDeploymentId` is never evicted. A delayed activation runs this preamble under the same lock it is
1135
- * about to activate under, so without the pin retention would delete the artifact the request named —
1136
- * immediately, when the knob is `0`. The pin is applied after the kept set is chosen, so a pinned build in
1137
- * the eviction tail leaves `maxCount + 1` on disk for the life of the request; the next preamble that does
1138
- * not pin it brings the count back down.
1149
+ * A `pinned` id is never evicted. A delayed activation runs this preamble under the same lock it is about to
1150
+ * activate under, so without the pin retention would delete the artifact the request named — immediately, when the
1151
+ * knob is `0` — and a settlement's pins keep the releases it just put back. Pins are applied after the kept set is
1152
+ * chosen, so each pinned build in the eviction tail leaves one more than `maxCount` on disk until a pass that does
1153
+ * not pin it.
1139
1154
  */
1140
1155
  export async function pruneDormantBuilds(
1141
1156
  componentName: string,
1142
1157
  builds: DormantBuild[],
1143
1158
  maxCount: number,
1144
- pinnedDeploymentId?: string
1159
+ pinned?: string | readonly string[]
1145
1160
  ): Promise<void> {
1161
+ const pins = new Set(typeof pinned === 'string' ? [pinned] : (pinned ?? []));
1146
1162
  const current: DormantBuild[] = [];
1163
+ const seen = new Set<string>();
1147
1164
  for (const build of builds) {
1165
+ if (seen.has(build.deploymentDirPath)) continue;
1166
+ seen.add(build.deploymentDirPath);
1148
1167
  try {
1149
1168
  const fresh = await dormantBuildAt(build.deploymentDirPath, componentName);
1150
1169
  if (fresh && !(await presentOrAbsent(join(build.deploymentDirPath, ACTIVATION_JOURNAL)))) current.push(fresh);
@@ -1162,7 +1181,7 @@ export async function pruneDormantBuilds(
1162
1181
  (left.deploymentId < right.deploymentId ? -1 : left.deploymentId > right.deploymentId ? 1 : 0)
1163
1182
  )
1164
1183
  .slice(Math.max(0, maxCount))
1165
- .filter((build) => build.deploymentId !== pinnedDeploymentId);
1184
+ .filter((build) => !pins.has(build.deploymentId));
1166
1185
  for (const build of evictions) {
1167
1186
  try {
1168
1187
  await rm(build.deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
@@ -1191,7 +1210,7 @@ async function dormantBuildsOf(componentsRootDirPath: string, componentName: str
1191
1210
  }
1192
1211
  const builds: DormantBuild[] = [];
1193
1212
  for (const deployment of deployments) {
1194
- if (!deployment.isDirectory()) continue;
1213
+ if (!isDeploymentDirectoryEntry(deployment)) continue;
1195
1214
  const deploymentDirPath = join(stagingRoot, deployment.name);
1196
1215
  try {
1197
1216
  if ((await candidateComponentName(deploymentDirPath)) !== componentName) continue;
@@ -1522,6 +1541,162 @@ async function writeArtifactDescriptor(
1522
1541
  }
1523
1542
  }
1524
1543
 
1544
+ async function writeDeploymentProvenance(
1545
+ candidateDirPath: string,
1546
+ componentName: string,
1547
+ deploymentId: string
1548
+ ): Promise<void> {
1549
+ const markerPath = join(candidateDirPath, DEPLOYMENT_PROVENANCE_FILE);
1550
+ await rm(markerPath, { recursive: true, force: true });
1551
+ await writeControlFileDurably(markerPath, formatDeploymentProvenance(componentName, deploymentId));
1552
+ }
1553
+
1554
+ /**
1555
+ * A link carries no provenance, whatever its target holds. A read that fails propagates rather than reading as
1556
+ * absent.
1557
+ */
1558
+ async function readDeploymentProvenance(treePath: string, componentName: string): Promise<string | undefined> {
1559
+ const tree = await presentOrAbsent(treePath);
1560
+ if (!tree?.isDirectory()) return undefined;
1561
+ const raw = await readFile(join(treePath, DEPLOYMENT_PROVENANCE_FILE), 'utf8').catch(
1562
+ (error: NodeJS.ErrnoException) => {
1563
+ if (error?.code === 'ENOENT' || error?.code === 'EISDIR') return undefined;
1564
+ throw error;
1565
+ }
1566
+ );
1567
+ return raw === undefined ? undefined : parseDeploymentProvenance(raw, componentName);
1568
+ }
1569
+
1570
+ async function isDeploymentRecord(deploymentDirPath: string, componentName: string): Promise<boolean> {
1571
+ if ((await candidateComponentName(deploymentDirPath)) !== componentName) return false;
1572
+ if (!(await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER)))) return false;
1573
+ for (const unexpected of [ACTIVATION_JOURNAL, UNSETTLED_MARKER, componentName]) {
1574
+ if (await presentOrAbsent(join(deploymentDirPath, unexpected))) return false;
1575
+ }
1576
+ try {
1577
+ return (await readArtifactDescriptor(deploymentDirPath, componentName)) !== undefined;
1578
+ } catch (error) {
1579
+ if (error instanceof ClientError) return false;
1580
+ throw error;
1581
+ }
1582
+ }
1583
+
1584
+ /**
1585
+ * "Not live" does not make a record stale: a crash between the commit and the re-home leaves the displaced tree in
1586
+ * `.deploy-aside`, retired or not, and a scan can reach the record before the journal that re-homes into it.
1587
+ */
1588
+ async function deploymentRecordIsReferenced(
1589
+ componentsRootDirPath: string,
1590
+ componentName: string,
1591
+ deploymentId: string
1592
+ ): Promise<boolean> {
1593
+ const liveDirPath = join(componentsRootDirPath, componentName);
1594
+ if ((await readDeploymentProvenance(liveDirPath, componentName)) === deploymentId) return true;
1595
+ const asideStagingDir = extractionStagingDirectory(liveDirPath);
1596
+ const entries = await readdir(asideStagingDir, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
1597
+ if (error?.code === 'ENOENT') return [];
1598
+ throw error;
1599
+ });
1600
+ for (const entry of entries) {
1601
+ if (!entry.isDirectory() || !entry.name.startsWith(IN_PROGRESS_ASIDE_PREFIX)) continue;
1602
+ if ((await readDeploymentProvenance(join(asideStagingDir, entry.name), componentName)) === deploymentId) {
1603
+ return true;
1604
+ }
1605
+ }
1606
+ return false;
1607
+ }
1608
+
1609
+ async function isReferencedDeploymentRecord(
1610
+ componentsRootDirPath: string,
1611
+ deploymentDirPath: string,
1612
+ owner: string
1613
+ ): Promise<boolean> {
1614
+ for (const expected of [CANDIDATE_COMPLETE_MARKER, CANDIDATE_ARTIFACT_FILE]) {
1615
+ if (!(await presentOrAbsent(join(deploymentDirPath, expected)))) return false;
1616
+ }
1617
+ if (await presentOrAbsent(join(deploymentDirPath, owner))) return false;
1618
+ return deploymentRecordIsReferenced(componentsRootDirPath, owner, basename(deploymentDirPath));
1619
+ }
1620
+
1621
+ /** A failed read keeps the directory: a leftover record costs a few bytes, a lost one costs the release. */
1622
+ async function keepsDeploymentRecord(deploymentDirPath: string): Promise<boolean> {
1623
+ return presentOrAbsent(join(deploymentDirPath, CANDIDATE_ARTIFACT_FILE)).then(Boolean, () => true);
1624
+ }
1625
+
1626
+ /** Retention orders by `.complete`'s mtime, and a kept release has to outrank stages nobody activated. */
1627
+ async function refreshCompletedAt(deploymentDirPath: string, componentName: string, deploymentId: string) {
1628
+ for (let attempt = 1; ; attempt++) {
1629
+ const now = new Date();
1630
+ try {
1631
+ await utimes(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER), now, now);
1632
+ return;
1633
+ } catch (error) {
1634
+ if (attempt >= 3) {
1635
+ logger.warn(
1636
+ `Kept deployment ${deploymentId} of ${componentName}, but retention may evict it ahead of older ` +
1637
+ `builds: ${errorMessage(error)}`
1638
+ );
1639
+ return;
1640
+ }
1641
+ await delay(100 * attempt);
1642
+ }
1643
+ }
1644
+ }
1645
+
1646
+ type DisplacedReleaseRetention = { verdict: 'kept'; deploymentId: string } | { verdict: 'ineligible' | 'failed' };
1647
+
1648
+ /**
1649
+ * Only an explicit retention decision deletes a displaced tree: `failed` leaves it for the next preparation to retry.
1650
+ * A failure after the rename propagates, as the aside syncs around it do.
1651
+ */
1652
+ async function retainDisplacedRelease(
1653
+ componentsRootDirPath: string,
1654
+ componentName: string,
1655
+ displacedTreePath: string
1656
+ ): Promise<DisplacedReleaseRetention> {
1657
+ const keepNone = getStagingRetentionMaxCount() === 0;
1658
+ let record: { deploymentId: string; recordDirPath: string } | undefined;
1659
+ try {
1660
+ const deploymentId = await readDeploymentProvenance(displacedTreePath, componentName);
1661
+ if (deploymentId !== undefined) {
1662
+ const recordDirPath = join(componentsRootDirPath, DEPLOY_STAGING_DIR, deploymentId);
1663
+ if (await isDeploymentRecord(recordDirPath, componentName)) record = { deploymentId, recordDirPath };
1664
+ }
1665
+ } catch (error) {
1666
+ if (keepNone) return { verdict: 'ineligible' };
1667
+ logger.warn(
1668
+ `Could not tell whether the previous release of ${componentName} at ${displacedTreePath} can be kept; ` +
1669
+ `leaving it for its next deploy:`,
1670
+ errorForLog(error)
1671
+ );
1672
+ return { verdict: 'failed' };
1673
+ }
1674
+ if (!record) return { verdict: 'ineligible' };
1675
+ const { deploymentId, recordDirPath } = record;
1676
+ if (keepNone) {
1677
+ // Nothing will ever put the tree back into its record, so the record goes with it.
1678
+ await rm(recordDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
1679
+ logger.warn(`Could not remove the record of deployment ${deploymentId} of ${componentName}:`, errorForLog(error))
1680
+ );
1681
+ return { verdict: 'ineligible' };
1682
+ }
1683
+ const keptTreePath = join(recordDirPath, componentName);
1684
+ try {
1685
+ await renameThroughTransientHolder(displacedTreePath, keptTreePath);
1686
+ } catch (error) {
1687
+ logger.warn(
1688
+ `Could not keep the previous release of ${componentName} as deployment ${deploymentId}; leaving it for ` +
1689
+ `its next deploy:`,
1690
+ errorForLog(error)
1691
+ );
1692
+ return { verdict: 'failed' };
1693
+ }
1694
+ await syncRenameParents(displacedTreePath, keptTreePath);
1695
+ await refreshCompletedAt(recordDirPath, componentName, deploymentId);
1696
+ logger.trace?.(`Kept the previous release of ${componentName} as dormant deployment ${deploymentId}`);
1697
+ return { verdict: 'kept', deploymentId };
1698
+ }
1699
+
1525
1700
  /**
1526
1701
  * A component name safe to join onto the components root: no separator, no traversal, not dot-prefixed.
1527
1702
  * Applied to EVERY source of the name — the journal and the sidecar — because validating one and trusting
@@ -1707,103 +1882,127 @@ async function ensureSecureStagingDirectory(stagingDir: string): Promise<void> {
1707
1882
  }
1708
1883
 
1709
1884
  /**
1710
- * Claim a deployment directory for this build, EXCLUSIVELY. The id is the public deployment id, which an
1711
- * operator can repeat and a redelivered replication can repeat for them, so tolerating an existing
1712
- * directory would let a replayed stage rewrite the bytes under an existing `.complete` and descriptor —
1713
- * and a crash mid-rebuild would leave a partial tree that still reads as certified.
1714
- *
1715
- * The caller holds the component's preparation lock, which is what makes the EEXIST verdicts sound: no
1716
- * other preparation of THIS component is running, and a directory belonging to another component is not
1717
- * this lock's to touch.
1885
+ * Exclusive, because an operator can repeat a public id; built aside and renamed onto it, so it never appears
1886
+ * unattributed. The caller's preparation lock is what makes the verdicts on an existing directory sound.
1718
1887
  */
1719
1888
  async function claimDeploymentDirectory(deploymentDirPath: string, componentName: string): Promise<void> {
1720
- // Every refusal below is a conflict over an id that already exists, which is the caller's to resolve by
1721
- // naming a different one — not a server fault, and not the 500 a bare Error reaches the caller as.
1722
- const taken = (message: string) => new ClientError(message, 409);
1889
+ const claimDirPath = join(
1890
+ dirname(deploymentDirPath),
1891
+ `${CLAIMING_PREFIX}${randomUUID()}-${claimOwnerDigest(componentName)}`
1892
+ );
1893
+ await mkdir(claimDirPath, { mode: 0o700 });
1723
1894
  try {
1724
- await mkdir(deploymentDirPath, { mode: 0o700 });
1725
- } catch (error) {
1726
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
1727
- const owner = await candidateComponentName(deploymentDirPath);
1728
- if (owner !== undefined && owner !== componentName) {
1729
- throw taken(
1730
- `Deployment id ${basename(deploymentDirPath)} already holds a build of '${owner}'; a deployment id ` +
1731
- `names one artifact for its lifetime`
1895
+ const claimed = await lstat(claimDirPath);
1896
+ if (!claimed.isDirectory() || claimed.isSymbolicLink()) {
1897
+ throw new Error(`Component deploy staging path is not a directory: ${claimDirPath}`);
1898
+ }
1899
+ if (process.platform !== 'win32' && (claimed.mode & 0o777) !== 0o700) {
1900
+ await chmod(claimDirPath, 0o700).catch((error) =>
1901
+ logger.warn(`Could not restrict component deploy staging permissions for ${claimDirPath}:`, errorForLog(error))
1732
1902
  );
1733
1903
  }
1734
- // Ownership has to be POSITIVE to reclaim, and EMPTINESS IS NOT A VERDICT. The sidecar below is
1735
- // written as part of the claim, so a directory naming nobody is another component between its own
1736
- // mkdir and that write, and a deploy can hold a nearly empty one for minutes while it resolves and
1737
- // packs. Nothing on disk separates that from a claim that got no further, and the lock that
1738
- // serializes it is not this one, so only the exclusive create may conclude the id is free: the
1739
- // directory can also have been discarded by a failed build between the first mkdir and this read,
1740
- // and another component can claim it in that same gap.
1741
- if (owner === undefined) {
1742
- await mkdir(deploymentDirPath, { mode: 0o700 }).catch((retry: NodeJS.ErrnoException) => {
1743
- if (retry?.code !== 'EEXIST') throw retry;
1744
- throw taken(
1745
- `Deployment id ${basename(deploymentDirPath)} is already claimed by a build that has not named its ` +
1746
- `component yet. If no deploy of any component is in flight, that directory is abandoned and has ` +
1747
- `to be removed by hand; deploying again without a deployment_id mints a fresh id`
1748
- );
1749
- });
1750
- } else {
1751
- if (await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER))) {
1752
- throw taken(
1753
- `Deployment id ${basename(deploymentDirPath)} already holds a completed build of '${componentName}'; ` +
1754
- `deploy it with deployment_id, or deploy again to build a new one`
1904
+ await publishClaimOwnership(claimDirPath, componentName);
1905
+ for (let attempt = 1; ; attempt++) {
1906
+ // Checked first: Windows refuses a rename onto an existing directory with a transient holder's EPERM.
1907
+ if (!(await presentOrAbsent(deploymentDirPath))) {
1908
+ try {
1909
+ await renameThroughTransientHolder(claimDirPath, deploymentDirPath);
1910
+ return;
1911
+ } catch (error) {
1912
+ // Only this component's lock can remove the claim, so a vanished source means the rename landed.
1913
+ if (!(await presentOrAbsent(claimDirPath))) return;
1914
+ if (!(await presentOrAbsent(deploymentDirPath))) throw error;
1915
+ }
1916
+ }
1917
+ if (attempt >= MAX_CLAIM_ATTEMPTS) {
1918
+ throw new ClientError(
1919
+ `Deployment id ${basename(deploymentDirPath)} was claimed again each time it was freed; deploying ` +
1920
+ `again without a deployment_id mints a fresh id`,
1921
+ 409
1755
1922
  );
1756
1923
  }
1757
- // This component's own preparation lock serializes the claim and nothing certified it, so nothing
1758
- // is lost by rebuilding over it.
1759
- await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1760
- await mkdir(deploymentDirPath, { mode: 0o700 });
1924
+ await makeRoomForClaim(deploymentDirPath, componentName);
1925
+ }
1926
+ } finally {
1927
+ // A claim that did not land names no id, so it is nobody else's.
1928
+ await rm(claimDirPath, { recursive: true, force: true }).catch(() => {});
1929
+ }
1930
+ }
1931
+
1932
+ const MAX_CLAIM_ATTEMPTS = 3;
1933
+
1934
+ /** Every refusal is a conflict the caller resolves by naming another id: a 409, not a server fault. */
1935
+ async function makeRoomForClaim(deploymentDirPath: string, componentName: string): Promise<void> {
1936
+ const taken = (message: string) => new ClientError(message, 409);
1937
+ const deploymentId = basename(deploymentDirPath);
1938
+ const owner = await candidateComponentName(deploymentDirPath);
1939
+ if (owner !== undefined && owner !== componentName) {
1940
+ throw taken(
1941
+ `Deployment id ${deploymentId} already holds a build of '${owner}'; a deployment id names one artifact for ` +
1942
+ `its lifetime`
1943
+ );
1944
+ }
1945
+ if (owner === undefined) {
1946
+ // Claims appear already attributed, so an EMPTY directory is only ever one a build created before naming it,
1947
+ // abandoned; `rmdir` removes nothing else.
1948
+ try {
1949
+ await rmdir(deploymentDirPath);
1950
+ return;
1951
+ } catch (error) {
1952
+ const code = (error as NodeJS.ErrnoException).code;
1953
+ if (code === 'ENOENT') return;
1954
+ if (code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error;
1761
1955
  }
1956
+ throw taken(
1957
+ `Deployment id ${deploymentId} is held by a directory that names no component. Remove it once you have ` +
1958
+ `determined it holds nothing you need; deploying again without a deployment_id mints a fresh id`
1959
+ );
1762
1960
  }
1763
- const claimed = await lstat(deploymentDirPath);
1764
- if (!claimed.isDirectory() || claimed.isSymbolicLink()) {
1765
- throw new Error(`Component deploy staging path is not a directory: ${deploymentDirPath}`);
1961
+ if (await presentOrAbsent(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER))) {
1962
+ throw taken(
1963
+ `Deployment id ${deploymentId} already holds a completed build of '${componentName}'; deploy it with ` +
1964
+ `deployment_id, or deploy again to build a new one`
1965
+ );
1766
1966
  }
1767
- if (process.platform !== 'win32' && (claimed.mode & 0o777) !== 0o700) {
1768
- await chmod(deploymentDirPath, 0o700).catch((error) =>
1769
- logger.warn(
1770
- `Could not restrict component deploy staging permissions for ${deploymentDirPath}:`,
1771
- errorForLog(error)
1772
- )
1967
+ // Nothing certified it, and this component's lock serializes its claims.
1968
+ await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1969
+ }
1970
+
1971
+ function claimOwnerDigest(componentName: string): string {
1972
+ return createHash('sha256').update(componentName).digest('hex').slice(0, CLAIM_OWNER_DIGEST_LENGTH);
1973
+ }
1974
+
1975
+ function isClaimOf(entryName: string, componentName: string): boolean {
1976
+ return (
1977
+ entryName.startsWith(CLAIMING_PREFIX) &&
1978
+ entryName.slice(CLAIMING_PREFIX.length + CLAIMING_UUID_LENGTH + 1) === claimOwnerDigest(componentName)
1979
+ );
1980
+ }
1981
+
1982
+ /** The caller holds the component's preparation lock, so none of its claims is in flight. */
1983
+ async function sweepAbandonedClaims(stagingRoot: string, entries: import('node:fs').Dirent[], componentName: string) {
1984
+ for (const entry of entries) {
1985
+ if (!entry.isDirectory() || !isClaimOf(entry.name, componentName)) continue;
1986
+ await rm(join(stagingRoot, entry.name), { recursive: true, force: true }).catch((error) =>
1987
+ logger.warn(`Could not remove the abandoned deploy claim ${entry.name}:`, errorForLog(error))
1773
1988
  );
1774
1989
  }
1775
- await publishClaimOwnership(deploymentDirPath, componentName);
1776
1990
  }
1777
1991
 
1778
1992
  /**
1779
- * Name the component that owns a deployment directory THIS CALL created, and take the directory back if that
1780
- * cannot be recorded.
1781
- *
1782
- * Ownership is published as part of the claim rather than at certification, because resolving and packing can
1783
- * take minutes and until a tree exists to infer an owner from, a directory answering to nobody is one another
1784
- * component will take for abandoned. Unattributed is a permanent refusal, though, so a failure here — a full
1785
- * disk, an EIO on the temp write or its sync — would burn this deployment id for good, every retry refused by
1786
- * its own wreckage. The removal is scoped to the directory this invocation made, and deliberately not
1787
- * broadened to one found by EEXIST: that directory may be another claimant's, and removing it is the race the
1788
- * refusal exists to prevent. Best-effort, because the claim failure is what the caller needs to see.
1789
- *
1790
- * `write` is a parameter so the failure is testable without a filesystem that can be made to fail on exactly
1791
- * this write and nothing else.
1993
+ * The claim names no id yet, so a failure burns nothing. `write` is a parameter so the failure is testable without a
1994
+ * filesystem that can be made to fail on exactly this write.
1792
1995
  */
1793
1996
  export async function publishClaimOwnership(
1794
- deploymentDirPath: string,
1997
+ claimDirPath: string,
1795
1998
  componentName: string,
1796
1999
  write: (filePath: string, contents: string) => Promise<void> = writeControlFileDurably
1797
2000
  ): Promise<void> {
1798
2001
  try {
1799
- await write(join(deploymentDirPath, CANDIDATE_COMPONENT_FILE), componentName);
2002
+ await write(join(claimDirPath, CANDIDATE_COMPONENT_FILE), componentName);
1800
2003
  } catch (error) {
1801
- await rm(deploymentDirPath, { recursive: true, force: true }).catch((cleanupError) =>
1802
- logger.warn(
1803
- `Could not remove the deployment directory ${deploymentDirPath} after failing to publish its ` +
1804
- `ownership; the id stays unusable until it is removed:`,
1805
- errorForLog(cleanupError)
1806
- )
2004
+ await rm(claimDirPath, { recursive: true, force: true }).catch((cleanupError) =>
2005
+ logger.warn(`Could not remove the unfinished deploy claim ${claimDirPath}:`, errorForLog(cleanupError))
1807
2006
  );
1808
2007
  throw error;
1809
2008
  }
@@ -1869,7 +2068,7 @@ async function journaledDeploymentForComponent(
1869
2068
  throw error;
1870
2069
  }
1871
2070
  for (const deployment of deployments) {
1872
- if (!deployment.isDirectory()) continue;
2071
+ if (!isDeploymentDirectoryEntry(deployment)) continue;
1873
2072
  const deploymentDirPath = join(stagingRoot, deployment.name);
1874
2073
  const journalPath = join(deploymentDirPath, ACTIVATION_JOURNAL);
1875
2074
  // NOTHING is swallowed here. This is the gate that authorizes restoring an old tree over what may be
@@ -1987,9 +2186,11 @@ async function settleStagingForComponent(
1987
2186
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
1988
2187
  throw error;
1989
2188
  }
2189
+ await sweepAbandonedClaims(stagingRoot, deployments, componentName);
1990
2190
  const dormant: DormantBuild[] = [];
2191
+ const kept: string[] = [];
1991
2192
  for (const deployment of deployments) {
1992
- if (!deployment.isDirectory()) continue;
2193
+ if (!isDeploymentDirectoryEntry(deployment)) continue;
1993
2194
  const deploymentDirPath = join(stagingRoot, deployment.name);
1994
2195
  // Ownership BEFORE parsing. Reading every journal first meant a truncated journal belonging to another
1995
2196
  // component threw here — blocking the deploy of a healthy component because an unrelated one is
@@ -2034,10 +2235,33 @@ async function settleStagingForComponent(
2034
2235
  // component's own unsettled activation in place while a new deploy proceeded over it, and an
2035
2236
  // activation interrupted before B1 has no rollback record for the restore gate to catch.
2036
2237
  if (journal.component !== componentName) continue;
2037
- await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, journal);
2238
+ kept.push(...(await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, journal)));
2038
2239
  }
2240
+ await catalogueKeptReleases(stagingRoot, componentName, kept, dormant);
2039
2241
  const maxCount = getStagingRetentionMaxCount();
2040
- if (dormant.length > maxCount) await pruneDormantBuilds(componentName, dormant, maxCount, pinnedDeploymentId);
2242
+ if (dormant.length > maxCount) {
2243
+ await pruneDormantBuilds(componentName, dormant, maxCount, [
2244
+ ...kept,
2245
+ ...(pinnedDeploymentId ? [pinnedDeploymentId] : []),
2246
+ ]);
2247
+ }
2248
+ }
2249
+
2250
+ /** Counted and pinned, or the pass a settlement ran inside can miss what it kept, or evict it after a failed refresh. */
2251
+ async function catalogueKeptReleases(
2252
+ stagingRoot: string,
2253
+ componentName: string,
2254
+ kept: string[],
2255
+ into: DormantBuild[]
2256
+ ): Promise<void> {
2257
+ const present = new Set(into.map((build) => build.deploymentDirPath));
2258
+ for (const deploymentId of kept) {
2259
+ const build = await dormantBuildAt(join(stagingRoot, deploymentId), componentName).catch(() => undefined);
2260
+ if (build && !present.has(build.deploymentDirPath)) {
2261
+ present.add(build.deploymentDirPath);
2262
+ into.push(build);
2263
+ }
2264
+ }
2041
2265
  }
2042
2266
 
2043
2267
  /**
@@ -2064,7 +2288,7 @@ export async function unsettleableComponentsFromDisk(componentsRootDirPath: stri
2064
2288
  throw error;
2065
2289
  }
2066
2290
  for (const deployment of deployments) {
2067
- if (!deployment.isDirectory()) continue;
2291
+ if (!isDeploymentDirectoryEntry(deployment)) continue;
2068
2292
  const deploymentDirPath = join(stagingRoot, deployment.name);
2069
2293
  // Per deployment. `candidateComponentName` propagates every non-ENOENT error now, and this pass runs
2070
2294
  // where the CALLER only warns — so one unreadable deployment escaping here would drop the verdict for
@@ -2152,12 +2376,20 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
2152
2376
  const dormant = new Map<string, DormantBuild[]>();
2153
2377
  const catalogue = (owner: string, build: DormantBuild) => {
2154
2378
  const builds = dormant.get(owner);
2155
- if (builds) builds.push(build);
2156
- else dormant.set(owner, [build]);
2379
+ if (!builds) dormant.set(owner, [build]);
2380
+ else if (!builds.some((existing) => existing.deploymentDirPath === build.deploymentDirPath)) builds.push(build);
2381
+ };
2382
+ const keptBy = new Map<string, string[]>();
2383
+ const keptDuring = async (owner: string, kept: string[]) => {
2384
+ if (kept.length === 0) return;
2385
+ keptBy.set(owner, [...(keptBy.get(owner) ?? []), ...kept]);
2386
+ const builds: DormantBuild[] = [];
2387
+ await catalogueKeptReleases(stagingRoot, owner, kept, builds);
2388
+ for (const build of builds) catalogue(owner, build);
2157
2389
  };
2158
2390
 
2159
2391
  for (const deployment of deployments) {
2160
- if (!deployment.isDirectory()) continue;
2392
+ if (!isDeploymentDirectoryEntry(deployment)) continue;
2161
2393
  const deploymentDirPath = join(stagingRoot, deployment.name);
2162
2394
  const journalPath = join(deploymentDirPath, ACTIVATION_JOURNAL);
2163
2395
  const fail = (component: string, error: unknown) => recordUnsettled(failures, component, error, deploymentDirPath);
@@ -2214,7 +2446,10 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
2214
2446
  throw splitAttributionError(deploymentDirPath, appeared.component, splitNames[0]);
2215
2447
  }
2216
2448
  activationToFail = appeared.component;
2217
- await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, appeared);
2449
+ await keptDuring(
2450
+ appeared.component,
2451
+ await settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, appeared)
2452
+ );
2218
2453
  // Settled. Anything that fails after this — releasing the lock, say — is not this
2219
2454
  // activation's, and attributing it here would fail a correctly settled component closed.
2220
2455
  activationToFail = undefined;
@@ -2258,6 +2493,8 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
2258
2493
  // Cleared, and still not a retainable build — an incomplete or treeless staged directory. That
2259
2494
  // is residue like any other, so it falls through to the removal below.
2260
2495
  }
2496
+ // Re-derived under the lock too: the release may have been re-homed, or displaced, since the scan.
2497
+ if (await isReferencedDeploymentRecord(componentsRootDirPath, deploymentDirPath, owner!)) return;
2261
2498
  // Cleanup, not settlement. There was no activation here — this is most often the residue a
2262
2499
  // SUCCESSFUL settlement leaves when its own sweep failed — so a sweep that fails again cannot
2263
2500
  // make anything unsettled, and recording it would refuse a live component on every worker
@@ -2299,6 +2536,22 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
2299
2536
  catalogue(owner, build);
2300
2537
  continue;
2301
2538
  }
2539
+ // The record of a release that is live, or displaced and not yet put back, is left alone without the
2540
+ // lock for the same reason. A read that fails leaves it in place: deleting it is the irreversible side.
2541
+ try {
2542
+ if (
2543
+ !(await presentOrAbsent(join(deploymentDirPath, UNSETTLED_MARKER))) &&
2544
+ (await isReferencedDeploymentRecord(componentsRootDirPath, deploymentDirPath, owner))
2545
+ ) {
2546
+ continue;
2547
+ }
2548
+ } catch (error) {
2549
+ logger.warn(
2550
+ `Leaving deploy staging ${deploymentDirPath} in place; it could not be read:`,
2551
+ errorForLog(error)
2552
+ );
2553
+ continue;
2554
+ }
2302
2555
  }
2303
2556
  // Scoped to THIS deployment, like the journaled branch below: a lock timeout or an EIO here used to
2304
2557
  // abort the entire scan, leaving every later deployment unsettled and unmarked.
@@ -2352,7 +2605,7 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
2352
2605
  continue;
2353
2606
  }
2354
2607
  const settling = journal;
2355
- await withComponentPreparationLock(
2608
+ const kept = await withComponentPreparationLock(
2356
2609
  join(componentsRootDirPath, settling.component),
2357
2610
  () => settleInterruptedActivation(componentsRootDirPath, deploymentDirPath, settling),
2358
2611
  {
@@ -2361,6 +2614,7 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
2361
2614
  isOwnerAlive: (lockOwner) => lockOwner.pid !== process.pid || isThreadRunning(lockOwner.threadId),
2362
2615
  }
2363
2616
  );
2617
+ await keptDuring(settling.component, kept);
2364
2618
  } catch (error) {
2365
2619
  // The journal named its component, so attribution is exact however the settle failed.
2366
2620
  await fail(journal.component, error);
@@ -2369,7 +2623,14 @@ export async function recoverInterruptedActivations(componentsRootDirPath: strin
2369
2623
 
2370
2624
  const maxCount = getStagingRetentionMaxCount();
2371
2625
  for (const [owner, builds] of dormant) {
2372
- for (const [component, error] of await reconcileDormantBuilds(componentsRootDirPath, owner, builds, maxCount)) {
2626
+ const pinned = keptBy.get(owner) ?? [];
2627
+ for (const [component, error] of await reconcileDormantBuilds(
2628
+ componentsRootDirPath,
2629
+ owner,
2630
+ builds,
2631
+ maxCount,
2632
+ pinned
2633
+ )) {
2373
2634
  if (!failures.has(component)) failures.set(component, error);
2374
2635
  }
2375
2636
  }
@@ -2409,7 +2670,8 @@ export async function reconcileDormantBuilds(
2409
2670
  componentsRootDirPath: string,
2410
2671
  owner: string,
2411
2672
  builds: DormantBuild[],
2412
- maxCount: number
2673
+ maxCount: number,
2674
+ pinned: readonly string[] = []
2413
2675
  ): Promise<Map<string, Error>> {
2414
2676
  const failures = new Map<string, Error>();
2415
2677
  let journaled: DormantBuild | undefined;
@@ -2430,6 +2692,7 @@ export async function reconcileDormantBuilds(
2430
2692
  join(componentsRootDirPath, owner),
2431
2693
  async () => {
2432
2694
  const stillDormant: DormantBuild[] = [];
2695
+ const keptHere: string[] = [];
2433
2696
  for (const build of builds) {
2434
2697
  let journal: ActivationJournal | undefined;
2435
2698
  try {
@@ -2451,12 +2714,16 @@ export async function reconcileDormantBuilds(
2451
2714
  continue;
2452
2715
  }
2453
2716
  try {
2454
- await settleInterruptedActivation(componentsRootDirPath, build.deploymentDirPath, journal);
2717
+ const kept = await settleInterruptedActivation(componentsRootDirPath, build.deploymentDirPath, journal);
2718
+ keptHere.push(...kept);
2719
+ await catalogueKeptReleases(join(componentsRootDirPath, DEPLOY_STAGING_DIR), owner, kept, stillDormant);
2455
2720
  } catch (error) {
2456
2721
  await recordUnsettled(failures, journal.component, error, build.deploymentDirPath);
2457
2722
  }
2458
2723
  }
2459
- if (stillDormant.length > maxCount) await pruneDormantBuilds(owner, stillDormant, maxCount);
2724
+ if (stillDormant.length > maxCount) {
2725
+ await pruneDormantBuilds(owner, stillDormant, maxCount, [...pinned, ...keptHere]);
2726
+ }
2460
2727
  },
2461
2728
  {
2462
2729
  purpose: 'activation-recovery',
@@ -2493,7 +2760,8 @@ async function sweepAsideRecords(
2493
2760
  componentName: string,
2494
2761
  liveDirPath: string,
2495
2762
  asideStagingDir: string
2496
- ): Promise<void> {
2763
+ ): Promise<string[]> {
2764
+ const kept: string[] = [];
2497
2765
  for (const record of records) {
2498
2766
  // RETIRING IS CORRECTNESS, not hygiene: the retired marker is what stops the legacy pass treating this
2499
2767
  // record as authoritative and restoring the displaced tree over the candidate that was just rolled
@@ -2501,6 +2769,11 @@ async function sweepAsideRecords(
2501
2769
  // the inversion this protocol exists to prevent, so a failure here PROPAGATES — the caller keeps the
2502
2770
  // journal and the next start retries.
2503
2771
  const retiredMarkerPath = await retireExtractionAside(record);
2772
+ // No prune here: settlement can run inside another request's preamble, whose pin it cannot see. A tree that
2773
+ // could not be kept stays retired in the aside for the legacy pass to retry.
2774
+ const retention = await retainDisplacedRelease(dirname(liveDirPath), componentName, record);
2775
+ if (retention.verdict === 'kept') kept.push(retention.deploymentId);
2776
+ if (retention.verdict === 'failed') continue;
2504
2777
  // Sweeping the displaced tree is hygiene: it bounds disk, and a failure costs space rather than
2505
2778
  // correctness, so it is logged. The retired marker above already makes the record non-authoritative.
2506
2779
  await cleanupExtractionPaths(
@@ -2509,6 +2782,7 @@ async function sweepAsideRecords(
2509
2782
  new Set([record, retiredMarkerPath])
2510
2783
  ).catch((error) => logger.warn(`Settled ${componentName} but could not sweep ${record}:`, errorForLog(error)));
2511
2784
  }
2785
+ return kept;
2512
2786
  }
2513
2787
 
2514
2788
  /**
@@ -2543,7 +2817,7 @@ async function settleInterruptedActivation(
2543
2817
  componentsRootDirPath: string,
2544
2818
  deploymentDirPath: string,
2545
2819
  journal: ActivationJournal
2546
- ): Promise<void> {
2820
+ ): Promise<string[]> {
2547
2821
  const liveDirPath = join(componentsRootDirPath, journal.component);
2548
2822
  const candidateDirPath = join(deploymentDirPath, journal.component);
2549
2823
  const asideStagingDir = extractionStagingDirectory(liveDirPath);
@@ -2562,7 +2836,10 @@ async function settleInterruptedActivation(
2562
2836
  const candidateComplete = await exists(join(deploymentDirPath, CANDIDATE_COMPLETE_MARKER));
2563
2837
  const asideRecords = await inProgressAsideRecords(asideStagingDir);
2564
2838
 
2839
+ let rolledForward = false;
2840
+ let kept: string[] = [];
2565
2841
  const rollForward = async () => {
2842
+ rolledForward = true;
2566
2843
  if (!liveExists) await renameThroughTransientHolder(candidateDirPath, liveDirPath);
2567
2844
  // Unconditional, not only when THIS pass performed the rename: a crash after normal activation
2568
2845
  // renamed the candidate but before it repaired the links leaves live present with stale targets, and
@@ -2577,7 +2854,7 @@ async function settleInterruptedActivation(
2577
2854
  // this roll-forward just displaced. Failing the component closed and retrying at the next start is
2578
2855
  // the cheaper mistake — the journal survives, so the verdict is re-derivable. Only the disk sweep
2579
2856
  // inside is best-effort.
2580
- await sweepAsideRecords(asideRecords, journal.component, liveDirPath, asideStagingDir);
2857
+ kept = await sweepAsideRecords(asideRecords, journal.component, liveDirPath, asideStagingDir);
2581
2858
  };
2582
2859
  const rollBack = async (restoreFrom?: string) => {
2583
2860
  if (restoreFrom) {
@@ -2666,7 +2943,7 @@ async function settleInterruptedActivation(
2666
2943
  `Returned the staged build ${basename(deploymentDirPath)} of ${journal.component} to dormant after an ` +
2667
2944
  `activation that never moved its live tree aside`
2668
2945
  );
2669
- return;
2946
+ return kept;
2670
2947
  }
2671
2948
  await rollBack();
2672
2949
  } else {
@@ -2688,7 +2965,7 @@ async function settleInterruptedActivation(
2688
2965
  `Settled the interrupted activation of ${journal.component} but could not flush its rollback record; ` +
2689
2966
  `leaving the journal for the next start: ${errorMessage(error)}`
2690
2967
  );
2691
- return;
2968
+ return kept;
2692
2969
  }
2693
2970
  // Best-effort, matching the activation path: the activation is settled by this point, so a transient
2694
2971
  // EBUSY removing staging must not throw out of the recovery pass and take the other components with it.
@@ -2699,10 +2976,12 @@ async function settleInterruptedActivation(
2699
2976
  await rm(journalPath, { force: true }).catch((error) =>
2700
2977
  logger.warn(`Settled ${journal.component} but could not remove its activation journal:`, errorForLog(error))
2701
2978
  );
2979
+ if (rolledForward && (await keepsDeploymentRecord(deploymentDirPath))) return kept;
2702
2980
  await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
2703
2981
  logger.warn(`Settled ${journal.component} but could not clean up its staging directory:`, errorForLog(error))
2704
2982
  );
2705
2983
  await rmdir(dirname(deploymentDirPath)).catch(() => {});
2984
+ return kept;
2706
2985
  }
2707
2986
 
2708
2987
  /** Mark a candidate build+validation complete. Idempotent, so a retried activation is not a failure. */
@@ -3042,14 +3321,20 @@ export async function activateCandidateApplication(
3042
3321
  }
3043
3322
  const settledRecord = asidePath ?? priorAbsentRecordPath!;
3044
3323
  let retired = false;
3324
+ let keptDeploymentId: string | undefined;
3045
3325
  // Skipped entirely when the swap is not known to be on storage, so the journal below survives.
3046
3326
  if (swapDurable) {
3047
3327
  try {
3048
3328
  const retiredMarkerPath = await retireExtractionAside(settledRecord);
3049
- // Retiring only MARKS the displaced tree disposable. Without this sweep the tree every deploy
3050
- // displaces stays under `.deploy-aside/<component>` forever, so the components root grows by a
3051
- // whole component version per deploy.
3052
- await cleanupExtractionPaths(application, asideStagingDir, new Set([settledRecord, retiredMarkerPath]));
3329
+ // A tree nothing can keep is swept, or the components root grows by a component version per deploy; one
3330
+ // whose keep FAILED stays retired here for a retry.
3331
+ const retention = asidePath
3332
+ ? await retainDisplacedRelease(dirname(liveDirPath), application.name, asidePath)
3333
+ : ({ verdict: 'ineligible' } as const);
3334
+ if (retention.verdict === 'kept') keptDeploymentId = retention.deploymentId;
3335
+ if (retention.verdict !== 'failed') {
3336
+ await cleanupExtractionPaths(application, asideStagingDir, new Set([settledRecord, retiredMarkerPath]));
3337
+ }
3053
3338
  // Before the journal goes: if the journal's removal persists but the record's does not, startup sees
3054
3339
  // an in-progress aside with no journal and the legacy pass restores the old tree over the new one.
3055
3340
  await syncDirectory(asideStagingDir);
@@ -3065,10 +3350,23 @@ export async function activateCandidateApplication(
3065
3350
  await rm(journalPath, { force: true }).catch((error) =>
3066
3351
  application.logger.warn(`Deployed ${application.name} but could not remove its activation journal:`, error)
3067
3352
  );
3068
- await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
3069
- application.logger.warn(`Deployed ${application.name} but could not clean up its staging directory:`, error)
3070
- );
3071
- await rmdir(dirname(deploymentDirPath)).catch(() => {});
3353
+ if (!(await keepsDeploymentRecord(deploymentDirPath))) {
3354
+ await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch((error) =>
3355
+ application.logger.warn(`Deployed ${application.name} but could not clean up its staging directory:`, error)
3356
+ );
3357
+ await rmdir(dirname(deploymentDirPath)).catch(() => {});
3358
+ }
3359
+ }
3360
+ // Only here, where the request's own artifact is already live: a prune inside settlement could evict the
3361
+ // artifact a request's preamble pinned. Best-effort, like every prune.
3362
+ if (keptDeploymentId !== undefined) {
3363
+ const maxCount = getStagingRetentionMaxCount();
3364
+ try {
3365
+ const builds = await dormantBuildsOf(dirname(liveDirPath), application.name);
3366
+ if (builds.length > maxCount) await pruneDormantBuilds(application.name, builds, maxCount, keptDeploymentId);
3367
+ } catch (error) {
3368
+ application.logger.warn(`Deployed ${application.name} but could not bound its kept releases:`, error);
3369
+ }
3072
3370
  }
3073
3371
  }
3074
3372
 
@@ -3276,6 +3574,10 @@ export async function buildCandidateApplication(
3276
3574
  // deployer's git token. `prepareApplication`'s finally still calls this; it is idempotent.
3277
3575
  await application.cleanupGitCredentialSession();
3278
3576
  await installApplication(application, candidateDirPath);
3577
+ // After the install, which can rewrite the tree; never through a link, whose target is not the deploy's to write.
3578
+ if (resolved.kind !== 'link') {
3579
+ await writeDeploymentProvenance(candidateDirPath, application.name, deploymentId);
3580
+ }
3279
3581
  return candidateDirPath;
3280
3582
  } catch (error) {
3281
3583
  await discardCandidate(application, deploymentId);
@@ -3344,6 +3646,22 @@ async function recoverOrCleanupStaleExtractionPaths(
3344
3646
  );
3345
3647
  return;
3346
3648
  }
3649
+ // Each retired tree left here is a displaced release: kept if it can be — the retry of a keep that failed — and
3650
+ // swept only when it cannot.
3651
+ for (const entry of entries) {
3652
+ if (!entry.isDirectory() || !entry.name.startsWith(IN_PROGRESS_ASIDE_PREFIX)) continue;
3653
+ const treePath = join(asideStagingDir, entry.name);
3654
+ const retention = await retainDisplacedRelease(dirname(application.dirPath), application.name, treePath).catch(
3655
+ (error) => {
3656
+ application.logger.warn(`Could not keep the previous release at ${treePath}; leaving it in place:`, error);
3657
+ return { verdict: 'failed' } as const;
3658
+ }
3659
+ );
3660
+ if (retention.verdict === 'failed') {
3661
+ paths.delete(treePath);
3662
+ paths.delete(retiredMarkerForAside(treePath));
3663
+ }
3664
+ }
3347
3665
  await cleanupExtractionPaths(application, asideStagingDir, paths);
3348
3666
  }
3349
3667
 
@@ -3558,9 +3876,11 @@ export async function retireComponentDirectory(
3558
3876
  asideStagingDir,
3559
3877
  new Set([droppedPath])
3560
3878
  );
3561
- // A dropped component has no next deploy to bound its dormant builds.
3879
+ // A dropped component has no next deploy to bound its dormant builds, and nothing left for its records to
3880
+ // describe.
3562
3881
  try {
3563
3882
  await pruneDormantBuilds(componentName, await dormantBuildsOf(dirname(componentDirPath), componentName), 0);
3883
+ await reclaimDeploymentRecordsOf(dirname(componentDirPath), componentName);
3564
3884
  } catch (error) {
3565
3885
  componentLogger.warn(
3566
3886
  `Dropped ${componentName} but could not reclaim its dormant staged builds:`,
@@ -3571,6 +3891,31 @@ export async function retireComponentDirectory(
3571
3891
  };
3572
3892
  }
3573
3893
 
3894
+ /** Journaled directories stay: an activation nobody settled is evidence, not litter. */
3895
+ async function reclaimDeploymentRecordsOf(componentsRootDirPath: string, componentName: string): Promise<void> {
3896
+ const stagingRoot = join(componentsRootDirPath, DEPLOY_STAGING_DIR);
3897
+ const entries = await readdir(stagingRoot, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
3898
+ if (error?.code === 'ENOENT') return [];
3899
+ throw error;
3900
+ });
3901
+ await sweepAbandonedClaims(stagingRoot, entries, componentName);
3902
+ for (const entry of entries) {
3903
+ if (!isDeploymentDirectoryEntry(entry)) continue;
3904
+ const deploymentDirPath = join(stagingRoot, entry.name);
3905
+ try {
3906
+ if ((await candidateComponentName(deploymentDirPath)) !== componentName) continue;
3907
+ if (await presentOrAbsent(join(deploymentDirPath, ACTIVATION_JOURNAL))) continue;
3908
+ if (await presentOrAbsent(join(deploymentDirPath, componentName))) continue;
3909
+ await rm(deploymentDirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
3910
+ } catch (error) {
3911
+ logger.warn(
3912
+ `Could not reclaim the deployment record ${deploymentDirPath} of ${componentName}:`,
3913
+ errorForLog(error)
3914
+ );
3915
+ }
3916
+ }
3917
+ }
3918
+
3574
3919
  async function cleanupExtractionPaths(
3575
3920
  application: ExtractionContext,
3576
3921
  asideStagingDir: string,
@@ -4188,6 +4533,7 @@ export class Application {
4188
4533
  isNewComponent: boolean = true;
4189
4534
  packageMetadataChanged: boolean = false;
4190
4535
  installationIsOpaque: boolean = false;
4536
+ alreadyActive: boolean = false;
4191
4537
 
4192
4538
  constructor({ name, payload, packageIdentifier, install, onInstallLine, credentials }: ApplicationOptions) {
4193
4539
  this.name = name;
@@ -4419,6 +4765,15 @@ export async function prepareApplication(application: Application, options: Prep
4419
4765
  }
4420
4766
  ));
4421
4767
  if (mode === 'activate') {
4768
+ // A retry of an activation that swapped here and failed later or elsewhere: answered without a swap so
4769
+ // it can go on to the nodes still holding the artifact. Nothing can tell whether the running workers
4770
+ // loaded it, hence the restart request.
4771
+ if ((await readDeploymentProvenance(application.dirPath, application.name)) === artifactId) {
4772
+ application.alreadyActive = true;
4773
+ application.packageMetadataChanged = true;
4774
+ application.logger.debug?.(`Deployment ${artifactId} is already live; nothing to swap`);
4775
+ return;
4776
+ }
4422
4777
  await activateStagedArtifact(application, artifactId, previousPackageMetadata, options);
4423
4778
  return;
4424
4779
  }
@@ -4466,8 +4821,22 @@ export async function prepareApplication(application: Application, options: Prep
4466
4821
  await syncArtifactAncestors(candidateDeploymentDirPath(application.dirPath, artifactId));
4467
4822
  return;
4468
4823
  }
4469
- await markCandidateComplete(application.dirPath, artifactId, application.name);
4470
4824
  const declared = options.describeArtifact?.();
4825
+ // The directory outlives the swap as the record a later displacement puts this release back into;
4826
+ // a link has no bytes of its own to put back.
4827
+ const described = declared && !(await lstat(candidateDirPath)).isSymbolicLink() ? declared : undefined;
4828
+ if (described) {
4829
+ await writeArtifactDescriptor(application.dirPath, artifactId, {
4830
+ v: ARTIFACT_DESCRIPTOR_VERSION,
4831
+ component: application.name,
4832
+ rootConfig: described.rootConfig,
4833
+ installationIsOpaque: application.installationIsOpaque,
4834
+ isolated: described.isolated,
4835
+ });
4836
+ }
4837
+ await markCandidateComplete(application.dirPath, artifactId, application.name);
4838
+ // The record is meant to outlive the swap, so its own entry has to be on storage, as a stage's is.
4839
+ if (described) await syncArtifactAncestors(candidateDeploymentDirPath(application.dirPath, artifactId));
4471
4840
  await activateCandidateApplication(application, artifactId, {
4472
4841
  rootConfig: declared ? rootConfigEffectFromDeclaration(declared.rootConfig) : { kind: 'keep' },
4473
4842
  });
@@ -4551,11 +4920,13 @@ async function activateStagedArtifact(
4551
4920
  throw unusable('an activation of it is unsettled');
4552
4921
  }
4553
4922
  const candidateStat = await presentOrAbsent(candidateDirPath);
4554
- if (!candidateStat || !candidateStat.isDirectory()) {
4923
+ // The record of a release that is neither live nor kept here: nothing on this node answers to the id.
4924
+ if (!candidateStat) throw missing('that release is no longer on this node');
4925
+ if (!candidateStat.isDirectory()) {
4555
4926
  // Unlike an immediate deploy, a symlink is refused: a `file:` directory is linked rather than
4556
4927
  // copied, so what it points at now is not what was certified. Staging rejects the source for the
4557
4928
  // same reason; this is the other end of the same rule, for an artifact staged by an older build.
4558
- throw unusable('its build tree is missing or is a link rather than a copy');
4929
+ throw unusable('its build tree is a link rather than a copy');
4559
4930
  }
4560
4931
  const descriptor = await readArtifactDescriptor(deploymentDirPath, application.name);
4561
4932
  if (!descriptor) throw unusable('it does not record what its build decided');