@harperfast/harper 5.2.0-beta.4 → 5.2.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 (320) hide show
  1. package/bin/backup.ts +169 -0
  2. package/bin/cliOperations.ts +92 -73
  3. package/bin/copyDb.ts +21 -4
  4. package/bin/harper.ts +43 -55
  5. package/bin/help.ts +216 -0
  6. package/components/Application.ts +236 -46
  7. package/components/ApplicationScope.ts +26 -0
  8. package/components/EntryHandler.ts +410 -105
  9. package/components/RuntimeModuleTracker.ts +189 -0
  10. package/components/Scope.ts +120 -30
  11. package/components/componentLoader.ts +177 -25
  12. package/components/deployLifecycle.ts +119 -33
  13. package/components/mcp/toolRegistry.ts +10 -0
  14. package/components/mcp/tools/application.ts +12 -5
  15. package/components/mcp/tools/operations.ts +9 -0
  16. package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
  17. package/components/mcp/tools/schemas/operations.ts +9 -0
  18. package/components/operations.js +5 -6
  19. package/components/operationsValidation.js +32 -2
  20. package/components/scopeMount.ts +150 -0
  21. package/config/configUtils.ts +12 -9
  22. package/config-root.schema.json +14 -0
  23. package/dataLayer/backupManifest.ts +102 -0
  24. package/dataLayer/blobBackup.ts +286 -0
  25. package/dataLayer/harperBridge/ResourceBridge.ts +69 -5
  26. package/dataLayer/hdbInfoController.ts +8 -0
  27. package/dataLayer/restoreMarker.ts +276 -0
  28. package/dataLayer/rocksdbBackup.ts +1100 -0
  29. package/dataLayer/schemaDescribe.ts +2 -1
  30. package/dist/bin/backup.d.ts +9 -0
  31. package/dist/bin/backup.js +192 -0
  32. package/dist/bin/backup.js.map +1 -0
  33. package/dist/bin/cliOperations.d.ts +13 -0
  34. package/dist/bin/cliOperations.js +89 -70
  35. package/dist/bin/cliOperations.js.map +1 -1
  36. package/dist/bin/copyDb.js +13 -2
  37. package/dist/bin/copyDb.js.map +1 -1
  38. package/dist/bin/harper.d.ts +13 -0
  39. package/dist/bin/harper.js +45 -56
  40. package/dist/bin/harper.js.map +1 -1
  41. package/dist/bin/help.d.ts +8 -0
  42. package/dist/bin/help.js +192 -0
  43. package/dist/bin/help.js.map +1 -0
  44. package/dist/components/Application.d.ts +16 -1
  45. package/dist/components/Application.js +210 -38
  46. package/dist/components/Application.js.map +1 -1
  47. package/dist/components/ApplicationScope.d.ts +7 -0
  48. package/dist/components/ApplicationScope.js +22 -0
  49. package/dist/components/ApplicationScope.js.map +1 -1
  50. package/dist/components/EntryHandler.d.ts +4 -4
  51. package/dist/components/EntryHandler.js +386 -95
  52. package/dist/components/EntryHandler.js.map +1 -1
  53. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  54. package/dist/components/RuntimeModuleTracker.js +189 -0
  55. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  56. package/dist/components/Scope.d.ts +38 -1
  57. package/dist/components/Scope.js +117 -28
  58. package/dist/components/Scope.js.map +1 -1
  59. package/dist/components/componentLoader.d.ts +2 -6
  60. package/dist/components/componentLoader.js +160 -18
  61. package/dist/components/componentLoader.js.map +1 -1
  62. package/dist/components/deployLifecycle.d.ts +6 -2
  63. package/dist/components/deployLifecycle.js +109 -31
  64. package/dist/components/deployLifecycle.js.map +1 -1
  65. package/dist/components/mcp/toolRegistry.d.ts +7 -0
  66. package/dist/components/mcp/toolRegistry.js +10 -0
  67. package/dist/components/mcp/toolRegistry.js.map +1 -1
  68. package/dist/components/mcp/tools/application.js +11 -5
  69. package/dist/components/mcp/tools/application.js.map +1 -1
  70. package/dist/components/mcp/tools/operations.js +9 -0
  71. package/dist/components/mcp/tools/operations.js.map +1 -1
  72. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
  73. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  74. package/dist/components/mcp/tools/schemas/operations.js +9 -0
  75. package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
  76. package/dist/components/operations.js +6 -6
  77. package/dist/components/operations.js.map +1 -1
  78. package/dist/components/operationsValidation.js +34 -2
  79. package/dist/components/operationsValidation.js.map +1 -1
  80. package/dist/components/scopeMount.d.ts +86 -0
  81. package/dist/components/scopeMount.js +131 -0
  82. package/dist/components/scopeMount.js.map +1 -0
  83. package/dist/config/configUtils.js +13 -9
  84. package/dist/config/configUtils.js.map +1 -1
  85. package/dist/dataLayer/backupManifest.d.ts +26 -0
  86. package/dist/dataLayer/backupManifest.js +97 -0
  87. package/dist/dataLayer/backupManifest.js.map +1 -0
  88. package/dist/dataLayer/blobBackup.d.ts +87 -0
  89. package/dist/dataLayer/blobBackup.js +282 -0
  90. package/dist/dataLayer/blobBackup.js.map +1 -0
  91. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
  92. package/dist/dataLayer/harperBridge/ResourceBridge.js +45 -5
  93. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  94. package/dist/dataLayer/hdbInfoController.js +4 -0
  95. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  96. package/dist/dataLayer/restoreMarker.d.ts +122 -0
  97. package/dist/dataLayer/restoreMarker.js +261 -0
  98. package/dist/dataLayer/restoreMarker.js.map +1 -0
  99. package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
  100. package/dist/dataLayer/rocksdbBackup.js +1039 -0
  101. package/dist/dataLayer/rocksdbBackup.js.map +1 -0
  102. package/dist/dataLayer/schemaDescribe.js +2 -1
  103. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  104. package/dist/resources/DatabaseTransaction.d.ts +55 -0
  105. package/dist/resources/DatabaseTransaction.js +282 -109
  106. package/dist/resources/DatabaseTransaction.js.map +1 -1
  107. package/dist/resources/ResourceInterface.d.ts +8 -5
  108. package/dist/resources/ResourceInterface.js.map +1 -1
  109. package/dist/resources/Resources.js +22 -4
  110. package/dist/resources/Resources.js.map +1 -1
  111. package/dist/resources/Table.d.ts +6 -5
  112. package/dist/resources/Table.js +114 -37
  113. package/dist/resources/Table.js.map +1 -1
  114. package/dist/resources/analytics/write.js +6 -6
  115. package/dist/resources/analytics/write.js.map +1 -1
  116. package/dist/resources/blob.d.ts +8 -1
  117. package/dist/resources/blob.js +31 -14
  118. package/dist/resources/blob.js.map +1 -1
  119. package/dist/resources/databases.d.ts +59 -1
  120. package/dist/resources/databases.js +386 -41
  121. package/dist/resources/databases.js.map +1 -1
  122. package/dist/resources/jsResource.d.ts +4 -26
  123. package/dist/resources/jsResource.js +5 -59
  124. package/dist/resources/jsResource.js.map +1 -1
  125. package/dist/resources/models/Models.d.ts +11 -1
  126. package/dist/resources/models/Models.js +10 -1
  127. package/dist/resources/models/Models.js.map +1 -1
  128. package/dist/resources/models/backendRegistry.d.ts +9 -0
  129. package/dist/resources/models/backendRegistry.js +10 -0
  130. package/dist/resources/models/backendRegistry.js.map +1 -1
  131. package/dist/resources/models/openaiStream.d.ts +16 -1
  132. package/dist/resources/models/openaiStream.js +113 -21
  133. package/dist/resources/models/openaiStream.js.map +1 -1
  134. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  135. package/dist/resources/models/v1/chatCompletions.js +115 -0
  136. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  137. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  138. package/dist/resources/models/v1/embeddings.js +71 -0
  139. package/dist/resources/models/v1/embeddings.js.map +1 -0
  140. package/dist/resources/models/v1/errors.d.ts +54 -0
  141. package/dist/resources/models/v1/errors.js +130 -0
  142. package/dist/resources/models/v1/errors.js.map +1 -0
  143. package/dist/resources/models/v1/index.d.ts +36 -0
  144. package/dist/resources/models/v1/index.js +75 -0
  145. package/dist/resources/models/v1/index.js.map +1 -0
  146. package/dist/resources/models/v1/models.d.ts +26 -0
  147. package/dist/resources/models/v1/models.js +44 -0
  148. package/dist/resources/models/v1/models.js.map +1 -0
  149. package/dist/resources/models/v1/translation.d.ts +133 -0
  150. package/dist/resources/models/v1/translation.js +298 -0
  151. package/dist/resources/models/v1/translation.js.map +1 -0
  152. package/dist/resources/roles.d.ts +1 -1
  153. package/dist/resources/roles.js +54 -7
  154. package/dist/resources/roles.js.map +1 -1
  155. package/dist/resources/transaction.js +0 -3
  156. package/dist/resources/transaction.js.map +1 -1
  157. package/dist/security/jsLoader.js +84 -33
  158. package/dist/security/jsLoader.js.map +1 -1
  159. package/dist/security/role.js +4 -0
  160. package/dist/security/role.js.map +1 -1
  161. package/dist/security/superUserGuard.d.ts +7 -0
  162. package/dist/security/superUserGuard.js +23 -0
  163. package/dist/security/superUserGuard.js.map +1 -0
  164. package/dist/security/tokenAuthentication.d.ts +0 -1
  165. package/dist/security/tokenAuthentication.js +6 -2
  166. package/dist/security/tokenAuthentication.js.map +1 -1
  167. package/dist/security/user.d.ts +6 -1
  168. package/dist/security/user.js +23 -1
  169. package/dist/security/user.js.map +1 -1
  170. package/dist/server/REST.js +25 -9
  171. package/dist/server/REST.js.map +1 -1
  172. package/dist/server/fastifyRoutes.js +15 -1
  173. package/dist/server/fastifyRoutes.js.map +1 -1
  174. package/dist/server/http.d.ts +23 -2
  175. package/dist/server/http.js +99 -12
  176. package/dist/server/http.js.map +1 -1
  177. package/dist/server/itc/serverHandlers.js +7 -1
  178. package/dist/server/itc/serverHandlers.js.map +1 -1
  179. package/dist/server/jobs/jobProcess.js +20 -1
  180. package/dist/server/jobs/jobProcess.js.map +1 -1
  181. package/dist/server/jobs/jobRunner.js +10 -0
  182. package/dist/server/jobs/jobRunner.js.map +1 -1
  183. package/dist/server/jobs/jobs.js +11 -0
  184. package/dist/server/jobs/jobs.js.map +1 -1
  185. package/dist/server/middlewareChain.d.ts +10 -1
  186. package/dist/server/middlewareChain.js +81 -21
  187. package/dist/server/middlewareChain.js.map +1 -1
  188. package/dist/server/operationsServer.d.ts +0 -2
  189. package/dist/server/operationsServer.js.map +1 -1
  190. package/dist/server/serverHelpers/multipartParser.js +9 -0
  191. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  192. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  193. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  194. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  195. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  196. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  197. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  198. package/dist/server/serverHelpers/serverHandlers.js +27 -7
  199. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  200. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  201. package/dist/server/serverHelpers/serverUtilities.js +21 -7
  202. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  203. package/dist/server/static.js +68 -21
  204. package/dist/server/static.js.map +1 -1
  205. package/dist/server/storageReclamation.d.ts +17 -0
  206. package/dist/server/storageReclamation.js +90 -6
  207. package/dist/server/storageReclamation.js.map +1 -1
  208. package/dist/server/threads/socketRouter.js +20 -0
  209. package/dist/server/threads/socketRouter.js.map +1 -1
  210. package/dist/server/threads/threadServer.js +12 -0
  211. package/dist/server/threads/threadServer.js.map +1 -1
  212. package/dist/sqlEngine/diff/differential.js +7 -3
  213. package/dist/sqlEngine/diff/differential.js.map +1 -1
  214. package/dist/sqlTranslator/index.js +6 -1
  215. package/dist/sqlTranslator/index.js.map +1 -1
  216. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  217. package/dist/upgrade/upgradePrompt.js +22 -3
  218. package/dist/upgrade/upgradePrompt.js.map +1 -1
  219. package/dist/utility/OperationFunctionCaller.js +25 -6
  220. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  221. package/dist/utility/errors/commonErrors.d.ts +1 -0
  222. package/dist/utility/errors/commonErrors.js +1 -0
  223. package/dist/utility/errors/commonErrors.js.map +1 -1
  224. package/dist/utility/hdbTerms.d.ts +13 -1
  225. package/dist/utility/hdbTerms.js +13 -1
  226. package/dist/utility/hdbTerms.js.map +1 -1
  227. package/dist/utility/install/installer.js +32 -0
  228. package/dist/utility/install/installer.js.map +1 -1
  229. package/dist/utility/logging/harper_logger.d.ts +47 -0
  230. package/dist/utility/logging/harper_logger.js +803 -38
  231. package/dist/utility/logging/harper_logger.js.map +1 -1
  232. package/dist/utility/operation_authorization.js +13 -3
  233. package/dist/utility/operation_authorization.js.map +1 -1
  234. package/npm-shrinkwrap.json +198 -193
  235. package/package.json +12 -7
  236. package/resources/DESIGN.md +2 -0
  237. package/resources/DatabaseTransaction.ts +310 -104
  238. package/resources/ResourceInterface.ts +8 -5
  239. package/resources/Resources.ts +22 -4
  240. package/resources/Table.ts +341 -263
  241. package/resources/analytics/write.ts +22 -20
  242. package/resources/blob.ts +31 -14
  243. package/resources/databases.ts +387 -46
  244. package/resources/jsResource.ts +5 -62
  245. package/resources/models/Models.ts +14 -1
  246. package/resources/models/backendRegistry.ts +10 -0
  247. package/resources/models/openaiStream.ts +131 -19
  248. package/resources/models/v1/chatCompletions.ts +128 -0
  249. package/resources/models/v1/embeddings.ts +70 -0
  250. package/resources/models/v1/errors.ts +141 -0
  251. package/resources/models/v1/index.ts +72 -0
  252. package/resources/models/v1/models.ts +53 -0
  253. package/resources/models/v1/translation.ts +362 -0
  254. package/resources/roles.ts +67 -7
  255. package/resources/transaction.ts +0 -3
  256. package/security/jsLoader.ts +84 -30
  257. package/security/role.ts +7 -0
  258. package/security/superUserGuard.ts +20 -0
  259. package/security/tokenAuthentication.ts +6 -3
  260. package/security/user.ts +26 -1
  261. package/server/DESIGN.md +61 -34
  262. package/server/REST.ts +25 -9
  263. package/server/fastifyRoutes.ts +20 -1
  264. package/server/http.ts +100 -13
  265. package/server/itc/serverHandlers.js +7 -1
  266. package/server/jobs/jobProcess.ts +18 -1
  267. package/server/jobs/jobRunner.ts +10 -0
  268. package/server/jobs/jobs.ts +11 -0
  269. package/server/middlewareChain.ts +79 -20
  270. package/server/operationsServer.ts +0 -2
  271. package/server/serverHelpers/multipartParser.ts +9 -0
  272. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  273. package/server/serverHelpers/registeredOperations.ts +19 -15
  274. package/server/serverHelpers/serverHandlers.js +28 -7
  275. package/server/serverHelpers/serverUtilities.ts +29 -7
  276. package/server/static.ts +87 -23
  277. package/server/storageReclamation.ts +104 -8
  278. package/server/threads/socketRouter.ts +20 -0
  279. package/server/threads/threadServer.js +11 -0
  280. package/sqlTranslator/index.ts +6 -1
  281. package/static/defaultConfig.yaml +3 -0
  282. package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-aApwhRmz.js} +2 -2
  283. package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-aApwhRmz.js.map} +1 -1
  284. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-sC0H91n9.js} +4 -4
  285. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-sC0H91n9.js.map} +1 -1
  286. package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-DZ8JhHOI.js} +2 -2
  287. package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-DZ8JhHOI.js.map} +1 -1
  288. package/studio/web/assets/{applications-BDfH8urd.js → applications-9SEVLIO5.js} +2 -2
  289. package/studio/web/assets/{applications-BDfH8urd.js.map → applications-9SEVLIO5.js.map} +1 -1
  290. package/studio/web/assets/{index-BHo3c2Gk.js → index-Dy3uDGXb.js} +6 -6
  291. package/studio/web/assets/index-Dy3uDGXb.js.map +1 -0
  292. package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-CN1zq4I4.js} +4 -4
  293. package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-CN1zq4I4.js.map} +1 -1
  294. package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-BFAF07xr.js} +2 -2
  295. package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-BFAF07xr.js.map} +1 -1
  296. package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CKlYVvVN.js} +2 -2
  297. package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CKlYVvVN.js.map} +1 -1
  298. package/studio/web/assets/{profile-Doj5FVDE.js → profile-A1zhEdFG.js} +2 -2
  299. package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-A1zhEdFG.js.map} +1 -1
  300. package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-SEtBt_GV.js} +2 -2
  301. package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-SEtBt_GV.js.map} +1 -1
  302. package/studio/web/assets/{setup-DJwR0BHd.js → setup-BisINqdH.js} +2 -2
  303. package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-BisINqdH.js.map} +1 -1
  304. package/studio/web/assets/{status-Br_AbsJs.js → status-BuQoCc7l.js} +2 -2
  305. package/studio/web/assets/{status-Br_AbsJs.js.map → status-BuQoCc7l.js.map} +1 -1
  306. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-CAi_s1PC.js} +2 -2
  307. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-CAi_s1PC.js.map} +1 -1
  308. package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-DT74tlkM.js} +2 -2
  309. package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-DT74tlkM.js.map} +1 -1
  310. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-CGRGc1n7.js} +2 -2
  311. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-CGRGc1n7.js.map} +1 -1
  312. package/studio/web/index.html +1 -1
  313. package/upgrade/upgradePrompt.ts +22 -3
  314. package/utility/OperationFunctionCaller.ts +24 -3
  315. package/utility/errors/commonErrors.ts +2 -0
  316. package/utility/hdbTerms.ts +13 -1
  317. package/utility/install/installer.ts +37 -0
  318. package/utility/logging/harper_logger.ts +786 -41
  319. package/utility/operation_authorization.ts +31 -3
  320. package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
