@harperfast/harper 5.2.9 → 5.3.0-alpha.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 (668) hide show
  1. package/agent/mcpTools.ts +1 -1
  2. package/agent/session.ts +25 -14
  3. package/bin/cliOperations.ts +46 -9
  4. package/bin/copyDb.ts +282 -59
  5. package/bin/deploySetup.ts +16 -5
  6. package/bin/harper.ts +1 -1
  7. package/bin/help.ts +4 -1
  8. package/bin/lite.ts +4 -1
  9. package/bin/restart.ts +59 -3
  10. package/bin/run.ts +6 -11
  11. package/bin/upgrade.js +7 -3
  12. package/bin/workloadIdentity.ts +119 -0
  13. package/components/Application.ts +2414 -242
  14. package/components/ApplicationScope.ts +8 -0
  15. package/components/EntryHandler.ts +59 -39
  16. package/components/OptionsWatcher.ts +150 -74
  17. package/components/RuntimeModuleTracker.ts +38 -7
  18. package/components/Scope.ts +37 -15
  19. package/components/awaitRestart.ts +84 -0
  20. package/components/componentLoader.ts +340 -30
  21. package/components/componentPreparationLock.ts +16 -5
  22. package/components/mcp/adapters/harperHttp.ts +4 -0
  23. package/components/mcp/listChanged.ts +4 -0
  24. package/components/mcp/toolRegistry.ts +2 -0
  25. package/components/mcp/tools/operations.ts +9 -0
  26. package/components/mcp/tools/schemas/operationDescriptions.ts +2 -2
  27. package/components/operations.js +270 -78
  28. package/components/operationsValidation.js +49 -1
  29. package/components/status/ComponentStatusRegistry.ts +59 -0
  30. package/config/RootConfigWatcher.ts +80 -34
  31. package/config/configUtils.ts +291 -34
  32. package/config/harperConfigEnvVars.ts +170 -27
  33. package/config-root.schema.json +29 -0
  34. package/dataLayer/blobBackup.ts +160 -50
  35. package/dataLayer/delete.ts +6 -1
  36. package/dataLayer/harperBridge/ResourceBridge.ts +52 -8
  37. package/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  38. package/dataLayer/hdbInfoController.ts +34 -1
  39. package/dataLayer/insert.ts +44 -1
  40. package/dataLayer/rocksdbBackup.ts +53 -10
  41. package/dataLayer/schema.ts +11 -1
  42. package/dataLayer/schemaDescribe.ts +8 -1
  43. package/dist/agent/mcpTools.js +1 -1
  44. package/dist/agent/mcpTools.js.map +1 -1
  45. package/dist/agent/session.d.ts +22 -0
  46. package/dist/agent/session.js +26 -15
  47. package/dist/agent/session.js.map +1 -1
  48. package/dist/bin/cliOperations.js +48 -9
  49. package/dist/bin/cliOperations.js.map +1 -1
  50. package/dist/bin/copyDb.d.ts +12 -1
  51. package/dist/bin/copyDb.js +248 -60
  52. package/dist/bin/copyDb.js.map +1 -1
  53. package/dist/bin/deploySetup.d.ts +2 -0
  54. package/dist/bin/deploySetup.js +11 -3
  55. package/dist/bin/deploySetup.js.map +1 -1
  56. package/dist/bin/harper.js +1 -1
  57. package/dist/bin/harper.js.map +1 -1
  58. package/dist/bin/help.js +4 -1
  59. package/dist/bin/help.js.map +1 -1
  60. package/dist/bin/lite.js +4 -1
  61. package/dist/bin/lite.js.map +1 -1
  62. package/dist/bin/restart.js +54 -5
  63. package/dist/bin/restart.js.map +1 -1
  64. package/dist/bin/run.js +4 -10
  65. package/dist/bin/run.js.map +1 -1
  66. package/dist/bin/upgrade.js +4 -3
  67. package/dist/bin/upgrade.js.map +1 -1
  68. package/dist/bin/workloadIdentity.d.ts +18 -0
  69. package/dist/bin/workloadIdentity.js +100 -0
  70. package/dist/bin/workloadIdentity.js.map +1 -0
  71. package/dist/components/Application.d.ts +139 -16
  72. package/dist/components/Application.js +2215 -267
  73. package/dist/components/Application.js.map +1 -1
  74. package/dist/components/ApplicationScope.d.ts +8 -0
  75. package/dist/components/ApplicationScope.js +7 -0
  76. package/dist/components/ApplicationScope.js.map +1 -1
  77. package/dist/components/EntryHandler.js +26 -10
  78. package/dist/components/EntryHandler.js.map +1 -1
  79. package/dist/components/OptionsWatcher.d.ts +1 -0
  80. package/dist/components/OptionsWatcher.js +141 -74
  81. package/dist/components/OptionsWatcher.js.map +1 -1
  82. package/dist/components/RuntimeModuleTracker.js +40 -6
  83. package/dist/components/RuntimeModuleTracker.js.map +1 -1
  84. package/dist/components/Scope.js +38 -13
  85. package/dist/components/Scope.js.map +1 -1
  86. package/dist/components/awaitRestart.d.ts +33 -0
  87. package/dist/components/awaitRestart.js +61 -0
  88. package/dist/components/awaitRestart.js.map +1 -0
  89. package/dist/components/componentLoader.d.ts +38 -1
  90. package/dist/components/componentLoader.js +279 -22
  91. package/dist/components/componentLoader.js.map +1 -1
  92. package/dist/components/componentPreparationLock.d.ts +5 -0
  93. package/dist/components/componentPreparationLock.js +14 -6
  94. package/dist/components/componentPreparationLock.js.map +1 -1
  95. package/dist/components/mcp/adapters/harperHttp.js +4 -0
  96. package/dist/components/mcp/adapters/harperHttp.js.map +1 -1
  97. package/dist/components/mcp/listChanged.js +5 -0
  98. package/dist/components/mcp/listChanged.js.map +1 -1
  99. package/dist/components/mcp/toolRegistry.d.ts +1 -0
  100. package/dist/components/mcp/toolRegistry.js.map +1 -1
  101. package/dist/components/mcp/tools/operations.d.ts +5 -0
  102. package/dist/components/mcp/tools/operations.js +9 -0
  103. package/dist/components/mcp/tools/operations.js.map +1 -1
  104. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -2
  105. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  106. package/dist/components/operations.js +231 -77
  107. package/dist/components/operations.js.map +1 -1
  108. package/dist/components/operationsValidation.js +49 -1
  109. package/dist/components/operationsValidation.js.map +1 -1
  110. package/dist/components/status/ComponentStatusRegistry.d.ts +0 -4
  111. package/dist/components/status/ComponentStatusRegistry.js +63 -0
  112. package/dist/components/status/ComponentStatusRegistry.js.map +1 -1
  113. package/dist/config/RootConfigWatcher.d.ts +7 -1
  114. package/dist/config/RootConfigWatcher.js +64 -27
  115. package/dist/config/RootConfigWatcher.js.map +1 -1
  116. package/dist/config/configUtils.d.ts +9 -1
  117. package/dist/config/configUtils.js +254 -33
  118. package/dist/config/configUtils.js.map +1 -1
  119. package/dist/config/harperConfigEnvVars.d.ts +16 -0
  120. package/dist/config/harperConfigEnvVars.js +162 -25
  121. package/dist/config/harperConfigEnvVars.js.map +1 -1
  122. package/dist/dataLayer/blobBackup.d.ts +49 -20
  123. package/dist/dataLayer/blobBackup.js +139 -50
  124. package/dist/dataLayer/blobBackup.js.map +1 -1
  125. package/dist/dataLayer/delete.js +1 -1
  126. package/dist/dataLayer/delete.js.map +1 -1
  127. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +14 -1
  128. package/dist/dataLayer/harperBridge/ResourceBridge.js +51 -10
  129. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  130. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.d.ts +3 -1
  131. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js +3 -1
  132. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbMethods/DeleteAuditLogsBeforeResults.js.map +1 -1
  133. package/dist/dataLayer/hdbInfoController.d.ts +10 -0
  134. package/dist/dataLayer/hdbInfoController.js +30 -1
  135. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  136. package/dist/dataLayer/insert.d.ts +9 -1
  137. package/dist/dataLayer/insert.js +30 -0
  138. package/dist/dataLayer/insert.js.map +1 -1
  139. package/dist/dataLayer/rocksdbBackup.d.ts +2 -2
  140. package/dist/dataLayer/rocksdbBackup.js +45 -8
  141. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  142. package/dist/dataLayer/schema.js +8 -0
  143. package/dist/dataLayer/schema.js.map +1 -1
  144. package/dist/dataLayer/schemaDescribe.js +8 -1
  145. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  146. package/dist/index.d.ts +1 -1
  147. package/dist/index.js.map +1 -1
  148. package/dist/json/systemSchema.json +52 -0
  149. package/dist/resources/DatabaseTransaction.d.ts +55 -0
  150. package/dist/resources/DatabaseTransaction.js +459 -45
  151. package/dist/resources/DatabaseTransaction.js.map +1 -1
  152. package/dist/resources/LMDBTransaction.js +22 -4
  153. package/dist/resources/LMDBTransaction.js.map +1 -1
  154. package/dist/resources/PrimaryRocksDatabase.d.ts +1 -0
  155. package/dist/resources/PrimaryRocksDatabase.js +29 -0
  156. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  157. package/dist/resources/RecordEncoder.d.ts +20 -0
  158. package/dist/resources/RecordEncoder.js +94 -9
  159. package/dist/resources/RecordEncoder.js.map +1 -1
  160. package/dist/resources/RequestTarget.d.ts +2 -0
  161. package/dist/resources/RequestTarget.js.map +1 -1
  162. package/dist/resources/Resource.js +20 -11
  163. package/dist/resources/Resource.js.map +1 -1
  164. package/dist/resources/ResourceInterface.d.ts +20 -1
  165. package/dist/resources/ResourceInterface.js.map +1 -1
  166. package/dist/resources/RocksIndexStore.d.ts +6 -1
  167. package/dist/resources/RocksIndexStore.js +24 -9
  168. package/dist/resources/RocksIndexStore.js.map +1 -1
  169. package/dist/resources/RocksTransactionLogStore.d.ts +14 -1
  170. package/dist/resources/RocksTransactionLogStore.js +57 -17
  171. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  172. package/dist/resources/Table.d.ts +112 -8
  173. package/dist/resources/Table.js +1073 -191
  174. package/dist/resources/Table.js.map +1 -1
  175. package/dist/resources/auditStore.d.ts +11 -2
  176. package/dist/resources/auditStore.js +325 -71
  177. package/dist/resources/auditStore.js.map +1 -1
  178. package/dist/resources/blob.d.ts +129 -9
  179. package/dist/resources/blob.js +992 -114
  180. package/dist/resources/blob.js.map +1 -1
  181. package/dist/resources/branchDatabase.d.ts +48 -0
  182. package/dist/resources/branchDatabase.js +892 -0
  183. package/dist/resources/branchDatabase.js.map +1 -0
  184. package/dist/resources/crdt.js +50 -12
  185. package/dist/resources/crdt.js.map +1 -1
  186. package/dist/resources/databases.d.ts +150 -0
  187. package/dist/resources/databases.js +1200 -239
  188. package/dist/resources/databases.js.map +1 -1
  189. package/dist/resources/defineTable.d.ts +10 -2
  190. package/dist/resources/defineTable.js +9 -1
  191. package/dist/resources/defineTable.js.map +1 -1
  192. package/dist/resources/graphql.d.ts +1 -1
  193. package/dist/resources/graphql.js +50 -15
  194. package/dist/resources/graphql.js.map +1 -1
  195. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +1 -0
  196. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +6 -2
  197. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  198. package/dist/resources/longLivedTransactions.d.ts +71 -0
  199. package/dist/resources/longLivedTransactions.js +358 -0
  200. package/dist/resources/longLivedTransactions.js.map +1 -0
  201. package/dist/resources/models/backendRegistry.d.ts +26 -0
  202. package/dist/resources/models/backendRegistry.js +60 -2
  203. package/dist/resources/models/backendRegistry.js.map +1 -1
  204. package/dist/resources/models/bootstrap.d.ts +33 -1
  205. package/dist/resources/models/bootstrap.js +416 -31
  206. package/dist/resources/models/bootstrap.js.map +1 -1
  207. package/dist/resources/recordLock.d.ts +80 -0
  208. package/dist/resources/recordLock.js +184 -0
  209. package/dist/resources/recordLock.js.map +1 -0
  210. package/dist/resources/replayLogs.d.ts +14 -1
  211. package/dist/resources/replayLogs.js +152 -24
  212. package/dist/resources/replayLogs.js.map +1 -1
  213. package/dist/resources/replayLogsGuards.d.ts +94 -7
  214. package/dist/resources/replayLogsGuards.js +111 -7
  215. package/dist/resources/replayLogsGuards.js.map +1 -1
  216. package/dist/resources/search.js +158 -6
  217. package/dist/resources/search.js.map +1 -1
  218. package/dist/resources/transactionBroadcast.d.ts +1 -1
  219. package/dist/resources/transactionBroadcast.js +2 -2
  220. package/dist/resources/transactionBroadcast.js.map +1 -1
  221. package/dist/security/auth.js +61 -30
  222. package/dist/security/auth.js.map +1 -1
  223. package/dist/security/authn/oidc/claims.d.ts +22 -0
  224. package/dist/security/authn/oidc/claims.js +71 -0
  225. package/dist/security/authn/oidc/claims.js.map +1 -0
  226. package/dist/security/authn/oidc/identityToken.d.ts +27 -0
  227. package/dist/security/authn/oidc/identityToken.js +111 -0
  228. package/dist/security/authn/oidc/identityToken.js.map +1 -0
  229. package/dist/security/authn/oidc/jwks.d.ts +25 -0
  230. package/dist/security/authn/oidc/jwks.js +261 -0
  231. package/dist/security/authn/oidc/jwks.js.map +1 -0
  232. package/dist/security/authn/oidc/providers/generic.d.ts +13 -0
  233. package/dist/security/authn/oidc/providers/generic.js +34 -0
  234. package/dist/security/authn/oidc/providers/generic.js.map +1 -0
  235. package/dist/security/authn/oidc/providers/githubActions.d.ts +11 -0
  236. package/dist/security/authn/oidc/providers/githubActions.js +129 -0
  237. package/dist/security/authn/oidc/providers/githubActions.js.map +1 -0
  238. package/dist/security/authn/oidc/providers/index.d.ts +37 -0
  239. package/dist/security/authn/oidc/providers/index.js +24 -0
  240. package/dist/security/authn/oidc/providers/index.js.map +1 -0
  241. package/dist/security/authn/oidc/tokenExchange.d.ts +12 -0
  242. package/dist/security/authn/oidc/tokenExchange.js +306 -0
  243. package/dist/security/authn/oidc/tokenExchange.js.map +1 -0
  244. package/dist/security/authn/oidc/trustPolicyOperations.d.ts +49 -0
  245. package/dist/security/authn/oidc/trustPolicyOperations.js +358 -0
  246. package/dist/security/authn/oidc/trustPolicyOperations.js.map +1 -0
  247. package/dist/security/authn/oidc/types.d.ts +38 -0
  248. package/dist/security/authn/oidc/types.js +6 -0
  249. package/dist/security/authn/oidc/types.js.map +1 -0
  250. package/dist/security/certificateVerification/index.js +40 -11
  251. package/dist/security/certificateVerification/index.js.map +1 -1
  252. package/dist/security/certificateVerification/trustedIssuers.d.ts +24 -0
  253. package/dist/security/certificateVerification/trustedIssuers.js +79 -0
  254. package/dist/security/certificateVerification/trustedIssuers.js.map +1 -0
  255. package/dist/security/certificateVerification/types.d.ts +1 -0
  256. package/dist/security/credentialProvenance.d.ts +35 -0
  257. package/dist/security/credentialProvenance.js +51 -0
  258. package/dist/security/credentialProvenance.js.map +1 -0
  259. package/dist/security/credentialRejection.d.ts +4 -0
  260. package/dist/security/credentialRejection.js +24 -0
  261. package/dist/security/credentialRejection.js.map +1 -0
  262. package/dist/security/deferredAuthentication.d.ts +36 -0
  263. package/dist/security/deferredAuthentication.js +70 -0
  264. package/dist/security/deferredAuthentication.js.map +1 -0
  265. package/dist/security/impersonation.d.ts +21 -0
  266. package/dist/security/impersonation.js +108 -9
  267. package/dist/security/impersonation.js.map +1 -1
  268. package/dist/security/jsLoader.d.ts +6 -0
  269. package/dist/security/jsLoader.js +75 -15
  270. package/dist/security/jsLoader.js.map +1 -1
  271. package/dist/security/keys.js +301 -71
  272. package/dist/security/keys.js.map +1 -1
  273. package/dist/security/operationScope.d.ts +21 -0
  274. package/dist/security/operationScope.js +36 -0
  275. package/dist/security/operationScope.js.map +1 -0
  276. package/dist/security/permissionsTranslator.js +21 -0
  277. package/dist/security/permissionsTranslator.js.map +1 -1
  278. package/dist/security/tokenAuthentication.d.ts +19 -1
  279. package/dist/security/tokenAuthentication.js +191 -10
  280. package/dist/security/tokenAuthentication.js.map +1 -1
  281. package/dist/security/user.js +4 -3
  282. package/dist/security/user.js.map +1 -1
  283. package/dist/server/DurableSubscriptionsSession.d.ts +2 -2
  284. package/dist/server/DurableSubscriptionsSession.js +67 -10
  285. package/dist/server/DurableSubscriptionsSession.js.map +1 -1
  286. package/dist/server/REST.js +73 -0
  287. package/dist/server/REST.js.map +1 -1
  288. package/dist/server/graphqlQuerying.js +4 -0
  289. package/dist/server/graphqlQuerying.js.map +1 -1
  290. package/dist/server/http.d.ts +11 -0
  291. package/dist/server/http.js +88 -15
  292. package/dist/server/http.js.map +1 -1
  293. package/dist/server/itc/serverHandlers.js +8 -1
  294. package/dist/server/itc/serverHandlers.js.map +1 -1
  295. package/dist/server/jobs/jobProcess.js +6 -2
  296. package/dist/server/jobs/jobProcess.js.map +1 -1
  297. package/dist/server/jobs/jobs.js +4 -1
  298. package/dist/server/jobs/jobs.js.map +1 -1
  299. package/dist/server/liveSubscriptionAuth.d.ts +26 -4
  300. package/dist/server/liveSubscriptionAuth.js +105 -39
  301. package/dist/server/liveSubscriptionAuth.js.map +1 -1
  302. package/dist/server/loadRootComponents.js +49 -10
  303. package/dist/server/loadRootComponents.js.map +1 -1
  304. package/dist/server/mqtt.d.ts +2 -0
  305. package/dist/server/mqtt.js +165 -30
  306. package/dist/server/mqtt.js.map +1 -1
  307. package/dist/server/nodeName.d.ts +2 -0
  308. package/dist/server/nodeName.js +107 -23
  309. package/dist/server/nodeName.js.map +1 -1
  310. package/dist/server/serverHelpers/Headers.d.ts +25 -0
  311. package/dist/server/serverHelpers/Headers.js +139 -1
  312. package/dist/server/serverHelpers/Headers.js.map +1 -1
  313. package/dist/server/serverHelpers/contentTypes.d.ts +9 -0
  314. package/dist/server/serverHelpers/contentTypes.js +32 -25
  315. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  316. package/dist/server/serverHelpers/deployValidationState.d.ts +3 -0
  317. package/dist/server/serverHelpers/deployValidationState.js +9 -19
  318. package/dist/server/serverHelpers/deployValidationState.js.map +1 -1
  319. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +12 -0
  320. package/dist/server/serverHelpers/operationAuthorizationState.js +24 -2
  321. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -1
  322. package/dist/server/serverHelpers/registeredOperations.d.ts +5 -4
  323. package/dist/server/serverHelpers/registeredOperations.js +74 -21
  324. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  325. package/dist/server/serverHelpers/requestSanitization.d.ts +11 -0
  326. package/dist/server/serverHelpers/requestSanitization.js +20 -0
  327. package/dist/server/serverHelpers/requestSanitization.js.map +1 -0
  328. package/dist/server/serverHelpers/serverHandlers.js +6 -3
  329. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  330. package/dist/server/serverHelpers/serverUtilities.d.ts +18 -0
  331. package/dist/server/serverHelpers/serverUtilities.js +90 -20
  332. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  333. package/dist/server/serverHelpers/sharedMessageEncoding.d.ts +67 -0
  334. package/dist/server/serverHelpers/sharedMessageEncoding.js +280 -0
  335. package/dist/server/serverHelpers/sharedMessageEncoding.js.map +1 -0
  336. package/dist/server/serverHelpers/uwsServer.js +19 -1
  337. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  338. package/dist/server/static.js +24 -28
  339. package/dist/server/static.js.map +1 -1
  340. package/dist/server/storageReclamation.d.ts +12 -0
  341. package/dist/server/storageReclamation.js +26 -0
  342. package/dist/server/storageReclamation.js.map +1 -1
  343. package/dist/server/threads/itc.d.ts +7 -2
  344. package/dist/server/threads/itc.js +5 -1
  345. package/dist/server/threads/itc.js.map +1 -1
  346. package/dist/server/threads/manageThreads.d.ts +26 -3
  347. package/dist/server/threads/manageThreads.js +517 -65
  348. package/dist/server/threads/manageThreads.js.map +1 -1
  349. package/dist/server/threads/socketRouter.js +89 -26
  350. package/dist/server/threads/socketRouter.js.map +1 -1
  351. package/dist/server/threads/threadHeapMemory.d.ts +2 -0
  352. package/dist/server/threads/threadHeapMemory.js +31 -0
  353. package/dist/server/threads/threadHeapMemory.js.map +1 -0
  354. package/dist/server/threads/threadServer.js +46 -15
  355. package/dist/server/threads/threadServer.js.map +1 -1
  356. package/dist/sqlEngine/config.d.ts +1 -3
  357. package/dist/sqlEngine/config.js +19 -16
  358. package/dist/sqlEngine/config.js.map +1 -1
  359. package/dist/sqlTranslator/index.d.ts +1 -1
  360. package/dist/sqlTranslator/index.js +30 -7
  361. package/dist/sqlTranslator/index.js.map +1 -1
  362. package/dist/upgrade/directives/5-3-0.d.ts +7 -0
  363. package/dist/upgrade/directives/5-3-0.js +148 -0
  364. package/dist/upgrade/directives/5-3-0.js.map +1 -0
  365. package/dist/upgrade/directives/directivesController.js +2 -1
  366. package/dist/upgrade/directives/directivesController.js.map +1 -1
  367. package/dist/utility/OperationFunctionCaller.js +2 -1
  368. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  369. package/dist/utility/common_utils.d.ts +16 -0
  370. package/dist/utility/common_utils.js +32 -6
  371. package/dist/utility/common_utils.js.map +1 -1
  372. package/dist/utility/componentNames.d.ts +8 -0
  373. package/dist/utility/componentNames.js +12 -1
  374. package/dist/utility/componentNames.js.map +1 -1
  375. package/dist/utility/environment/environmentManager.js +3 -6
  376. package/dist/utility/environment/environmentManager.js.map +1 -1
  377. package/dist/utility/environment/systemInformation.d.ts +1 -0
  378. package/dist/utility/environment/systemInformation.js +1 -0
  379. package/dist/utility/environment/systemInformation.js.map +1 -1
  380. package/dist/utility/errors/commonErrors.d.ts +2 -0
  381. package/dist/utility/errors/commonErrors.js +2 -0
  382. package/dist/utility/errors/commonErrors.js.map +1 -1
  383. package/dist/utility/errors/hdbError.d.ts +17 -0
  384. package/dist/utility/errors/hdbError.js +30 -1
  385. package/dist/utility/errors/hdbError.js.map +1 -1
  386. package/dist/utility/globalSchema.d.ts +18 -0
  387. package/dist/utility/hdbTerms.d.ts +16 -0
  388. package/dist/utility/hdbTerms.js +18 -2
  389. package/dist/utility/hdbTerms.js.map +1 -1
  390. package/dist/utility/logging/harper_logger.d.ts +2 -0
  391. package/dist/utility/logging/harper_logger.js +91 -14
  392. package/dist/utility/logging/harper_logger.js.map +1 -1
  393. package/dist/utility/logging/logRotator.js +76 -49
  394. package/dist/utility/logging/logRotator.js.map +1 -1
  395. package/dist/utility/nodeIdentity.d.ts +9 -0
  396. package/dist/utility/nodeIdentity.js +58 -0
  397. package/dist/utility/nodeIdentity.js.map +1 -0
  398. package/dist/utility/npmUtilities.js +9 -7
  399. package/dist/utility/npmUtilities.js.map +1 -1
  400. package/dist/utility/operationPermissions.d.ts +3 -1
  401. package/dist/utility/operationPermissions.js +16 -1
  402. package/dist/utility/operationPermissions.js.map +1 -1
  403. package/dist/utility/operation_authorization.d.ts +10 -7
  404. package/dist/utility/operation_authorization.js +212 -41
  405. package/dist/utility/operation_authorization.js.map +1 -1
  406. package/dist/utility/watchPath.d.ts +29 -0
  407. package/dist/utility/watchPath.js +68 -0
  408. package/dist/utility/watchPath.js.map +1 -0
  409. package/dist/utility/watcherFallback.d.ts +86 -0
  410. package/dist/utility/watcherFallback.js +278 -1
  411. package/dist/utility/watcherFallback.js.map +1 -1
  412. package/dist/validation/configValidator.d.ts +12 -0
  413. package/dist/validation/configValidator.js +199 -72
  414. package/dist/validation/configValidator.js.map +1 -1
  415. package/dist/validation/installValidator.js +12 -0
  416. package/dist/validation/installValidator.js.map +1 -1
  417. package/dist/validation/validationWrapper.d.ts +11 -0
  418. package/dist/validation/validationWrapper.js +16 -3
  419. package/dist/validation/validationWrapper.js.map +1 -1
  420. package/index.ts +2 -0
  421. package/json/systemSchema.json +52 -0
  422. package/npm-shrinkwrap.json +196 -194
  423. package/package.json +7 -6
  424. package/resources/DESIGN.md +114 -52
  425. package/resources/DatabaseTransaction.ts +521 -43
  426. package/resources/LMDBTransaction.ts +26 -4
  427. package/resources/PrimaryRocksDatabase.ts +28 -1
  428. package/resources/RecordEncoder.ts +103 -8
  429. package/resources/RequestTarget.ts +2 -0
  430. package/resources/Resource.ts +17 -9
  431. package/resources/ResourceInterface.ts +23 -0
  432. package/resources/RocksIndexStore.ts +30 -9
  433. package/resources/RocksTransactionLogStore.ts +78 -21
  434. package/resources/Table.ts +1110 -159
  435. package/resources/auditStore.ts +331 -73
  436. package/resources/blob.ts +1029 -110
  437. package/resources/branchDatabase.ts +941 -0
  438. package/resources/crdt.ts +76 -12
  439. package/resources/databases.ts +1310 -252
  440. package/resources/defineTable.ts +18 -2
  441. package/resources/graphql.ts +70 -16
  442. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +6 -2
  443. package/resources/longLivedTransactions.ts +360 -0
  444. package/resources/models/backendRegistry.ts +84 -2
  445. package/resources/models/bootstrap.ts +473 -28
  446. package/resources/recordLock.ts +253 -0
  447. package/resources/replayLogs.ts +147 -26
  448. package/resources/replayLogsGuards.ts +171 -8
  449. package/resources/search.ts +154 -7
  450. package/resources/transactionBroadcast.ts +3 -3
  451. package/security/auth.ts +68 -29
  452. package/security/authn/oidc/claims.ts +72 -0
  453. package/security/authn/oidc/identityToken.ts +129 -0
  454. package/security/authn/oidc/jwks.ts +260 -0
  455. package/security/authn/oidc/providers/generic.ts +40 -0
  456. package/security/authn/oidc/providers/githubActions.ts +137 -0
  457. package/security/authn/oidc/providers/index.ts +52 -0
  458. package/security/authn/oidc/tokenExchange.ts +300 -0
  459. package/security/authn/oidc/trustPolicyOperations.ts +343 -0
  460. package/security/authn/oidc/types.ts +41 -0
  461. package/security/certificateVerification/index.ts +54 -13
  462. package/security/certificateVerification/trustedIssuers.ts +76 -0
  463. package/security/certificateVerification/types.ts +1 -0
  464. package/security/credentialProvenance.ts +47 -0
  465. package/security/credentialRejection.ts +22 -0
  466. package/security/deferredAuthentication.ts +71 -0
  467. package/security/impersonation.ts +117 -12
  468. package/security/jsLoader.ts +81 -18
  469. package/security/keys.ts +298 -72
  470. package/security/operationScope.ts +33 -0
  471. package/security/permissionsTranslator.js +23 -0
  472. package/security/tokenAuthentication.ts +233 -12
  473. package/security/user.ts +4 -3
  474. package/server/DESIGN.md +183 -16
  475. package/server/DurableSubscriptionsSession.ts +71 -11
  476. package/server/REST.ts +79 -1
  477. package/server/graphqlQuerying.ts +4 -0
  478. package/server/http.ts +99 -18
  479. package/server/itc/serverHandlers.js +8 -1
  480. package/server/jobs/jobProcess.ts +8 -2
  481. package/server/jobs/jobs.ts +4 -1
  482. package/server/liveSubscriptionAuth.ts +129 -46
  483. package/server/loadRootComponents.js +50 -8
  484. package/server/mqtt.ts +179 -38
  485. package/server/nodeName.ts +103 -21
  486. package/server/serverHelpers/Headers.ts +131 -0
  487. package/server/serverHelpers/contentTypes.ts +29 -21
  488. package/server/serverHelpers/deployValidationState.ts +24 -13
  489. package/server/serverHelpers/operationAuthorizationState.ts +34 -3
  490. package/server/serverHelpers/registeredOperations.ts +79 -22
  491. package/server/serverHelpers/requestSanitization.ts +15 -0
  492. package/server/serverHelpers/serverHandlers.js +6 -3
  493. package/server/serverHelpers/serverUtilities.ts +104 -31
  494. package/server/serverHelpers/sharedMessageEncoding.ts +307 -0
  495. package/server/serverHelpers/uwsServer.ts +17 -2
  496. package/server/static.ts +23 -29
  497. package/server/storageReclamation.ts +23 -0
  498. package/server/threads/itc.js +11 -1
  499. package/server/threads/manageThreads.js +526 -56
  500. package/server/threads/socketRouter.ts +81 -26
  501. package/server/threads/threadHeapMemory.ts +26 -0
  502. package/server/threads/threadServer.js +43 -15
  503. package/sqlTranslator/index.ts +31 -8
  504. package/studio/web/assets/Chat-BnCBegQz.js +2267 -0
  505. package/studio/web/assets/FloatingChat-CoDW1ySS.js +23 -0
  506. package/studio/web/assets/{abnfDiagram-VCTEODGH-C0_BAZyO.js → abnfDiagram-VCTEODGH-B0BebmD2.js} +1 -1
  507. package/studio/web/assets/{alertDialog-DIHt7Z0r.js → alertDialog-CQyAJJhl.js} +1 -1
  508. package/studio/web/assets/{apiToken-c3Rd-w6g.js → apiToken-Bwk5BLXW.js} +1 -1
  509. package/studio/web/assets/applications-DHxGi7JH.js +296 -0
  510. package/studio/web/assets/architecture-7GRP2DOG-LB-MLAAb.js +1 -0
  511. package/studio/web/assets/{architectureDiagram-5GKGNRK7-BWzrASgm.js → architectureDiagram-5GKGNRK7-7SW3GD-K.js} +1 -1
  512. package/studio/web/assets/authStore-C3Nfubqr.js +3 -0
  513. package/studio/web/assets/{blockDiagram-NRAW4CY4-BdJX9Khj.js → blockDiagram-I7D4REHJ-BqguiadH.js} +2 -2
  514. package/studio/web/assets/{button-DhiX-njv.js → button-BIsUKRZq.js} +2 -2
  515. package/studio/web/assets/{c4Diagram-UCG6FXSJ-CI6MzGmQ.js → c4Diagram-7LVT6UL2-LBNf8t_X.js} +1 -1
  516. package/studio/web/assets/channel-yictG-U-.js +1 -0
  517. package/studio/web/assets/{chevron-up-Df2c8uoU.js → chevron-up-DtKGqDn3.js} +1 -1
  518. package/studio/web/assets/{chunk-TEH6E4GO-P87k5mNi.js → chunk-4HAMMTFA-DWtTut21.js} +1 -1
  519. package/studio/web/assets/{chunk-75Z2AOVW-BT8tVmks.js → chunk-75Z2AOVW-QGQD6th2.js} +1 -1
  520. package/studio/web/assets/{chunk-DU6HZSFF-9kAOOmI6.js → chunk-DU6HZSFF-Chq20Ba5.js} +1 -1
  521. package/studio/web/assets/{chunk-F27PBJKO-BW7ao8AY.js → chunk-F27PBJKO-BVA5EPhV.js} +1 -1
  522. package/studio/web/assets/{chunk-GMAD6QVW-BNyXpoQO.js → chunk-GMAD6QVW-BeS7S07A.js} +1 -1
  523. package/studio/web/assets/{chunk-OBVCFTLP-D4wWOqDQ.js → chunk-GVQU2GXP-sbwVIQ8i.js} +1 -1
  524. package/studio/web/assets/{chunk-G27WJ6UU-COyLMcgK.js → chunk-IMKFNOWR-Bnh3tAVd.js} +1 -1
  525. package/studio/web/assets/{chunk-JQ64N6SF-Cyz1IeLf.js → chunk-L3NEJ4N5-COfUyKII.js} +1 -1
  526. package/studio/web/assets/chunk-OSK3NFVY-ByciRftO.js +10 -0
  527. package/studio/web/assets/{chunk-P2QGCYS3-DmIFY4d7.js → chunk-P2QGCYS3-CP1VhG_c.js} +1 -1
  528. package/studio/web/assets/{chunk-POPQ4Y6H-BPrvMyKz.js → chunk-POPQ4Y6H-ClWhhkwW.js} +1 -1
  529. package/studio/web/assets/{chunk-PWAF6VOD-2zB6IW9i.js → chunk-PWAF6VOD-1z1THyS5.js} +1 -1
  530. package/studio/web/assets/{chunk-RHFEMEQ7-2FgyI8YU.js → chunk-SHT3W25Y-LpQkMsah.js} +2 -2
  531. package/studio/web/assets/{chunk-SVP7TREG-FwtbH2QC.js → chunk-SVP7TREG-jtdAHw0S.js} +1 -1
  532. package/studio/web/assets/{chunk-LCL6LL3I-HOzK_ppE.js → chunk-TICWLB2K-VOwzetX-.js} +1 -1
  533. package/studio/web/assets/classDiagram-ZZMXUADV-VaEwSy_g.js +1 -0
  534. package/studio/web/assets/classDiagram-v2-VYDZK3BY-VaEwSy_g.js +1 -0
  535. package/studio/web/assets/{createLucideIcon-BKGPfjm2.js → createLucideIcon-CzW9508A.js} +1 -1
  536. package/studio/web/assets/{cssMode-CEN2mzSA.js → cssMode-s0cWI-_M.js} +1 -1
  537. package/studio/web/assets/{cynefin-OW5HDTMX-BRkpLFQV.js → cynefin-OW5HDTMX-BbdbCvub.js} +1 -1
  538. package/studio/web/assets/{cynefinDiagram-5FMLGOSQ-CHT1DaX6.js → cynefinDiagram-5FMLGOSQ-TP-aIqbt.js} +1 -1
  539. package/studio/web/assets/{dagre-3AP2YEHR-DpUXBh63.js → dagre-GXQ25YYZ-DShnGpGo.js} +1 -1
  540. package/studio/web/assets/{diagram-S7CK7UJ4-BuymVFZT.js → diagram-S7CK7UJ4-aoCVTtcy.js} +1 -1
  541. package/studio/web/assets/{diagram-UQ7AKVKN-CyP148RM.js → diagram-UQ7AKVKN-DglXtQ6x.js} +1 -1
  542. package/studio/web/assets/{diagram-VSXAHHWV-CoCAg3M9.js → diagram-VSXAHHWV-fhEdmkwM.js} +1 -1
  543. package/studio/web/assets/{diagram-VX7I27RA-BpOqCFca.js → diagram-VX7I27RA-DccVJet6.js} +1 -1
  544. package/studio/web/assets/{diagram-Z3DM3KII-Bfpw7Vbj.js → diagram-Z3DM3KII-D-RyJJb7.js} +1 -1
  545. package/studio/web/assets/{dialog-CBf0Mr1d.js → dialog-Cn2uWgD4.js} +1 -1
  546. package/studio/web/assets/{dist-lkA3O3eM.js → dist-DP8UjMB_.js} +1 -1
  547. package/studio/web/assets/{download-BtTOBem-.js → download-B5T5r7ss.js} +1 -1
  548. package/studio/web/assets/{ebnfDiagram-PWID7BFC-DS_6aWqL.js → ebnfDiagram-PWID7BFC-DJGpIpz_.js} +1 -1
  549. package/studio/web/assets/{editor-D8oDeCTL.js → editor-DNcRHK54.js} +1 -1
  550. package/studio/web/assets/{erDiagram-SSCWMZ5O-DJNk6Fgw.js → erDiagram-RLTQ6QDP-CIfNlgkC.js} +1 -1
  551. package/studio/web/assets/eventmodeling-NTZA5JFV-CLxnp2CR.js +1 -0
  552. package/studio/web/assets/flowDiagram-HODETNUW-BIbhmz9f.js +1 -0
  553. package/studio/web/assets/{ganttDiagram-EL5Y4UJY-2pOExxMY.js → ganttDiagram-EL5Y4UJY-BxToTzzD.js} +1 -1
  554. package/studio/web/assets/{getAnalytics-D4LKGeVy.js → getAnalytics-GHK8ORfM.js} +1 -1
  555. package/studio/web/assets/{gitGraph-4MIJSDKK-CH5ZxwzF.js → gitGraph-4MIJSDKK-D2s2w8lE.js} +1 -1
  556. package/studio/web/assets/{gitGraphDiagram-WWUBYQGX-DVIsIhbO.js → gitGraphDiagram-WWUBYQGX-Dwntd4-x.js} +1 -1
  557. package/studio/web/assets/{html-u3vOg7LJ.js → html-Bdssedlg.js} +1 -1
  558. package/studio/web/assets/{htmlMode-DyO31v-P.js → htmlMode-CoDlJ3fw.js} +1 -1
  559. package/studio/web/assets/{index-Cxj2_wsl.css → index-7RMEgVG1.css} +1 -1
  560. package/studio/web/assets/index-D6sxmFLR.js +824 -0
  561. package/studio/web/assets/index.lazy-tmU5BS8s.js +2 -0
  562. package/studio/web/assets/{info-A6RAGUB7-CPQfTnaG.js → info-A6RAGUB7-DYjkvb0C.js} +1 -1
  563. package/studio/web/assets/{infoDiagram-RXCK75RN-DlwLYlwm.js → infoDiagram-27XIBGKW-Bnp1FJE5.js} +1 -1
  564. package/studio/web/assets/{ishikawaDiagram-5VMMS53U-BRXRp29U.js → ishikawaDiagram-5VMMS53U-D9Xh2r6X.js} +1 -1
  565. package/studio/web/assets/{javascript-CUvxOyTC.js → javascript-B8meVSTH.js} +1 -1
  566. package/studio/web/assets/{journeyDiagram-EYS64GPL-B0ou8k0n.js → journeyDiagram-3NMN7TZE-CokIi6ll.js} +2 -2
  567. package/studio/web/assets/{jsonMode-f_IwbF3D.js → jsonMode-DpIPd35T.js} +1 -1
  568. package/studio/web/assets/{kanban-definition-3QL26DDD-uYg7iYzp.js → kanban-definition-UXKFOSKX-CukSFJfX.js} +1 -1
  569. package/studio/web/assets/{languageServices-DXtZ6rEF.js → languageServices-C_5FMJzQ.js} +1 -1
  570. package/studio/web/assets/{lspLanguageFeatures-B4pCF1zO.js → lspLanguageFeatures-BIzNBzPK.js} +1 -1
  571. package/studio/web/assets/{mermaid-parser.core-Ck-fC8b7.js → mermaid-parser.core-BlEsOWNO.js} +3 -3
  572. package/studio/web/assets/{mermaid.core-CP8aNNYm.js → mermaid.core-BlkGaMIH.js} +5 -5
  573. package/studio/web/assets/{mindmap-definition-FBJOCRG2-CgTZ-rit.js → mindmap-definition-YA3MSWOX-IprMc_0j.js} +1 -1
  574. package/studio/web/assets/{notifications-D3tIQ4sg.js → notifications-CQf18QKb.js} +1 -1
  575. package/studio/web/assets/notifications-CvZivSbh.js +1 -0
  576. package/studio/web/assets/{packet-AYTQ26CC-DEyoPtPb.js → packet-AYTQ26CC-Bi3V04Zi.js} +1 -1
  577. package/studio/web/assets/{pegDiagram-XKGWAZYB-DrD-7sD9.js → pegDiagram-XKGWAZYB-BNuPDLZY.js} +1 -1
  578. package/studio/web/assets/{pie-WAS4IAKB-wjj-EI1d.js → pie-WAS4IAKB-_6DoDbng.js} +1 -1
  579. package/studio/web/assets/{pieDiagram-E7YTZNPT-GntqDCzv.js → pieDiagram-E7YTZNPT-DqNb6Ht2.js} +1 -1
  580. package/studio/web/assets/{profile-DZWU7MgT.js → profile-DdOwtntb.js} +1 -1
  581. package/studio/web/assets/{quadrantDiagram-AXDQQJYC-0UeqXQGd.js → quadrantDiagram-AXDQQJYC-BGH9E2YR.js} +1 -1
  582. package/studio/web/assets/{radar-RG4KPBEZ-DFSA5h7k.js → radar-RG4KPBEZ-DDdVczcL.js} +1 -1
  583. package/studio/web/assets/{railroad-74A4TZTK-CaOUG9wR.js → railroad-74A4TZTK-BJUP4Jds.js} +1 -1
  584. package/studio/web/assets/railroad-abnf-HS5TGJTU-Bm3L1L0h.js +1 -0
  585. package/studio/web/assets/railroad-ebnf-LZEXJU2U-CKzLGlkw.js +1 -0
  586. package/studio/web/assets/railroad-peg-WCYAUIDC-S8xLjslx.js +1 -0
  587. package/studio/web/assets/{railroadDiagram-O6MQD6OU-yHUELZaV.js → railroadDiagram-O6MQD6OU-DGTPh2KZ.js} +1 -1
  588. package/studio/web/assets/{regions-CkyurXzE.js → regions-n69fwagr.js} +1 -1
  589. package/studio/web/assets/{register-BUyhWjBO.js → register-PfWTCXWB.js} +3 -3
  590. package/studio/web/assets/{requirementDiagram-EFPCY7ZU-DNEGFjuW.js → requirementDiagram-BXWQKSXE-BJnO6uLz.js} +1 -1
  591. package/studio/web/assets/{sankeyDiagram-P5KCCOFB-DpyAmSVR.js → sankeyDiagram-P5KCCOFB-0vSOdymH.js} +1 -1
  592. package/studio/web/assets/{sequenceDiagram-WJ2MYXX4-TyaT7xNk.js → sequenceDiagram-WJ2MYXX4-hETizDWE.js} +1 -1
  593. package/studio/web/assets/{setComponentFile-CeyKSZAa.js → setComponentFile-Bg6O7X0S.js} +1 -1
  594. package/studio/web/assets/{setup-D2kn7cAA.js → setup-CUx_aUDl.js} +2 -2
  595. package/studio/web/assets/{stateDiagram-HBIQ2CUA-CeEdTArZ.js → stateDiagram-D77RDMKH-CdYQ_KtC.js} +1 -1
  596. package/studio/web/assets/stateDiagram-v2-MP3YSRHH-CdKuzQMT.js +1 -0
  597. package/studio/web/assets/status-D7BVKqX9.js +61 -0
  598. package/studio/web/assets/{swimlanes-XN3QIQJK-B54FmF46.js → swimlanes-42K2YHIH-B8cHIpU4.js} +1 -1
  599. package/studio/web/assets/swimlanesDiagram-VR7AAH4N-DmOSJwaH.js +8 -0
  600. package/studio/web/assets/{tabs-B_G5zscN.js → tabs-BrHu7gJi.js} +1 -1
  601. package/studio/web/assets/{timeline-definition-24CTP7MA-D-a9ujbo.js → timeline-definition-24CTP7MA-BJWYSXqF.js} +1 -1
  602. package/studio/web/assets/{toggleHighContrast-C0UW6rI2.js → toggleHighContrast-DBSyXzMr.js} +1 -1
  603. package/studio/web/assets/{treeView-Q6P3EWNA-CrW_6JnS.js → treeView-Q6P3EWNA-qxe_v6CQ.js} +1 -1
  604. package/studio/web/assets/{treemap-WGGIJYW6-BxyYLdP_.js → treemap-WGGIJYW6-dDo97XXF.js} +1 -1
  605. package/studio/web/assets/{tsMode-DBC0zmDx.js → tsMode-BByKCjBS.js} +1 -1
  606. package/studio/web/assets/{typescript-DApRQir3.js → typescript-DDLnLpw9.js} +1 -1
  607. package/studio/web/assets/{useEntityRestURL-DB6JStU1.js → useEntityRestURL-31CHGaHk.js} +1 -1
  608. package/studio/web/assets/{useLocalStorage-Dtj1QS8_.js → useLocalStorage-BqMR3D8_.js} +1 -1
  609. package/studio/web/assets/vendor-core-c2JRRJpV.js +58 -0
  610. package/studio/web/assets/vendor-datadog-CLUcJXOo.js +6 -0
  611. package/studio/web/assets/{vendor-react-Dyj4O3HE.js → vendor-react-CJV_K1u4.js} +1 -1
  612. package/studio/web/assets/vendor-tanstack-DxzraizX.js +1 -0
  613. package/studio/web/assets/{vendor-ui-vhu-UHhF.js → vendor-ui-BUjK0h8a.js} +2 -2
  614. package/studio/web/assets/{vennDiagram-4TSXK5OY-Cy7s7Mpy.js → vennDiagram-4TSXK5OY-A3i-lCdl.js} +1 -1
  615. package/studio/web/assets/{wardley-WFR3VGLG-BeBL35g2.js → wardley-WFR3VGLG-B0ik-_6g.js} +1 -1
  616. package/studio/web/assets/{wardleyDiagram-VM6X3IG4-BylmIGSg.js → wardleyDiagram-VM6X3IG4-CjrkKWUR.js} +1 -1
  617. package/studio/web/assets/{workers-C0bFIedw.js → workers-pR3jRY9D.js} +1 -1
  618. package/studio/web/assets/x-DIzaLEdK.js +1 -0
  619. package/studio/web/assets/{xml-HWd01lU-.js → xml-2iRnMhQO.js} +1 -1
  620. package/studio/web/assets/{xychartDiagram-S5SC5T6Z-CoKALMXr.js → xychartDiagram-S5SC5T6Z-Biok4GYV.js} +1 -1
  621. package/studio/web/assets/{yaml-CIH0Nt-h.js → yaml-Bf92gJpd.js} +1 -1
  622. package/studio/web/index.html +14 -14
  623. package/upgrade/directives/5-3-0.ts +132 -0
  624. package/upgrade/directives/directivesController.ts +2 -1
  625. package/utility/OperationFunctionCaller.ts +2 -1
  626. package/utility/common_utils.ts +30 -5
  627. package/utility/componentNames.ts +12 -0
  628. package/utility/environment/environmentManager.ts +3 -7
  629. package/utility/environment/systemInformation.ts +7 -0
  630. package/utility/errors/commonErrors.ts +4 -0
  631. package/utility/errors/hdbError.ts +29 -0
  632. package/utility/hdbTerms.ts +17 -0
  633. package/utility/logging/harper_logger.ts +87 -14
  634. package/utility/logging/logRotator.ts +72 -45
  635. package/utility/nodeIdentity.ts +45 -0
  636. package/utility/npmUtilities.ts +10 -8
  637. package/utility/operationPermissions.ts +18 -1
  638. package/utility/operation_authorization.ts +231 -42
  639. package/utility/watchPath.ts +63 -0
  640. package/utility/watcherFallback.ts +270 -0
  641. package/validation/configValidator.ts +209 -72
  642. package/validation/installValidator.ts +15 -0
  643. package/validation/validationWrapper.ts +18 -4
  644. package/studio/web/assets/Chat-JpO8EtUu.js +0 -2067
  645. package/studio/web/assets/FloatingChat-Bcj3xSZu.js +0 -23
  646. package/studio/web/assets/applications-ByqLRKyZ.js +0 -296
  647. package/studio/web/assets/architecture-7GRP2DOG-DNdx5tEU.js +0 -1
  648. package/studio/web/assets/authStore-qKmCZcaf.js +0 -3
  649. package/studio/web/assets/channel-DtCV8PTL.js +0 -1
  650. package/studio/web/assets/chunk-R7TYR2AO-Irip67yr.js +0 -10
  651. package/studio/web/assets/classDiagram-DTDB5LWJ-DbO_dCNE.js +0 -1
  652. package/studio/web/assets/classDiagram-v2-JRS7N3AN-DbO_dCNE.js +0 -1
  653. package/studio/web/assets/eventmodeling-NTZA5JFV-5jbe4A5P.js +0 -1
  654. package/studio/web/assets/flowDiagram-A5DVABFB-Dp9Ezlow.js +0 -1
  655. package/studio/web/assets/index-aSt5tY-L.js +0 -824
  656. package/studio/web/assets/index.lazy-B9jiPwT8.js +0 -2
  657. package/studio/web/assets/notifications-CUoYgU98.js +0 -1
  658. package/studio/web/assets/railroad-abnf-HS5TGJTU-Bc0Qi0WH.js +0 -1
  659. package/studio/web/assets/railroad-ebnf-LZEXJU2U-G8rVVZ2C.js +0 -1
  660. package/studio/web/assets/railroad-peg-WCYAUIDC-CrehKBhC.js +0 -1
  661. package/studio/web/assets/stateDiagram-v2-4QOOHH4V-D4tuw9Su.js +0 -1
  662. package/studio/web/assets/status-D7Xn5ePA.js +0 -61
  663. package/studio/web/assets/swimlanesDiagram-VK2B7HYN-XOhmNEvq.js +0 -8
  664. package/studio/web/assets/vendor-core-RCcadM3e.js +0 -73
  665. package/studio/web/assets/vendor-datadog-BRv-mOv1.js +0 -6
  666. package/studio/web/assets/vendor-tanstack-BiFWSB3W.js +0 -1
  667. package/studio/web/assets/x-B9o9hsep.js +0 -1
  668. /package/studio/web/assets/{sizeCapture-X5ZJPWSS-B0uUizjq.js → sizeCapture-INFHLROL-B0uUizjq.js} +0 -0
