@harperfast/harper 5.3.0-alpha.1 → 5.3.0-beta.1

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 (291) hide show
  1. package/bin/cliOperations.ts +12 -0
  2. package/bin/restart.ts +66 -6
  3. package/components/Application.ts +1134 -112
  4. package/components/OptionsWatcher.ts +368 -102
  5. package/components/Scope.ts +20 -1
  6. package/components/componentLoader.ts +43 -4
  7. package/components/deploymentOperations.ts +4 -1
  8. package/components/deploymentRecorder.ts +9 -2
  9. package/components/operations.js +284 -52
  10. package/components/operationsValidation.js +49 -2
  11. package/components/packageComponent.ts +25 -1
  12. package/components/requestRestart.ts +11 -0
  13. package/config/RootConfigWatcher.ts +191 -37
  14. package/config/configReadRetry.ts +62 -0
  15. package/config/configUtils.ts +78 -26
  16. package/config/parseConfigFile.ts +34 -0
  17. package/config/readConfigFileSync.ts +44 -0
  18. package/config/watcherArming.ts +59 -0
  19. package/config-root.schema.json +4 -0
  20. package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
  21. package/dist/bin/cliOperations.js +13 -0
  22. package/dist/bin/cliOperations.js.map +1 -1
  23. package/dist/bin/restart.js +42 -6
  24. package/dist/bin/restart.js.map +1 -1
  25. package/dist/components/Application.d.ts +104 -9
  26. package/dist/components/Application.js +954 -102
  27. package/dist/components/Application.js.map +1 -1
  28. package/dist/components/OptionsWatcher.d.ts +4 -1
  29. package/dist/components/OptionsWatcher.js +378 -104
  30. package/dist/components/OptionsWatcher.js.map +1 -1
  31. package/dist/components/Scope.js +15 -1
  32. package/dist/components/Scope.js.map +1 -1
  33. package/dist/components/componentLoader.js +35 -3
  34. package/dist/components/componentLoader.js.map +1 -1
  35. package/dist/components/deploymentOperations.js +4 -1
  36. package/dist/components/deploymentOperations.js.map +1 -1
  37. package/dist/components/deploymentRecorder.d.ts +4 -2
  38. package/dist/components/deploymentRecorder.js +1 -0
  39. package/dist/components/deploymentRecorder.js.map +1 -1
  40. package/dist/components/operations.d.ts +28 -0
  41. package/dist/components/operations.js +263 -54
  42. package/dist/components/operations.js.map +1 -1
  43. package/dist/components/operationsValidation.js +48 -2
  44. package/dist/components/operationsValidation.js.map +1 -1
  45. package/dist/components/packageComponent.js +24 -0
  46. package/dist/components/packageComponent.js.map +1 -1
  47. package/dist/components/requestRestart.d.ts +1 -0
  48. package/dist/components/requestRestart.js +7 -0
  49. package/dist/components/requestRestart.js.map +1 -1
  50. package/dist/config/RootConfigWatcher.d.ts +2 -0
  51. package/dist/config/RootConfigWatcher.js +189 -35
  52. package/dist/config/RootConfigWatcher.js.map +1 -1
  53. package/dist/config/configReadRetry.d.ts +8 -0
  54. package/dist/config/configReadRetry.js +62 -0
  55. package/dist/config/configReadRetry.js.map +1 -0
  56. package/dist/config/configUtils.d.ts +10 -9
  57. package/dist/config/configUtils.js +63 -27
  58. package/dist/config/configUtils.js.map +1 -1
  59. package/dist/config/parseConfigFile.d.ts +4 -0
  60. package/dist/config/parseConfigFile.js +35 -0
  61. package/dist/config/parseConfigFile.js.map +1 -0
  62. package/dist/config/readConfigFileSync.d.ts +1 -0
  63. package/dist/config/readConfigFileSync.js +47 -0
  64. package/dist/config/readConfigFileSync.js.map +1 -0
  65. package/dist/config/watcherArming.d.ts +15 -0
  66. package/dist/config/watcherArming.js +59 -0
  67. package/dist/config/watcherArming.js.map +1 -0
  68. package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
  69. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  70. package/dist/index.d.ts +1 -0
  71. package/dist/index.js +4 -1
  72. package/dist/index.js.map +1 -1
  73. package/dist/json/systemSchema.json +3 -0
  74. package/dist/resources/DatabaseTransaction.d.ts +25 -0
  75. package/dist/resources/DatabaseTransaction.js +224 -18
  76. package/dist/resources/DatabaseTransaction.js.map +1 -1
  77. package/dist/resources/LMDBTransaction.d.ts +2 -1
  78. package/dist/resources/LMDBTransaction.js +22 -3
  79. package/dist/resources/LMDBTransaction.js.map +1 -1
  80. package/dist/resources/PrimaryRocksDatabase.js +22 -6
  81. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  82. package/dist/resources/RecordEncoder.d.ts +1 -1
  83. package/dist/resources/RecordEncoder.js +21 -5
  84. package/dist/resources/RecordEncoder.js.map +1 -1
  85. package/dist/resources/Resource.js +97 -13
  86. package/dist/resources/Resource.js.map +1 -1
  87. package/dist/resources/ResourceInterface.d.ts +8 -0
  88. package/dist/resources/RocksIndexStore.js +2 -1
  89. package/dist/resources/RocksIndexStore.js.map +1 -1
  90. package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
  91. package/dist/resources/RocksTransactionLogStore.js +104 -33
  92. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  93. package/dist/resources/Table.d.ts +58 -7
  94. package/dist/resources/Table.js +1095 -346
  95. package/dist/resources/Table.js.map +1 -1
  96. package/dist/resources/analytics/write.js +10 -3
  97. package/dist/resources/analytics/write.js.map +1 -1
  98. package/dist/resources/auditStore.d.ts +170 -0
  99. package/dist/resources/auditStore.js +457 -11
  100. package/dist/resources/auditStore.js.map +1 -1
  101. package/dist/resources/dataLoader.js +3 -4
  102. package/dist/resources/dataLoader.js.map +1 -1
  103. package/dist/resources/databases.d.ts +16 -13
  104. package/dist/resources/databases.js +624 -176
  105. package/dist/resources/databases.js.map +1 -1
  106. package/dist/resources/derivedIndexRegistry.d.ts +5 -0
  107. package/dist/resources/derivedIndexRegistry.js +68 -0
  108. package/dist/resources/derivedIndexRegistry.js.map +1 -0
  109. package/dist/resources/derivedIndexRuntime.d.ts +215 -0
  110. package/dist/resources/derivedIndexRuntime.js +2027 -0
  111. package/dist/resources/derivedIndexRuntime.js.map +1 -0
  112. package/dist/resources/graphql.js +3 -2
  113. package/dist/resources/graphql.js.map +1 -1
  114. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
  115. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +848 -39
  116. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  117. package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
  118. package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
  119. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
  120. package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
  121. package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
  122. package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
  123. package/dist/resources/nodeIdMapping.d.ts +5 -0
  124. package/dist/resources/nodeIdMapping.js +49 -0
  125. package/dist/resources/nodeIdMapping.js.map +1 -1
  126. package/dist/resources/recordLock.d.ts +47 -4
  127. package/dist/resources/recordLock.js +138 -7
  128. package/dist/resources/recordLock.js.map +1 -1
  129. package/dist/resources/recordLockCoordinator.d.ts +557 -0
  130. package/dist/resources/recordLockCoordinator.js +2565 -0
  131. package/dist/resources/recordLockCoordinator.js.map +1 -0
  132. package/dist/resources/replayLogs.js +5 -0
  133. package/dist/resources/replayLogs.js.map +1 -1
  134. package/dist/resources/replicatedApplyFailure.d.ts +16 -0
  135. package/dist/resources/replicatedApplyFailure.js +63 -0
  136. package/dist/resources/replicatedApplyFailure.js.map +1 -0
  137. package/dist/resources/scheduler/scheduler.js +3 -3
  138. package/dist/resources/scheduler/scheduler.js.map +1 -1
  139. package/dist/resources/search.d.ts +10 -4
  140. package/dist/resources/search.js +160 -40
  141. package/dist/resources/search.js.map +1 -1
  142. package/dist/resources/tracked.d.ts +5 -1
  143. package/dist/resources/tracked.js +74 -23
  144. package/dist/resources/tracked.js.map +1 -1
  145. package/dist/security/jsLoader.js +6 -4
  146. package/dist/security/jsLoader.js.map +1 -1
  147. package/dist/server/REST.js +33 -2
  148. package/dist/server/REST.js.map +1 -1
  149. package/dist/server/http.d.ts +5 -1
  150. package/dist/server/http.js +34 -2
  151. package/dist/server/http.js.map +1 -1
  152. package/dist/server/serverHelpers/Headers.d.ts +2 -0
  153. package/dist/server/serverHelpers/Headers.js +6 -0
  154. package/dist/server/serverHelpers/Headers.js.map +1 -1
  155. package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
  156. package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
  157. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
  158. package/dist/server/serverHelpers/Request.d.ts +5 -10
  159. package/dist/server/serverHelpers/Request.js +38 -136
  160. package/dist/server/serverHelpers/Request.js.map +1 -1
  161. package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
  162. package/dist/server/serverHelpers/contentTypes.js +189 -15
  163. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  164. package/dist/server/serverHelpers/serverUtilities.js +96 -17
  165. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  166. package/dist/server/storageReclamation.js +1 -1
  167. package/dist/server/storageReclamation.js.map +1 -1
  168. package/dist/server/threads/isolatedApplications.d.ts +47 -0
  169. package/dist/server/threads/isolatedApplications.js +171 -0
  170. package/dist/server/threads/isolatedApplications.js.map +1 -0
  171. package/dist/server/threads/logRotationTransport.d.ts +1 -0
  172. package/dist/server/threads/logRotationTransport.js +33 -0
  173. package/dist/server/threads/logRotationTransport.js.map +1 -0
  174. package/dist/server/threads/manageThreads.d.ts +64 -6
  175. package/dist/server/threads/manageThreads.js +261 -12
  176. package/dist/server/threads/manageThreads.js.map +1 -1
  177. package/dist/server/threads/socketRouter.d.ts +1 -0
  178. package/dist/server/threads/socketRouter.js +196 -13
  179. package/dist/server/threads/socketRouter.js.map +1 -1
  180. package/dist/server/threads/threadServer.js +30 -7
  181. package/dist/server/threads/threadServer.js.map +1 -1
  182. package/dist/utility/errors/hdbError.d.ts +24 -0
  183. package/dist/utility/errors/hdbError.js +58 -1
  184. package/dist/utility/errors/hdbError.js.map +1 -1
  185. package/dist/utility/hdbTerms.d.ts +2 -0
  186. package/dist/utility/hdbTerms.js +2 -0
  187. package/dist/utility/hdbTerms.js.map +1 -1
  188. package/dist/utility/logging/harper_logger.js +217 -38
  189. package/dist/utility/logging/harper_logger.js.map +1 -1
  190. package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
  191. package/dist/utility/logging/logGenerationCoordinator.js +184 -0
  192. package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
  193. package/dist/utility/logging/logRotation.d.ts +46 -0
  194. package/dist/utility/logging/logRotation.js +365 -0
  195. package/dist/utility/logging/logRotation.js.map +1 -0
  196. package/dist/utility/logging/logRotator.d.ts +1 -1
  197. package/dist/utility/logging/logRotator.js +172 -92
  198. package/dist/utility/logging/logRotator.js.map +1 -1
  199. package/dist/utility/npmUtilities.js +6 -4
  200. package/dist/utility/npmUtilities.js.map +1 -1
  201. package/dist/utility/watcherFallback.d.ts +0 -45
  202. package/dist/utility/watcherFallback.js +1 -125
  203. package/dist/utility/watcherFallback.js.map +1 -1
  204. package/dist/validation/configValidator.js +6 -3
  205. package/dist/validation/configValidator.js.map +1 -1
  206. package/index.ts +6 -0
  207. package/json/systemSchema.json +3 -0
  208. package/npm-shrinkwrap.json +131 -41
  209. package/package.json +10 -3
  210. package/resources/DESIGN.md +124 -19
  211. package/resources/DatabaseTransaction.ts +230 -17
  212. package/resources/LMDBTransaction.ts +21 -3
  213. package/resources/PrimaryRocksDatabase.ts +20 -7
  214. package/resources/RecordEncoder.ts +25 -5
  215. package/resources/Resource.ts +97 -13
  216. package/resources/ResourceInterface.ts +8 -0
  217. package/resources/RocksIndexStore.ts +2 -1
  218. package/resources/RocksTransactionLogStore.ts +111 -31
  219. package/resources/Table.ts +1224 -393
  220. package/resources/analytics/write.ts +10 -3
  221. package/resources/auditStore.ts +460 -11
  222. package/resources/dataLoader.ts +3 -4
  223. package/resources/databases.ts +610 -146
  224. package/resources/derivedIndexRegistry.ts +56 -0
  225. package/resources/derivedIndexRuntime.ts +2292 -0
  226. package/resources/graphql.ts +3 -2
  227. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +905 -46
  228. package/resources/indexes/hnswDerivedIndex.ts +531 -0
  229. package/resources/indexes/hnswPlaneBinding.ts +174 -0
  230. package/resources/nodeIdMapping.ts +50 -0
  231. package/resources/recordLock.ts +173 -7
  232. package/resources/recordLockCoordinator.ts +3043 -0
  233. package/resources/replayLogs.ts +5 -0
  234. package/resources/replicatedApplyFailure.ts +77 -0
  235. package/resources/scheduler/scheduler.ts +4 -4
  236. package/resources/search.ts +169 -49
  237. package/resources/tracked.ts +73 -22
  238. package/security/jsLoader.ts +6 -4
  239. package/server/DESIGN.md +11 -0
  240. package/server/REST.ts +36 -3
  241. package/server/http.ts +34 -2
  242. package/server/serverHelpers/Headers.ts +5 -1
  243. package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
  244. package/server/serverHelpers/Request.ts +33 -131
  245. package/server/serverHelpers/contentTypes.ts +188 -15
  246. package/server/serverHelpers/serverUtilities.ts +143 -24
  247. package/server/storageReclamation.ts +2 -2
  248. package/server/threads/isolatedApplications.ts +157 -0
  249. package/server/threads/logRotationTransport.ts +40 -0
  250. package/server/threads/manageThreads.js +254 -12
  251. package/server/threads/socketRouter.ts +217 -11
  252. package/server/threads/threadServer.js +30 -7
  253. package/studio/web/assets/{Chat-BnCBegQz.js → Chat-D3j-1yY1.js} +1 -1
  254. package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-BxJGYcfB.js} +3 -3
  255. package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-CT55oWOe.js} +1 -1
  256. package/studio/web/assets/{applications-DHxGi7JH.js → applications-D9Ct9_vm.js} +1 -1
  257. package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-DV8H7VwA.js} +1 -1
  258. package/studio/web/assets/{editor-DNcRHK54.js → editor-uatc0unt.js} +1 -1
  259. package/studio/web/assets/{html-Bdssedlg.js → html-Bm6D6paN.js} +1 -1
  260. package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CEn7tpLG.js} +1 -1
  261. package/studio/web/assets/{index-D6sxmFLR.js → index-BIXW6Pu4.js} +5 -5
  262. package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-UI7L-Vrk.js} +1 -1
  263. package/studio/web/assets/{javascript-B8meVSTH.js → javascript-CJ0G3AFZ.js} +1 -1
  264. package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-DQADAYEa.js} +1 -1
  265. package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-CAQJXWcI.js} +1 -1
  266. package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-CCQ8P5sY.js} +1 -1
  267. package/studio/web/assets/{notifications-CQf18QKb.js → notifications-BbxTU6Aw.js} +1 -1
  268. package/studio/web/assets/{notifications-CvZivSbh.js → notifications-Cvb3P1lB.js} +1 -1
  269. package/studio/web/assets/{profile-DdOwtntb.js → profile-Yyb7gsvL.js} +1 -1
  270. package/studio/web/assets/{regions-n69fwagr.js → regions-OgjGHlU5.js} +1 -1
  271. package/studio/web/assets/{register-PfWTCXWB.js → register-6qwNEOY3.js} +2 -2
  272. package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BilDMtgB.js} +1 -1
  273. package/studio/web/assets/{setup-CUx_aUDl.js → setup-J6qJ7OIU.js} +2 -2
  274. package/studio/web/assets/{status-D7BVKqX9.js → status-0RWGcfyD.js} +1 -1
  275. package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-BIn-vErT.js} +1 -1
  276. package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-DgUXku4d.js} +1 -1
  277. package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-C9orXcsM.js} +1 -1
  278. package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-BEoXXbUB.js} +1 -1
  279. package/studio/web/assets/{workers-pR3jRY9D.js → workers-JVzSDmgx.js} +1 -1
  280. package/studio/web/assets/{xml-2iRnMhQO.js → xml-Cq-S8S4X.js} +1 -1
  281. package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-sfoRdh1M.js} +1 -1
  282. package/studio/web/index.html +1 -1
  283. package/utility/errors/hdbError.ts +54 -0
  284. package/utility/hdbTerms.ts +2 -0
  285. package/utility/logging/harper_logger.ts +209 -30
  286. package/utility/logging/logGenerationCoordinator.ts +196 -0
  287. package/utility/logging/logRotation.ts +367 -0
  288. package/utility/logging/logRotator.ts +196 -91
  289. package/utility/npmUtilities.ts +6 -4
  290. package/utility/watcherFallback.ts +0 -122
  291. package/validation/configValidator.ts +6 -3