@@ -33,7 +33,7 @@ import { handleLocalTimeForGets } from './RecordEncoder.ts';
33
33
  import { deleteRootBlobPathsForDB } from './blob.ts';
34
34
  import { CUSTOM_INDEXES } from './indexes/customIndexes.ts';
35
35
  import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.ts';
36
- import { RocksDatabase, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
36
+ import { RocksDatabase, supportedCompression, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
37
37
  import { PrimaryRocksDatabase } from './PrimaryRocksDatabase.ts';
38
38
  import { replayLogs } from './replayLogs.ts';
39
39
  import { totalmem } from 'node:os';
@@ -41,6 +41,15 @@ import { RocksIndexStore } from './RocksIndexStore.ts';
41
41
  import { when } from '../utility/when.ts';
42
42
  import { resolveRocksMemoryConfig } from '../utility/rocksMemoryConfig.ts';
43
43
  import { isProcessRunning } from '../utility/processManagement/processManagement.js';
44
+ import {
45
+ acquireRestoreLock,
46
+ checkRestoreState,
47
+ releaseRestoreLock,
48
+ restoreMarkerPresent,
49
+ scanBlockedRestores,
50
+ RESTORE_META_DIR,
51
+ type RestoreLock,
52
+ } from '../dataLayer/restoreMarker.ts';
44
53
 
45
54
  /**
46
55
  * Check if Harper is running in read-only mode.
@@ -89,6 +98,80 @@ const DEFAULT_DATABASE_NAME = 'data';
89
98
  const DEFINED_TABLES = Symbol('defined-tables');
90
99
  const DEFAULT_COMPRESSION_THRESHOLD = (envGet(CONFIG_PARAMS.STORAGE_PAGESIZE) || 4096) - 60; // larger than this requires multiple pages
91
100
  initSync();
101
+ /**
102
+ * The RocksDB block/blob codec for every column family this process opens (`storage.rocks.compression`),
103
+ * or `undefined` to leave rocksdb-js on its own default (lz4 wherever the native build has it).
104
+ *
105
+ * Resolved on the first open and then frozen, deliberately. RocksDB fixes a column family's codec
106
+ * for as long as it is open and rejects a reopen that disagrees, and Harper's worker threads share
107
+ * one process-wide column-family registry — so every open, in every thread, has to resolve the same
108
+ * value. Re-reading config per open does not guarantee that: on a fresh install the system families
109
+ * are created by `mountHdb()` before the config file exists, so the main thread would resolve
110
+ * nothing and the workers would resolve the configured codec, after which `__dbis__` cannot be
111
+ * reopened and Harper fails with "The system database failed to load". The installer stages this
112
+ * value before `mountHdb()` (see utility/install/installer.ts) so that first open already sees it.
113
+ *
114
+ * Unset is NOT "use the build default" for a family that already exists — see toRocksCompression.
115
+ */
116
+ let resolvedRocksCompression: string | undefined;
117
+ let rocksCompressionResolved = false;
118
+
119
+ export function getRocksCompression(): string | undefined {
120
+ if (!rocksCompressionResolved) {
121
+ resolvedRocksCompression = readDatabaseCodec();
122
+ rocksCompressionResolved = true;
123
+ }
124
+ return resolvedRocksCompression;
125
+ }
126
+
127
+ /**
128
+ * Test-only: un-freezes the resolved codec. Production code never calls this — the freeze is the
129
+ * invariant (see getRocksCompression above) — but a test process runs many unrelated test files in
130
+ * one process, so whichever file happens to open a RocksDatabase first freezes this for everyone
131
+ * after it. Tests that need to exercise config changes call this to get back to the unresolved state.
132
+ */
133
+ export function resetRocksCompression(): void {
134
+ resolvedRocksCompression = undefined;
135
+ rocksCompressionResolved = false;
136
+ }
137
+
138
+ /**
139
+ * The codec every column family in this process opens under.
140
+ *
141
+ * Compression is a deployment setting, not a per-table one. RocksDB opens all of a database's
142
+ * column families in one call, so the codec has to be decided before the first open — which is
143
+ * before Harper has read any table's metadata (that catalog is itself one of the families being
144
+ * opened). Resolving one codec from configuration and applying it to every family is what makes
145
+ * that possible; it is passed with `compressionForAllColumnFamilies` so families this process
146
+ * never names individually adopt it too, which is what lets a database created before the codec
147
+ * existed start compressing.
148
+ *
149
+ * `storage.rocks.compression` names a codec outright. Otherwise `storage.compression` (default
150
+ * true) decides enabled-or-not and the build default fills in the algorithm. Per-table metadata
151
+ * still records the LMDB-era boolean, but no longer selects: a table persisted as disabled inside
152
+ * a deployment that enables compression would need its own codec, and it cannot have one.
153
+ */
154
+ function readDatabaseCodec(): string | undefined {
155
+ const explicit = readRocksCompressionConfig();
156
+ if (explicit) return explicit;
157
+ return toRocksCompression(getDefaultCompression()) as string | undefined;
158
+ }
159
+
160
+ function readRocksCompressionConfig(): string | undefined {
161
+ const configured = envGet(CONFIG_PARAMS.STORAGE_ROCKS_COMPRESSION);
162
+ if (configured === undefined || configured === null || configured === '') return undefined;
163
+ const requested = String(configured).trim().toLowerCase();
164
+ if (!requested) return undefined;
165
+ // Rejected here rather than at the open: an unsupported name throws inside RocksDatabase.open,
166
+ // which surfaces as the system database failing to load partway through startup.
167
+ if (!supportedCompression.includes(requested)) {
168
+ throw new Error(
169
+ `storage.rocks.compression="${requested}" is not available in this build of @harperfast/rocksdb-js. Supported: ${supportedCompression.join(', ')}`
170
+ );
171
+ }
172
+ return requested;
173
+ }
174
+
92
175
  // I don't know if this is the best place for this, but somewhere we need to specify which tables
93
176
  // replicate by default:
94
177
  export const NON_REPLICATING_SYSTEM_TABLES = [
@@ -167,8 +250,52 @@ export const databaseEventsEmitter = new EventEmitter<DatabaseWatcherEventMap>()
167
250
  export const tables: Tables = Object.create(null);
168
251
  export const databases: Databases = Object.create(null);
169
252
 
253
+ /**
254
+ * Codec used to honor an "enabled, unspecified" compression setting, or `undefined` where the
255
+ * native build cannot provide it (in which case the request degrades to the build default rather
256
+ * than throwing).
257
+ */
258
+ const DEFAULT_ENABLED_CODEC = supportedCompression.includes('lz4') ? 'lz4' : undefined;
259
+
260
+ /**
261
+ * Map a persisted (LMDB-era) compression value to what rocksdb-js accepts. Table metadata carries
262
+ * values where a defined falsy value (false, '') means compression was explicitly disabled, and
263
+ * `true` / `{ threshold, ... }` mean enabled with defaults — `storage.compression` defaults to
264
+ * `true` (defaultConfig.yaml), so essentially every pre-existing table asked for compression.
265
+ *
266
+ * "Enabled" resolves to an explicit codec rather than to unset. Unset is not equivalent: RocksDB
267
+ * persists the codec per column family and a reopen that requests nothing inherits what the family
268
+ * already has, applying the build default only when the family does not yet exist. Leaving these
269
+ * unset therefore silently ignores the operator's request on every database created before the
270
+ * native build carried codecs — it keeps writing uncompressed forever, while a brand-new database
271
+ * gets lz4. Naming the codec makes the setting mean the same thing in both cases.
272
+ *
273
+ * This governs newly written files; existing SSTs keep their codec until write traffic rewrites
274
+ * them (`db.compact()` will not — see getRocksCompression above).
275
+ */
276
+ export function toRocksCompression(compression: unknown): unknown {
277
+ if (compression === undefined) return undefined;
278
+ if (!compression) return 'none';
279
+ // An object carrying an explicit `algorithm` is already a rocksdb-js request; anything else
280
+ // (`true`, or an LMDB descriptor like { startingOffset, threshold }) is "enabled, unspecified".
281
+ if (compression === true || (typeof compression === 'object' && !(compression as { algorithm?: unknown }).algorithm))
282
+ return DEFAULT_ENABLED_CODEC;
283
+ return compression;
284
+ }
285
+
170
286
  function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSort?: boolean }) {
171
287
  options.disableWAL ??= true;
288
+ const legacyOptions = options as { compression?: unknown };
289
+ // A configured codec applies to every column family, overriding whatever per-table metadata
290
+ // carries — that metadata records the LMDB-era boolean, so without this there is no way to
291
+ // select a RocksDB codec for a deployment.
292
+ // One codec for every column family, and applied to every family this open touches — not just
293
+ // the one being named. RocksDB opens them all at once and a family's codec cannot change while
294
+ // it is open, so a family this process never names individually would otherwise stay on
295
+ // whatever it was created with, forever.
296
+ const databaseCodec = getRocksCompression();
297
+ legacyOptions.compression = databaseCodec;
298
+ if (databaseCodec) (options as { compressionForAllColumnFamilies?: boolean }).compressionForAllColumnFamilies = true;
172
299
  // Apply read-only mode if enabled
173
300
  if (isReadOnlyMode()) {
174
301
  options.readOnly = true;
@@ -315,11 +442,18 @@ export function getDatabases(): Databases {
315
442
  if (databasePath && existsSync(databasePath)) {
316
443
  // First load all the databases from our main database folder
317
444
  // TODO: Load any databases defined with explicit storage paths from the config
318
- for (const databaseEntry of readdirSync(databasePath, { withFileTypes: true })) {
445
+ const entries = readdirSync(databasePath, { withFileTypes: true });
446
+ const blockedByRestore = databasesBlockedByRestore(databasePath);
447
+ for (const databaseEntry of entries) {
319
448
  // in-progress migration staging dirs are not databases until atomically renamed into place
320
449
  if (databaseEntry.name.endsWith(MIGRATING_DIR_SUFFIX)) continue;
450
+ // the restore-metadata directory is reserved: never load it as a database, even if a
451
+ // (out-of-band) RocksDB directory happens to occupy that reserved name — the API can't
452
+ // create it (schemaRegex forbids the backtick), but the scan opens any CURRENT+MANIFEST dir
453
+ if (databaseEntry.name === RESTORE_META_DIR) continue;
321
454
  const dbName = basename(databaseEntry.name, '.mdb');
322
455
  const dbPath = join(databasePath, databaseEntry.name);
456
+ if (blockedByRestore.has(dbName)) continue;
323
457
 
324
458
  if (
325
459
  databaseEntry.isFile() &&
@@ -376,8 +510,12 @@ export function getDatabases(): Databases {
376
510
  const schemaConfig = schemaConfigs[dbName];
377
511
  const databasePath = schemaConfig.path;
378
512
  if (existsSync(databasePath)) {
379
- for (const databaseEntry of readdirSync(databasePath, { withFileTypes: true })) {
380
- if (databaseEntry.name.endsWith(MIGRATING_DIR_SUFFIX)) continue;
513
+ const entries = readdirSync(databasePath, { withFileTypes: true });
514
+ const blockedByRestore = databasesBlockedByRestore(databasePath);
515
+ for (const databaseEntry of entries) {
516
+ if (databaseEntry.name.endsWith(MIGRATING_DIR_SUFFIX)) continue; // migration staging dir
517
+ if (databaseEntry.name === RESTORE_META_DIR) continue; // reserved restore-metadata dir
518
+ if (blockedByRestore.has(basename(databaseEntry.name, '.mdb'))) continue;
381
519
  if (databaseEntry.isFile() && extname(databaseEntry.name).toLowerCase() === '.mdb') {
382
520
  readMetaDb(join(databasePath, databaseEntry.name), basename(databaseEntry.name, '.mdb'), dbName);
383
521
  } else {
@@ -452,6 +590,29 @@ export function getDatabases(): Databases {
452
590
  return databases;
453
591
  }
454
592
 
593
+ /**
594
+ * Scan a databases directory's entries for restore lock/marker files and return the names of
595
+ * databases that must not be loaded: a held restore lock means a restore is in progress in some
596
+ * process; an unheld lock with a surviving `.restoring` marker means a restore was interrupted
597
+ * mid-purge (the directory may be partial garbage) and must be rerun. The files live *next to*
598
+ * the database directory, so this also covers a database whose directory is missing or empty.
599
+ */
600
+ function databasesBlockedByRestore(databasePath: string): Set<string> {
601
+ const blocked = new Set<string>();
602
+ for (const [dbName, state] of scanBlockedRestores(databasePath)) {
603
+ if (state === 'in-progress') {
604
+ logger.warn(`A restore of database '${dbName}' is in progress; not loading it`);
605
+ blocked.add(dbName);
606
+ } else if (state === 'incomplete') {
607
+ logger.error(
608
+ `Incomplete restore of database '${dbName}' detected (a restore started but did not finish); not loading it — rerun the restore to recover`
609
+ );
610
+ blocked.add(dbName);
611
+ }
612
+ }
613
+ return blocked;
614
+ }
615
+
455
616
  /**
456
617
  * This is responsible for reading the internal dbi of a single database file to get a list of all the tables and
457
618
  * their indexed or registered attributes
@@ -530,7 +691,7 @@ function initStores(
530
691
  ...internalDbiInit,
531
692
  disableWAL: false,
532
693
  name: INTERNAL_DBIS_NAME,
533
- } as any) as RocksDatabaseEx;
694
+ } as any);
534
695
  } else {
535
696
  attributesDbi = rootStore.openDB(INTERNAL_DBIS_NAME, internalDbiInit as any);
536
697
  }
@@ -916,18 +1077,12 @@ function setTable(tables, tableName, Table) {
916
1077
  return Table;
917
1078
  }
918
1079
  /**
919
- * Get root store for a database
920
- * @param options
921
- * @returns
1080
+ * Resolve the directory that holds (or would hold) a database's storage, from the databases
1081
+ * config, storage path config/env, or the hdb root — without opening anything. This is the
1082
+ * parent directory selection used by `database()`; a RocksDB database lives at
1083
+ * `join(resolveDatabaseStorageRoot(...), databaseName)`.
922
1084
  */
923
- export function database({ database: databaseName, table: tableName }) {
924
- if (!databaseName) databaseName = DEFAULT_DATABASE_NAME;
925
- getDatabases();
926
- ensureDB(databaseName);
927
- const definedDatabase = definedDatabases.get(databaseName);
928
- if ((definedDatabase as any)?.rootStore) {
929
- return (definedDatabase as any).rootStore;
930
- }
1085
+ export function resolveDatabaseStorageRoot(databaseName: string, tableName?: string): string {
931
1086
  const databaseConfig = envGet(CONFIG_PARAMS.DATABASES) || {};
932
1087
  if (process.env.SCHEMAS_DATA_PATH) {
933
1088
  databaseConfig.data = { path: process.env.SCHEMAS_DATA_PATH };
@@ -952,6 +1107,35 @@ export function database({ database: databaseName, table: tableName }) {
952
1107
  `Unable to determine database storage path. Ensure STORAGE_PATH, HDB_ROOT, or a valid config path is set.`
953
1108
  );
954
1109
  }
1110
+ return databasePath;
1111
+ }
1112
+
1113
+ /**
1114
+ * Resolve the directory path of a RocksDB database (whether or not it exists or is loaded).
1115
+ */
1116
+ export function resolveDatabasePath(databaseName: string): string {
1117
+ return join(resolveDatabaseStorageRoot(databaseName), databaseName);
1118
+ }
1119
+
1120
+ /**
1121
+ * Get root store for a database
1122
+ * @param options
1123
+ * @returns
1124
+ */
1125
+ export function database({ database: databaseName, table: tableName }) {
1126
+ if (!databaseName) databaseName = DEFAULT_DATABASE_NAME;
1127
+ getDatabases();
1128
+ ensureDB(databaseName);
1129
+ const definedDatabase = definedDatabases.get(databaseName);
1130
+ if ((definedDatabase as any)?.rootStore) {
1131
+ return (definedDatabase as any).rootStore;
1132
+ }
1133
+ const databaseConfig = envGet(CONFIG_PARAMS.DATABASES) || {};
1134
+ if (process.env.SCHEMAS_DATA_PATH) {
1135
+ databaseConfig.data = { path: process.env.SCHEMAS_DATA_PATH };
1136
+ }
1137
+ const tablePath = tableName && databaseConfig[databaseName]?.tables?.[tableName]?.path;
1138
+ const databasePath = resolveDatabaseStorageRoot(databaseName, tableName);
955
1139
 
956
1140
  let rootStore: RootDatabaseKind;
957
1141
  const useRocksdb = (process.env.HARPER_STORAGE_ENGINE || envGet(CONFIG_PARAMS.STORAGE_ENGINE)) !== 'lmdb';
@@ -959,6 +1143,10 @@ export function database({ database: databaseName, table: tableName }) {
959
1143
  const path = join(databasePath, tablePath ? tableName : databaseName);
960
1144
  rootStore = rocksdbDatabaseEnvs.get(path);
961
1145
  if (!rootStore || rootStore.status === 'closed') {
1146
+ // this on-demand open (create_table/create_database and friends) must not resurrect a
1147
+ // database that a restore is rewriting (or left half-purged) — the scan-time restore
1148
+ // checks don't cover this path
1149
+ throwIfBlockedByRestore(path, databaseName);
962
1150
  rootStore = openRocksDatabase(path, {
963
1151
  disableWAL: false,
964
1152
  enableStats: true,
@@ -981,6 +1169,51 @@ export function database({ database: databaseName, table: tableName }) {
981
1169
  if (definedDatabase) (definedDatabase as any).rootStore = rootStore;
982
1170
  return rootStore;
983
1171
  }
1172
+ function throwIfBlockedByRestore(dbPath: string, databaseName: string): void {
1173
+ const restoreState = checkRestoreState(dbPath);
1174
+ if (restoreState !== 'clear') {
1175
+ const error: any = new Error(
1176
+ restoreState === 'in-progress'
1177
+ ? `Database '${databaseName}' is being restored; retry when the restore completes`
1178
+ : `Database '${databaseName}' has an incomplete restore; rerun restore_backup to recover it`
1179
+ );
1180
+ error.statusCode = 409;
1181
+ throw error;
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * Take the per-database restore lock for a drop, refusing (409) if a restore holds it (in-progress)
1187
+ * or a crashed restore left a marker (incomplete). Pushes the acquired lock onto `held` so the
1188
+ * caller releases it after the drop. On refusal, releases anything already held and throws.
1189
+ *
1190
+ * The lock is not reentrant within a process, so a path already in `held` must be skipped — every
1191
+ * table in a RocksDB database shares one root store (and one lock path), and re-acquiring it in the
1192
+ * same drop would spuriously 409 on the second table.
1193
+ */
1194
+ function lockDatabaseForDrop(dbPath: string, databaseName: string, held: RestoreLock[]): void {
1195
+ if (held.some((h) => h.dbPath === dbPath)) return;
1196
+ let lock: RestoreLock;
1197
+ try {
1198
+ lock = acquireRestoreLock(dbPath);
1199
+ } catch (error) {
1200
+ for (const h of held) releaseRestoreLock(h);
1201
+ throw error; // 409: a restore is in progress and holds the lock
1202
+ }
1203
+ // We now hold the lock, so no restore is active. A surviving marker is therefore debris from a
1204
+ // crashed restore (incomplete) — refuse rather than delete a directory that still needs recovery.
1205
+ if (restoreMarkerPresent(dbPath)) {
1206
+ releaseRestoreLock(lock);
1207
+ for (const h of held) releaseRestoreLock(h);
1208
+ const error: any = new Error(
1209
+ `Database '${databaseName}' has an incomplete restore; rerun restore_backup to recover it`
1210
+ );
1211
+ error.statusCode = 409;
1212
+ throw error;
1213
+ }
1214
+ held.push(lock);
1215
+ }
1216
+
984
1217
  /**
985
1218
  * Delete the database
986
1219
  * @param databaseName
@@ -990,17 +1223,105 @@ export async function dropDatabase(databaseName) {
990
1223
  const dbTables = databases[databaseName];
991
1224
  let rootStore;
992
1225
 
993
- for (const tableName in dbTables) {
994
- const table = dbTables[tableName];
995
- rootStore = table.primaryStore.rootStore;
996
- lmdbDatabaseEnvs.delete(rootStore.path);
997
- rocksdbDatabaseEnvs.delete(rootStore.path);
1226
+ // Hold the per-database restore lock across the entire drop so its file deletion can never
1227
+ // interleave with a restore's purge-and-copy on the same directory — a destroy landing after a
1228
+ // restore's copy would gut a "successful" restore, and vice versa. Restore takes the same lock
1229
+ // (before writing its marker), so both operations serialize on this one primitive rather than on
1230
+ // a check-then-act marker probe. Released in the finally below.
1231
+ const restoreLocks: RestoreLock[] = [];
1232
+ try {
1233
+ for (const tableName in dbTables) {
1234
+ const table = dbTables[tableName];
1235
+ rootStore = table.primaryStore.rootStore;
1236
+ if (rootStore instanceof RocksDatabase) lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
1237
+ lmdbDatabaseEnvs.delete(rootStore.path);
1238
+ rocksdbDatabaseEnvs.delete(rootStore.path);
1239
+ }
1240
+
1241
+ for (const tableName in dbTables) {
1242
+ databaseEventsEmitter.emit('dropTable', tableName, databaseName);
1243
+ }
1244
+
1245
+ if (databaseName === 'data') {
1246
+ for (const tableName in tables) {
1247
+ delete tables[tableName];
1248
+ }
1249
+ delete tables[DEFINED_TABLES];
1250
+ }
1251
+ delete databases[databaseName];
1252
+
1253
+ databaseEventsEmitter.emit('dropDatabase', databaseName);
1254
+
1255
+ if (rootStore) {
1256
+ if (rootStore.status === 'open') {
1257
+ if (rootStore instanceof RocksDatabase) {
1258
+ rootStore.close();
1259
+ rootStore.destroy();
1260
+ } else {
1261
+ await rootStore.close();
1262
+ await unlink(rootStore.path);
1263
+ }
1264
+ }
1265
+ } else {
1266
+ rootStore = database({ database: databaseName, table: null });
1267
+ // a tableless database resolves its root store here rather than in the loop above, so take
1268
+ // the drop lock now (still before any destructive step)
1269
+ if (rootStore instanceof RocksDatabase) lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
1270
+ if (rootStore instanceof RocksDatabase) {
1271
+ rootStore.close();
1272
+ rootStore.destroy();
1273
+ } else if (rootStore.status === 'open') {
1274
+ await rootStore.close();
1275
+ await unlink(rootStore.path);
1276
+ }
1277
+ }
1278
+
1279
+ await deleteRootBlobPathsForDB(rootStore);
1280
+ } finally {
1281
+ for (const lock of restoreLocks) releaseRestoreLock(lock);
998
1282
  }
1283
+ }
999
1284
 
1285
+ /**
1286
+ * Close a RocksDB database's store handles on the current thread and unregister it, without
1287
+ * touching its files. Used by the restore_backup flow: every thread must release its handles so
1288
+ * `backups.restore()` can purge and rewrite the (fully closed) database directory. A subsequent
1289
+ * `resetDatabases()`/`getDatabases()` rescan reloads it (or skips it while a restore is in
1290
+ * progress, per the restore marker checks in the scan).
1291
+ */
1292
+ export function closeDatabase(databaseName: string): boolean {
1293
+ const dbTables = databases[databaseName];
1294
+ if (!dbTables) return false;
1295
+ const rootStores = new Set<any>();
1296
+ const closeStore = (store: any, description: string) => {
1297
+ try {
1298
+ store?.close?.();
1299
+ } catch (error) {
1300
+ logger.warn(`Error closing ${description} while closing database ${databaseName}:`, error);
1301
+ }
1302
+ };
1000
1303
  for (const tableName in dbTables) {
1001
- databaseEventsEmitter.emit('dropTable', tableName, databaseName);
1304
+ const table: any = dbTables[tableName];
1305
+ if (!table?.primaryStore) continue;
1306
+ if (table.primaryStore.rootStore) rootStores.add(table.primaryStore.rootStore);
1307
+ for (const indexName in table.indices || {}) {
1308
+ closeStore(table.indices[indexName], `index ${tableName}.${indexName}`);
1309
+ }
1310
+ closeStore(table.primaryStore, `table ${tableName}`);
1002
1311
  }
1003
-
1312
+ // a database with no tables (an empty schema, or one whose tables were all dropped) still holds
1313
+ // an open root store, tracked only on the defined-database entry rather than any table — include
1314
+ // it so its handles are released too (the Set dedupes it against the per-table root stores above)
1315
+ const definedRoot = (definedDatabases?.get(databaseName) as any)?.rootStore;
1316
+ if (definedRoot) rootStores.add(definedRoot);
1317
+ for (const rootStore of rootStores) {
1318
+ closeStore(rootStore.dbisDb, 'attributes store');
1319
+ closeStore(rootStore, 'root store');
1320
+ lmdbDatabaseEnvs.delete(rootStore.path);
1321
+ rocksdbDatabaseEnvs.delete(rootStore.path);
1322
+ }
1323
+ const definedDatabase = definedDatabases?.get(databaseName);
1324
+ if (definedDatabase) (definedDatabase as any).rootStore = undefined;
1004
1325
  if (databaseName === 'data') {
1005
1326
  for (const tableName in tables) {
1006
1327
  delete tables[tableName];
@@ -1008,31 +1329,41 @@ export async function dropDatabase(databaseName) {
1008
1329
  delete tables[DEFINED_TABLES];
1009
1330
  }
1010
1331
  delete databases[databaseName];
1332
+ return true;
1333
+ }
1011
1334
 
1012
- databaseEventsEmitter.emit('dropDatabase', databaseName);
1013
-
1014
- if (rootStore) {
1015
- if (rootStore.status === 'open') {
1016
- if (rootStore instanceof RocksDatabase) {
1017
- rootStore.close();
1018
- rootStore.destroy();
1019
- } else {
1020
- await rootStore.close();
1021
- await unlink(rootStore.path);
1335
+ /**
1336
+ * Close every RocksDB (user) database this thread has open, releasing its native handles.
1337
+ *
1338
+ * rocksdb-js's registry is process-global across worker threads, and a thread that exits WITHOUT
1339
+ * closing leaks its handles (the process-global refCount never drops), while the only alternative,
1340
+ * `shutdown()`, tears down rocksdb for the entire process. So a worker thread that opens databases
1341
+ * and then exits — notably a job worker (jobProcess), which opens the whole database graph via
1342
+ * `getDatabases()` and exits when the job finishes — must close its handles explicitly, or those
1343
+ * handles linger process-wide (and, e.g., block an online `restore_backup` from confirming the
1344
+ * database is closed). The `system` database is intentionally left open: it is non-enumerable here
1345
+ * (skipped by the loop), is never restored online, and the exiting worker may still touch the job
1346
+ * table during teardown. Best-effort: closing failures are swallowed inside `closeDatabase`.
1347
+ */
1348
+ export function closeLoadedDatabases(): void {
1349
+ // snapshot the names first: closeDatabase() deletes from `databases` as it goes
1350
+ for (const databaseName of Object.keys(databases)) {
1351
+ const dbTables = databases[databaseName];
1352
+ if (!dbTables) continue;
1353
+ let isRocks = false;
1354
+ for (const tableName in dbTables) {
1355
+ if (dbTables[tableName]?.primaryStore?.rootStore instanceof RocksDatabase) {
1356
+ isRocks = true;
1357
+ break;
1022
1358
  }
1023
1359
  }
1024
- } else {
1025
- rootStore = database({ database: databaseName, table: null });
1026
- if (rootStore instanceof RocksDatabase) {
1027
- rootStore.close();
1028
- rootStore.destroy();
1029
- } else if (rootStore.status === 'open') {
1030
- await rootStore.close();
1031
- await unlink(rootStore.path);
1360
+ // a tableless database exposes no table root store, so also check the defined-database
1361
+ // entry otherwise its open root store would leak on worker exit
1362
+ if (!isRocks && (definedDatabases?.get(databaseName) as any)?.rootStore instanceof RocksDatabase) {
1363
+ isRocks = true;
1032
1364
  }
1365
+ if (isRocks) closeDatabase(databaseName);
1033
1366
  }
1034
-
1035
- await deleteRootBlobPathsForDB(rootStore);
1036
1367
  }
1037
1368
  // HNSW_NO_AUTOVERSION kill-switch: when set, a NEW index initializes as legacy rather than
1038
1369
  // versioned. process.env values are strings, so a bare truthiness check would treat "0"/"false"
@@ -1122,7 +1453,11 @@ function openIndex(dbiKey: string, rootStore: RootDatabaseKind, attribute: any)
1122
1453
  // Enable cache (WeakLRUCache + VT) for all custom-object index stores so the VT is
1123
1454
  // available before resolveIndexFormat decides the format. Versioned stores need the VT
1124
1455
  // for cached traversal; legacy stores pay a small per-write cache.delete() overhead only.
1125
- dbi = openRocksDatabase(rootStore.path, { ...dbiInit, name: dbiKey, cache: isCustomObjectIndex } as any) as any;
1456
+ dbi = openRocksDatabase(rootStore.path, {
1457
+ ...dbiInit,
1458
+ name: dbiKey,
1459
+ cache: isCustomObjectIndex,
1460
+ } as any) as any;
1126
1461
  (dbi as any).rootStore = rootStore;
1127
1462
  // Custom-index object stores (e.g. HNSW) write graph nodes via plain put() with no staged
1128
1463
  // transaction timestamp, so their values carry no version and the PrimaryRocksDatabase
@@ -1345,6 +1680,10 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
1345
1680
  clearInterruptedDropEntries(rootStore.path, tableName);
1346
1681
  }
1347
1682
  if (rootStore instanceof RocksDatabase) {
1683
+ // Usually a genuinely new column family (existingTableMeta above found no catalog
1684
+ // entry), but an interrupted drop just completed above can leave the physical CF
1685
+ // behind under its old codec even though the catalog entry is gone — same fallback
1686
+ // as the reconcile paths covers that remnant case too.
1348
1687
  primaryStore = openRocksDatabase(rootStore.path, { ...dbiInit, name: dbiName, cache: true } as any);
1349
1688
  } else {
1350
1689
  primaryStore = (rootStore as any).openDB(dbiName, dbiInit as any);
@@ -1955,7 +2294,7 @@ function completeInterruptedDrop(rootStore, attributesDbi, databaseName: string,
1955
2294
  if (rootStore instanceof RocksDatabase) {
1956
2295
  for (const columnName of (rootStore as any).columns) {
1957
2296
  if (columnName.startsWith(tableName + '/')) {
1958
- const columnStore = openRocksDatabase(rootStore.path, { name: columnName } as any);
2297
+ const columnStore = openRocksDatabase(rootStore.path, { name: columnName });
1959
2298
  try {
1960
2299
  columnStore.dropSync();
1961
2300
  } catch (error) {
@@ -2052,7 +2391,9 @@ export function getDefaultCompression() {
2052
2391
  if (STORAGE_COMPRESSION_DICTIONARY)
2053
2392
  LMDB_COMPRESSION_OPTS['dictionary'] = readFileSync(STORAGE_COMPRESSION_DICTIONARY);
2054
2393
  if (STORAGE_COMPRESSION_THRESHOLD) LMDB_COMPRESSION_OPTS['threshold'] = STORAGE_COMPRESSION_THRESHOLD;
2055
- return LMDB_COMPRESSION && LMDB_COMPRESSION_OPTS;
2394
+ // normalize disabled to false so a falsy config value ('' or null) is never persisted
2395
+ // into table metadata as-is (openRocksDatabase maps defined-falsy to 'none')
2396
+ return LMDB_COMPRESSION ? LMDB_COMPRESSION_OPTS : false;
2056
2397
  }
2057
2398
 
2058
2399
  /**
@@ -69,41 +69,13 @@ export class ResourceLoadError extends Error {
69
69
  *
70
70
  * Once a file has been loaded it cannot be unloaded without a restart.
71
71
  *
72
- * Thus, this plugin only handle files as they are added (`add` event). All other events result in a restart request.
72
+ * Thus, this plugin only handles files as they are added (`add` event). All other events result in a restart request.
73
73
  *
74
- * A redeploy tears down and reinstalls the component's files while this scope's watcher is paused
75
- * (see `Scope`/`EntryHandler` deploy lifecycle); on resume the fresh chokidar scan re-emits every
76
- * existing file as `'add'` — including ones whose contents just changed. Treating those as plain
77
- * adds would silently re-run against the stale module cache and never flag a restart (harper#1817).
78
- * So we track which files this scope has already loaded: a re-`add` of a known file is a redeploy of
79
- * loaded code we cannot hot-swap, and is handled like a `change` — request a restart. A first-time
80
- * `add` (initial load, or a genuinely new file added at runtime) still loads without a restart.
81
- *
82
- * A redeploy that *deletes* a loaded file is a different shape of the same problem: the fresh
83
- * chokidar scan only reports what's currently on disk, so a file that's gone produces no event at
84
- * all — no re-`add`, no `unlink` — and the modified-file handling above never sees it. Left
85
- * unhandled, the deleted resource stays registered and active in memory (harper#1817 follow-up). So
86
- * we also track which files the post-redeploy scan pass reports, and once that scan's `ready` fires,
87
- * diff it against everything this scope has ever loaded: anything missing was deleted, and is
88
- * handled the same way as a modified file — request a restart.
89
- *
90
- * That diff must only run for an actual redeploy rescan, not every time `EntryHandler` emits
91
- * `ready` — it also refires after each ordinary runtime add/change once that file's read settles
92
- * (its initial-scan-complete latch never resets outside a full rescan), and diffing against that
93
- * would falsely treat every other already-loaded file as deleted. So the diff window is gated by
94
- * the scope's own `deploy:start`/`deploy:end` bracket (see `Scope`): `deploy:start` pauses the
95
- * watcher and opens the window (and is where we reset the scan-file tracking, since no file events
96
- * can land while paused), and the first `ready` afterward — the resumed watcher's fresh scan
97
- * completing — closes it and runs the diff.
74
+ * EntryHandler preserves file identity across a deploy pause/resume and emits `change` or `unlink`
75
+ * for loaded files that changed or disappeared, so those events request the required restart.
98
76
  */
99
77
  export async function handleApplication(scope: Scope) {
100
- const loadedResourceFiles = new Set<string>();
101
- // Files reported as `add` since the most recent `deploy:start`, populated only while
102
- // `awaitingPostRedeployScan` is true — see the gating note above.
103
- let currentScanFiles = new Set<string>();
104
- let awaitingPostRedeployScan = false;
105
-
106
- const entryHandler = scope.handleEntry(async function handleResourceEntry(entryEvent) {
78
+ scope.handleEntry(async function handleResourceEntry(entryEvent) {
107
79
  if (entryEvent.entryType !== 'file') {
108
80
  scope.logger.warn(
109
81
  `jsResource plugin cannot handle entry type ${entryEvent.entryType}. Modify the 'files' option in ${scope.configFilePath} to only include files.`
@@ -111,13 +83,7 @@ export async function handleApplication(scope: Scope) {
111
83
  return;
112
84
  }
113
85
 
114
- if (awaitingPostRedeployScan && entryEvent.eventType === 'add') {
115
- // Recorded unconditionally — before the loaded/re-add branch below — so the post-scan
116
- // deletion diff sees every file this scan reported, whether newly loaded or already known.
117
- currentScanFiles.add(entryEvent.absolutePath);
118
- }
119
-
120
- if (entryEvent.eventType !== 'add' || loadedResourceFiles.has(entryEvent.absolutePath)) {
86
+ if (entryEvent.eventType !== 'add') {
121
87
  scope.requestRestart();
122
88
  return;
123
89
  }
@@ -133,9 +99,6 @@ export async function handleApplication(scope: Scope) {
133
99
  scope.logger.debug?.(`Registered root resource: ${path}`);
134
100
  }
135
101
  recurseForResources(scope, resourceModule, root);
136
- // Record the load so a later re-`add` of this same file (a redeploy re-scan) is treated
137
- // as a change and requests a restart rather than silently re-serving stale cached code.
138
- loadedResourceFiles.add(entryEvent.absolutePath);
139
102
  // A JS resource that extends an exported @table is the one carrying author opt-ins
140
103
  // (`static mcpTools`/`mcpPrompts`), and it registers here — after the schema-derived
141
104
  // table class and after the MCP component's boot scan. Signal so listing surfaces
@@ -146,26 +109,6 @@ export async function handleApplication(scope: Scope) {
146
109
  throw new ResourceLoadError(entryEvent.absolutePath, error);
147
110
  }
148
111
  });
149
-
150
- // Optional chaining: a mock/test scope may not implement EventEmitter, and Scope#handleEntry
151
- // itself can return undefined (e.g. MissingDefaultFilesOptionError). In real use `scope` is
152
- // always an EventEmitter and `entryHandler` is always the EntryHandler backing this watcher.
153
- scope.on?.('deploy:start', () => {
154
- awaitingPostRedeployScan = true;
155
- currentScanFiles = new Set();
156
- });
157
-
158
- entryHandler?.on?.('ready', () => {
159
- if (!awaitingPostRedeployScan) return;
160
- awaitingPostRedeployScan = false;
161
- for (const loadedFile of loadedResourceFiles) {
162
- if (!currentScanFiles.has(loadedFile)) {
163
- // Known file that the just-completed scan never reported — deleted during the redeploy.
164
- loadedResourceFiles.delete(loadedFile);
165
- scope.requestRestart();
166
- }
167
- }
168
- });
169
112
  }
170
113
 
171
114
  function recurseForResources(scope: Scope, resourceModule: any, prefix: string) {