package/resources/blob.ts CHANGED
@@ -12,7 +12,16 @@
12
12
  */
13
13
 
14
14
  import { addExtension, pack, Packr } from 'msgpackr';
15
- import { readFile, rename, statfs, readdir, rmdir, unlink as unlinkPromised } from 'node:fs/promises';
15
+ import {
16
+ readFile,
17
+ rename,
18
+ statfs,
19
+ readdir,
20
+ rmdir,
21
+ open as openFile,
22
+ type FileHandle,
23
+ unlink as unlinkPromised,
24
+ } from 'node:fs/promises';
16
25
  import {
17
26
  close,
18
27
  closeSync,
@@ -27,21 +36,24 @@ import {
27
36
  unlink,
28
37
  readdirSync,
29
38
  existsSync,
39
+ fstat,
30
40
  fstatSync,
41
+ stat,
31
42
  watch,
32
43
  write,
33
44
  statSync,
34
45
  writeFile,
35
46
  type FSWatcher,
36
47
  } from 'node:fs';
37
- import type { StatsFs } from 'node:fs';
48
+ import type { ReadStream, StatsFs } from 'node:fs';
38
49
  import { createDeflate, createInflate, inflate } from 'node:zlib';
39
- import { Readable, pipeline } from 'node:stream';
50
+ import { Readable, Transform, pipeline } from 'node:stream';
40
51
  import { ensureDirSync } from 'fs-extra';
41
52
  import { get as envGet, getHdbBasePath } from '../utility/environment/environmentManager.ts';
42
53
  import { CONFIG_PARAMS, MAX_SET_TIMEOUT_MS } from '../utility/hdbTerms.ts';
43
54
  import { join, dirname } from 'path';
44
55
  import { logger } from '../utility/logging/logger.ts';
56
+ import { resolveWatchTarget } from '../utility/watchPath.ts';
45
57
  import type { RootDatabase } from 'lmdb';
46
58
  import { asyncSerialization, hasAsyncSerialization } from '../server/serverHelpers/contentTypes.ts';
47
59
  import { getHeapStatistics } from 'node:v8';
@@ -54,9 +66,10 @@ type StorageInfo = {
54
66
  store?: any;
55
67
  filePath?: string;
56
68
  recordId?: number;
57
- contentBuffer?: any;
69
+ contentBuffer?: Buffer;
58
70
  source?: Readable;
59
71
  compress?: boolean;
72
+ storedCodec?: 'deflate'; // the source bytes are already the stored (compressed) representation
60
73
  flush?: boolean;
61
74
  start?: number;
62
75
  end?: number;
@@ -64,18 +77,30 @@ type StorageInfo = {
64
77
  saved?: boolean; // saving settled successfully; distinguishes durable from still-streaming when fileId is already assigned
65
78
  asString?: string;
66
79
  deleteOnFailure?: boolean;
80
+ // Slices share this state with their source, so condemning either instance invalidates every view
81
+ // that can still re-encode the same fileId.
82
+ fileState?: { discarded?: boolean };
83
+ // The file `blobFileMissingOrIncompleteAsync` last found damaged, for the locked recheck to recognize
84
+ probedDamage?: { fileSize: number; header: Buffer };
67
85
  };
86
+ type BlobFileInfo = { store?: any; fileId?: string };
87
+
88
+ function discardStorage(storageInfo: StorageInfo): void {
89
+ (storageInfo.fileState ??= {}).discarded = true;
90
+ }
68
91
  const FILE_STORAGE_THRESHOLD = 8192; // if the file is below this size, we will store it in memory, or within the record itself, otherwise we will store it in a file
69
92
  // We want to keep the file path private (but accessible to the extension)
70
93
  const HEADER_SIZE = 8;
71
94
  const UNCOMPRESSED_TYPE = 0;
72
- const DEFLATE_TYPE = 1;
95
+ export const DEFLATE_TYPE = 1;
73
96
  const ERROR_TYPE = 0xff;
74
97
  // A write that aborted on a re-streamable external source (replication receive / origin fetch) stamps the
75
98
  // file with this type so a downstream read returns 503 (retry) rather than 500 (confidently incomplete).
76
- // The bytes are still expected — the receive side holds a blob gap and re-streams on reconnect, which
77
- // overwrites this stub; a terminal give-up unlinks the file (→ 404). Distinct from ERROR_TYPE (a permanent
78
- // corrupt/error stub, replicated as-is). See harper-pro#481.
99
+ // The re-stream builds a fresh blob and takes a NEW file id (harper-pro's `createBlob` → `saveBlob`), so
100
+ // this stub is orphaned rather than overwritten; a terminal give-up unlinks it (→ 404). No path rewrites
101
+ // a published blob file in place: `repairBlobFile` is the only same-id writer and it publishes via a
102
+ // `.repair` temp + rename. Distinct from ERROR_TYPE (a permanent corrupt/error stub, replicated as-is).
103
+ // See harper-pro#481.
79
104
  const PENDING_TYPE = 0xfe;
80
105
  const BLOB_REPAIR_SUFFIX = '.repair';
81
106
  const DEFAULT_HEADER = new Uint8Array([0, UNCOMPRESSED_TYPE, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
@@ -219,6 +244,54 @@ const BLOB_GONE_STATUS = 404;
219
244
  // retryable class from the permanent ones without duplicating the status code.
220
245
  export const BLOB_UNAVAILABLE_STATUS = 503;
221
246
  const BLOB_CORRUPT_STATUS = 500;
247
+ /**
248
+ * Arm the one-shot watch that wakes an in-progress blob read of `filePath`, or return `undefined`
249
+ * for a read that has to poll instead. `isLive` decides whether the read still owns a callback's
250
+ * watcher: a callback from one it has already replaced must not act, or it would close the live
251
+ * watcher and start a second read at the same position. `onFailure` runs only for a live watcher
252
+ * that fails after registration — a registration that throws latches `mustPoll` and leaves the
253
+ * caller in its own no-watcher branch.
254
+ */
255
+ export function watchInProgressFile(
256
+ filePath: string,
257
+ watchTarget: { path: string; mustPoll: boolean },
258
+ handlers: {
259
+ isLive: (watcher: FSWatcher) => boolean;
260
+ onChange: () => void;
261
+ onFailure: () => void;
262
+ },
263
+ watchFile: typeof watch = watch
264
+ ): FSWatcher | undefined {
265
+ if (watchTarget.mustPoll) return undefined;
266
+ let watcher: FSWatcher;
267
+ try {
268
+ watcher = watchFile(watchTarget.path, { persistent: false }, () => {
269
+ if (handlers.isLive(watcher)) handlers.onChange();
270
+ });
271
+ } catch (error) {
272
+ // fs.watch throws synchronously when the OS watcher pool is exhausted (EMFILE/ENOSPC). Latch
273
+ // on the stream-scoped target, or the caller's re-poll re-enters here and re-attempts the same
274
+ // failing registration for the rest of the read.
275
+ logger.debug?.(`Could not watch ${filePath} for in-progress writes, polling instead:`, error);
276
+ watchTarget.mustPoll = true;
277
+ return undefined;
278
+ }
279
+ // An FSWatcher that fails after registration emits 'error'; with no listener Node rethrows it out
280
+ // of the watcher callback.
281
+ watcher.on('error', (error) => {
282
+ if (!handlers.isLive(watcher)) return;
283
+ logger.debug?.(`Watch of ${filePath} failed, polling instead:`, error);
284
+ watchTarget.mustPoll = true;
285
+ try {
286
+ watcher.close();
287
+ } catch {
288
+ // A close() that throws here must not skip onFailure — the poll fallback is what recovers
289
+ // the read, and the exception would otherwise escape this listener uncaught.
290
+ }
291
+ handlers.onFailure();
292
+ });
293
+ return watcher;
294
+ }
222
295
  class BlobReadError extends Error {
223
296
  statusCode: number;
224
297
  code?: string;
@@ -234,6 +307,45 @@ class BlobReadError extends Error {
234
307
  if (statusCode === BLOB_GONE_STATUS) this.code = 'ENOENT';
235
308
  }
236
309
  }
310
+ /**
311
+ * Wait until no writer holds the blob's write lock, bounded by the blob read timeout. This is the
312
+ * completeness authority for a compressed body: its header stores the uncompressed size, so unlike
313
+ * an uncompressed blob its length on disk proves nothing, and inflating a partial deflate stream
314
+ * errors. Resolving means the writer (if any) has finished — not that this caller holds the lock.
315
+ */
316
+ function waitForBlobWriteCompletion(storageInfo: StorageInfo): Promise<void> {
317
+ const store = storageInfo.store;
318
+ const lockKey = storageInfo.fileId + ':blob';
319
+ return new Promise((resolve, reject) => {
320
+ // Probe first: the writer lock is free on the overwhelming majority of reads, so arm the
321
+ // timeout only when a writer actually holds it — an uncontended compressed read pays a
322
+ // tryLock, not a setTimeout.
323
+ let settled = false;
324
+ let timer: NodeJS.Timeout;
325
+ function onReleased() {
326
+ if (settled) return;
327
+ settled = true;
328
+ clearTimeout(timer);
329
+ resolve();
330
+ }
331
+ if (store.tryLock(lockKey, onReleased)) {
332
+ store.unlock(lockKey);
333
+ return resolve();
334
+ }
335
+ timer = setTimeout(() => {
336
+ if (settled) return;
337
+ settled = true;
338
+ reject(
339
+ new BlobReadError(
340
+ `Blob ${storageInfo.fileId} is unavailable; the in-progress write did not complete in time`,
341
+ BLOB_UNAVAILABLE_STATUS
342
+ )
343
+ );
344
+ }, getBlobReadTimeout());
345
+ timer.unref();
346
+ });
347
+ }
348
+
237
349
  // We want FileBackedBlob instances to be an instanceof Blob, but we don't want to actually extend the class and call Blob's constructor, which is quite expensive because it has to set it up as a transferrable.
238
350
  function InstanceOfBlobWithNoConstructor() {}
239
351
  InstanceOfBlobWithNoConstructor.prototype = Blob.prototype;
@@ -335,7 +447,6 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
335
447
  }
336
448
 
337
449
  size = Number(headerValue & 0xffffffffffffn);
338
- if (size < end) size = end;
339
450
  if (size < UNKNOWN_SIZE) {
340
451
  if (isFullRead && descriptorSize != null && descriptorSize < UNKNOWN_SIZE && size !== descriptorSize) {
341
452
  // the header claims a different (uncompressed) size than the record descriptor: the file
@@ -465,9 +576,34 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
465
576
  const pending = waitForCompletion(size === UNKNOWN_SIZE, true);
466
577
  if (pending) return pending;
467
578
  return new Promise<Buffer>((resolve, reject) => {
468
- // start/end index into the UNCOMPRESSED content, so inflate first, then slice
469
- inflate(rawBytes.subarray(HEADER_SIZE), (error, result) => {
470
- if (error) reject(error);
579
+ // start/end index into the UNCOMPRESSED content, so inflate first, then slice — with
580
+ // output capped at the header's declared size, so a body that inflates past its own
581
+ // header (a lying or corrupted header) is refused instead of allocating without bound,
582
+ // matching the streaming read path's ceiling. zlib rejects a cap of 0, so an empty
583
+ // blob's cap is 1 and the exact-length check below covers it.
584
+ inflate(rawBytes.subarray(HEADER_SIZE), { maxOutputLength: Math.max(size, 1) }, (error, result) => {
585
+ if (error) {
586
+ const code = (error as { code?: string }).code ?? '';
587
+ reject(
588
+ code === 'ERR_BUFFER_TOO_LARGE'
589
+ ? new BlobReadError(
590
+ `Blob ${filePath} inflates past its declared size of ${size}`,
591
+ BLOB_CORRUPT_STATUS
592
+ )
593
+ : code.startsWith('Z_')
594
+ ? new BlobReadError(
595
+ `Blob ${filePath} is not a valid deflate stream: ${error.message}`,
596
+ BLOB_CORRUPT_STATUS
597
+ )
598
+ : error
599
+ );
600
+ } else if (result.length !== size)
601
+ reject(
602
+ new BlobReadError(
603
+ `Blob ${filePath} inflates to ${result.length} bytes, not its declared size of ${size}`,
604
+ BLOB_CORRUPT_STATUS
605
+ )
606
+ );
471
607
  else resolve(sliceContent(result));
472
608
  });
473
609
  });
@@ -515,6 +651,19 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
515
651
  let position = 0;
516
652
  let totalContentRead = 0;
517
653
  let watcher: FSWatcher;
654
+ // Drop the live watcher before closing it, so a callback racing the close cannot pass its
655
+ // `isLive` check — and so a close() on an already-failed handle cannot abandon the teardown
656
+ // it was part of. Every site that retires this watcher goes through here.
657
+ const closeWatcher = () => {
658
+ const opened = watcher;
659
+ watcher = null;
660
+ try {
661
+ opened?.close();
662
+ } catch (error) {
663
+ logger.debug?.(`Could not close the in-progress watch of ${filePath}:`, error);
664
+ }
665
+ };
666
+ let watchTarget: { path: string; mustPoll: boolean };
518
667
  let timer: NodeJS.Timeout;
519
668
  // The start() open-retry timer lives in a different scope/phase than pull()'s `timer`; track it
520
669
  // separately so a cancel() during the file-creation wait clears it instead of leaking an fd (#1457).
@@ -543,6 +692,181 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
543
692
  // full reads can be compared against the header.
544
693
  const descriptorSize = this.size;
545
694
  const isFullRead = start === undefined && end === undefined;
695
+ let compressedRead: AsyncGenerator<Buffer> | undefined;
696
+ let teardownCompressedRead: (() => void) | undefined;
697
+ // `bodyPrefix` is what the first raw read already pulled past the header; `bodyPosition` is where
698
+ // the file read continues. Both stay valid across the writer wait because a body is only ever
699
+ // appended to, and the stamps that do overwrite it (PENDING/ERROR) change the header re-read below.
700
+ async function* readCompressedBlob(bodyPrefix: Buffer, bodyPosition: number): AsyncGenerator<Buffer> {
701
+ // The writer's lock is the only completeness authority for a compressed body (the header
702
+ // stores the uncompressed size, so body length proves nothing; a partial deflate stream
703
+ // errors on inflate). Wait for it — bounded, like every other blob read wait (#1423).
704
+ await waitForBlobWriteCompletion(storageInfo);
705
+ if (cancelled || fd == null) return;
706
+ // An in-place repair renames a fresh (uncompressed) file over the path while holding the
707
+ // writer lock, so the descriptor we opened before the wait can be left on the orphaned inode.
708
+ // The repair may finish either during our wait or in the gap between the first read and the
709
+ // lock probe, so detect it by file identity rather than by whether we waited: if the path now
710
+ // resolves to a different inode than our descriptor, the file was swapped. Reopen so the header
711
+ // re-read and the stream below see the current file — a repaired (uncompressed) header is then
712
+ // reported retryable 503 (mirroring openStoredBlobBody), and the prefix read from the old inode
713
+ // is discarded. If the inode is unchanged the descriptor is still valid and the same-fd re-read
714
+ // still catches an in-place PENDING/ERROR stamp (harper-pro#481).
715
+ let fileSwapped = false;
716
+ try {
717
+ const currentFd = fd;
718
+ const [openStat, pathStat] = await Promise.all([
719
+ new Promise<{ ino: number }>((res, rej) => fstat(currentFd, (error, s) => (error ? rej(error) : res(s)))),
720
+ new Promise<{ ino: number }>((res, rej) => stat(filePath, (error, s) => (error ? rej(error) : res(s)))),
721
+ ]);
722
+ fileSwapped = openStat.ino !== pathStat.ino;
723
+ } catch {
724
+ // the path is momentarily unstatable, or the descriptor was closed by a concurrent cancel;
725
+ // fall through — the cancellation recheck below returns, and otherwise the header re-read
726
+ // reports the outcome. The blob hold makes a vanished path unlikely here.
727
+ }
728
+ if (cancelled || fd == null) return; // the identity stat awaited; a cancel may have landed
729
+ if (fileSwapped) {
730
+ // Null `fd` before closing it: a closed descriptor number left in `fd` across the await
731
+ // below would be closed a second time by a concurrent cancel()/closeFd() — after the OS may
732
+ // have reassigned it to an unrelated file or socket (#1457).
733
+ const staleFd = fd;
734
+ fd = null;
735
+ close(staleFd);
736
+ fd = await new Promise<number>((resolveOpen, rejectOpen) =>
737
+ open(filePath, 'r', (error, openedFd) => (error ? rejectOpen(error) : resolveOpen(openedFd)))
738
+ );
739
+ if (cancelled) {
740
+ closeFd();
741
+ return;
742
+ }
743
+ bodyPrefix = Buffer.alloc(0);
744
+ bodyPosition = HEADER_SIZE;
745
+ }
746
+ // Re-read the header from the (current) fd: the writer we just waited for finalizes the size
747
+ // in place, and an aborted receive stamps PENDING/ERROR over the same inode (harper-pro#481).
748
+ const header = Buffer.allocUnsafe(HEADER_SIZE);
749
+ const headerBytes = await new Promise<number>((resolveRead, rejectRead) =>
750
+ read(fd, header, 0, HEADER_SIZE, 0, (error, bytesRead) => (error ? rejectRead(error) : resolveRead(bytesRead)))
751
+ );
752
+ if (cancelled || fd == null) return;
753
+ if (headerBytes < HEADER_SIZE) throw new BlobReadError(`Incomplete blob for ${filePath}`, BLOB_CORRUPT_STATUS);
754
+ const headerValue = new DataView(header.buffer, header.byteOffset, HEADER_SIZE).getBigUint64(0);
755
+ const type = Number(headerValue >> 48n);
756
+ if (type === ERROR_TYPE || type === PENDING_TYPE) {
757
+ const message = Buffer.allocUnsafe(256);
758
+ const messageBytes = await new Promise<number>((resolveRead) =>
759
+ read(fd, message, 0, message.length, HEADER_SIZE, (error, bytesRead) => resolveRead(error ? 0 : bytesRead))
760
+ );
761
+ throw type === ERROR_TYPE
762
+ ? new BlobReadError('Error in blob: ' + message.subarray(0, messageBytes), BLOB_CORRUPT_STATUS)
763
+ : new BlobReadError('Blob pending replication for ' + filePath, BLOB_UNAVAILABLE_STATUS);
764
+ }
765
+ const declaredSize = Number(headerValue & 0xffffffffffffn);
766
+ if (type === UNCOMPRESSED_TYPE) {
767
+ // We sniffed DEFLATE, waited, and the file is now uncompressed: an in-place repair
768
+ // replaced it with a healthy uncompressed body. Report retryable so the caller re-reads
769
+ // the current file through the ordinary path rather than misclassifying it as corrupt.
770
+ throw new BlobReadError(`Blob ${filePath} was replaced while reading; retry`, BLOB_UNAVAILABLE_STATUS);
771
+ }
772
+ if (type !== DEFLATE_TYPE || declaredSize === UNKNOWN_SIZE) {
773
+ // the writer is confirmed done, so a placeholder size (or a header no longer readable as
774
+ // deflate) is a write that never finished — confidently incomplete, not merely in progress
775
+ throw new BlobReadError(`Incomplete blob for ${filePath}`, BLOB_CORRUPT_STATUS);
776
+ }
777
+ if (isFullRead && descriptorSize != null && descriptorSize < UNKNOWN_SIZE && declaredSize !== descriptorSize) {
778
+ throw new BlobReadError(
779
+ `Blob size mismatch for ${filePath}: record descriptor expects ${descriptorSize} bytes, on-disk header reports ${declaredSize}`,
780
+ BLOB_CORRUPT_STATUS
781
+ );
782
+ }
783
+ if (blob.size !== declaredSize) {
784
+ (blob as any).size = declaredSize;
785
+ if (blob.#onSize) {
786
+ for (const callback of blob.#onSize) callback(declaredSize);
787
+ }
788
+ }
789
+ // The read stream takes sole ownership of the descriptor (nulling `fd` leaves closeFd() only
790
+ // the hold to release): an fs stream's worker-thread close(2) races any other user of the fd.
791
+ const source = createReadStream(filePath, { fd, start: bodyPosition, autoClose: true });
792
+ fd = null;
793
+ const inflater = createInflate();
794
+ teardownCompressedRead = () => {
795
+ source.destroy();
796
+ inflater.destroy();
797
+ };
798
+ // pipe() does not forward source errors to its destination; without this a disk fault
799
+ // mid-read would be an unhandled 'error' on the source stream.
800
+ source.on('error', (error) => inflater.destroy(error));
801
+ if (bodyPrefix.length > 0) inflater.write(bodyPrefix);
802
+ source.pipe(inflater);
803
+ const sliceStart = start ?? 0;
804
+ let inflatedTotal = 0; // content offset consumed so far (emitted or discarded)
805
+ try {
806
+ for await (const chunk of inflater as AsyncIterable<Buffer>) {
807
+ const chunkStart = inflatedTotal;
808
+ inflatedTotal += chunk.length;
809
+ if (inflatedTotal > declaredSize) {
810
+ // refused before emitting, so a body that lies about its length cannot stream unbounded output
811
+ throw new BlobReadError(
812
+ `Blob ${filePath} inflates past its declared size of ${declaredSize}`,
813
+ BLOB_CORRUPT_STATUS
814
+ );
815
+ }
816
+ const emitFrom = Math.max(sliceStart - chunkStart, 0);
817
+ const emitTo = end === undefined ? chunk.length : Math.min(end - chunkStart, chunk.length);
818
+ if (emitTo > emitFrom)
819
+ yield emitFrom === 0 && emitTo === chunk.length ? chunk : chunk.subarray(emitFrom, emitTo);
820
+ if (end !== undefined && inflatedTotal >= end) return; // slice satisfied; stop inflating
821
+ }
822
+ if (inflatedTotal !== declaredSize) {
823
+ // clean deflate end short of the declared size: torn despite the writer being done
824
+ throw new BlobReadError(
825
+ `Incomplete blob for ${filePath}: inflated to ${inflatedTotal} bytes of a declared ${declaredSize}`,
826
+ BLOB_CORRUPT_STATUS
827
+ );
828
+ }
829
+ } catch (error) {
830
+ if (cancelled) return;
831
+ if (!(error instanceof BlobReadError) && (error as { code?: string }).code?.startsWith?.('Z_')) {
832
+ // the writer is confirmed done, so a zlib failure is real corruption, not an in-progress body
833
+ throw new BlobReadError(
834
+ `Error reading compressed blob for ${filePath}: ${(error as Error).message}`,
835
+ BLOB_CORRUPT_STATUS
836
+ );
837
+ }
838
+ throw error;
839
+ } finally {
840
+ teardownCompressedRead();
841
+ teardownCompressedRead = undefined;
842
+ }
843
+ }
844
+ const pumpCompressedRead = (controller: ReadableStreamDefaultController) =>
845
+ compressedRead.next().then(
846
+ (step) => {
847
+ if (cancelled) return;
848
+ if (step.done) {
849
+ closeFd();
850
+ try {
851
+ controller.close();
852
+ } catch {
853
+ // controller may already be closed
854
+ }
855
+ return;
856
+ }
857
+ try {
858
+ controller.enqueue(step.value);
859
+ } catch (error) {
860
+ logger.debug?.('Error enqueuing chunk', error);
861
+ }
862
+ },
863
+ (error: Error) => {
864
+ closeFd();
865
+ if (cancelled) return;
866
+ blob.#onError?.forEach((callback) => callback(error));
867
+ throw error;
868
+ }
869
+ );
546
870
 
547
871
  return new ReadableStream({
548
872
  start() {
@@ -595,6 +919,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
595
919
  return new Promise(openFile);
596
920
  },
597
921
  pull: (controller) => {
922
+ if (compressedRead) return pumpCompressedRead(controller);
598
923
  let size = 0;
599
924
  let retries = 100;
600
925
  // No-progress deadline for the incomplete-content wait below, mirroring the open-retry loop's
@@ -615,7 +940,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
615
940
  settled = true;
616
941
  closeFd();
617
942
  clearTimeout(timer);
618
- if (watcher) watcher.close();
943
+ closeWatcher();
619
944
  reject(error);
620
945
  blob.#onError?.forEach((callback) => callback(error));
621
946
  }
@@ -663,34 +988,11 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
663
988
  return onError(new BlobReadError('Blob pending replication for ' + filePath, BLOB_UNAVAILABLE_STATUS));
664
989
  }
665
990
  if (buffer[1] === DEFLATE_TYPE) {
666
- // We can't seek/slice a deflate stream by uncompressed offset, so hand off to the
667
- // buffered inflate path (bytes() inflates then slices) and emit it as one chunk.
668
- // Safe by construction: the read loop never streams the raw compressed body.
669
- return blob.bytes().then(
670
- (bytes: Buffer) => {
671
- // bytes() resolves asynchronously; the consumer may have cancelled meanwhile.
672
- // Settle exactly once and route the close through closeFd() so we never touch a
673
- // reassigned/nulled descriptor (#1457).
674
- if (settled || cancelled) return resolve();
675
- settled = true;
676
- closeFd();
677
- if (bytes.length > 0) {
678
- try {
679
- controller.enqueue(bytes);
680
- } catch (error) {
681
- logger.debug?.('Error enqueuing chunk', error);
682
- return resolve();
683
- }
684
- }
685
- try {
686
- controller.close();
687
- } catch {
688
- // controller may already be closed
689
- }
690
- resolve();
691
- },
692
- (error: Error) => onError(error)
693
- );
991
+ // a deflate body cannot be length-checked or offset-seeked: this and every later
992
+ // pull go through the streaming inflate iterator instead of the raw read loop
993
+ settled = true;
994
+ compressedRead = readCompressedBlob(buffer.subarray(HEADER_SIZE, bytesRead), bytesRead);
995
+ return pumpCompressedRead(controller).then(resolve, reject);
694
996
  }
695
997
  size = Number(headerValue & 0xffffffffffffn);
696
998
  if (size < UNKNOWN_SIZE) {
@@ -738,10 +1040,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
738
1040
  );
739
1041
  if (updatedSize === UNKNOWN_SIZE) return false;
740
1042
  size = updatedSize;
741
- if (watcher) {
742
- watcher.close();
743
- watcher = null;
744
- }
1043
+ closeWatcher();
745
1044
  // The header reports a known final size but the bytes at `position` have not arrived.
746
1045
  // Re-entering readMore() synchronously here busy-spins the worker at ~100% CPU on a
747
1046
  // present-but-truncated blob (header rewritten to a self-consistent smaller size, lock
@@ -763,25 +1062,42 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
763
1062
  return true;
764
1063
  };
765
1064
  // the file is not finished being written, watch the file for changes to resume reading
766
- // set up a watcher to be notified of file changes
767
- watcher = watch(filePath, { persistent: false }, () => {
768
- if (watcher) {
769
- watcher.close();
770
- watcher = null;
1065
+ watchTarget ??= resolveWatchTarget(filePath);
1066
+ watcher = watchInProgressFile(filePath, watchTarget, {
1067
+ isLive: (candidate) => watcher === candidate,
1068
+ onChange: () => {
1069
+ closeWatcher();
771
1070
  clearTimeout(timer); // clear it
772
1071
  readMore(resolve, reject);
773
- }
1072
+ },
1073
+ onFailure: () => {
1074
+ watcher = null;
1075
+ clearTimeout(timer);
1076
+ timer = setTimeout(() => readMore(resolve, reject), 20).unref();
1077
+ },
774
1078
  });
775
1079
  // immediately try to read again in case there was a change before we started watching,
776
1080
  // readSync should be fine here, the data should be in memory
777
1081
  if (readSync(fd, buffer, 0, buffer.length, position) > 0) {
778
1082
  // never mind with the watcher, let's read more data
779
- if (watcher) {
780
- watcher.close();
781
- watcher = null;
782
- }
1083
+ closeWatcher();
783
1084
  readMore(resolve, reject);
784
1085
  } else if (!resumeIfWriterFinished()) {
1086
+ if (!watcher) {
1087
+ // Nothing will wake this read, so poll on the deadline resumeIfWriterFinished uses
1088
+ // rather than sitting out the full read timeout and 503-ing a healthy write.
1089
+ if (Date.now() >= incompleteDeadline) {
1090
+ onError(
1091
+ new BlobReadError(
1092
+ `File read timed out reading from ${filePath}, read ${totalContentRead} bytes, but size is supposed to be ${size} bytes`,
1093
+ BLOB_UNAVAILABLE_STATUS
1094
+ )
1095
+ );
1096
+ } else {
1097
+ timer = setTimeout(() => readMore(resolve, reject), 20).unref();
1098
+ }
1099
+ return;
1100
+ }
785
1101
  // set a timer for the watcher too. A write that stalls past the bound returns a
786
1102
  // prompt 503 (retryable) instead of holding the connection for the full 60s (#1423).
787
1103
  timer = setTimeout(() => {
@@ -868,10 +1184,11 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
868
1184
  },
869
1185
  cancel() {
870
1186
  cancelled = true;
1187
+ teardownCompressedRead?.();
871
1188
  closeFd(); // releases the hold, including when cancelled before any open succeeded
872
1189
  clearTimeout(timer);
873
1190
  clearTimeout(openTimer);
874
- if (watcher) watcher.close();
1191
+ closeWatcher();
875
1192
  },
876
1193
  });
877
1194
  function checkIfIsBeingWritten() {
@@ -892,6 +1209,7 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl
892
1209
  if (sourceStorageInfo?.fileId) {
893
1210
  const slicedStorageInfo = {
894
1211
  ...sourceStorageInfo,
1212
+ fileState: (sourceStorageInfo.fileState ??= {}),
895
1213
  start,
896
1214
  end,
897
1215
  };
@@ -926,7 +1244,9 @@ const RECLAMATION_AGE_CAP = 1_200_000;
926
1244
  const HELD_RECHECK_INTERVAL = 1000;
927
1245
 
928
1246
  interface PendingReclamation {
929
- blob: Blob;
1247
+ blobs: WeakRef<Blob>[];
1248
+ seenBlobs: WeakSet<Blob>;
1249
+ fileInfo: BlobFileInfo;
930
1250
  deadline: number;
931
1251
  enqueuedAt: number;
932
1252
  supersededAt: number;
@@ -1056,7 +1376,7 @@ export function holdBlobFile(blob: Blob): (() => void) | null {
1056
1376
  * Whether a record version referencing this file again was written since it was queued — by any
1057
1377
  * worker. Reading clears it, so the next supersession starts from a clean slate.
1058
1378
  */
1059
- function consumeRereferenced(storageInfo: StorageInfo | undefined): boolean {
1379
+ function consumeRereferenced(storageInfo: BlobFileInfo | undefined): boolean {
1060
1380
  const store = storageInfo?.store;
1061
1381
  const fileId = storageInfo?.fileId;
1062
1382
  if (!store || !fileId) return false;
@@ -1077,14 +1397,14 @@ function consumeRereferenced(storageInfo: StorageInfo | undefined): boolean {
1077
1397
  * the retention window. The second of granularity is padded rather than rounded: a snapshot opened
1078
1398
  * in the same second as the supersession is treated as possibly older than it.
1079
1399
  */
1080
- function snapshotStillSees(storageInfo: StorageInfo | undefined, supersededAt: number): boolean {
1400
+ function snapshotStillSees(storageInfo: BlobFileInfo | undefined, supersededAt: number): boolean {
1081
1401
  const oldestSnapshotSeconds = storageInfo?.store?.getOldestSnapshotTimestamp?.();
1082
1402
  if (!oldestSnapshotSeconds) return false;
1083
1403
  return oldestSnapshotSeconds * 1000 <= supersededAt + 1000;
1084
1404
  }
1085
1405
 
1086
1406
  /** Undo a reclaimer's claim once its unlink has landed, leaving the slot usable again. */
1087
- function releaseReclaimClaim(storageInfo: StorageInfo | undefined): void {
1407
+ function releaseReclaimClaim(storageInfo: BlobFileInfo | undefined): void {
1088
1408
  const store = storageInfo?.store;
1089
1409
  const fileId = storageInfo?.fileId;
1090
1410
  if (!store || !fileId) return;
@@ -1096,7 +1416,7 @@ function releaseReclaimClaim(storageInfo: StorageInfo | undefined): void {
1096
1416
  * Whether anything is still using the file. `claim` is for the reclaimer: it atomically takes the
1097
1417
  * count from 0 to RECLAIMING so a hold cannot be acquired between this check and the unlink.
1098
1418
  */
1099
- function isBlobHeld(storageInfo: StorageInfo | undefined, claim = false): boolean {
1419
+ function isBlobHeld(storageInfo: BlobFileInfo | undefined, claim = false): boolean {
1100
1420
  const store = storageInfo?.store;
1101
1421
  const fileId = storageInfo?.fileId;
1102
1422
  if (!store || !fileId) return false;
@@ -1171,8 +1491,23 @@ export function deleteBlob(blob: Blob): void {
1171
1491
  if (state) Atomics.store(state.table, state.slot + REREFERENCED, 0);
1172
1492
  }
1173
1493
  // Reusing the queued entry when two writes supersede the same file keeps the age cap measuring
1174
- // from the first supersession.
1175
- const pending = pendingReclamation.get(filePath) ?? { blob, deadline: 0, enqueuedAt: now, supersededAt: now };
1494
+ // from the first supersession and tracks every live blob instance that carries the condemned fileId.
1495
+ const pending = pendingReclamation.get(filePath) ?? {
1496
+ blobs: [],
1497
+ seenBlobs: new WeakSet<Blob>(),
1498
+ fileInfo: { store: storageInfo?.store, fileId: storageInfo?.fileId },
1499
+ deadline: 0,
1500
+ enqueuedAt: now,
1501
+ supersededAt: now,
1502
+ };
1503
+ if (!pending.seenBlobs.has(blob)) {
1504
+ pending.seenBlobs.add(blob);
1505
+ pending.blobs.push(new WeakRef(blob));
1506
+ }
1507
+ if (pending.unlinking) {
1508
+ if (storageInfo) discardStorage(storageInfo);
1509
+ return;
1510
+ }
1176
1511
  scheduleReclamation(enqueue(filePath, pending, Math.max(pending.deadline, now + getReclamationDelay())));
1177
1512
  }
1178
1513
 
@@ -1219,7 +1554,7 @@ function runReclamation(): void {
1219
1554
  earliest = pending.deadline;
1220
1555
  break; // insertion order is deadline order; nothing behind this entry is due
1221
1556
  }
1222
- const storageInfo = storageInfoForBlob.get(pending.blob);
1557
+ const storageInfo = pending.fileInfo;
1223
1558
  const expired = now - pending.enqueuedAt >= ageCap;
1224
1559
  let held: boolean;
1225
1560
  try {
@@ -1276,6 +1611,14 @@ function runReclamation(): void {
1276
1611
  // Keep the entry until the unlink lands so a concurrent re-reference can tell that the file is
1277
1612
  // already going away instead of silently adopting a doomed path.
1278
1613
  pending.unlinking = true;
1614
+ // Once reclamation has claimed this file, a later write must not preserve its soon-to-be-deleted
1615
+ // fileId. The retention window above still permits legitimate re-references before this point.
1616
+ for (const blobRef of pending.blobs) {
1617
+ const blob = blobRef.deref();
1618
+ if (!blob) continue;
1619
+ const instanceStorageInfo = storageInfoForBlob.get(blob);
1620
+ if (instanceStorageInfo) discardStorage(instanceStorageInfo);
1621
+ }
1279
1622
  unlink(filePath, (error) => {
1280
1623
  pendingReclamation.delete(filePath);
1281
1624
  if (pendingReclamation.size === 0) queueTailDeadline = 0;
@@ -1302,6 +1645,81 @@ export type BlobCreationOptions = {
1302
1645
  size?: number; // the size of the data, if known ahead of time
1303
1646
  saveBeforeCommit?: boolean; // save the blob before the transaction is committed
1304
1647
  };
1648
+ /**
1649
+ * `StorageInfo.contentBuffer` must always hold a `Buffer`: `Buffer.prototype.toString()` decodes
1650
+ * UTF-8, while the inherited `Uint8Array.prototype.toString()` joins the byte values with commas.
1651
+ */
1652
+ function asBuffer(bytes: Uint8Array): Buffer {
1653
+ return Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1654
+ }
1655
+
1656
+ type BlobCompressionEntry = false | { codec?: 'deflate'; threshold?: number };
1657
+ const DEFAULT_COMPRESSION_THRESHOLD = FILE_STORAGE_THRESHOLD;
1658
+ /**
1659
+ * Already-compressed containers (the deployment recorder writes `application/gzip`). An operator entry
1660
+ * for the same key overrides these; a broader operator wildcard does not — specificity beats origin.
1661
+ */
1662
+ const BUILT_IN_COMPRESSION_ENTRIES: Record<string, BlobCompressionEntry> = {
1663
+ 'application/gzip': false,
1664
+ 'application/zip': false,
1665
+ 'application/zstd': false,
1666
+ 'image/*': false,
1667
+ 'video/*': false,
1668
+ 'audio/*': false,
1669
+ };
1670
+ /** The matcher works on the canonical lowercase base media type, with any `;` parameters stripped. */
1671
+ function normalizeContentType(type: string | undefined): string {
1672
+ if (!type) return '';
1673
+ const parametersStart = type.indexOf(';');
1674
+ return (parametersStart === -1 ? type : type.slice(0, parametersStart)).trim().toLowerCase();
1675
+ }
1676
+ // The normalized policy map is rebuilt only when the configured value object changes identity, so
1677
+ // the per-save cost is one config lookup and a reference compare.
1678
+ let compressionPolicyForConfig: unknown;
1679
+ let compressionPolicy: Map<string, BlobCompressionEntry> | null = null;
1680
+ let invalidCompressionConfigLogged = false;
1681
+ function getBlobCompressionPolicy(): Map<string, BlobCompressionEntry> | null {
1682
+ const configured = envGet(CONFIG_PARAMS.STORAGE_BLOBS_COMPRESSION);
1683
+ if (configured === compressionPolicyForConfig) return compressionPolicy;
1684
+ compressionPolicyForConfig = configured;
1685
+ compressionPolicy = null;
1686
+ if (configured == null) return null;
1687
+ if (typeof configured !== 'object' || Array.isArray(configured)) {
1688
+ if (!invalidCompressionConfigLogged) {
1689
+ invalidCompressionConfigLogged = true;
1690
+ logger.warn?.('Ignoring invalid storage.blobs.compression value; expected a map of content types');
1691
+ }
1692
+ return null;
1693
+ }
1694
+ const policy = new Map<string, BlobCompressionEntry>(Object.entries(BUILT_IN_COMPRESSION_ENTRIES));
1695
+ for (const [key, entry] of Object.entries(configured as Record<string, BlobCompressionEntry>)) {
1696
+ policy.set(normalizeContentType(key) || 'default', entry);
1697
+ }
1698
+ compressionPolicy = policy;
1699
+ return policy;
1700
+ }
1701
+ /**
1702
+ * Whether a new blob of `type` and (uncompressed) `size` should be deflate-compressed on disk,
1703
+ * per the opt-in `storage.blobs.compression` map. Absent config means never. Matching precedence
1704
+ * is exact type, then `type/*`, then `default`. An unknown size never compresses: the threshold
1705
+ * cannot be evaluated, and compressing a small streamed write under a large configured threshold
1706
+ * would violate the operator's limit.
1707
+ */
1708
+ export function resolveBlobCompression(type: string | undefined, size: number | undefined): boolean {
1709
+ const policy = getBlobCompressionPolicy();
1710
+ if (!policy) return false;
1711
+ const normalized = normalizeContentType(type);
1712
+ let entry = policy.get(normalized);
1713
+ if (entry === undefined && normalized) {
1714
+ const slash = normalized.indexOf('/');
1715
+ if (slash > 0) entry = policy.get(normalized.slice(0, slash + 1) + '*');
1716
+ }
1717
+ entry ??= policy.get('default');
1718
+ if (!entry) return false;
1719
+ if (entry.codec !== undefined && entry.codec !== 'deflate') return false; // enforced by config validation; fail safe
1720
+ if (size === undefined) return false;
1721
+ return size >= (entry.threshold ?? DEFAULT_COMPRESSION_THRESHOLD);
1722
+ }
1305
1723
  /**
1306
1724
  * Create a blob from a readable stream or a buffer by creating a file in the blob storage path with a new unique internal id, that
1307
1725
  * can be saved/stored.
@@ -1321,7 +1739,7 @@ export function createBlob(
1321
1739
  storageInfoForBlob.set(blob, storageInfo);
1322
1740
  if (source instanceof Uint8Array) {
1323
1741
  blob.size = source.length;
1324
- storageInfo.contentBuffer = source;
1742
+ storageInfo.contentBuffer = asBuffer(source);
1325
1743
  } else if (source instanceof Readable) {
1326
1744
  storageInfo.source = source;
1327
1745
  } else if (typeof source === 'string') storageInfo.contentBuffer = Buffer.from(source);
@@ -1332,6 +1750,25 @@ export function createBlob(
1332
1750
  }
1333
1751
  _assignPackageExport('createBlob', createBlob);
1334
1752
 
1753
+ /**
1754
+ * Create a blob whose source stream carries the STORED representation — an already-deflated body —
1755
+ * rather than content bytes, so a replication receiver can land a peer's compressed blob without
1756
+ * recompressing it. `size` is the uncompressed content length (it goes in the header; a raw
1757
+ * compressed source cannot derive it) and the body is verified on write: it must be a well-formed
1758
+ * deflate stream inflating to exactly `size` bytes, or the save rejects (see
1759
+ * createStoredDeflateVerifier). Internal to replication on purpose — not part of BlobCreationOptions
1760
+ * and not exported to components.
1761
+ */
1762
+ export function createBlobFromStoredBody(
1763
+ source: NodeJS.ReadableStream,
1764
+ options: { type?: string; size: number; codec: 'deflate' }
1765
+ ): Blob {
1766
+ if (options.codec !== 'deflate') throw new Error(`Unsupported stored blob codec: ${options.codec}`);
1767
+ const blob = createBlob(source, { type: options.type, size: options.size });
1768
+ storageInfoForBlob.get(blob).storedCodec = options.codec;
1769
+ return blob;
1770
+ }
1771
+
1335
1772
  // When set (during a migration via encodeBlobsWithFilePath), saveBlob pushes the in-flight
1336
1773
  // writeBlob save promise here so the migration can await every blob's durable write before
1337
1774
  // declaring the database done. Without this, the migration's `await targetDbi.put(...)`
@@ -1345,6 +1782,15 @@ export function saveBlob(blob: FileBackedBlob, deleteOnFailure = false) {
1345
1782
  storageInfo = { storageIndex: 0, fileId: null, store: currentStore };
1346
1783
  storageInfoForBlob.set(blob, storageInfo);
1347
1784
  } else {
1785
+ if (storageInfo.fileState?.discarded) {
1786
+ // The file this blob was saved to has been deleted (an aborted/skipped write's cleanup, or an
1787
+ // explicit delete). Re-storing it would commit a reference to a file that no longer exists —
1788
+ // a permanently unreadable record, and for a replicated one a blob the peer can never fetch.
1789
+ // Fail here, where the cause is still known, instead of at the eventual read (issue #2062).
1790
+ throw new Error(
1791
+ 'Blob was discarded (its file was deleted by an aborted or superseded write) and can no longer be stored; the data must be re-supplied'
1792
+ );
1793
+ }
1348
1794
  if (storageInfo.fileId) return storageInfo; // if there is any file id, we are already saving and can return the info
1349
1795
  storageInfo.store = currentStore;
1350
1796
  }
@@ -1356,6 +1802,13 @@ export function saveBlob(blob: FileBackedBlob, deleteOnFailure = false) {
1356
1802
  }
1357
1803
  return storageInfo; // nothing more to do if it supposed to be saved in the record
1358
1804
  }
1805
+ // The policy resolves here, not in createBlob, because the HTTP-upload path never sees creation
1806
+ // options — every local write funnels through saveBlob. An explicit `compress` option (either
1807
+ // value) and a pre-compressed source both take precedence.
1808
+ if (storageInfo.compress === undefined && !storageInfo.storedCodec) {
1809
+ const size = (blob as FileBackedBlob).size ?? storageInfo.contentBuffer?.length;
1810
+ if (resolveBlobCompression(blob.type, size)) storageInfo.compress = true;
1811
+ }
1359
1812
  generateFilePath(storageInfo);
1360
1813
  if (storageInfo.source) writeBlobWithStream(blob as any, storageInfo.source, storageInfo);
1361
1814
  else if (storageInfo.contentBuffer) writeBlobWithBuffer(blob as any, storageInfo);
@@ -1431,6 +1884,87 @@ export function shouldDestroyIdleBlobSource(paused: boolean, bytesWritten: numbe
1431
1884
  return !(paused && bytesWritten > lastProgressBytes);
1432
1885
  }
1433
1886
 
1887
+ /**
1888
+ * Pass-through for a pre-compressed (stored deflate) blob body that concurrently inflates what it
1889
+ * forwards and fails the pipeline unless the body is a well-formed deflate stream inflating to
1890
+ * exactly `expectedSize` bytes. This is what lets a raw peer-preserved body land under a finalized
1891
+ * DEFLATE header without trusting the peer: a truncated, corrupt, oversized (bomb), or trailing-
1892
+ * garbage body rejects the save instead of being published.
1893
+ */
1894
+ export function createStoredDeflateVerifier(expectedSize: number, fileId: string): Transform {
1895
+ const inflater = createInflate();
1896
+ let inflatedLength = 0;
1897
+ let failure: Error | undefined;
1898
+ // The in-flight transform() callback, kept so a failure can settle it directly. A mid-write
1899
+ // zlib data error (a corrupt, not merely truncated, body) fires 'error'/'close' but NEVER invokes
1900
+ // the pending `inflater.write` callback, so a transform that waited only on that callback would
1901
+ // stall forever — hanging the save pipeline and leaking the blob write lock. settlePending() is
1902
+ // the escape: whichever of the write callback or the failure runs first clears it; the other is a
1903
+ // no-op, so callback() is still called exactly once per chunk.
1904
+ let pending: { callback: (error?: Error | null, data?: Buffer) => void; chunk: Buffer } | undefined;
1905
+ const settlePending = () => {
1906
+ if (!pending) return;
1907
+ const { callback, chunk } = pending;
1908
+ pending = undefined;
1909
+ callback(failure, failure ? undefined : chunk);
1910
+ };
1911
+ const fail = (error: Error) => {
1912
+ failure ??= error;
1913
+ inflater.destroy();
1914
+ settlePending();
1915
+ };
1916
+ inflater.on('data', (inflated: Buffer) => {
1917
+ inflatedLength += inflated.length;
1918
+ if (inflatedLength > expectedSize)
1919
+ fail(new Error(`Pre-compressed blob body for ${fileId} inflates past its declared size of ${expectedSize}`));
1920
+ });
1921
+ inflater.on('error', (error) =>
1922
+ fail(new Error(`Pre-compressed blob body for ${fileId} is not a valid deflate stream`, { cause: error }))
1923
+ );
1924
+ // Trailing data past the final deflate block is detected best-effort (zlib may acknowledge a write
1925
+ // before emitting the early 'end' it triggers); readers ignore trailing bytes, so the residue is
1926
+ // only wasted disk, while truncation, wrong length, invalid stream, and oversize are deterministic.
1927
+ const trailingData = () =>
1928
+ new Error(`Pre-compressed blob body for ${fileId} continues past the end of its deflate stream`);
1929
+ let sawDeflateEnd = false;
1930
+ inflater.on('end', () => {
1931
+ if (!inflater.writableEnded) fail(trailingData());
1932
+ sawDeflateEnd = true;
1933
+ });
1934
+ return new Transform({
1935
+ transform(chunk: Buffer, _encoding, callback) {
1936
+ if (!failure && sawDeflateEnd) fail(trailingData());
1937
+ if (failure) return callback(failure);
1938
+ // Forward the chunk only once zlib has consumed it: this is the backpressure coupling and
1939
+ // what keeps the end/overflow/error detections ordered ahead of the next chunk. If zlib
1940
+ // data-errors while consuming this chunk it drops the write callback, so fail()/settlePending()
1941
+ // settles this transform instead (see the `pending` note above).
1942
+ pending = { callback, chunk };
1943
+ inflater.write(chunk, (writeError) => {
1944
+ if (!pending) return; // already settled by a failure; do not call callback twice
1945
+ pending = undefined;
1946
+ const error = failure ?? (writeError as Error | undefined);
1947
+ callback(error, error ? undefined : chunk);
1948
+ });
1949
+ },
1950
+ flush(callback) {
1951
+ if (failure) return callback(failure);
1952
+ inflater.on('close', () => {
1953
+ if (!failure && inflatedLength !== expectedSize)
1954
+ failure = new Error(
1955
+ `Pre-compressed blob body for ${fileId} inflated to ${inflatedLength} bytes; expected ${expectedSize}`
1956
+ );
1957
+ callback(failure);
1958
+ });
1959
+ inflater.end();
1960
+ },
1961
+ destroy(error, callback) {
1962
+ inflater.destroy();
1963
+ callback(error);
1964
+ },
1965
+ });
1966
+ }
1967
+
1434
1968
  function writeBlobWithStream(
1435
1969
  blob: Blob,
1436
1970
  stream: Readable,
@@ -1444,7 +1978,7 @@ function writeBlobWithStream(
1444
1978
  const repairTargetPath = options?.repairTargetPath;
1445
1979
  const repairing = repairTargetPath !== undefined;
1446
1980
  const repairTempLockKey = options?.repairTempLockKey;
1447
- const { filePath, fileId, store, compress, flush } = storageInfo;
1981
+ const { filePath, fileId, store, compress, storedCodec, flush } = storageInfo;
1448
1982
  storageInfo.saving = new Promise((resolve, reject) => {
1449
1983
  const lockKey = fileId + ':blob';
1450
1984
  let lockAcquired = repairing;
@@ -1489,7 +2023,7 @@ function writeBlobWithStream(
1489
2023
  let headerValue = BigInt(size);
1490
2024
  const header = new Uint8Array(HEADER_SIZE);
1491
2025
  const headerView = new DataView(header.buffer);
1492
- headerValue |= BigInt(compress ? DEFLATE_TYPE : UNCOMPRESSED_TYPE) << 48n;
2026
+ headerValue |= BigInt(compress || storedCodec ? DEFLATE_TYPE : UNCOMPRESSED_TYPE) << 48n;
1493
2027
  headerView.setBigInt64(0, headerValue);
1494
2028
  return header;
1495
2029
  }
@@ -1528,8 +2062,8 @@ function writeBlobWithStream(
1528
2062
  // half-replicated blob returns 503 (retry) instead of 500 (confidently incomplete → the peer
1529
2063
  // advances its resume cursor past it = silent loss, harper-pro#481). Hold the write lock until the
1530
2064
  // marker is durable so no concurrent read/send observes the bare partial file (lock-free + short =
1531
- // classified 500 = the very loss this prevents). The re-stream overwrites this stub
1532
- // (createWriteStream flags 'w'); a terminal give-up on the receive side unlinks it (→ 404). Build
2065
+ // classified 500 = the very loss this prevents). The re-stream builds a fresh blob and so takes a
2066
+ // new file id, leaving this stub for orphan GC; a terminal give-up unlinks it (→ 404). Build
1533
2067
  // the header directly rather than via createHeader so its compress-type OR can't collide with the
1534
2068
  // PENDING type bits.
1535
2069
  // Bounded so a `writeFile` that never calls back cannot leave `saving` un-settled for the
@@ -1633,6 +2167,24 @@ function writeBlobWithStream(
1633
2167
  } else void replaceTarget();
1634
2168
  return;
1635
2169
  }
2170
+ if (compressedStream && (blob as { size?: number }).size !== compressedStream.bytesWritten) {
2171
+ // A known-size compressed write stamped the declared size into the header up front, but
2172
+ // the source ended with a different content length. Unlike the uncompressed path — whose
2173
+ // short body reads as retryable-incomplete (503) — this file would inflate to the wrong
2174
+ // size and every reader would reject it as permanently corrupt (500). Fail the save so the
2175
+ // record never commits a reference to an unreadable file (matches the storedCodec verifier
2176
+ // and the repair size check); the unreferenced file is left for orphan GC. The unknown-size
2177
+ // path syncs blob.size to bytesWritten before reaching here, so it never trips this.
2178
+ store.unlock(lockKey);
2179
+ reject(
2180
+ new Error(
2181
+ `Blob ${fileId} deflated ${compressedStream.bytesWritten} bytes but its header declares ${(blob as { size?: number }).size}`
2182
+ )
2183
+ );
2184
+ close(fd);
2185
+ (writeStream as any).fd = null;
2186
+ return;
2187
+ }
1636
2188
  store.unlock(lockKey);
1637
2189
  if (flush) {
1638
2190
  // we just use fdatasync because we really aren't that concerned with flushing file metadata
@@ -1650,6 +2202,12 @@ function writeBlobWithStream(
1650
2202
  }
1651
2203
  }
1652
2204
  try {
2205
+ if (storedCodec && blob.size === undefined) {
2206
+ // The header must carry the uncompressed length, and it cannot be derived from a raw
2207
+ // compressed source (bytesWritten would be the compressed count). Refuse before any file
2208
+ // exists at the path so the failure reads as a cleanly absent blob, not a torn one.
2209
+ throw new Error(`Cannot store a pre-compressed blob without a known uncompressed size (fileId=${fileId})`);
2210
+ }
1653
2211
  if (!lockAcquired) {
1654
2212
  if (!store.tryLock(lockKey)) throw new Error(`Unable to get lock for blob file ${fileId}`);
1655
2213
  lockAcquired = true;
@@ -1677,7 +2235,13 @@ function writeBlobWithStream(
1677
2235
  stream.on('resume', armIdleTimer);
1678
2236
  armIdleTimer();
1679
2237
  }
1680
- if (compress) {
2238
+ if (storedCodec) {
2239
+ // The source bytes are already the stored deflate representation (a peer preserving its
2240
+ // codec). Verify — not just copy — so a truncated or forged body can never land under a
2241
+ // finalized DEFLATE header: the verifier inflates the pass-through bytes concurrently and
2242
+ // fails the pipeline unless they inflate to exactly the declared uncompressed size.
2243
+ pipeline(stream, createStoredDeflateVerifier(blob.size as number, fileId), writeStream, finished);
2244
+ } else if (compress) {
1681
2245
  if (!wroteSize) writeStream.write(COMPRESS_HEADER);
1682
2246
  compressedStream = createDeflate();
1683
2247
  pipeline(stream, compressedStream, writeStream, finished);
@@ -1724,9 +2288,11 @@ export function getFilePathForBlob(blob: FileBackedBlob): string {
1724
2288
  * Repairs a damaged file-backed blob under its existing fileId. The damage check and atomic file
1725
2289
  * replacement share the blob lock, so a failed repair never modifies the referenced file. Callers
1726
2290
  * must establish an exact record identity tie (same version and source node) and positional blob
1727
- * pairing before calling. The received byte count must match the reported source size and, when
1728
- * present, the stored descriptor. On a synchronous `undefined`, no repair started and `source`
1729
- * remains owned by the caller.
2291
+ * pairing before calling, and classify the file with `blobFileMissingOrIncompleteAsync` first — a
2292
+ * compressed body's damage is only provable by inflating it, which the locked recheck here will
2293
+ * not do. The received byte count must match the reported source size and, when present, the
2294
+ * stored descriptor. On a synchronous `undefined`, no repair started and `source` remains owned
2295
+ * by the caller.
1730
2296
  */
1731
2297
  export function repairBlobFile(
1732
2298
  blob: Blob,
@@ -1789,7 +2355,9 @@ export function repairBlobFile(
1789
2355
  filePath: repairFilePath,
1790
2356
  saving: undefined,
1791
2357
  flush: true,
2358
+ // the repair source is inflated content; its size verification counts uncompressed bytes
1792
2359
  compress: false,
2360
+ storedCodec: undefined,
1793
2361
  };
1794
2362
  writeBlobWithStream(blob as any, source, repairStorageInfo, {
1795
2363
  expectedSize,
@@ -1813,6 +2381,89 @@ export function repairBlobFile(
1813
2381
  }
1814
2382
  }
1815
2383
 
2384
+ /** Fail-closed storage and descriptor errors that must abort the capture instead of producing an unverified backup. */
2385
+ const SYSTEMIC_IO_ERRORS = new Set(['EMFILE', 'ENFILE', 'ENOSPC', 'EIO', 'EROFS']);
2386
+
2387
+ export function isSystemicIoError(error: unknown): boolean {
2388
+ return SYSTEMIC_IO_ERRORS.has((error as { code?: string })?.code ?? '');
2389
+ }
2390
+
2391
+ /**
2392
+ * How a consumer that captures a blob root (backup snapshot, backup archive) should treat one file.
2393
+ * `skip` is not a blob to capture at all; `pending` and `gone` require markers; `capture` is taken as-is.
2394
+ * `capture` means settled as far as the path can show, which is short of a guarantee: a known-size write
2395
+ * that has landed every byte is indistinguishable here from a finished one, and if it then aborts, the
2396
+ * PENDING stamp rewrites that inode in place, truncating any same-filesystem hard link taken from it.
2397
+ * Telling the two apart needs the blob write lock, which is keyed by file id and unreachable from a walk.
2398
+ */
2399
+ export async function classifyBlobFileForCapture(filePath: string): Promise<BlobCaptureDisposition> {
2400
+ if (filePath.endsWith(BLOB_REPAIR_SUFFIX)) return 'skip';
2401
+ let header: Buffer;
2402
+ let fileSize: number;
2403
+ let handle: FileHandle;
2404
+ try {
2405
+ handle = await openFile(filePath, 'r');
2406
+ } catch (error) {
2407
+ // Reclamation can unlink a superseded blob after the engine checkpoint but before this walk. The
2408
+ // checkpointed record may still reference it, so reserve the id. Treat local absence as terminal
2409
+ // rather than making every read wait for repair; this gives up peer repair of that checkpointed version.
2410
+ if ((error as { code?: string }).code === 'ENOENT') return 'gone';
2411
+ throw error;
2412
+ }
2413
+ try {
2414
+ fileSize = (await handle.stat()).size;
2415
+ header = Buffer.alloc(HEADER_SIZE);
2416
+ const { bytesRead } = await handle.read(header, 0, HEADER_SIZE, 0);
2417
+ header = header.subarray(0, bytesRead);
2418
+ } finally {
2419
+ await handle.close();
2420
+ }
2421
+ if (blobHeaderIsAbortMarker(header)) return 'capture';
2422
+ if (blobHeaderIndicatesIncomplete(header, fileSize)) return 'pending';
2423
+ // A deflate header records the *uncompressed* length, so the check above compares lengths only for
2424
+ // UNCOMPRESSED_TYPE and a short compressed body reaches here looking whole. Both producers of one
2425
+ // need this: saveBlob stamps a known size before the first compressed byte, so a live write is
2426
+ // invisible above and caught only here, and an unclean shutdown can leave a torn body the
2427
+ // asynchronous repair sweep has not reached yet. Compression is opt-in via storage.blobs.compression
2428
+ // (off by default): on a corpus with it disabled this branch is never taken, but where an operator
2429
+ // enables it each completeness check fully inflates the compressed body it reaches.
2430
+ if (header[1] !== DEFLATE_TYPE) return 'capture';
2431
+ const uncompressedSize = Number(
2432
+ new DataView(header.buffer, header.byteOffset, HEADER_SIZE).getBigUint64(0) & 0xffffffffffffn
2433
+ );
2434
+ return (await inflatesToExactly(filePath, uncompressedSize)) ? 'capture' : 'pending';
2435
+ }
2436
+
2437
+ /**
2438
+ * Whether the header is a deliberate abort marker rather than content. Written once and never
2439
+ * rewritten, so a consumer sharing blob inodes can keep them: dropping a PENDING marker downgrades a
2440
+ * retryable 503 to a 404 the replication layer reads as "cleanly gone" (harper-pro#481).
2441
+ */
2442
+ function blobHeaderIsAbortMarker(header: Buffer): boolean {
2443
+ if (header.length < HEADER_SIZE) return false;
2444
+ const type = header.readUInt16BE(0);
2445
+ return type === PENDING_TYPE || type === ERROR_TYPE;
2446
+ }
2447
+
2448
+ /**
2449
+ * What a consumer capturing a blob root should do with one file. `pending` is a blob that was not
2450
+ * whole *yet* (retryable 503); `gone` is absent from this capture and represented as terminal 500.
2451
+ * Both still put a file at the id, because `getNextFileId` recovers the counter by scanning the
2452
+ * directory and an absent file lets a restored record's id be reissued.
2453
+ */
2454
+ export type BlobCaptureDisposition = 'skip' | 'capture' | 'pending' | 'gone';
2455
+
2456
+ /** The stand-in bytes for a blob that could not be captured whole. */
2457
+ export function createCaptureMarker(disposition: 'pending' | 'gone', message: string): Buffer {
2458
+ const messageBuffer = Buffer.from(message);
2459
+ const header = Buffer.alloc(HEADER_SIZE);
2460
+ new DataView(header.buffer, header.byteOffset, HEADER_SIZE).setBigInt64(
2461
+ 0,
2462
+ BigInt(messageBuffer.length) | (BigInt(disposition === 'gone' ? ERROR_TYPE : PENDING_TYPE) << 48n)
2463
+ );
2464
+ return Buffer.concat([header, messageBuffer]);
2465
+ }
2466
+
1816
2467
  export function blobHeaderIndicatesIncomplete(header: Buffer, fileSize: number): boolean {
1817
2468
  if (fileSize < HEADER_SIZE || header.length < HEADER_SIZE) return true;
1818
2469
  const type = header.readUInt16BE(0);
@@ -1823,10 +2474,78 @@ export function blobHeaderIndicatesIncomplete(header: Buffer, fileSize: number):
1823
2474
  }
1824
2475
 
1825
2476
  /**
1826
- * Whether a file-backed blob's backing file is missing or incomplete on disk — the gate for the
1827
- * copy-delivery repair. This blocking probe is the locked final recheck after the caller's async
1828
- * exact-duplicate prefilter. Compressed bodies require the asynchronous repair sweep for
1829
- * verification. Returns undefined for blobs the question does not apply to.
2477
+ * What a blob file's header and length alone say about its completeness: `true` when the file is
2478
+ * missing, short, unfinished, an error/pending stub, or disagrees with the record descriptor; `false`
2479
+ * when an uncompressed body is whole; `null` for a deflate body, whose header records the uncompressed
2480
+ * length and so proves nothing about the body — only inflating it can. `undefined` when the question
2481
+ * does not apply or the file could not be inspected.
2482
+ */
2483
+ function classifyBlobFileByHeader(
2484
+ blob: Blob,
2485
+ fileSize: number,
2486
+ header: Buffer,
2487
+ headerBytes: number
2488
+ ): boolean | null | undefined {
2489
+ if (fileSize < HEADER_SIZE || headerBytes < HEADER_SIZE) return true;
2490
+ if (blobHeaderIndicatesIncomplete(header, fileSize)) return true;
2491
+ const descriptorSize = (blob as { size?: number }).size;
2492
+ if (descriptorSize !== undefined && header.readUIntBE(2, 6) !== descriptorSize) return true;
2493
+ return header.readUInt16BE(0) === DEFLATE_TYPE ? null : false;
2494
+ }
2495
+
2496
+ /**
2497
+ * Whether a file-backed blob's backing file is missing or incomplete on disk — the unlocked,
2498
+ * non-blocking classification a copy-delivery repair candidate gets first (harper-pro#699). A deflate
2499
+ * body is inflated (streamed, off the event loop) to decide; the file it judged damaged is remembered
2500
+ * so the locked recheck in `repairBlobFile` can recognize it without inflating again. Returns
2501
+ * undefined for blobs the question does not apply to, or that could not be inspected.
2502
+ */
2503
+ export async function blobFileMissingOrIncompleteAsync(blob: Blob): Promise<boolean | undefined> {
2504
+ if (!(blob instanceof FileBackedBlob)) return undefined;
2505
+ const storageInfo = storageInfoForBlob.get(blob);
2506
+ if (!storageInfo?.fileId) return undefined;
2507
+ storageInfo.probedDamage = undefined; // every probe starts over; no early exit may leave a stale verdict behind
2508
+ const filePath = getFilePath(storageInfo);
2509
+ let file: FileHandle;
2510
+ try {
2511
+ file = await openFile(filePath, 'r');
2512
+ } catch (error) {
2513
+ if ((error as { code?: string })?.code === 'ENOENT') return true;
2514
+ logger.debug?.('Unable to open blob file for in-place repair inspection', error);
2515
+ return undefined;
2516
+ }
2517
+ let fileSize: number;
2518
+ const header = Buffer.allocUnsafe(HEADER_SIZE);
2519
+ let verdict: boolean | null | undefined;
2520
+ try {
2521
+ fileSize = (await file.stat()).size;
2522
+ const { bytesRead } = await file.read(header, 0, HEADER_SIZE, 0);
2523
+ verdict = classifyBlobFileByHeader(blob, fileSize, header, bytesRead);
2524
+ } catch (error) {
2525
+ logger.debug?.('Unable to inspect blob file for in-place repair', error);
2526
+ return undefined;
2527
+ } finally {
2528
+ await file.close().catch(() => {});
2529
+ }
2530
+ if (verdict !== null) return verdict;
2531
+ try {
2532
+ verdict = !(await inflatesToExactly(filePath, header.readUIntBE(2, 6)));
2533
+ } catch (error) {
2534
+ logger.debug?.('Unable to inspect blob file for in-place repair', error);
2535
+ return undefined;
2536
+ }
2537
+ if (verdict) storageInfo.probedDamage = { fileSize, header };
2538
+ return verdict;
2539
+ }
2540
+
2541
+ /**
2542
+ * The locked, synchronous recheck of `blobFileMissingOrIncompleteAsync`'s verdict, run by
2543
+ * `repairBlobFile` under the blob's write lock so a repair can only replace a file no writer is
2544
+ * still producing. It repeats the header classification, and for a deflate body — which it cannot
2545
+ * afford to inflate on the event loop — answers `true` only while the file is still the one the
2546
+ * probe found damaged: a body can only grow (writers append; a repair replaces the file) so an
2547
+ * unchanged length and header mean unchanged bytes, and anything else is `undefined` (not provably
2548
+ * damaged — decline, and let the next probe or the repair sweep classify it).
1830
2549
  */
1831
2550
  export function blobFileMissingOrIncomplete(blob: Blob): boolean | undefined {
1832
2551
  try {
@@ -1843,13 +2562,12 @@ export function blobFileMissingOrIncomplete(blob: Blob): boolean | undefined {
1843
2562
  return undefined;
1844
2563
  }
1845
2564
  try {
1846
- const size = fstatSync(fd).size;
1847
- if (size < HEADER_SIZE) return true;
2565
+ const fileSize = fstatSync(fd).size;
1848
2566
  const header = Buffer.allocUnsafe(HEADER_SIZE);
1849
- if (readSync(fd, header, 0, HEADER_SIZE, 0) < HEADER_SIZE) return true;
1850
- if (blobHeaderIndicatesIncomplete(header, size)) return true;
1851
- const descriptorSize = (blob as { size?: number }).size;
1852
- return descriptorSize !== undefined && header.readUIntBE(2, 6) !== descriptorSize;
2567
+ const verdict = classifyBlobFileByHeader(blob, fileSize, header, readSync(fd, header, 0, HEADER_SIZE, 0));
2568
+ if (verdict !== null) return verdict;
2569
+ const probed = storageInfo.probedDamage;
2570
+ return probed?.fileSize === fileSize && probed.header.equals(header) ? true : undefined;
1853
2571
  } finally {
1854
2572
  closeSync(fd);
1855
2573
  }
@@ -2377,6 +3095,10 @@ export function cleanupUnusedBlobs(blobs: Blob[] | undefined, retainedFileIds?:
2377
3095
  const storageInfo = storageInfoForBlob.get(blob);
2378
3096
  if (!storageInfo?.fileId || (blob as FileBackedBlob).saveInRecord) continue; // no file written, nothing to clean up
2379
3097
  if (retainedFileIds?.has(storageInfo.fileId)) continue; // the committed record still references this blob
3098
+ // Tombstone the instance as soon as the deletion is DECIDED, not when the unlink is issued: the
3099
+ // unlink waits for an in-flight save to settle, and a re-store in that window would otherwise
3100
+ // mint a reference to a file that is already condemned (issue #2062).
3101
+ discardStorage(storageInfo);
2380
3102
  const settle = storageInfo.saving ?? Promise.resolve();
2381
3103
  settle.then(
2382
3104
  () => deleteBlob(blob),
@@ -2431,7 +3153,7 @@ addExtension({
2431
3153
  storageInfoForBlob.set(blob, {
2432
3154
  storageIndex: 0,
2433
3155
  fileId: null,
2434
- contentBuffer: blobInfo[1] as any,
3156
+ contentBuffer: asBuffer(blobInfo[1] as Uint8Array),
2435
3157
  });
2436
3158
  blob.size = blobInfo[1]?.length;
2437
3159
  }
@@ -2465,6 +3187,11 @@ addExtension({
2465
3187
  throw new Error('Unable to save blob without file id');
2466
3188
  }
2467
3189
  storageInfo.recordId = encodeForStorageForRecordId;
3190
+ // Per-node hint only — a replication sender uses it to skip the header sniff for the
3191
+ // uncompressed majority, but always confirms against the local file header before sending
3192
+ // raw: a relayed record (or an in-place repair) can outlive the storage form recorded here.
3193
+ // Namespaced away from `codec`, which a media blob plausibly carries as its own property.
3194
+ if (storageInfo.compress || storageInfo.storedCodec) options.storedCodec = 'deflate';
2468
3195
  // A record version being written now references this file, so any reclamation queued by an
2469
3196
  // earlier supersession is void — the retain-on-update check in RecordEncoder only covers the
2470
3197
  // write that supersedes, not a file already awaiting reclamation from a previous one.
@@ -2574,7 +3301,7 @@ function polyfillBlob() {
2574
3301
  * @param database
2575
3302
  */
2576
3303
  export async function cleanupOrphans(database: any, databaseName?: string) {
2577
- const { HAS_BLOBS } = await import('./auditStore.ts');
3304
+ const { HAS_BLOBS, isAuditEntryWrite } = await import('./auditStore.ts');
2578
3305
  let store: RootDatabase;
2579
3306
  let auditStore: RootDatabase;
2580
3307
  let orphansDeleted = 0;
@@ -2656,7 +3383,10 @@ export async function cleanupOrphans(database: any, databaseName?: string) {
2656
3383
  const primaryStore = (auditStore as any).tableStores[(auditRecord as any).tableId];
2657
3384
  if (!primaryStore) continue;
2658
3385
  const entry = primaryStore?.getEntry((auditRecord as any).recordId);
2659
- if (!entry || entry.version !== auditRecord.version || !entry.value) {
3386
+ // Only the write this audit record describes had its blobs scanned by the table loop above.
3387
+ // Identity is the log key, not the version: a version match can name a different write and
3388
+ // would skip an audit value whose blobs are still referenced.
3389
+ if (!entry?.value || !isAuditEntryWrite(entry, auditRecord as any)) {
2660
3390
  checkObjectForReferences((auditRecord as any).getValue(primaryStore));
2661
3391
  }
2662
3392
  // slow this down a bit to reduce excessive load, this runs approximately at 10k per second
@@ -2713,10 +3443,12 @@ export async function cleanupOrphans(database: any, databaseName?: string) {
2713
3443
  }
2714
3444
 
2715
3445
  async function isBlobFileComplete(storageInfo: StorageInfo): Promise<boolean> {
2716
- let filePath: string;
3446
+ return isBlobFileCompleteAtPath(getFilePath(storageInfo));
3447
+ }
3448
+
3449
+ async function isBlobFileCompleteAtPath(filePath: string): Promise<boolean> {
2717
3450
  let fileSize: number;
2718
3451
  try {
2719
- filePath = getFilePath(storageInfo);
2720
3452
  fileSize = statSync(filePath).size;
2721
3453
  } catch (e) {
2722
3454
  if ((e as any).code === 'ENOENT') return false;
@@ -2738,32 +3470,55 @@ async function isBlobFileComplete(storageInfo: StorageInfo): Promise<boolean> {
2738
3470
  // for a compressed blob it does not, so the body length can't be compared to it directly.
2739
3471
  const size = Number(headerValue & 0xffffffffffffn);
2740
3472
  if (header[1] === DEFLATE_TYPE) {
2741
- // A compressed blob's header size is the uncompressed length, so it can't be compared to the
2742
- // compressed on-disk body. Verify by streaming the body through inflate and counting the
2743
- // decompressed bytes: a fully-written deflate stream inflates to exactly `size` bytes; a
2744
- // truncated one errors (Z_BUF_ERROR) or yields fewer. Streaming (rather than inflateSync on
2745
- // the whole buffer) keeps memory bounded during the repair sweep, which may touch many large
2746
- // blobs.
2747
- return new Promise<boolean>((resolve) => {
2748
- let inflatedLength = 0;
2749
- const source = createReadStream(filePath, { start: HEADER_SIZE });
2750
- const inflate = createInflate();
2751
- const fail = () => {
2752
- source.destroy();
2753
- resolve(false);
2754
- };
2755
- source.on('error', fail);
2756
- inflate.on('error', fail);
2757
- inflate.on('data', (chunk: Buffer) => {
2758
- inflatedLength += chunk.length;
2759
- });
2760
- inflate.on('end', () => resolve(inflatedLength === size));
2761
- source.pipe(inflate);
2762
- });
3473
+ // This function's contract is to resolve true/false; harper-pro's repair sweep awaits it without a
3474
+ // catch (replication/blobRepair.ts), so an I/O fault must not become a rejection here. The capture
3475
+ // classifier calls inflatesToExactly directly, where the distinction does matter.
3476
+ return inflatesToExactly(filePath, size).catch(() => false);
2763
3477
  }
2764
3478
  return true;
2765
3479
  }
2766
3480
 
3481
+ /**
3482
+ * Whether the deflate body after the header inflates to exactly `size` bytes. A compressed blob's
3483
+ * header records the uncompressed length, so it cannot be compared to the on-disk body; a truncated
3484
+ * stream errors (Z_BUF_ERROR) or yields fewer bytes. Streamed rather than inflateSync so memory stays
3485
+ * bounded over a sweep that may touch many large blobs, and abandoned as soon as the output passes
3486
+ * `size` so a body that lies about its length costs no more than the length it claimed.
3487
+ */
3488
+ export function inflatesToExactly(filePath: string, size: number): Promise<boolean> {
3489
+ return new Promise<boolean>((resolve, reject) => {
3490
+ let inflatedLength = 0;
3491
+ const source = createReadStream(filePath, { start: HEADER_SIZE });
3492
+ const inflate = createInflate();
3493
+ // Both: pipe() does not tear down the destination when the source errors, so the inflate's
3494
+ // native zlib handle would sit allocated until GC — once per blob, on every backup walk.
3495
+ const stop = () => {
3496
+ source.destroy();
3497
+ inflate.destroy();
3498
+ };
3499
+ // A zlib error is the answer (body truncated or corrupt); an I/O error is a failure to answer and
3500
+ // must propagate, or a systemic fault would classify a corpus of complete blobs as incomplete.
3501
+ // The reject arm is unverified: it needs a read fault raised mid-inflate on a file that opened
3502
+ // cleanly, which no test here can produce.
3503
+ const fail = (error: NodeJS.ErrnoException) => {
3504
+ stop();
3505
+ if (error?.code?.startsWith('Z_')) resolve(false);
3506
+ else reject(error);
3507
+ };
3508
+ source.on('error', fail);
3509
+ inflate.on('error', fail);
3510
+ inflate.on('data', (chunk: Buffer) => {
3511
+ inflatedLength += chunk.length;
3512
+ if (inflatedLength > size) {
3513
+ stop();
3514
+ resolve(false);
3515
+ }
3516
+ });
3517
+ inflate.on('end', () => resolve(inflatedLength === size));
3518
+ source.pipe(inflate);
3519
+ });
3520
+ }
3521
+
2767
3522
  /**
2768
3523
  * Resolves true if the given blob's backing file is present and complete: no error stub, not an
2769
3524
  * in-flight placeholder, and the content matches the header (uncompressed blobs by body length,
@@ -2777,6 +3532,170 @@ export async function isBlobComplete(blob: Blob): Promise<boolean> {
2777
3532
  return isBlobFileComplete(storageInfo);
2778
3533
  }
2779
3534
 
3535
+ export type StoredBlobBody = {
3536
+ codec: 'deflate';
3537
+ size: number; // uncompressed content length, from the header
3538
+ /** The raw post-header bytes. Single-use; releases the file on completion, error, or teardown. */
3539
+ stream: () => AsyncGenerator<Buffer>;
3540
+ /** Release the descriptor and file hold without streaming. */
3541
+ close: () => void;
3542
+ };
3543
+
3544
+ /**
3545
+ * Open a blob's STORED body for transfer, without inflating it — the sender half of replication's
3546
+ * codec preservation. Returns undefined whenever the stored form is not a settled deflate file
3547
+ * (uncompressed, sliced, still being written, missing, or already condemned); the caller then falls
3548
+ * back to the ordinary content stream, which knows how to wait and how to report errors.
3549
+ *
3550
+ * Deliberately synchronous through the decision: the caller decides raw-vs-inflated inside the same
3551
+ * synchronous block that puts the owning record frame on the wire. The stream opens its own
3552
+ * descriptor later and re-reads the header first: a settled file's header never changes except by
3553
+ * a repair publishing a fresh (uncompressed) file over the path, so a header that differs from the
3554
+ * sniffed one means the announced form is gone — reported as transient (503), since a retry
3555
+ * re-decides against the new file. The stream verifies as it sends — a concurrent inflate must
3556
+ * land on exactly `size` bytes — so a torn body that survived the header checks becomes a loud,
3557
+ * permanently-classified error instead of replicated garbage.
3558
+ */
3559
+ export function openStoredBlobBody(blob: Blob): StoredBlobBody | undefined {
3560
+ if (!(blob instanceof FileBackedBlob)) return undefined;
3561
+ const storageInfo = storageInfoForBlob.get(blob);
3562
+ if (!storageInfo?.fileId || !storageInfo.store) return undefined;
3563
+ if (storageInfo.start !== undefined || storageInfo.end !== undefined) return undefined;
3564
+ if (storageInfo.fileState?.discarded) return undefined;
3565
+ const releaseHold = holdBlobFile(blob);
3566
+ if (!releaseHold) return undefined; // already being reclaimed
3567
+ let fd: number | undefined;
3568
+ let filePath: string;
3569
+ const decline = () => {
3570
+ if (fd !== undefined) {
3571
+ try {
3572
+ closeSync(fd);
3573
+ } catch (error) {
3574
+ logger.debug?.('Error closing stored blob body descriptor', error);
3575
+ }
3576
+ }
3577
+ releaseHold();
3578
+ return undefined;
3579
+ };
3580
+ let size: number;
3581
+ const header = Buffer.allocUnsafe(HEADER_SIZE);
3582
+ try {
3583
+ const lockKey = storageInfo.fileId + ':blob';
3584
+ // A held write lock means the body may still be streaming to disk; the normal read path knows
3585
+ // how to wait for it, so decline rather than duplicate that machinery here.
3586
+ if (!storageInfo.store.tryLock(lockKey)) return decline();
3587
+ storageInfo.store.unlock(lockKey);
3588
+ filePath = getFilePath(storageInfo);
3589
+ fd = openSync(filePath, 'r');
3590
+ if (readSync(fd, header, 0, HEADER_SIZE, 0) < HEADER_SIZE) return decline();
3591
+ const headerValue = new DataView(header.buffer, header.byteOffset, HEADER_SIZE).getBigUint64(0);
3592
+ if (Number(headerValue >> 48n) !== DEFLATE_TYPE) return decline();
3593
+ size = Number(headerValue & 0xffffffffffffn);
3594
+ if (size === UNKNOWN_SIZE) return decline();
3595
+ const descriptorSize = (blob as { size?: number }).size;
3596
+ if (descriptorSize !== undefined && descriptorSize < UNKNOWN_SIZE && descriptorSize !== size) return decline();
3597
+ closeSync(fd);
3598
+ fd = undefined;
3599
+ } catch (error) {
3600
+ logger.debug?.('Unable to open stored blob body', storageInfo.fileId, error);
3601
+ return decline();
3602
+ }
3603
+ let holdReleased = false;
3604
+ const releaseHoldOnce = () => {
3605
+ if (holdReleased) return;
3606
+ holdReleased = true;
3607
+ releaseHold();
3608
+ };
3609
+ let streamStarted = false;
3610
+ const failVerification = (detail: string) =>
3611
+ new BlobReadError(`Stored blob body for ${filePath} ${detail}`, BLOB_CORRUPT_STATUS);
3612
+ return {
3613
+ codec: 'deflate',
3614
+ size,
3615
+ close: () => {
3616
+ streamStarted = true;
3617
+ releaseHoldOnce();
3618
+ },
3619
+ stream: async function* () {
3620
+ if (streamStarted) throw new Error(`Stored blob body for ${filePath} was already consumed or closed`);
3621
+ streamStarted = true;
3622
+ let streamFd: number | undefined;
3623
+ let source: ReadStream;
3624
+ try {
3625
+ streamFd = openSync(filePath, 'r');
3626
+ const currentHeader = Buffer.allocUnsafe(HEADER_SIZE);
3627
+ const headerBytes = readSync(streamFd, currentHeader, 0, HEADER_SIZE, 0);
3628
+ if (headerBytes < HEADER_SIZE || !currentHeader.equals(header)) {
3629
+ throw new BlobReadError(
3630
+ `Stored blob body for ${filePath} was replaced before it was streamed`,
3631
+ BLOB_UNAVAILABLE_STATUS
3632
+ );
3633
+ }
3634
+ // the stream owns the descriptor from here (an fs stream's worker-thread close(2) races any other user)
3635
+ source = createReadStream(filePath, { fd: streamFd, start: HEADER_SIZE, autoClose: true });
3636
+ } catch (error) {
3637
+ if (streamFd !== undefined) closeSync(streamFd);
3638
+ releaseHoldOnce();
3639
+ throw error;
3640
+ }
3641
+ const verifier = createInflate();
3642
+ let inflated = 0;
3643
+ let verifyFailure: Error | undefined;
3644
+ // A mid-write zlib data error (a corrupt, not merely truncated, body) fires 'error'/'close'
3645
+ // but drops the pending `verifier.write` callback, so the per-chunk await below would hang
3646
+ // forever — suspending this generator inside the try and leaking the descriptor and the file
3647
+ // hold its finally releases. settleWrite() unblocks it from error/close too; whichever runs
3648
+ // first resolves the promise and clears the ref, so the write callback is a harmless no-op.
3649
+ let resolvePendingWrite: (() => void) | undefined;
3650
+ const settleWrite = () => {
3651
+ const resolve = resolvePendingWrite;
3652
+ resolvePendingWrite = undefined;
3653
+ resolve?.();
3654
+ };
3655
+ verifier.on('data', (chunk: Buffer) => {
3656
+ inflated += chunk.length;
3657
+ if (inflated > size && !verifyFailure) {
3658
+ verifyFailure = failVerification(`inflates past its declared size of ${size}`);
3659
+ verifier.destroy();
3660
+ }
3661
+ });
3662
+ verifier.on('error', (error) => {
3663
+ verifyFailure ??= failVerification(`is not a valid deflate stream: ${error.message}`);
3664
+ settleWrite();
3665
+ });
3666
+ verifier.on('close', settleWrite); // a destroy() with no 'error' (oversize) also drops the write callback
3667
+ try {
3668
+ for await (const chunk of source as AsyncIterable<Buffer>) {
3669
+ if (verifyFailure) throw verifyFailure;
3670
+ if (!verifier.destroyed) {
3671
+ // Yield only once the verifier consumed the chunk, so a detection surfaces before the
3672
+ // bytes it condemns are handed to the caller's wire frame (and never after the caller
3673
+ // has already seen a clean end).
3674
+ await new Promise<void>((resolveWrite) => {
3675
+ resolvePendingWrite = resolveWrite;
3676
+ verifier.write(chunk, settleWrite);
3677
+ });
3678
+ if (verifyFailure) throw verifyFailure;
3679
+ }
3680
+ yield chunk;
3681
+ }
3682
+ if (!verifier.destroyed) {
3683
+ await new Promise<void>((resolveEnd) => {
3684
+ verifier.on('close', resolveEnd);
3685
+ verifier.end();
3686
+ });
3687
+ }
3688
+ if (verifyFailure) throw verifyFailure;
3689
+ if (inflated !== size) throw failVerification(`inflated to ${inflated} bytes of a declared ${size}`);
3690
+ } finally {
3691
+ source.destroy();
3692
+ verifier.destroy();
3693
+ releaseHoldOnce();
3694
+ }
3695
+ },
3696
+ };
3697
+ }
3698
+
2780
3699
  /**
2781
3700
  * Async generator that yields records whose referenced blob files are missing, truncated, or in an
2782
3701
  * error state. Used by the blob repair sweep to find candidates for peer-fetched recovery.