@@ -2,6 +2,7 @@
2
2
 
3
3
  const path = require('node:path');
4
4
  const { isMainThread, parentPort } = require('node:worker_threads');
5
+ const { isDeepStrictEqual } = require('node:util');
5
6
  const fs = require('fs-extra');
6
7
  const fg = require('fast-glob');
7
8
  const normalize = require('normalize-path');
@@ -48,6 +49,7 @@ const {
48
49
  const { ProgressEmitter } = require('../server/serverHelpers/progressEmitter.ts');
49
50
 
50
51
  const DROP_COMPONENT_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
52
+ const ISOLATED_TOPOLOGY_REQUEST_TIMEOUT_MS = 5000;
51
53
 
52
54
  function componentDropLockOptions(project) {
53
55
  return {
@@ -588,13 +590,67 @@ async function deployComponent(req) {
588
590
  // replicated ciphertext (reference, not embed); already-reference entries pass through, and with
589
591
  // no custody a literal token stays as a transient, this-node-only fallback (#1158). Peers
590
592
  // re-running a replicated deploy already carry references and never re-ingest.
593
+ const { isIsolatedApplication } = require('../server/threads/isolatedApplications.ts');
594
+ const requestedIsolation = req.isolated;
595
+ const isReplicatedExecution = typeof req._deploymentId === 'string';
596
+ const mode = req.deployment_id ? 'activate' : req.activate === false ? 'stage' : 'deploy';
597
+ const isActivation = mode === 'activate';
598
+ // this thread's cached config may predate an earlier deploy that changed the entry without a restart
599
+ env.initSync(true);
600
+ const initiallyIsolated = isIsolatedApplication(req.project);
601
+ if (requestedIsolation !== undefined && !req.package) {
602
+ throw handleHDBError(
603
+ new Error(),
604
+ `'isolated' is only supported for package deployments; set it on the application's root config entry instead`,
605
+ HTTP_STATUS_CODES.BAD_REQUEST
606
+ );
607
+ }
608
+ // Only the originating node rejects admission: replicated peers must commit the same desired config,
609
+ // then report any node-local inability to run it through component lifecycle status.
610
+ const assertIsolationAdmission = async (isolated) => {
611
+ if (!isolated || isReplicatedExecution) return;
612
+ const {
613
+ isolatedApplicationRefusal,
614
+ isolatedApplicationCapacityRefusal,
615
+ presentIsolatedApplicationNames,
616
+ } = require('../server/threads/isolatedApplications.ts');
617
+ let runningApplications;
618
+ try {
619
+ runningApplications = await manageThreads.getRunningIsolatedApplications(ISOLATED_TOPOLOGY_REQUEST_TIMEOUT_MS);
620
+ } catch (error) {
621
+ throw handleHDBError(
622
+ error,
623
+ `Cannot deploy '${req.project}' as an isolated application: the main thread's worker topology is unavailable: ${error.message}`,
624
+ HTTP_STATUS_CODES.SERVICE_UNAVAILABLE
625
+ );
626
+ }
627
+ runningApplications = new Set([
628
+ ...runningApplications,
629
+ ...presentIsolatedApplicationNames().filter((application) => !isolatedApplicationRefusal(application)),
630
+ ]);
631
+ const refusal =
632
+ isolatedApplicationRefusal(req.project) ?? isolatedApplicationCapacityRefusal(req.project, runningApplications);
633
+ if (refusal) {
634
+ throw handleHDBError(
635
+ new Error(),
636
+ `Cannot deploy '${req.project}' as an isolated application: ${refusal}`,
637
+ HTTP_STATUS_CODES.CONFLICT
638
+ );
639
+ }
640
+ };
641
+ // Skipped for an activation: the artifact's descriptor is the authority for the isolation it wants, and
642
+ // `admitIsolation` runs the real check under the preparation lock. Admitting the CURRENT value here would
643
+ // refuse an activation whose artifact turns isolation OFF for a component whose present isolated state is
644
+ // itself the refusal — a 409 for the request that would fix it.
645
+ if (!isActivation) await assertIsolationAdmission(requestedIsolation ?? initiallyIsolated);
591
646
  const { ingestCredentials, resolveCredentials } = require('./secretOperations.ts');
592
- req.credentials = await ingestCredentials(req, req.credentials, req.project);
647
+ // An activation resolves, fetches and installs nothing, so there is no credential for it to carry.
648
+ // The validator rejects one; this keeps the ingest itself off the path rather than relying on that.
649
+ if (!isActivation) req.credentials = await ingestCredentials(req, req.credentials, req.project);
593
650
  // References are safe to persist (config + deployment row) and replicate; a no-custody literal
594
651
  // token is not — it is used only for this node's install below, then stripped before replication.
595
652
  const credentialReferences = (req.credentials ?? []).filter((entry) => entry && entry.secret !== undefined);
596
653
 
597
- // Write to root config if the request contains a package identifier
598
654
  if (req.package) {
599
655
  // Check if trying to overwrite a core component (requires force)
600
656
  // Lazy-load to avoid circular dependency with componentLoader
@@ -606,29 +662,9 @@ async function deployComponent(req) {
606
662
  HTTP_STATUS_CODES.CONFLICT
607
663
  );
608
664
  }
609
-
610
- const applicationConfig = { package: req.package };
611
- // Avoid writing an empty `install:` block
612
- if (req.install_command || req.install_timeout || req.install_allow_scripts !== undefined) {
613
- applicationConfig.install = {
614
- command: req.install_command,
615
- timeout: req.install_timeout,
616
- allowInstallScripts: req.install_allow_scripts,
617
- };
618
- }
619
- if (req.urlPath !== undefined) applicationConfig.urlPath = req.urlPath;
620
- if (req.host !== undefined) applicationConfig.host = req.host;
621
- // Same convention as host/urlPath above, and for the same reason: a package redeploy rebuilds
622
- // this application's whole root-config entry from request fields, so a deployment-level key that
623
- // isn't re-supplied here is silently dropped -- for branchedDatabases specifically, that means an
624
- // application which believed it had a private fork resumes sharing the base after the next
625
- // restart, with nothing in this operation to say so.
626
- if (req.branchedDatabases !== undefined) applicationConfig.branchedDatabases = req.branchedDatabases;
627
- // Persist credential references (never tokens) so every cold install of this component —
628
- // reboot, new peer, rollback — re-resolves the credential from the store.
629
- if (credentialReferences.length) applicationConfig.credentials = credentialReferences;
630
- await configUtils.addConfig(req.project, applicationConfig);
631
665
  }
666
+ let wasIsolated;
667
+ let nowIsolated;
632
668
 
633
669
  // Create a hdb_deployment row up front so the deploy is observable and auditable
634
670
  // even if the CLI disconnects. The row also holds the payload in a Blob attribute,
@@ -638,7 +674,6 @@ async function deployComponent(req) {
638
674
  // recording so we don't accumulate one row per node for the same deploy. The row
639
675
  // reaches peers via the table's standard replication; the peer-side branch below
640
676
  // reads payload_blob back from there.
641
- const isReplicatedExecution = typeof req._deploymentId === 'string';
642
677
  // An SSE-bound caller already attached a ProgressEmitter (created in the server
643
678
  // handler so it can also drive the response stream). Reuse it; otherwise spin up a
644
679
  // fresh emitter so the recorder still gets phase events for non-SSE deploys.
@@ -650,6 +685,7 @@ async function deployComponent(req) {
650
685
  project: req.project,
651
686
  package_identifier: req.package ?? null,
652
687
  user: req.hdb_user?.username,
688
+ activated_from: req.deployment_id ?? null,
653
689
  restart_mode: req.restart === 'rolling' ? 'rolling' : req.restart ? 'immediate' : null,
654
690
  // Reference form only — the rollback source for re-resolving the credential.
655
691
  credentials: credentialReferences.length ? credentialReferences : null,
@@ -674,12 +710,18 @@ async function deployComponent(req) {
674
710
  // Bounded ring buffer of install stdout/stderr so a non-SSE caller sees the tail
675
711
  // in the thrown error. SSE callers still stream every line live.
676
712
  const installCapture = createInstallCapture();
713
+ let stagedOnOrigin = false;
677
714
  try {
678
715
  // On the origin, tee the tarball (Buffer or Readable from the multipart parser)
679
716
  // through a hash-and-size tap into the row's payload_blob, then re-source extraction
680
717
  // from the persisted blob. When `system` replicates, the blob becomes the channel
681
718
  // peers read from; when it doesn't, the blob stays local for audit and rollback.
682
- if (recorder && req.payload != null) {
719
+ if (isActivation) {
720
+ // A replicated activation has exactly the shape the peer-side blob branch below keys on — neither
721
+ // `package` nor `payload` — so without this branch every peer would wait out `deployment_timeout`
722
+ // for a payload its row never had while the origin swapped successfully.
723
+ extractionPayload = undefined;
724
+ } else if (recorder && req.payload != null) {
683
725
  await recorder.ingestPayload(req.payload);
684
726
  extractionPayload = recorder.row.payload_blob.stream();
685
727
  } else if (isReplicatedExecution && req.payload == null && !req.package) {
@@ -715,9 +757,13 @@ async function deployComponent(req) {
715
757
  if (isReplicatedExecution) {
716
758
  credentialsWaitMs = coerceTimeoutMs(req.deployment_timeout, DEFAULT_AWAIT_ROW_TIMEOUT_MS);
717
759
  }
718
- const resolvedCredentials = await resolveCredentials(req.credentials, req.project, {
719
- waitMs: credentialsWaitMs,
720
- });
760
+ // Skipped for an activation, which runs no pack, clone or install: waiting for an hdb_secret row to
761
+ // replicate in would only delay a swap that has nothing to authenticate to.
762
+ const resolvedCredentials = isActivation
763
+ ? undefined
764
+ : await resolveCredentials(req.credentials, req.project, {
765
+ waitMs: credentialsWaitMs,
766
+ });
721
767
 
722
768
  const application = new Application({
723
769
  name: req.project,
@@ -755,12 +801,90 @@ async function deployComponent(req) {
755
801
  // committed" — so a later failure arrives after both phases reported success. The operation's error
756
802
  // is the authority on whether the deploy landed, not the phase stream.
757
803
  emit('phase', { phase: 'prepare', status: 'start' });
804
+ // The root-config entry a package deploy publishes. A stage records it with the artifact instead of
805
+ // publishing it, so a staged release nobody activated cannot leave config naming it — and the
806
+ // activation that eventually publishes it does so in the same order a normal deploy does.
807
+ let stagedRootConfig = null;
758
808
  await prepareApplication(application, {
809
+ // `.deploy-staging/<artifactId>`. The public deployment id, so the id the caller was handed is
810
+ // the id a later `deployment_id` request can name; an activation names the artifact's own id,
811
+ // not the new row's.
812
+ artifactId: req.deployment_id ?? req._deploymentId,
813
+ mode,
814
+ describeArtifact: () => ({ rootConfig: stagedRootConfig, isolated: Boolean(nowIsolated) }),
815
+ // Returns its own undo. Publication happens BEFORE the swap, in the same order a normal deploy
816
+ // uses, so an activation that fails pre-commit would otherwise leave config naming a release that
817
+ // is not live — and `installApplications()` would resolve and install that package from scratch at
818
+ // the next boot, over a component whose certified artifact is sitting right there. Restoring is
819
+ // best-effort and not durable (that is #2315 step 3's work); it is still the difference between a
820
+ // failed activation that changed nothing and one that re-points the component.
821
+ publishRootConfig: async (entry) => {
822
+ const previous = configUtils.getConfigObj()?.[req.project];
823
+ await configUtils.addConfig(req.project, entry);
824
+ env.initSync(true);
825
+ // Read BACK, not `entry` — see `publishedEntryStillStands`.
826
+ const published = configUtils.getConfigObj()?.[req.project];
827
+ return async () => {
828
+ if (
829
+ !publishedEntryStillStands(
830
+ published,
831
+ () => env.initSync(true),
832
+ () => configUtils.getConfigObj()?.[req.project]
833
+ )
834
+ ) {
835
+ return;
836
+ }
837
+ if (previous === undefined) configUtils.deleteConfigFromFile([req.project]);
838
+ else await configUtils.addConfig(req.project, previous);
839
+ env.initSync(true);
840
+ };
841
+ },
842
+ admitIsolation: async (descriptor) => {
843
+ env.initSync(true);
844
+ wasIsolated = isIsolatedApplication(req.project);
845
+ // A package artifact restores its own root-config entry, so its recorded intent is what will
846
+ // be in force. A payload artifact publishes no config, so the effective configuration is the
847
+ // only authority there — a saved `false` must not admit past an isolation the operator set
848
+ // between the stage and this call.
849
+ nowIsolated = descriptor.rootConfig ? descriptor.isolated : wasIsolated;
850
+ await assertIsolationAdmission(nowIsolated);
851
+ },
852
+ beforePrepare: async () => {
853
+ if (isActivation) return;
854
+ env.initSync(true);
855
+ wasIsolated = isIsolatedApplication(req.project);
856
+ nowIsolated = requestedIsolation ?? wasIsolated;
857
+ await assertIsolationAdmission(nowIsolated);
858
+ if (!isReplicatedExecution && req.package) req.isolated = nowIsolated;
859
+ if (!req.package) return;
860
+ const applicationConfig = { package: req.package };
861
+ if (req.install_command || req.install_timeout || req.install_allow_scripts !== undefined) {
862
+ applicationConfig.install = {
863
+ command: req.install_command,
864
+ timeout: req.install_timeout,
865
+ allowInstallScripts: req.install_allow_scripts,
866
+ };
867
+ }
868
+ if (req.urlPath !== undefined) applicationConfig.urlPath = req.urlPath;
869
+ if (req.host !== undefined) applicationConfig.host = req.host;
870
+ if (req.branchedDatabases !== undefined) applicationConfig.branchedDatabases = req.branchedDatabases;
871
+ if (nowIsolated) applicationConfig.isolated = true;
872
+ if (credentialReferences.length) applicationConfig.credentials = credentialReferences;
873
+ if (mode === 'stage') {
874
+ stagedRootConfig = applicationConfig;
875
+ return;
876
+ }
877
+ await configUtils.addConfig(req.project, applicationConfig);
878
+ env.initSync(true);
879
+ },
759
880
  validateCandidate: async (candidateDirPath) => {
760
881
  emit('phase', { phase: 'prepare', status: 'done' });
761
882
  await validateComponentLoads(candidateDirPath, emit);
762
883
  },
763
884
  });
885
+ // The build is certified on disk from here on, so every later failure — a peer result, or a rejection
886
+ // thrown by the replication layer itself — still leaves an artifact this id can activate.
887
+ if (mode === 'stage') stagedOnOrigin = true;
764
888
  const rollingRestart = req.restart === 'rolling';
765
889
  // if doing a rolling restart set restart to false so that other nodes don't also restart.
766
890
  req.restart = rollingRestart ? false : req.restart;
@@ -803,7 +927,18 @@ async function deployComponent(req) {
803
927
  // when the per-peer callback already fired for these.
804
928
  recorder.recordPeers(response.replicated);
805
929
  }
806
- if (req.restart === true) {
930
+ // A still-isolated application restarts only its own dedicated worker. Everything else restarts the
931
+ // pool: a shared application, and either direction of an isolation flip, where the reconcile in
932
+ // restartWorkers starts or stops the moving application's own worker.
933
+ const restartScope = wasIsolated && nowIsolated ? application.name : undefined;
934
+ if (mode === 'stage') {
935
+ // No restart and no restart-required flag: nothing about the running component changed. The
936
+ // marker is what tells the origin which peers understood the request — see the confirmation
937
+ // check below — and it is set on every node, origin included, since a peer returns this same
938
+ // object as its operation response.
939
+ response.staged = true;
940
+ response.message = `Staged: ${application.name}`;
941
+ } else if (req.restart === true) {
807
942
  emit('phase', { phase: 'restart', status: 'start' });
808
943
  // Workers not yet replaced keep serving the pre-deploy component set, and where the OS lets
809
944
  // replacements share a port they keep accepting connections for the whole rolling restart, so
@@ -811,7 +946,7 @@ async function deployComponent(req) {
811
946
  // never heard of it.
812
947
  const { awaitRestart } = require('./awaitRestart.ts');
813
948
  const restart = await awaitRestart((onProgress) =>
814
- manageThreads.restartWorkers('http', undefined, undefined, onProgress)
949
+ manageThreads.restartWorkers('http', undefined, undefined, onProgress, restartScope)
815
950
  );
816
951
  emit('phase', { phase: 'restart', status: 'done' });
817
952
  logRestartOutcome(restart, `deploying ${application.name}`);
@@ -822,6 +957,8 @@ async function deployComponent(req) {
822
957
  const jobResponse = await serverUtilities.executeJob({
823
958
  operation: 'restart_service',
824
959
  service: 'http',
960
+ scope: manageThreads.encodeRestartScope(restartScope),
961
+ scopeFallback: restartScope === undefined ? undefined : manageThreads.encodeRestartScope(undefined),
825
962
  replicated: true,
826
963
  });
827
964
  emit('phase', { phase: 'restart', status: 'done' });
@@ -841,10 +978,13 @@ async function deployComponent(req) {
841
978
  // An existing component's watched files are handled by Scope/EntryHandler. Package
842
979
  // metadata is deliberately outside most plugin globs, so compare it across the atomic
843
980
  // swap as well: a dependency or module-entry change also invalidates loaded code.
844
- if (application.isNewComponent || application.packageMetadataChanged) {
845
- const { requestRestart } = require('./requestRestart.ts');
846
- requestRestart();
847
- }
981
+ const { requestRestartAfterDeploy } = require('./requestRestart.ts');
982
+ requestRestartAfterDeploy(
983
+ application.isNewComponent,
984
+ application.packageMetadataChanged,
985
+ wasIsolated,
986
+ nowIsolated
987
+ );
848
988
  response.message = `Successfully deployed: ${application.name}`;
849
989
  }
850
990
 
@@ -860,12 +1000,37 @@ async function deployComponent(req) {
860
1000
  const detail = failedPeers
861
1001
  .map((peer) => `${peer.node ?? 'unknown'} (${peer.error?.message ?? 'unknown error'})`)
862
1002
  .join(', ');
863
- throw new ServerError(
864
- `Component '${application.name}' was deployed on the origin node but failed to replicate to ` +
865
- `${failedPeers.length} of ${recorder.row.peer_results.length} peer node(s): ${detail}. ` +
866
- `See deployment ${recorder.deploymentId} (get_deployment) for details, or pass ` +
867
- `ignore_replication_errors: true to treat replication failures as non-fatal.`
1003
+ const replicationError = new ServerError(
1004
+ `Component '${application.name}' was ${mode === 'stage' ? 'staged' : 'deployed'} on the origin node ` +
1005
+ `but failed to replicate to ${failedPeers.length} of ${recorder.row.peer_results.length} peer ` +
1006
+ `node(s): ${detail}. See deployment ${recorder.deploymentId} (get_deployment) for details, or ` +
1007
+ `pass ignore_replication_errors: true to treat replication failures as non-fatal.`
868
1008
  );
1009
+ throw replicationError;
1010
+ }
1011
+
1012
+ // A peer running a build that predates staged deploys accepts `activate: false` as an unknown
1013
+ // field and performs its ordinary deploy, so it is now serving a release the operator asked only
1014
+ // to stage. Nothing can prevent that — the operations channel carries no peer version or
1015
+ // capability — so the origin reports it, from the one signal that separates the two cases: a node
1016
+ // that staged says so in its response. Read from the raw aggregate, because the recorder's peer
1017
+ // normalization keeps only node/status/error.
1018
+ //
1019
+ // The wording stays at "did not confirm". An unreachable peer and one that dropped the request
1020
+ // look identical from here, and telling an operator those nodes went live would be a guess.
1021
+ if (mode === 'stage') {
1022
+ const unconfirmed = unconfirmedStagingPeers(response?.replicated);
1023
+ if (unconfirmed.length > 0) {
1024
+ const detail = unconfirmed.map((peer) => peer.node ?? 'unknown').join(', ');
1025
+ const unconfirmedError = new ServerError(
1026
+ `Component '${application.name}' was staged on the origin node, but ${unconfirmed.length} peer ` +
1027
+ `node(s) did not confirm staging: ${detail}. Either they are unreachable, or they run a build ` +
1028
+ `that predates staged deploys — which treats this request as an ordinary deploy, so the ` +
1029
+ `release is already serving there. Check those nodes before activating deployment ` +
1030
+ `${recorder.deploymentId}, or pass ignore_replication_errors: true to accept the difference.`
1031
+ );
1032
+ throw unconfirmedError;
1033
+ }
869
1034
  }
870
1035
  }
871
1036
 
@@ -879,14 +1044,28 @@ async function deployComponent(req) {
879
1044
  // the tarball when it's still the artifact you'd debug or retry with: failed deploys
880
1045
  // don't reach this branch, and a deploy that reached here only because
881
1046
  // ignore_replication_errors masked failed peers keeps its payload for those peers.
1047
+ // Never for a stage: the artifact on disk is not yet a release anyone can re-derive, and retention
1048
+ // can evict it (it shares `deployment_stagingRetention_maxCount` with incidental dormant builds),
1049
+ // so dropping the tarball here is how a staged release becomes unrecoverable — no tree, no bytes,
1050
+ // and a row still reporting `staged`. An operator can still reclaim it deliberately with
1051
+ // delete_deployment_payload once they accept that.
882
1052
  const payloadSize = recorder.row.payload_size;
883
1053
  const retentionMaxSize = getPayloadRetentionMaxSize();
884
- if (typeof payloadSize === 'number' && payloadSize > retentionMaxSize && recorder.getFailedPeers().length === 0) {
1054
+ if (
1055
+ mode !== 'stage' &&
1056
+ typeof payloadSize === 'number' &&
1057
+ payloadSize > retentionMaxSize &&
1058
+ recorder.getFailedPeers().length === 0
1059
+ ) {
885
1060
  const freed = recorder.dropPayload();
886
1061
  if (freed > 0) emit('payload_dropped', { payload_size: freed, max_size: retentionMaxSize });
887
1062
  }
888
- emit('phase', { phase: 'success', status: 'done' });
889
- await recorder.finish('success');
1063
+ emit('phase', { phase: mode === 'stage' ? 'staged' : 'success', status: 'done' });
1064
+ await recorder.finish(mode === 'stage' ? 'staged' : 'success');
1065
+ if (mode === 'stage') {
1066
+ response.message =
1067
+ `Staged: ${application.name}. Deploy it with deploy_component ` + `deployment_id=${recorder.deploymentId}`;
1068
+ }
890
1069
  }
891
1070
  return response;
892
1071
  } catch (err) {
@@ -927,7 +1106,7 @@ async function deployComponent(req) {
927
1106
  // install output, and failed_peers the caller needs.
928
1107
  if (recorder) {
929
1108
  try {
930
- await recorder.finish('failed', err);
1109
+ await recorder.finish(stagedOnOrigin ? 'staged' : 'failed', err);
931
1110
  } catch (finishErr) {
932
1111
  log.warn('Failed to record deployment failure row', finishErr);
933
1112
  }
@@ -936,6 +1115,43 @@ async function deployComponent(req) {
936
1115
  }
937
1116
  }
938
1117
 
1118
+ /**
1119
+ * Whether the root-config entry an activation published is still the one on disk — the condition for that
1120
+ * activation's failure to take it back. A `set_configuration` acknowledged while the swap was retrying is
1121
+ * not this operation's to overwrite, and restoring a snapshot taken before it would silently drop it.
1122
+ *
1123
+ * Split out from the caller so it can be tested without a deploy: this is the only guard on a hazard the
1124
+ * preparation lock does not cover, and both ways it can regress fail silently. `refreshConfig` is called
1125
+ * BEFORE the read and is load-bearing — `set_configuration` writes the config file without refreshing this
1126
+ * process's config object, so a cached read compares against a value that predates the very change the
1127
+ * guard exists to protect and always concludes nothing moved. It narrows the window rather than closing
1128
+ * it; serializing config publication is #2315 step 3.
1129
+ *
1130
+ * `published` must be the entry as read back after publication, not as passed in: only two values that went
1131
+ * through the same write-and-parse round trip are comparable, and comparing against the argument would read
1132
+ * any serialization difference as a concurrent change and skip every undo.
1133
+ */
1134
+ function publishedEntryStillStands(published, refreshConfig, readCurrentEntry) {
1135
+ refreshConfig();
1136
+ return isDeepStrictEqual(readCurrentEntry(), published);
1137
+ }
1138
+
1139
+ /**
1140
+ * Peers that did not answer a stage with `staged: true` — either unreachable, or running a build that
1141
+ * predates staged deploys and therefore treated the request as an ordinary deploy.
1142
+ *
1143
+ * Split out from the caller so it can be tested without a cluster: this is the only safety net for the
1144
+ * mixed-version hazard #2315 records as accepted, and a regression in it fails silently. The entry shape
1145
+ * belongs to harper-pro's replicator rather than this repo — `normalizePeerResult` already tolerates more
1146
+ * than one — so the marker is read flat or from a wrapped body; assuming flat would report every peer in a
1147
+ * fully-upgraded cluster as unconfirmed.
1148
+ */
1149
+ function unconfirmedStagingPeers(replicated) {
1150
+ if (!Array.isArray(replicated)) return [];
1151
+ const confirmed = (peer) => peer?.staged === true || peer?.value?.staged === true || peer?.body?.staged === true;
1152
+ return replicated.filter((peer) => peer && !confirmed(peer));
1153
+ }
1154
+
939
1155
  // Ring buffer of install stdout/stderr lines, capped by both line count and bytes so
940
1156
  // a chatty install can't unbounded-grow the error response. snapshot() reports whether
941
1157
  // the head was dropped so callers can flag truncation.
@@ -1311,13 +1527,29 @@ async function dropComponent(req) {
1311
1527
  const componentsRoot = configUtils.getConfigPath(hdbTerms.CONFIG_PARAMS.COMPONENTSROOT);
1312
1528
  const componentPath = path.join(componentsRoot, project);
1313
1529
  const pathToComponent = path.join(componentsRoot, projectPath);
1530
+ let restartScope;
1314
1531
 
1315
1532
  let response;
1316
1533
  await withComponentPreparationLock(
1317
1534
  componentPath,
1318
1535
  async () => {
1536
+ if (req.restart === true) {
1537
+ let runningApplications;
1538
+ try {
1539
+ runningApplications = await manageThreads.getRunningIsolatedApplications(
1540
+ ISOLATED_TOPOLOGY_REQUEST_TIMEOUT_MS
1541
+ );
1542
+ } catch (error) {
1543
+ throw handleHDBError(
1544
+ error,
1545
+ `Cannot drop '${project}' with a restart: the main thread's worker topology is unavailable: ${error.message}`,
1546
+ HTTP_STATUS_CODES.SERVICE_UNAVAILABLE
1547
+ );
1548
+ }
1549
+ if (runningApplications.includes(project)) restartScope = project;
1550
+ }
1319
1551
  const componentSymlink = path.join(env.get(hdbTerms.CONFIG_PARAMS.ROOTPATH), 'node_modules', project);
1320
- if (await fs.pathExists(componentSymlink)) {
1552
+ if (!file && (await fs.pathExists(componentSymlink))) {
1321
1553
  await fs.unlink(componentSymlink);
1322
1554
  }
1323
1555
 
@@ -1328,7 +1560,7 @@ async function dropComponent(req) {
1328
1560
  }
1329
1561
 
1330
1562
  const packageJsonPath = path.join(env.get(hdbTerms.CONFIG_PARAMS.ROOTPATH), 'package.json');
1331
- if (await fs.pathExists(packageJsonPath)) {
1563
+ if (!file && (await fs.pathExists(packageJsonPath))) {
1332
1564
  const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
1333
1565
  if (packageJson?.dependencies?.[project]) {
1334
1566
  delete packageJson.dependencies[project];
@@ -1336,11 +1568,8 @@ async function dropComponent(req) {
1336
1568
  await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf8');
1337
1569
  }
1338
1570
 
1339
- configUtils.deleteConfigFromFile([project]);
1340
- // The main thread's cached config still names the project; the restart below installs every
1341
- // package application in that cache, and installing this one would take the lock held here.
1342
- // (A worker's RESTART message refreshes main's config the same way.)
1343
- if (isMainThread) env.initSync(true);
1571
+ if (!file) configUtils.deleteConfigFromFile([project]);
1572
+ if (!file && isMainThread) env.initSync(true);
1344
1573
  response = await server.replication.replicateOperation(req);
1345
1574
  const { applicationHasBranchStorage, removeBranchesForApplication } = require('../resources/branchDatabase.ts');
1346
1575
  const branched = !file && applicationHasBranchStorage(project);
@@ -1356,6 +1585,7 @@ async function dropComponent(req) {
1356
1585
  parentPort.postMessage({
1357
1586
  type: hdbTerms.ITC_EVENT_TYPES.RESTART,
1358
1587
  workerType: 'http',
1588
+ scope: manageThreads.encodeRestartScope(restartScope),
1359
1589
  removeBranchesFor: file ? undefined : project,
1360
1590
  });
1361
1591
  if (branched) {
@@ -1366,7 +1596,7 @@ async function dropComponent(req) {
1366
1596
  }
1367
1597
  const { awaitRestart } = require('./awaitRestart.ts');
1368
1598
  const restart = await awaitRestart((onProgress) =>
1369
- manageThreads.restartWorkers('http', undefined, undefined, onProgress)
1599
+ manageThreads.restartWorkers('http', undefined, undefined, onProgress, restartScope)
1370
1600
  );
1371
1601
  logRestartOutcome(restart, `dropping ${projectPath}`);
1372
1602
  if (!branched) return;
@@ -1399,6 +1629,8 @@ exports.addComponent = addComponent;
1399
1629
  exports.dropCustomFunctionProject = dropCustomFunctionProject;
1400
1630
  exports.packageComponent = packageComponent;
1401
1631
  exports.deployComponent = deployComponent;
1632
+ exports.unconfirmedStagingPeers = unconfirmedStagingPeers;
1633
+ exports.publishedEntryStillStands = publishedEntryStillStands;
1402
1634
  exports.getComponents = getComponents;
1403
1635
  exports.getComponentFile = getComponentFile;
1404
1636
  exports.setComponentFile = setComponentFile;
@@ -29,6 +29,12 @@ const ENV_KEY_REGEX = /^[\w.-]+$/;
29
29
  const HOSTNAME_SCHEMA = Joi.string().hostname();
30
30
  const IPV6_SCHEMA = Joi.string().ip({ version: 'ipv6' });
31
31
 
32
+ // A deployment id names both an hdb_deployment row and a staging directory, so it is held to the shape
33
+ // the recorder mints — `randomUUID()`, which is lowercase. LOWERCASE ONLY, because a case-insensitive
34
+ // filesystem resolves an uppercase id to the real directory while every string compare against it fails:
35
+ // the retention pin would not recognise the artifact the request is about to activate, and would evict it.
36
+ const DEPLOYMENT_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
37
+
32
38
  module.exports = {
33
39
  getDropCustomFunctionValidator,
34
40
  setCustomFunctionValidator,
@@ -492,7 +498,32 @@ function deployComponentValidator(req) {
492
498
  .required()
493
499
  .messages({ 'string.pattern.base': HDB_ERROR_MSGS.BAD_PROJECT_NAME }),
494
500
  package: Joi.string().optional(),
495
- restart: Joi.alternatives().try(Joi.boolean(), Joi.string().valid('rolling')).optional(),
501
+ // `activate: false` builds and certifies the component, then stops, leaving an artifact a later
502
+ // request activates by `deployment_id`. STRICT: `validateBySchema` throws away Joi's converted
503
+ // value, so a coercing boolean would let `activate: "false"` validate here and then read as truthy
504
+ // in the handler — deploying the very release the caller asked not to make live.
505
+ activate: Joi.boolean().strict().optional(),
506
+ // Activate a build staged earlier by `activate: false`. It becomes a path segment
507
+ // (`.deploy-staging/<id>/<component>`), so the charset is pinned as tightly as the value it names.
508
+ deployment_id: Joi.string().pattern(DEPLOYMENT_ID_REGEX).optional().messages({
509
+ 'string.pattern.base': `'deployment_id' must be a UUID`,
510
+ }),
511
+ // Set by deployComponent from the origin's deployment row and carried to peers by replication.
512
+ // Validated because it is a path segment on EVERY deploy and unknown keys are admitted, so an
513
+ // operator-supplied one would otherwise reach the filesystem unchecked.
514
+ _deploymentId: Joi.string().pattern(DEPLOYMENT_ID_REGEX).optional().messages({
515
+ 'string.pattern.base': `'_deploymentId' must be a UUID`,
516
+ }),
517
+ restart: Joi.alternatives()
518
+ .try(Joi.boolean(), Joi.string().valid('rolling'))
519
+ .optional()
520
+ // Nothing goes live, so there is nothing to restart into.
521
+ .when('activate', {
522
+ is: false,
523
+ then: Joi.forbidden().messages({
524
+ 'any.unknown': `'restart' cannot be combined with 'activate: false': staging does not change the running component`,
525
+ }),
526
+ }),
496
527
  install_command: Joi.string().optional(),
497
528
  install_timeout: Joi.number().optional(),
498
529
  install_allow_scripts: Joi.boolean().optional(),
@@ -534,6 +565,7 @@ function deployComponentValidator(req) {
534
565
  // like urlPath/host above, not component config: reusing assertBranchedDatabases here (rather
535
566
  // than re-deriving its rules in Joi) gives a synchronous 400 at deploy time instead of a load
536
567
  // failure discovered much later and disconnected from the request that caused it.
568
+ isolated: Joi.boolean().optional(),
537
569
  branchedDatabases: Joi.custom((value, helpers) => {
538
570
  try {
539
571
  assertBranchedDatabases(req.project, value);
@@ -575,7 +607,22 @@ function deployComponentValidator(req) {
575
607
  }),
576
608
  })
577
609
  .with('urlPath', 'package')
578
- .with('host', 'package');
610
+ .with('host', 'package')
611
+ // An activation has no build inputs and takes no routing or packaging opinions: the artifact's own
612
+ // descriptor is the authority for what it publishes, and silently ignoring these would hide that.
613
+ .without('deployment_id', [
614
+ 'package',
615
+ 'payload',
616
+ 'activate',
617
+ 'install_command',
618
+ 'install_timeout',
619
+ 'install_allow_scripts',
620
+ 'credentials',
621
+ 'urlPath',
622
+ 'host',
623
+ 'branchedDatabases',
624
+ 'isolated',
625
+ ]);
579
626
 
580
627
  return validator.validateBySchema(req, deployProjSchema);
581
628
  }
@@ -1,4 +1,4 @@
1
- import { join, relative, sep } from 'node:path';
1
+ import { basename, join, relative, sep } from 'node:path';
2
2
  import { stat, readdir } from 'node:fs/promises';
3
3
  import { Readable, pipeline } from 'node:stream';
4
4
  import tar from 'tar-fs';
@@ -13,6 +13,27 @@ const DEFAULT_OPTIONS: PackageOptions = { skip_node_modules: false, skip_symlink
13
13
 
14
14
  const WEBPACK_CACHE_SEGMENT = join('cache', 'webpack');
15
15
 
16
+ /**
17
+ * `node_modules/harper` is Harper's, whatever is on disk there.
18
+ *
19
+ * Every non-root component load runs `symlinkHarperModule`, which does `rmSync(harperModule, {
20
+ * recursive: true, force: true })` and then links the running install (`components/componentLoader.ts`).
21
+ * It does that whether or not the path already existed, so component content there survives no load and
22
+ * packaging it cannot be preserving component source — while packaging it walks the whole install, since
23
+ * the packer dereferences symlinks and recurses into linked directories.
24
+ *
25
+ * `harperdb` is deliberately NOT here, and the asymmetry is the reason: the loader only *repairs*
26
+ * `node_modules/harperdb` when that path already exists. A legacy component shipping a real
27
+ * npm-installed `harperdb` that has never been loaded on this host therefore owns it — and excluding it
28
+ * would strip it from the tarball, after which `installApplication` skips installing (it returns early
29
+ * when `node_modules` exists), the loader's present-gate never fires, and `import from 'harperdb'`
30
+ * fails on the target — and that holds whether the path is skipped by name or by resolved identity,
31
+ * because an archive that simply OMITS it cannot restore it. So a `harperdb` link to the install is
32
+ * still followed. Closing that means packing a placeholder at the path so the loader's present-gate can
33
+ * fire on the target, not skipping it; tracked in harper#2577.
34
+ */
35
+ const INSTALL_OWNED_PATH = join('node_modules', 'harper');
36
+
16
37
  /**
17
38
  * Whether `fullPath` (an absolute path under `directory`) should be excluded from the package when
18
39
  * `skip_node_modules` is set. The path is first made relative to `directory`, so packaging a component
@@ -21,6 +42,9 @@ const WEBPACK_CACHE_SEGMENT = join('cache', 'webpack');
21
42
  * excluded the whole tree. Shared by the stream packer and the directory walk so they cannot diverge.
22
43
  */
23
44
  function isExcluded(directory: string, fullPath: string, options: PackageOptions): boolean {
45
+ // Unconditional, including under `skip_symlinks`: packed literally, the link ships an absolute path to
46
+ // the packaging host's install root, which arrives dangling.
47
+ if (basename(fullPath) === 'harper' && relative(directory, fullPath) === INSTALL_OWNED_PATH) return true;
24
48
  if (!options.skip_node_modules) return false;
25
49
  const rel = relative(directory, fullPath);
26
50
  return rel.split(sep).includes('node_modules') || rel.includes(WEBPACK_CACHE_SEGMENT);
@@ -24,6 +24,17 @@ export function requestRestart() {
24
24
  restartArrayBuffer.notify();
25
25
  }
26
26
 
27
+ export function requestRestartAfterDeploy(
28
+ isNewComponent: boolean,
29
+ packageMetadataChanged: boolean,
30
+ wasIsolated: boolean,
31
+ isIsolated: boolean
32
+ ): boolean {
33
+ if (!isNewComponent && !packageMetadataChanged && wasIsolated === isIsolated) return false;
34
+ requestRestart();
35
+ return true;
36
+ }
37
+
27
38
  export function restartNeeded() {
28
39
  ensureInitialized();
29
40
  return restartNeededArray[0] === 1;