@harperfast/harper 5.3.0-beta.3 → 5.3.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/bin/cliOperations.ts +25 -3
  2. package/bin/run.ts +18 -0
  3. package/components/Application.ts +52 -78
  4. package/components/DESIGN.md +76 -3
  5. package/components/Scope.ts +10 -4
  6. package/components/anthropic/index.ts +10 -1
  7. package/components/bedrock/index.ts +10 -1
  8. package/components/componentLoader.ts +23 -14
  9. package/components/mcp/tools/operations.ts +3 -1
  10. package/components/ollama/index.ts +10 -1
  11. package/components/openai/index.ts +265 -18
  12. package/components/operations.js +40 -1
  13. package/config/configUtils.ts +3 -19
  14. package/dataLayer/DESIGN.md +29 -5
  15. package/dataLayer/harperBridge/ResourceBridge.ts +39 -3
  16. package/dataLayer/restoreMarker.ts +250 -14
  17. package/dataLayer/schema.ts +2 -7
  18. package/dataLayer/schemaDescribe.ts +10 -0
  19. package/dist/bin/cliOperations.js +28 -3
  20. package/dist/bin/cliOperations.js.map +1 -1
  21. package/dist/bin/run.js +19 -0
  22. package/dist/bin/run.js.map +1 -1
  23. package/dist/components/Application.d.ts +8 -2
  24. package/dist/components/Application.js +43 -69
  25. package/dist/components/Application.js.map +1 -1
  26. package/dist/components/Scope.d.ts +1 -1
  27. package/dist/components/Scope.js +9 -4
  28. package/dist/components/Scope.js.map +1 -1
  29. package/dist/components/anthropic/index.d.ts +0 -10
  30. package/dist/components/anthropic/index.js +9 -1
  31. package/dist/components/anthropic/index.js.map +1 -1
  32. package/dist/components/bedrock/index.d.ts +0 -12
  33. package/dist/components/bedrock/index.js +9 -1
  34. package/dist/components/bedrock/index.js.map +1 -1
  35. package/dist/components/componentLoader.js +23 -14
  36. package/dist/components/componentLoader.js.map +1 -1
  37. package/dist/components/mcp/tools/operations.js +1 -1
  38. package/dist/components/mcp/tools/operations.js.map +1 -1
  39. package/dist/components/ollama/index.d.ts +0 -13
  40. package/dist/components/ollama/index.js +9 -1
  41. package/dist/components/ollama/index.js.map +1 -1
  42. package/dist/components/openai/index.d.ts +11 -15
  43. package/dist/components/openai/index.js +224 -12
  44. package/dist/components/openai/index.js.map +1 -1
  45. package/dist/components/operations.d.ts +9 -0
  46. package/dist/components/operations.js +32 -2
  47. package/dist/components/operations.js.map +1 -1
  48. package/dist/config/configUtils.d.ts +1 -2
  49. package/dist/config/configUtils.js +7 -22
  50. package/dist/config/configUtils.js.map +1 -1
  51. package/dist/dataLayer/harperBridge/ResourceBridge.js +29 -2
  52. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  53. package/dist/dataLayer/restoreMarker.d.ts +25 -3
  54. package/dist/dataLayer/restoreMarker.js +213 -13
  55. package/dist/dataLayer/restoreMarker.js.map +1 -1
  56. package/dist/dataLayer/schema.js +1 -4
  57. package/dist/dataLayer/schema.js.map +1 -1
  58. package/dist/dataLayer/schemaDescribe.js +10 -0
  59. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/json/systemSchema.json +28 -0
  63. package/dist/resources/DatabaseTransaction.d.ts +21 -1
  64. package/dist/resources/DatabaseTransaction.js +147 -8
  65. package/dist/resources/DatabaseTransaction.js.map +1 -1
  66. package/dist/resources/PrimaryRocksDatabase.js +1 -1
  67. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  68. package/dist/resources/Table.d.ts +31 -3
  69. package/dist/resources/Table.js +397 -79
  70. package/dist/resources/Table.js.map +1 -1
  71. package/dist/resources/auditStore.js +16 -10
  72. package/dist/resources/auditStore.js.map +1 -1
  73. package/dist/resources/blob.d.ts +1 -0
  74. package/dist/resources/blob.js +14 -5
  75. package/dist/resources/blob.js.map +1 -1
  76. package/dist/resources/branchDatabase.js +3 -3
  77. package/dist/resources/branchDatabase.js.map +1 -1
  78. package/dist/resources/databaseDropPreparation.d.ts +20 -0
  79. package/dist/resources/databaseDropPreparation.js +106 -0
  80. package/dist/resources/databaseDropPreparation.js.map +1 -0
  81. package/dist/resources/databases.d.ts +26 -21
  82. package/dist/resources/databases.js +1040 -204
  83. package/dist/resources/databases.js.map +1 -1
  84. package/dist/resources/derivedIndexRegistry.d.ts +23 -0
  85. package/dist/resources/derivedIndexRegistry.js +92 -0
  86. package/dist/resources/derivedIndexRegistry.js.map +1 -1
  87. package/dist/resources/derivedIndexRuntime.d.ts +10 -0
  88. package/dist/resources/derivedIndexRuntime.js +136 -25
  89. package/dist/resources/derivedIndexRuntime.js.map +1 -1
  90. package/dist/resources/derivedIndexes.d.ts +39 -0
  91. package/dist/resources/derivedIndexes.js +733 -0
  92. package/dist/resources/derivedIndexes.js.map +1 -0
  93. package/dist/resources/fullTextSchema.d.ts +8 -0
  94. package/dist/resources/fullTextSchema.js +42 -0
  95. package/dist/resources/fullTextSchema.js.map +1 -1
  96. package/dist/resources/fullTextSchemaLifecycle.d.ts +0 -5
  97. package/dist/resources/fullTextSchemaLifecycle.js +0 -9
  98. package/dist/resources/fullTextSchemaLifecycle.js.map +1 -1
  99. package/dist/resources/graphql.js +101 -9
  100. package/dist/resources/graphql.js.map +1 -1
  101. package/dist/resources/indexes/hnswDerivedIndex.d.ts +0 -10
  102. package/dist/resources/indexes/hnswDerivedIndex.js +0 -230
  103. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -1
  104. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +3 -0
  105. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +57 -8
  106. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -1
  107. package/dist/resources/models/Models.d.ts +26 -3
  108. package/dist/resources/models/Models.js +256 -13
  109. package/dist/resources/models/Models.js.map +1 -1
  110. package/dist/resources/models/TestBackend.d.ts +3 -1
  111. package/dist/resources/models/TestBackend.js +35 -1
  112. package/dist/resources/models/TestBackend.js.map +1 -1
  113. package/dist/resources/models/analyticsTable.d.ts +2 -2
  114. package/dist/resources/models/analyticsTable.js +4 -2
  115. package/dist/resources/models/analyticsTable.js.map +1 -1
  116. package/dist/resources/models/backendHelpers.d.ts +24 -0
  117. package/dist/resources/models/backendHelpers.js +18 -1
  118. package/dist/resources/models/backendHelpers.js.map +1 -1
  119. package/dist/resources/models/backendRegistry.d.ts +10 -21
  120. package/dist/resources/models/backendRegistry.js +74 -18
  121. package/dist/resources/models/backendRegistry.js.map +1 -1
  122. package/dist/resources/models/bootstrap.d.ts +7 -0
  123. package/dist/resources/models/bootstrap.js +36 -4
  124. package/dist/resources/models/bootstrap.js.map +1 -1
  125. package/dist/resources/models/decideHook.d.ts +33 -0
  126. package/dist/resources/models/decideHook.js +105 -0
  127. package/dist/resources/models/decideHook.js.map +1 -0
  128. package/dist/resources/models/decision.d.ts +64 -0
  129. package/dist/resources/models/decision.js +507 -0
  130. package/dist/resources/models/decision.js.map +1 -0
  131. package/dist/resources/models/decisionStore.d.ts +134 -0
  132. package/dist/resources/models/decisionStore.js +350 -0
  133. package/dist/resources/models/decisionStore.js.map +1 -0
  134. package/dist/resources/models/embedHook.d.ts +66 -8
  135. package/dist/resources/models/embedHook.js +228 -61
  136. package/dist/resources/models/embedHook.js.map +1 -1
  137. package/dist/resources/models/generativeDecision.d.ts +58 -0
  138. package/dist/resources/models/generativeDecision.js +315 -0
  139. package/dist/resources/models/generativeDecision.js.map +1 -0
  140. package/dist/resources/models/types.d.ts +232 -4
  141. package/dist/security/authn/oidc/tokenExchange.js +3 -33
  142. package/dist/security/authn/oidc/tokenExchange.js.map +1 -1
  143. package/dist/security/authn/oidc/tokenUseTable.d.ts +9 -0
  144. package/dist/security/authn/oidc/tokenUseTable.js +28 -0
  145. package/dist/security/authn/oidc/tokenUseTable.js.map +1 -0
  146. package/dist/security/certificateVerification/certificateVerificationSource.d.ts +0 -1
  147. package/dist/security/certificateVerification/certificateVerificationSource.js +3 -3
  148. package/dist/security/certificateVerification/certificateVerificationSource.js.map +1 -1
  149. package/dist/security/certificateVerification/crlVerification.js +77 -165
  150. package/dist/security/certificateVerification/crlVerification.js.map +1 -1
  151. package/dist/security/certificateVerification/types.d.ts +0 -3
  152. package/dist/security/certificateVerification/verificationTables.d.ts +19 -0
  153. package/dist/security/certificateVerification/verificationTables.js +125 -0
  154. package/dist/security/certificateVerification/verificationTables.js.map +1 -0
  155. package/dist/security/certificateVerification/verificationUtils.d.ts +1 -1
  156. package/dist/security/certificateVerification/verificationUtils.js +6 -31
  157. package/dist/security/certificateVerification/verificationUtils.js.map +1 -1
  158. package/dist/security/jsLoader.d.ts +5 -0
  159. package/dist/security/jsLoader.js +8 -0
  160. package/dist/security/jsLoader.js.map +1 -1
  161. package/dist/server/Server.d.ts +1 -0
  162. package/dist/server/Server.js.map +1 -1
  163. package/dist/server/http.js +2 -2
  164. package/dist/server/http.js.map +1 -1
  165. package/dist/server/itc/serverHandlers.js +21 -2
  166. package/dist/server/itc/serverHandlers.js.map +1 -1
  167. package/dist/server/jobs/jobOwnership.d.ts +25 -0
  168. package/dist/server/jobs/jobOwnership.js +127 -0
  169. package/dist/server/jobs/jobOwnership.js.map +1 -0
  170. package/dist/server/jobs/jobProcess.js +9 -2
  171. package/dist/server/jobs/jobProcess.js.map +1 -1
  172. package/dist/server/jobs/jobs.js +18 -5
  173. package/dist/server/jobs/jobs.js.map +1 -1
  174. package/dist/server/loadRootComponents.js +10 -0
  175. package/dist/server/loadRootComponents.js.map +1 -1
  176. package/dist/server/serverHelpers/contentTypes.js +11 -1
  177. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  178. package/dist/server/serverHelpers/serverHandlers.js +12 -2
  179. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  180. package/dist/server/threads/itc.d.ts +4 -1
  181. package/dist/server/threads/itc.js +29 -6
  182. package/dist/server/threads/itc.js.map +1 -1
  183. package/dist/server/threads/manageThreads.d.ts +12 -4
  184. package/dist/server/threads/manageThreads.js +241 -119
  185. package/dist/server/threads/manageThreads.js.map +1 -1
  186. package/dist/server/threads/threadServer.d.ts +1 -0
  187. package/dist/server/threads/threadServer.js +15 -1
  188. package/dist/server/threads/threadServer.js.map +1 -1
  189. package/dist/server/threads/windowsProcessTree.d.ts +91 -0
  190. package/dist/server/threads/windowsProcessTree.js +361 -0
  191. package/dist/server/threads/windowsProcessTree.js.map +1 -0
  192. package/dist/server/throttle.d.ts +1 -1
  193. package/dist/server/throttle.js +8 -1
  194. package/dist/server/throttle.js.map +1 -1
  195. package/dist/upgrade/directives/5-3-0.js +53 -46
  196. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  197. package/dist/utility/errors/hdbError.d.ts +10 -1
  198. package/dist/utility/errors/hdbError.js +55 -11
  199. package/dist/utility/errors/hdbError.js.map +1 -1
  200. package/dist/utility/fsync.d.ts +2 -0
  201. package/dist/utility/fsync.js +20 -0
  202. package/dist/utility/fsync.js.map +1 -0
  203. package/dist/utility/globalSchema.d.ts +18 -0
  204. package/dist/utility/hdbTerms.d.ts +3 -0
  205. package/dist/utility/hdbTerms.js +3 -0
  206. package/dist/utility/hdbTerms.js.map +1 -1
  207. package/dist/utility/signalling.d.ts +7 -1
  208. package/dist/utility/signalling.js +27 -3
  209. package/dist/utility/signalling.js.map +1 -1
  210. package/dist/utility/when.d.ts +1 -0
  211. package/dist/utility/when.js +21 -0
  212. package/dist/utility/when.js.map +1 -1
  213. package/dist/validation/configValidator.js +30 -3
  214. package/dist/validation/configValidator.js.map +1 -1
  215. package/index.ts +13 -0
  216. package/json/systemSchema.json +28 -0
  217. package/npm-shrinkwrap.json +151 -55
  218. package/package.json +14 -13
  219. package/resources/DESIGN.md +80 -100
  220. package/resources/DatabaseTransaction.ts +157 -10
  221. package/resources/PrimaryRocksDatabase.ts +2 -2
  222. package/resources/Table.ts +494 -101
  223. package/resources/auditStore.ts +18 -10
  224. package/resources/blob.ts +13 -6
  225. package/resources/branchDatabase.ts +3 -3
  226. package/resources/databaseDropPreparation.ts +120 -0
  227. package/resources/databases.ts +1155 -205
  228. package/resources/derivedIndexRegistry.ts +122 -0
  229. package/resources/derivedIndexRuntime.ts +157 -30
  230. package/resources/derivedIndexes.ts +856 -0
  231. package/resources/fullTextSchema.ts +55 -0
  232. package/resources/fullTextSchemaLifecycle.ts +0 -15
  233. package/resources/graphql.ts +107 -12
  234. package/resources/indexes/DESIGN.md +7 -0
  235. package/resources/indexes/hnswDerivedIndex.ts +0 -260
  236. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +72 -9
  237. package/resources/models/DESIGN.md +39 -0
  238. package/resources/models/Models.ts +361 -18
  239. package/resources/models/TestBackend.ts +56 -1
  240. package/resources/models/analyticsTable.ts +6 -4
  241. package/resources/models/backendHelpers.ts +17 -0
  242. package/resources/models/backendRegistry.ts +76 -21
  243. package/resources/models/bootstrap.ts +57 -7
  244. package/resources/models/decideHook.ts +135 -0
  245. package/resources/models/decision.ts +505 -0
  246. package/resources/models/decisionStore.ts +419 -0
  247. package/resources/models/embedHook.ts +272 -55
  248. package/resources/models/generativeDecision.ts +459 -0
  249. package/resources/models/types.ts +231 -4
  250. package/security/DESIGN.md +22 -2
  251. package/security/authn/oidc/tokenExchange.ts +6 -34
  252. package/security/authn/oidc/tokenUseTable.ts +27 -0
  253. package/security/certificateVerification/certificateVerificationSource.ts +2 -3
  254. package/security/certificateVerification/crlVerification.ts +96 -174
  255. package/security/certificateVerification/types.ts +0 -3
  256. package/security/certificateVerification/verificationTables.ts +124 -0
  257. package/security/certificateVerification/verificationUtils.ts +8 -32
  258. package/security/jsLoader.ts +9 -1
  259. package/server/DESIGN.md +27 -1
  260. package/server/Server.ts +2 -1
  261. package/server/http.ts +4 -2
  262. package/server/itc/serverHandlers.js +29 -1
  263. package/server/jobs/jobOwnership.ts +89 -0
  264. package/server/jobs/jobProcess.ts +8 -2
  265. package/server/jobs/jobs.ts +15 -4
  266. package/server/loadRootComponents.js +11 -0
  267. package/server/serverHelpers/contentTypes.ts +13 -1
  268. package/server/serverHelpers/serverHandlers.js +11 -2
  269. package/server/threads/itc.js +32 -6
  270. package/server/threads/manageThreads.js +268 -122
  271. package/server/threads/threadServer.js +13 -1
  272. package/server/threads/windowsProcessTree.ts +409 -0
  273. package/server/throttle.ts +11 -1
  274. package/studio/web/assets/{Chat-CveGbxe6.js → Chat-Bq60AO-g.js} +1 -1
  275. package/studio/web/assets/{FloatingChat-IpCFgYWL.js → FloatingChat-D4560T0p.js} +3 -3
  276. package/studio/web/assets/{apiToken-CS8fFTgL.js → apiToken-BEvbFPMc.js} +1 -1
  277. package/studio/web/assets/{applications-D7F4Jopl.js → applications-CSUVTH4E.js} +1 -1
  278. package/studio/web/assets/{cssMode-Daw_oLPq.js → cssMode-DJyc9mHj.js} +1 -1
  279. package/studio/web/assets/{editor-B3Bs1zWX.js → editor-DDIsQfj6.js} +1 -1
  280. package/studio/web/assets/{html-CDaQwWiE.js → html-g1BJHxLy.js} +1 -1
  281. package/studio/web/assets/{htmlMode-Cb6emO4U.js → htmlMode-Vh5IglCQ.js} +1 -1
  282. package/studio/web/assets/{index-B4g2Fu_2.js → index-DGRAzvO4.js} +5 -5
  283. package/studio/web/assets/{index.lazy-NwhvF1q1.js → index.lazy-vgSic7et.js} +1 -1
  284. package/studio/web/assets/{javascript-2_86gB9Q.js → javascript-5VIolqf7.js} +1 -1
  285. package/studio/web/assets/{jsonMode-DX-i86uQ.js → jsonMode-DG4zQraI.js} +1 -1
  286. package/studio/web/assets/{languageServices-B85WN-Ej.js → languageServices-t5FBpX-i.js} +1 -1
  287. package/studio/web/assets/{lspLanguageFeatures-JCWHIsKg.js → lspLanguageFeatures-DgVnNPh-.js} +1 -1
  288. package/studio/web/assets/{notifications-D9ejzt52.js → notifications-BeWcX04m.js} +1 -1
  289. package/studio/web/assets/{notifications-B7hjKhai.js → notifications-DUGWrWoO.js} +1 -1
  290. package/studio/web/assets/{profile-D8B6No3F.js → profile-BvUEDBzW.js} +1 -1
  291. package/studio/web/assets/{regions-DFazIfTK.js → regions-CB-_2m7M.js} +1 -1
  292. package/studio/web/assets/{register-CwFLviLF.js → register-1w1Zr3b2.js} +2 -2
  293. package/studio/web/assets/{setComponentFile-VnJ80SSJ.js → setComponentFile-z4-qJCbA.js} +1 -1
  294. package/studio/web/assets/{setup-BYQxFy-C.js → setup-CS50uJ4x.js} +2 -2
  295. package/studio/web/assets/{status-Cn5XjmsL.js → status-CI0wOdOE.js} +1 -1
  296. package/studio/web/assets/{toggleHighContrast-g9xIh6TX.js → toggleHighContrast-BsAE4jPe.js} +1 -1
  297. package/studio/web/assets/{tsMode-EC-n0Wqc.js → tsMode-D83Gg3cq.js} +1 -1
  298. package/studio/web/assets/{typescript-DHmM9JCO.js → typescript-Dfp-GrNq.js} +1 -1
  299. package/studio/web/assets/{useEntityRestURL-BUdnVSVv.js → useEntityRestURL-BKetlINf.js} +1 -1
  300. package/studio/web/assets/{workers-BVl9bBB-.js → workers-CCvmDLH3.js} +1 -1
  301. package/studio/web/assets/{xml-C4_sqOkX.js → xml-06pZp00U.js} +1 -1
  302. package/studio/web/assets/{yaml-CVkbDqtW.js → yaml-iSnWMVlV.js} +1 -1
  303. package/studio/web/index.html +1 -1
  304. package/upgrade/directives/5-3-0.ts +61 -54
  305. package/utility/DESIGN.md +5 -1
  306. package/utility/errors/hdbError.ts +56 -8
  307. package/utility/fsync.ts +16 -0
  308. package/utility/hdbTerms.ts +3 -0
  309. package/utility/signalling.ts +33 -4
  310. package/utility/when.ts +22 -0
  311. package/validation/configValidator.ts +36 -7
@@ -3,7 +3,7 @@ import { randomBytes, randomUUID } from 'node:crypto';
3
3
  import { initSync, getHdbBasePath, get as envGet } from '../utility/environment/environmentManager.ts';
4
4
  import { INTERNAL_DBIS_NAME } from '../utility/lmdb/terms.ts';
5
5
  import { open, compareKeys, type Database, type RootDatabase } from 'lmdb';
6
- import { join, extname, basename } from 'node:path';
6
+ import { join, extname, basename, dirname, resolve } from 'node:path';
7
7
  import {
8
8
  closeSync,
9
9
  existsSync,
@@ -14,7 +14,7 @@ import {
14
14
  realpathSync,
15
15
  unlinkSync,
16
16
  } from 'node:fs';
17
- import { unlink } from 'node:fs/promises';
17
+ import { rm, unlink } from 'node:fs/promises';
18
18
  import {
19
19
  getBaseSchemaPath,
20
20
  getTransactionAuditStoreBasePath,
@@ -25,6 +25,7 @@ import {
25
25
  acquireUpdateAttributesLock,
26
26
  releaseUpdateAttributesLock,
27
27
  tryUpdateAttributesLock,
28
+ withUpdateAttributesLockNonBlocking,
28
29
  } from './Table.ts';
29
30
  import OpenEnvironmentObject from '../utility/lmdb/OpenEnvironmentObject.ts';
30
31
  import {
@@ -35,7 +36,7 @@ import {
35
36
  RESERVED_DATABASE_NAMES,
36
37
  } from '../utility/hdbTerms.ts';
37
38
  import { getConfigPath } from '../config/configUtils.ts';
38
- import { ClientError } from '../utility/errors/hdbError.ts';
39
+ import { ClientError, DatabaseClosingError } from '../utility/errors/hdbError.ts';
39
40
  import { _assignPackageExport } from '../globals.js';
40
41
  import { getIndexedValues } from '../utility/lmdb/commonUtility.ts';
41
42
  import * as signalling from '../utility/signalling.ts';
@@ -55,43 +56,78 @@ import {
55
56
  import { handleLocalTimeForGets } from './RecordEncoder.ts';
56
57
  import {
57
58
  databasePaths,
59
+ deleteBlobPaths,
58
60
  deleteBlobAndWait,
59
61
  deleteBlobsInObject,
60
- deleteRootBlobPathsForDB,
61
62
  findBlobsInObject,
63
+ getBlobPathsForDatabaseName,
64
+ getRootBlobPathsForDB,
62
65
  } from './blob.ts';
63
66
  import { removeStorageReclamation } from '../server/storageReclamation.ts';
64
67
  import { commonValidators, schemaRegex } from '../validation/common_validators.ts';
65
68
  import { CUSTOM_INDEXES } from './indexes/customIndexes.ts';
66
69
  import { planeFilePathFor, planeStalePathFor } from './indexes/hnswPlaneBinding.ts';
67
70
  import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.ts';
68
- import { RocksDatabase, supportedCompression, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
71
+ import { registryStatus, RocksDatabase, supportedCompression, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
69
72
  import { PrimaryRocksDatabase } from './PrimaryRocksDatabase.ts';
73
+ import {
74
+ databaseCommitsSuspended,
75
+ getDatabaseCommitDrainTimeoutMilliseconds,
76
+ permanentlySuspendDatabaseCommits,
77
+ suspendDatabaseCommits,
78
+ } from './DatabaseTransaction.ts';
70
79
  import { replayLogs } from './replayLogs.ts';
71
- import { attachDerivedIndexes } from './indexes/hnswDerivedIndex.ts';
80
+ import {
81
+ assertFullTextActivationSupported,
82
+ permanentlySuspendDerivedIndexActivation,
83
+ refreshDerivedIndexes,
84
+ retireFullTextIndexes,
85
+ suspendDerivedIndexActivation,
86
+ } from './derivedIndexes.ts';
87
+ import {
88
+ acquireFullTextRetirementFence,
89
+ fullTextClearInProgress,
90
+ fullTextRetirementInProgress,
91
+ } from './derivedIndexRegistry.ts';
72
92
  import { totalmem } from 'node:os';
73
93
  import { RocksIndexStore } from './RocksIndexStore.ts';
74
94
  import { resolveRocksMemoryConfig } from '../utility/rocksMemoryConfig.ts';
75
95
  import { isProcessRunning } from '../utility/processManagement/processManagement.js';
76
- import { compileFullTextDefinitions, type FullTextDefinition } from './fullTextSchema.ts';
96
+ import {
97
+ compileFullTextDefinitions,
98
+ persistedFullTextIndexNames,
99
+ reconcileFullTextIndexGenerations,
100
+ type FullTextDefinition,
101
+ type FullTextIndexGenerations,
102
+ } from './fullTextSchema.ts';
77
103
  import { projectAttributesToProperties } from './jsonSchemaTypes.ts';
78
104
  import {
79
105
  definitionsEqual,
80
106
  mergePeerFullTextDefinitions,
81
107
  readPersistedFullTextDefinitions,
82
108
  retainFullTextDefinitions,
83
- safeInterimFullTextDefinitions,
84
109
  serializeFullTextState,
85
110
  } from './fullTextSchemaLifecycle.ts';
111
+ import { assertDerivedFieldOwnership } from './models/embedHook.ts';
86
112
  import {
87
- acquireRestoreLock,
113
+ abandonDatabaseDrop,
114
+ beginDatabaseDrop,
115
+ cancelDatabaseDrop,
88
116
  checkRestoreState,
89
- releaseRestoreLock,
90
- restoreMarkerPresent,
117
+ completeDatabaseDrop,
118
+ databaseDropMarkerPresent,
119
+ scanBlockedDatabaseDrops,
91
120
  scanBlockedRestores,
92
121
  RESTORE_META_DIR,
93
- type RestoreLock,
122
+ type DatabaseDropLock,
94
123
  } from '../dataLayer/restoreMarker.ts';
124
+ import {
125
+ claimDatabaseDropPreparations,
126
+ databaseDropPrepared,
127
+ databaseDropPreparedWithin,
128
+ releaseDatabaseDropPreparations,
129
+ trackDatabaseDropPreparationTask,
130
+ } from './databaseDropPreparation.ts';
95
131
 
96
132
  /**
97
133
  * Check if Harper is running in read-only mode.
@@ -101,6 +137,10 @@ import {
101
137
  * - storage.readOnly config setting
102
138
  */
103
139
  let _isReadOnlyMode: boolean | undefined;
140
+ export function __setReadOnlyModeForTest(value: boolean | undefined): void {
141
+ _isReadOnlyMode = value;
142
+ }
143
+
104
144
  export function isReadOnlyMode(): boolean {
105
145
  if (_isReadOnlyMode !== undefined) return _isReadOnlyMode;
106
146
  // Check environment variable
@@ -454,6 +494,20 @@ export function openRocksDatabase(path: string, options: RocksDatabaseOptions &
454
494
 
455
495
  const lmdbDatabaseEnvs = new Map<string, LMDBRootDatabase>();
456
496
  const rocksdbDatabaseEnvs = new Map<string, RocksRootDatabase>();
497
+ type IncompleteDatabaseClose = {
498
+ databaseNames: Set<string>;
499
+ rootPaths: string[];
500
+ retry: () => Promise<void>;
501
+ task?: Promise<void>;
502
+ };
503
+ const incompleteDatabaseCloses = new Map<string, IncompleteDatabaseClose>();
504
+
505
+ function databaseRootUnavailable(rootPath: string): boolean {
506
+ return (
507
+ databaseDropPrepared(rootPath) ||
508
+ (incompleteDatabaseCloses.size > 0 && incompleteDatabaseCloses.has(resolve(rootPath)))
509
+ );
510
+ }
457
511
 
458
512
  // set the following in both global and exports
459
513
  _assignPackageExport('databases', databases);
@@ -474,6 +528,7 @@ const PEER_REDEFINABLE_FIELDS = [
474
528
  'elements',
475
529
  'properties',
476
530
  'embed',
531
+ 'decide',
477
532
  ];
478
533
  // `indexNulls` is derived from the durable descriptor, never sent by a peer, so naming it in the
479
534
  // discard warn would blame the peer for a field it did not write.
@@ -488,6 +543,24 @@ function applyDurableDeclaration(attribute: any, descriptor: any) {
488
543
  }
489
544
  }
490
545
 
546
+ function persistedFullTextIndexGenerations(
547
+ value: unknown,
548
+ definitions: readonly FullTextDefinition[]
549
+ ): FullTextIndexGenerations {
550
+ const persisted =
551
+ value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
552
+ const generations: FullTextIndexGenerations = Object.create(null);
553
+ for (const { name } of definitions) {
554
+ const generation = persisted[name];
555
+ if (typeof generation === 'string' && generation.length > 0) generations[name] = generation;
556
+ }
557
+ return generations;
558
+ }
559
+
560
+ function createFullTextIndexGeneration(): string {
561
+ return randomBytes(16).toString('hex');
562
+ }
563
+
491
564
  /**
492
565
  * True when a descriptor claims an index build no live operation in this process can own. The PID and
493
566
  * worker generation cannot answer that alone: a container reuses PID 1 and starts the in-memory
@@ -667,6 +740,11 @@ export function getDatabases(): Databases {
667
740
  // TODO: Load any databases defined with explicit storage paths from the config
668
741
  const entries = readdirSync(databasePath, { withFileTypes: true });
669
742
  const blockedByRestore = databasesBlockedByRestore(databasePath);
743
+ const blockedByDrop = databaseRootsBlockedByDrop(databasePath);
744
+ const configuredDropCoversDirectory = Object.entries(schemaConfigs).some(
745
+ ([name, config]: [string, any]) =>
746
+ config?.path && resolve(config.path) === resolve(databasePath) && blockedByDrop.databaseNames.has(name)
747
+ );
670
748
  for (const databaseEntry of entries) {
671
749
  // in-progress migration staging dirs are not databases until atomically renamed into place
672
750
  if (databaseEntry.name.endsWith(MIGRATING_DIR_SUFFIX)) continue;
@@ -678,6 +756,13 @@ export function getDatabases(): Databases {
678
756
  if (databaseEntry.name === BRANCH_ROOT_DIR) continue;
679
757
  const dbName = basename(databaseEntry.name, '.mdb');
680
758
  const dbPath = join(databasePath, databaseEntry.name);
759
+ if (databaseRootUnavailable(dbPath)) continue;
760
+ if (
761
+ configuredDropCoversDirectory ||
762
+ blockedByDrop.rootPaths.has(dbPath) ||
763
+ blockedByDrop.databaseNames.has(dbName)
764
+ )
765
+ continue;
681
766
  if (blockedByRestore.has(dbName)) continue;
682
767
  if (isOpenBranchPath(dbPath)) continue;
683
768
 
@@ -715,16 +800,14 @@ export function getDatabases(): Databases {
715
800
  if (!schemaEntry.isFile()) {
716
801
  const schemaPath = join(baseSchemaPath, schemaEntry.name);
717
802
  const schemaAuditPath = join(getTransactionAuditStoreBasePath(), schemaEntry.name);
803
+ const blockedByDrop = databaseRootsBlockedByDrop(schemaPath);
718
804
  for (const tableEntry of readdirSync(schemaPath, { withFileTypes: true })) {
719
805
  if (tableEntry.isFile() && extname(tableEntry.name).toLowerCase() === '.mdb') {
806
+ const tablePath = join(schemaPath, tableEntry.name);
807
+ if (databaseRootUnavailable(tablePath)) continue;
808
+ if (blockedByDrop.rootPaths.has(tablePath) || blockedByDrop.databaseNames.has(schemaEntry.name)) continue;
720
809
  const auditPath = join(schemaAuditPath, tableEntry.name);
721
- readMetaDb(
722
- join(schemaPath, tableEntry.name),
723
- basename(tableEntry.name, '.mdb'),
724
- schemaEntry.name,
725
- auditPath,
726
- true
727
- );
810
+ readMetaDb(tablePath, basename(tableEntry.name, '.mdb'), schemaEntry.name, auditPath, true);
728
811
  }
729
812
  }
730
813
  }
@@ -734,21 +817,25 @@ export function getDatabases(): Databases {
734
817
  if (schemaConfigs) {
735
818
  for (const dbName in schemaConfigs) {
736
819
  const schemaConfig = schemaConfigs[dbName];
820
+ if (databaseDropRecoveryPending(dbName)) continue;
737
821
  const databasePath = schemaConfig.path;
738
822
  if (existsSync(databasePath)) {
739
823
  const entries = readdirSync(databasePath, { withFileTypes: true });
740
824
  const blockedByRestore = databasesBlockedByRestore(databasePath);
825
+ const blockedByDrop = databaseRootsBlockedByDrop(databasePath);
741
826
  for (const databaseEntry of entries) {
742
827
  if (databaseEntry.name.endsWith(MIGRATING_DIR_SUFFIX)) continue; // migration staging dir
743
828
  if (databaseEntry.name === RESTORE_META_DIR) continue; // reserved restore-metadata dir
744
829
  if (databaseEntry.name === BRANCH_ROOT_DIR) continue; // reserved branch root
745
830
  if (blockedByRestore.has(basename(databaseEntry.name, '.mdb'))) continue;
746
- if (isOpenBranchPath(join(databasePath, databaseEntry.name))) continue;
831
+ const dbPath = join(databasePath, databaseEntry.name);
832
+ if (databaseRootUnavailable(dbPath)) continue;
833
+ if (blockedByDrop.rootPaths.has(dbPath) || blockedByDrop.databaseNames.has(dbName)) continue;
834
+ if (isOpenBranchPath(dbPath)) continue;
747
835
  if (databaseEntry.isFile() && extname(databaseEntry.name).toLowerCase() === '.mdb') {
748
- readMetaDb(join(databasePath, databaseEntry.name), basename(databaseEntry.name, '.mdb'), dbName);
836
+ readMetaDb(dbPath, basename(databaseEntry.name, '.mdb'), dbName);
749
837
  } else {
750
838
  try {
751
- const dbPath = join(databasePath, databaseEntry.name);
752
839
  const files = readdirSync(dbPath, { withFileTypes: true });
753
840
  if (
754
841
  files.find((file) => file.name === 'CURRENT')?.isFile() &&
@@ -770,7 +857,7 @@ export function getDatabases(): Databases {
770
857
  for (const tableName in tableConfigs) {
771
858
  const tableConfig = tableConfigs[tableName];
772
859
  const tablePath = join(tableConfig.path, basename(tableName + '.mdb'));
773
- if (existsSync(tablePath)) {
860
+ if (!databaseRootUnavailable(tablePath) && !databaseDropMarkerPresent(tablePath) && existsSync(tablePath)) {
774
861
  readMetaDb(tablePath, tableName, dbName, null, true);
775
862
  }
776
863
  }
@@ -780,6 +867,7 @@ export function getDatabases(): Databases {
780
867
  }
781
868
  // now remove any databases or tables that have been removed
782
869
  for (const dbName in databases) {
870
+ if (databaseUsesPreparedRoot(dbName)) continue;
783
871
  const definedTables = definedDatabases.get(dbName);
784
872
  if (definedTables) {
785
873
  const tables = databases[dbName];
@@ -1010,6 +1098,42 @@ function databasesBlockedByRestore(databasePath: string): Set<string> {
1010
1098
  return blocked;
1011
1099
  }
1012
1100
 
1101
+ const loggedDropMarkersByDirectory = new Map<string, Set<string>>();
1102
+
1103
+ function databaseRootsBlockedByDrop(databasePath: string): {
1104
+ rootPaths: Set<string>;
1105
+ databaseNames: Set<string>;
1106
+ } {
1107
+ const rootPaths = new Set<string>();
1108
+ const databaseNames = new Set<string>();
1109
+ const directoryKey = resolve(databasePath);
1110
+ const previouslyLogged = loggedDropMarkersByDirectory.get(directoryKey) ?? new Set<string>();
1111
+ const currentMarkers = new Set<string>();
1112
+ for (const drop of scanBlockedDatabaseDrops(databasePath)) {
1113
+ currentMarkers.add(drop.markerPath);
1114
+ const message = drop.databaseName
1115
+ ? `Incomplete drop of database '${drop.databaseName}' detected; not loading ${drop.rootPath} — retry drop_database to recover (${drop.markerPath})`
1116
+ : `Incomplete database drop with invalid marker metadata detected; not loading ${drop.rootPath} — inspect the root and marker before manual recovery (${drop.markerPath})`;
1117
+ if (previouslyLogged.has(drop.markerPath)) logger.trace(message);
1118
+ else logger.error(message);
1119
+ rootPaths.add(drop.rootPath);
1120
+ if (drop.databaseName) databaseNames.add(drop.databaseName);
1121
+ }
1122
+ if (currentMarkers.size > 0) loggedDropMarkersByDirectory.set(directoryKey, currentMarkers);
1123
+ else loggedDropMarkersByDirectory.delete(directoryKey);
1124
+ return { rootPaths, databaseNames };
1125
+ }
1126
+
1127
+ function configuredDropPendingWithin(databasePath: string, schemaConfigs: Record<string, any>): boolean {
1128
+ const normalizedPath = resolve(databasePath);
1129
+ return Object.entries(schemaConfigs).some(
1130
+ ([name, config]: [string, any]) =>
1131
+ config?.path &&
1132
+ resolve(config.path) === normalizedPath &&
1133
+ scanBlockedDatabaseDrops(config.path).some((drop) => drop.databaseName === name)
1134
+ );
1135
+ }
1136
+
1013
1137
  /**
1014
1138
  * This is responsible for reading the internal dbi of a single database file to get a list of all the tables and
1015
1139
  * their indexed or registered attributes
@@ -1220,14 +1344,14 @@ function initStores(
1220
1344
  }
1221
1345
  if (failedAttempts < MAX_INTERRUPTED_DROP_ATTEMPTS) {
1222
1346
  try {
1223
- completeInterruptedDrop(rootStore, attributesDbi, databaseName, tableName);
1347
+ const completed = completeInterruptedDrop(rootStore, attributesDbi, databaseName, tableName);
1224
1348
  // Sweep every generation this worker has ever tracked for this table, not
1225
1349
  // just the one just resolved: if a prior generation was exhausted here,
1226
1350
  // then resolved+recreated+re-dropped by another worker as this generation
1227
1351
  // without this worker ever observing a live row in between (the only other
1228
1352
  // place that sweeps), the prior generation's entry would otherwise never
1229
1353
  // be cleared.
1230
- clearInterruptedDropEntries(path, tableName);
1354
+ if (completed) clearInterruptedDropEntries(path, tableName);
1231
1355
  definedTables?.delete(tableName);
1232
1356
  } catch (error) {
1233
1357
  const attempt = failedAttempts + 1;
@@ -1319,9 +1443,16 @@ function initStores(
1319
1443
  for (const warning of warnings) logger.warn(warning);
1320
1444
  } else if (warnings.length === 0) warnedFullTextStates.delete(warningKey);
1321
1445
  }
1446
+ const fullTextIndexGenerations = persistedFullTextIndexGenerations(
1447
+ primaryAttribute.fullTextIndexGenerations,
1448
+ fullTextIndexes
1449
+ );
1450
+ const fullTextIndexRetirements = persistedFullTextIndexNames(primaryAttribute.fullTextIndexRetirements);
1322
1451
  if (table && !recreateTable) {
1323
1452
  if (primaryAttribute.audit === true && table.audit !== true) table.enableAuditing();
1324
1453
  table.fullTextIndexes = fullTextIndexes;
1454
+ table.fullTextIndexGenerations = fullTextIndexGenerations;
1455
+ table.fullTextIndexRetirements = fullTextIndexRetirements;
1325
1456
  indices = table.indices;
1326
1457
  existingAttributes = table.attributes;
1327
1458
  table.schemaVersion++;
@@ -1474,6 +1605,8 @@ function initStores(
1474
1605
  indices,
1475
1606
  attributes,
1476
1607
  fullTextIndexes,
1608
+ fullTextIndexGenerations,
1609
+ fullTextIndexRetirements,
1477
1610
  schemaDefined: primaryAttribute.schemaDefined,
1478
1611
  dbisDB: attributesDbi,
1479
1612
  })
@@ -1481,8 +1614,7 @@ function initStores(
1481
1614
  table.schemaVersion = 1;
1482
1615
  if (!destination) databaseEventsEmitter.emit('updateTable', table);
1483
1616
  }
1484
- void table.derivedIndexRuntime?.close();
1485
- table.derivedIndexRuntime = attachDerivedIndexes(table);
1617
+ refreshDerivedIndexes(table);
1486
1618
  if (Array.isArray(primaryAttribute.relationships)) {
1487
1619
  relationshipsToHydrate.push({ table, databaseName, tableName, definitions: primaryAttribute.relationships });
1488
1620
  } else if (primaryAttribute.relationships !== undefined) {
@@ -1557,7 +1689,7 @@ export interface BranchDatabase {
1557
1689
  * a relationship whose target the application also branched against that branch.
1558
1690
  */
1559
1691
  relatedBranches?: Map<string, BranchDatabase>;
1560
- close(): void;
1692
+ close(): Promise<void>;
1561
1693
  }
1562
1694
 
1563
1695
  /** `undefined` marks a path reserved by an open still in flight, which owns it just as firmly. */
@@ -1833,10 +1965,28 @@ export function openBranchDatabase(
1833
1965
  releaseBranchIdentity(storeName);
1834
1966
  const stranded = rocksdbDatabaseEnvs.get(path);
1835
1967
  rocksdbDatabaseEnvs.delete(path);
1968
+ const auditCleanup = (stranded as any)?.auditStore?.stopAuditCleanup?.();
1969
+ auditCleanup?.catch((cleanupError) =>
1970
+ logger.warn(`Error retiring audit cleanup for branch database at ${path}`, cleanupError)
1971
+ );
1836
1972
  closeBranchHandles(path, stranded, openedStores, tables);
1837
1973
  throw error;
1838
1974
  }
1839
- let closed = false;
1975
+ let closing: Promise<void> | undefined;
1976
+ let handleTeardownStarted = false;
1977
+ const unregisterBranch = () => {
1978
+ if (openBranches.get(path) === branch) openBranches.delete(path);
1979
+ releaseBranchIdentity(storeName);
1980
+ rocksdbDatabaseEnvs.delete(path);
1981
+ manageThreads.markBranchStorePath(path, false);
1982
+ };
1983
+ const closeRemainingHandles = () => {
1984
+ const closeFailures = closeBranchHandles(path, rootStore, openedStores, tables);
1985
+ if (closeFailures.length) {
1986
+ throw new AggregateError(closeFailures, `Could not close branch database '${databaseName}'`);
1987
+ }
1988
+ unregisterBranch();
1989
+ };
1840
1990
  const branch: BranchDatabase = {
1841
1991
  tables,
1842
1992
  rootStore,
@@ -1848,13 +1998,54 @@ export function openBranchDatabase(
1848
1998
  close() {
1849
1999
  // guard on the handle, not on the registrations: those are keyed by path, and a closed
1850
2000
  // branch frees its path, so a stale handle would otherwise tear down its successor
1851
- if (closed) return;
1852
- closed = true;
1853
- openBranches.delete(path);
1854
- releaseBranchIdentity(storeName);
1855
- rocksdbDatabaseEnvs.delete(path);
1856
- manageThreads.markBranchStorePath(path, false);
1857
- closeBranchHandles(path, rootStore, openedStores, tables);
2001
+ if (closing) return closing;
2002
+ if (handleTeardownStarted) {
2003
+ const operation = Promise.resolve().then(closeRemainingHandles);
2004
+ const retryable = operation.catch((error) => {
2005
+ if (closing === retryable) closing = undefined;
2006
+ throw error;
2007
+ });
2008
+ closing = retryable;
2009
+ closing.catch(() => {});
2010
+ return closing;
2011
+ }
2012
+ const releaseActivation = suspendDerivedIndexActivation(rootStore);
2013
+ const commitSuspension = suspendDatabaseCommits([rootStore]);
2014
+ const deadline = Date.now() + getDatabaseCommitDrainTimeoutMilliseconds();
2015
+ let closed = false;
2016
+ const operation = commitSuspension
2017
+ .waitForDrain({ deadline, databaseName })
2018
+ .then(() => settleTableMaintenance(tables, deadline))
2019
+ .then(() => settleBranchDerivedIndexes(tables))
2020
+ .then(() => (rootStore as any).auditStore?.stopAuditCleanup?.())
2021
+ .then(() => {
2022
+ handleTeardownStarted = true;
2023
+ closeRemainingHandles();
2024
+ commitSuspension.release();
2025
+ releaseActivation();
2026
+ closed = true;
2027
+ })
2028
+ .finally(() => {
2029
+ if (!closed && !handleTeardownStarted) {
2030
+ commitSuspension.release();
2031
+ releaseActivation();
2032
+ resumeTableMaintenance(tables);
2033
+ for (const table of Object.values(tables)) refreshDerivedIndexes(table);
2034
+ } else if (!closed) {
2035
+ // Native handle teardown started, so this wrapper graph cannot safely return to service.
2036
+ permanentlySuspendDatabaseCommits([rootStore]);
2037
+ permanentlySuspendDerivedIndexActivation(rootStore);
2038
+ commitSuspension.release();
2039
+ releaseActivation();
2040
+ }
2041
+ });
2042
+ const retryable = operation.catch((error) => {
2043
+ if (closing === retryable) closing = undefined;
2044
+ throw error;
2045
+ });
2046
+ closing = retryable;
2047
+ closing.catch(() => {});
2048
+ return closing;
1858
2049
  },
1859
2050
  };
1860
2051
  openBranches.set(path, branch);
@@ -1875,9 +2066,9 @@ function closeBranchHandles(
1875
2066
  rootStore?: RootDatabaseKind,
1876
2067
  openedStores: any[] = [],
1877
2068
  tables: Tables = {}
1878
- ): void {
2069
+ ): unknown[] {
1879
2070
  const reclamationPaths = new Set<string>([path]);
1880
- (rootStore as any)?.auditStore?.stopAuditCleanup?.();
2071
+ const closeFailures: unknown[] = [];
1881
2072
  const closeStore = (store: any, description: string) => {
1882
2073
  if (!store || store.status === 'closed') return;
1883
2074
  if (store.path) reclamationPaths.add(store.path);
@@ -1885,6 +2076,7 @@ function closeBranchHandles(
1885
2076
  store.close?.();
1886
2077
  } catch (error) {
1887
2078
  logger.warn(`Error closing ${description} for branch database at ${path}`, error);
2079
+ closeFailures.push(error);
1888
2080
  }
1889
2081
  };
1890
2082
  // the class, before its stores: an expiration timer or a reclamation handler on a closed store
@@ -1902,11 +2094,34 @@ function closeBranchHandles(
1902
2094
  closeStore(rootStore, 'root store');
1903
2095
  if (rootStore) databasePaths.delete(rootStore as RootDatabase);
1904
2096
  for (const reclamationPath of reclamationPaths) removeStorageReclamation(reclamationPath);
2097
+ return closeFailures;
2098
+ }
2099
+
2100
+ async function settleBranchDerivedIndexes(tables: Tables): Promise<void> {
2101
+ const attachments = new Set<any>();
2102
+ for (const tableName in tables) {
2103
+ const attachment = tables[tableName]?.derivedIndexRuntime;
2104
+ if (attachment) attachments.add(attachment);
2105
+ }
2106
+ await Promise.all([...attachments].map((attachment) => attachment.close()));
1905
2107
  }
1906
2108
 
1907
2109
  /** Branches are process-local, so this is shutdown, not a data operation. */
1908
- export function closeBranchDatabases(): void {
1909
- for (const branch of [...openBranches.values()]) branch?.close();
2110
+ export function branchDatabasesHaveWork(): boolean {
2111
+ return openBranches.size > 0;
2112
+ }
2113
+
2114
+ export async function closeBranchDatabases(requireClosed = false): Promise<void> {
2115
+ const results = await Promise.allSettled([...openBranches.values()].map((branch) => branch?.close()));
2116
+ const failures: unknown[] = [];
2117
+ for (const result of results) {
2118
+ if (result.status === 'rejected') {
2119
+ logger.warn('Error closing branch database during worker teardown', result.reason);
2120
+ failures.push(result.reason);
2121
+ }
2122
+ }
2123
+ if (requireClosed && failures.length > 0)
2124
+ throw new AggregateError(failures, 'Could not close every branch database during worker teardown');
1910
2125
  }
1911
2126
 
1912
2127
  export function resetDatabases() {
@@ -2037,12 +2252,24 @@ export function resolveDatabasePath(databaseName: string): string {
2037
2252
  * @returns
2038
2253
  */
2039
2254
  export function database({ database: databaseName, table: tableName }) {
2255
+ return openDatabaseRoot({ database: databaseName, table: tableName });
2256
+ }
2257
+
2258
+ /**
2259
+ * Open a database root. Destructive DDL uses the internal bypass only after it owns the database's
2260
+ * drop-preparation fence; public callers must continue to fail while that fence is held.
2261
+ */
2262
+ function openDatabaseRoot(
2263
+ { database: databaseName, table: tableName }: { database: string; table?: string },
2264
+ { allowPreparedDrop = false }: { allowPreparedDrop?: boolean } = {}
2265
+ ) {
2040
2266
  if (!databaseName) databaseName = DEFAULT_DATABASE_NAME;
2041
2267
  getDatabases();
2042
- ensureDB(databaseName);
2043
- const definedDatabase = definedDatabases.get(databaseName);
2268
+ let definedDatabase = definedDatabases.get(databaseName);
2044
2269
  if ((definedDatabase as any)?.rootStore) {
2045
- return (definedDatabase as any).rootStore;
2270
+ const rootStore = (definedDatabase as any).rootStore;
2271
+ if (!allowPreparedDrop && databaseRootUnavailable(rootStore.path)) throw new DatabaseClosingError(databaseName);
2272
+ return rootStore;
2046
2273
  }
2047
2274
  const databaseConfig = envGet(CONFIG_PARAMS.DATABASES) || {};
2048
2275
  if (process.env.SCHEMAS_DATA_PATH) {
@@ -2050,11 +2277,39 @@ export function database({ database: databaseName, table: tableName }) {
2050
2277
  }
2051
2278
  const tablePath = tableName && databaseConfig[databaseName]?.tables?.[tableName]?.path;
2052
2279
  const databasePath = resolveDatabaseStorageRoot(databaseName, tableName);
2280
+ if (!allowPreparedDrop && configuredDropPendingWithin(databasePath, databaseConfig))
2281
+ throw new DatabaseClosingError(databaseName);
2282
+ // A configured path is a scan root, not an exact database path, so an unresolved alias can
2283
+ // target any child store. Keep first-open fenced until the destructive operation completes.
2284
+ if (!allowPreparedDrop && databaseConfig[databaseName]?.path) {
2285
+ const blockedRestores = scanBlockedRestores(databasePath);
2286
+ if (blockedRestores.length > 0) {
2287
+ const error: any = new Error(
2288
+ `Database '${databaseName}' shares a configured path with an ${blockedRestores[0][1]} restore of '${blockedRestores[0][0]}'; complete that restore before reopening it`
2289
+ );
2290
+ error.statusCode = 409;
2291
+ throw error;
2292
+ }
2293
+ if (databaseDropPreparedWithin(databasePath)) throw new DatabaseClosingError(databaseName);
2294
+ if (scanBlockedDatabaseDrops(databasePath).length > 0) {
2295
+ const error: any = new Error(
2296
+ `Database '${databaseName}' shares a configured path with an incomplete drop; retry drop_database to recover it`
2297
+ );
2298
+ error.statusCode = 409;
2299
+ throw error;
2300
+ }
2301
+ }
2053
2302
 
2054
2303
  let rootStore: RootDatabaseKind;
2055
2304
  const useRocksdb = (process.env.HARPER_STORAGE_ENGINE || envGet(CONFIG_PARAMS.STORAGE_ENGINE)) !== 'lmdb';
2305
+ const path = useRocksdb
2306
+ ? join(databasePath, tablePath ? tableName : databaseName)
2307
+ : join(databasePath, `${tablePath ? tableName : databaseName}.mdb`);
2308
+ if (!allowPreparedDrop && databaseRootUnavailable(path)) throw new DatabaseClosingError(databaseName);
2309
+ throwIfBlockedByRestore(path, databaseName);
2310
+ ensureDB(databaseName);
2311
+ definedDatabase = definedDatabases.get(databaseName);
2056
2312
  if (useRocksdb) {
2057
- const path = join(databasePath, tablePath ? tableName : databaseName);
2058
2313
  // the scan is not the only way to reach a branch's directory: a branch leaves its store in
2059
2314
  // `rocksdbDatabaseEnvs`, so without this an on-demand open would staple it onto
2060
2315
  // `definedDatabases` and the next `closeDatabase` would close it under the live handle
@@ -2068,7 +2323,6 @@ export function database({ database: databaseName, table: tableName }) {
2068
2323
  // this on-demand open (create_table/create_database and friends) must not resurrect a
2069
2324
  // database that a restore is rewriting (or left half-purged) — the scan-time restore
2070
2325
  // checks don't cover this path
2071
- throwIfBlockedByRestore(path, databaseName);
2072
2326
  rootStore = openRocksDatabase(path, {
2073
2327
  disableWAL: false,
2074
2328
  enableStats: true,
@@ -2076,7 +2330,6 @@ export function database({ database: databaseName, table: tableName }) {
2076
2330
  rocksdbDatabaseEnvs.set(path, rootStore as any);
2077
2331
  }
2078
2332
  } else {
2079
- const path = join(databasePath, `${tablePath ? tableName : databaseName}.mdb`);
2080
2333
  rootStore = lmdbDatabaseEnvs.get(path);
2081
2334
  if (!rootStore || rootStore.status === 'closed') {
2082
2335
  // TODO: validate database name
@@ -2092,6 +2345,13 @@ export function database({ database: databaseName, table: tableName }) {
2092
2345
  return rootStore;
2093
2346
  }
2094
2347
  function throwIfBlockedByRestore(dbPath: string, databaseName: string): void {
2348
+ if (databaseDropMarkerPresent(dbPath)) {
2349
+ const error: any = new Error(
2350
+ `Database '${databaseName}' has an incomplete drop; retry drop_database to recover it`
2351
+ );
2352
+ error.statusCode = 409;
2353
+ throw error;
2354
+ }
2095
2355
  const restoreState = checkRestoreState(dbPath);
2096
2356
  if (restoreState !== 'clear') {
2097
2357
  const error: any = new Error(
@@ -2104,189 +2364,612 @@ function throwIfBlockedByRestore(dbPath: string, databaseName: string): void {
2104
2364
  }
2105
2365
  }
2106
2366
 
2107
- /**
2108
- * Take the per-database restore lock for a drop, refusing (409) if a restore holds it (in-progress)
2109
- * or a crashed restore left a marker (incomplete). Pushes the acquired lock onto `held` so the
2110
- * caller releases it after the drop. On refusal, releases anything already held and throws.
2111
- *
2112
- * The lock is not reentrant within a process, so a path already in `held` must be skipped — every
2113
- * table in a RocksDB database shares one root store (and one lock path), and re-acquiring it in the
2114
- * same drop would spuriously 409 on the second table.
2115
- */
2116
- function lockDatabaseForDrop(dbPath: string, databaseName: string, held: RestoreLock[]): void {
2117
- if (held.some((h) => h.dbPath === dbPath)) return;
2118
- let lock: RestoreLock;
2367
+ function lockDatabaseForDrop(
2368
+ dbPath: string,
2369
+ databaseName: string,
2370
+ held: DatabaseDropLock[],
2371
+ blobDatabaseName = databaseName,
2372
+ blobPaths = getBlobPathsForDatabaseName(blobDatabaseName)
2373
+ ): void {
2374
+ if (held.some((lock) => lock.dbPath === dbPath)) return;
2119
2375
  try {
2120
- lock = acquireRestoreLock(dbPath);
2376
+ held.push(beginDatabaseDrop(dbPath, databaseName, blobDatabaseName, blobPaths));
2121
2377
  } catch (error) {
2122
- for (const h of held) releaseRestoreLock(h);
2123
- throw error; // 409: a restore is in progress and holds the lock
2124
- }
2125
- // We now hold the lock, so no restore is active. A surviving marker is therefore debris from a
2126
- // crashed restore (incomplete) — refuse rather than delete a directory that still needs recovery.
2127
- if (restoreMarkerPresent(dbPath)) {
2128
- releaseRestoreLock(lock);
2129
- for (const h of held) releaseRestoreLock(h);
2130
- const error: any = new Error(
2131
- `Database '${databaseName}' has an incomplete restore; rerun restore_backup to recover it`
2132
- );
2133
- error.statusCode = 409;
2378
+ const cleanupFailures = [];
2379
+ for (const lock of held.splice(0)) {
2380
+ try {
2381
+ cancelDatabaseDrop(lock);
2382
+ } catch (cleanupError) {
2383
+ cleanupFailures.push(cleanupError);
2384
+ }
2385
+ }
2386
+ if (cleanupFailures.length > 0)
2387
+ throw new AggregateError([error, ...cleanupFailures], `Could not cancel database drop '${databaseName}'`);
2134
2388
  throw error;
2135
2389
  }
2136
- held.push(lock);
2390
+ }
2391
+
2392
+ function releaseDatabaseDropLocks(locks: DatabaseDropLock[], retainMarkers: boolean): void {
2393
+ const failures = [];
2394
+ while (locks.length > 0) {
2395
+ try {
2396
+ const lock = locks.shift()!;
2397
+ if (retainMarkers) abandonDatabaseDrop(lock);
2398
+ else cancelDatabaseDrop(lock);
2399
+ } catch (error) {
2400
+ failures.push(error);
2401
+ }
2402
+ }
2403
+ if (failures.length > 0) throw new AggregateError(failures, 'Could not release database drop locks');
2404
+ }
2405
+
2406
+ function inferDropBlobDatabaseName(databaseName: string, rootPath: string): string {
2407
+ const schemaConfigs = envGet(CONFIG_PARAMS.DATABASES) || {};
2408
+ const databaseConfig = schemaConfigs[databaseName];
2409
+ const normalizedRootPath = resolve(rootPath);
2410
+ for (const [tableName, tableConfig] of Object.entries(databaseConfig?.tables || {}) as [string, any][]) {
2411
+ if (!tableConfig?.path) continue;
2412
+ if (
2413
+ [resolve(tableConfig.path, tableName), resolve(tableConfig.path, `${tableName}.mdb`)].includes(normalizedRootPath)
2414
+ )
2415
+ return databaseName;
2416
+ }
2417
+ if (resolve(dirname(rootPath)) === resolve(getBaseSchemaPath(), databaseName)) return databaseName;
2418
+
2419
+ const physicalName = basename(rootPath).replace(/\.mdb$/, '');
2420
+ const rootDirectory = resolve(dirname(rootPath));
2421
+ if (databaseConfig?.path && resolve(databaseConfig.path) === rootDirectory) {
2422
+ if (!schemaConfigs[physicalName]?.path) return physicalName;
2423
+ for (const [configuredName, configured] of Object.entries(schemaConfigs) as [string, any][]) {
2424
+ if (configured?.path && resolve(configured.path) === rootDirectory) return configuredName;
2425
+ }
2426
+ }
2427
+ try {
2428
+ const storageRoot = resolveDatabaseStorageRoot(databaseName);
2429
+ if ([resolve(storageRoot, databaseName), resolve(storageRoot, `${databaseName}.mdb`)].includes(normalizedRootPath))
2430
+ return databaseName;
2431
+ } catch {}
2432
+ return physicalName;
2433
+ }
2434
+
2435
+ const incompleteDatabaseDropStores = new Map<string, RootDatabaseKind>();
2436
+
2437
+ function rememberIncompleteDatabaseClose(
2438
+ databaseNames: Iterable<string>,
2439
+ rootPaths: Iterable<string>,
2440
+ retry: () => Promise<void>
2441
+ ): void {
2442
+ const names = new Set(databaseNames);
2443
+ const normalizedPaths = [...new Set([...rootPaths].map((rootPath) => resolve(rootPath)))];
2444
+ const incompleteClose: IncompleteDatabaseClose = { databaseNames: names, rootPaths: normalizedPaths, retry };
2445
+ for (const rootPath of normalizedPaths) incompleteDatabaseCloses.set(rootPath, incompleteClose);
2446
+ logger.warn(
2447
+ `Database '${[...names].join("', '")}' handles remain partially closed on this worker; the affected roots stay unavailable until drop_database is retried or the worker restarts`,
2448
+ normalizedPaths
2449
+ );
2450
+ }
2451
+
2452
+ function incompleteDatabaseClosePaths(databaseName: string): string[] {
2453
+ const rootPaths = new Set<string>();
2454
+ for (const incompleteClose of new Set(incompleteDatabaseCloses.values())) {
2455
+ if (!incompleteClose.databaseNames.has(databaseName)) continue;
2456
+ for (const rootPath of incompleteClose.rootPaths) rootPaths.add(rootPath);
2457
+ }
2458
+ return [...rootPaths];
2459
+ }
2460
+
2461
+ async function retryIncompleteDatabaseClose(rootPath: string): Promise<void> {
2462
+ const incompleteClose = incompleteDatabaseCloses.get(resolve(rootPath));
2463
+ if (!incompleteClose) return;
2464
+ if (!incompleteClose.task) {
2465
+ const operation = incompleteClose.retry().then(() => {
2466
+ for (const path of incompleteClose.rootPaths) {
2467
+ if (incompleteDatabaseCloses.get(path) === incompleteClose) incompleteDatabaseCloses.delete(path);
2468
+ }
2469
+ });
2470
+ let retryable: Promise<void>;
2471
+ retryable = operation.catch((error) => {
2472
+ if (incompleteClose.task === retryable) incompleteClose.task = undefined;
2473
+ throw error;
2474
+ });
2475
+ incompleteClose.task = retryable;
2476
+ }
2477
+ await incompleteClose.task;
2478
+ }
2479
+
2480
+ async function destroyIncompleteDatabaseRoot(rootPath: string): Promise<void> {
2481
+ const rootStore = incompleteDatabaseDropStores.get(rootPath);
2482
+ if (!rootStore || !existsSync(rootPath)) {
2483
+ incompleteDatabaseDropStores.delete(rootPath);
2484
+ await rm(rootPath, { recursive: true, force: true });
2485
+ return;
2486
+ }
2487
+ if (rootStore instanceof RocksDatabase) {
2488
+ if (rootStore.status === 'open') {
2489
+ await (rootStore as any).dbisDb?.close?.();
2490
+ await rootStore.close();
2491
+ }
2492
+ await rootStore.destroy();
2493
+ } else {
2494
+ if (rootStore.status === 'open') await rootStore.close();
2495
+ await rm(rootPath, { force: true });
2496
+ }
2497
+ incompleteDatabaseDropStores.delete(rootPath);
2498
+ }
2499
+
2500
+ async function resumeIncompleteDatabaseDrop(databaseName: string, rootPaths: Iterable<string>): Promise<void> {
2501
+ const dropLocks: DatabaseDropLock[] = [];
2502
+ let destructiveWorkStarted = false;
2503
+ try {
2504
+ const paths = new Set(rootPaths);
2505
+ for (const rootPath of paths) await retryIncompleteDatabaseClose(rootPath);
2506
+ if (
2507
+ registryStatus().some(
2508
+ (entry) => paths.has(entry.path) && entry.refCount > 0 && !incompleteDatabaseDropStores.has(entry.path)
2509
+ )
2510
+ )
2511
+ throw new DatabaseClosingError(databaseName);
2512
+ for (const rootPath of paths)
2513
+ lockDatabaseForDrop(rootPath, databaseName, dropLocks, inferDropBlobDatabaseName(databaseName, rootPath));
2514
+ destructiveWorkStarted = true;
2515
+ for (const lock of dropLocks) await destroyIncompleteDatabaseRoot(lock.dbPath);
2516
+ await deleteBlobPaths(dropLocks.flatMap((lock) => lock.blobPaths));
2517
+ while (dropLocks.length > 0) completeDatabaseDrop(dropLocks.shift()!);
2518
+ } finally {
2519
+ releaseDatabaseDropLocks(dropLocks, destructiveWorkStarted);
2520
+ }
2137
2521
  }
2138
2522
 
2139
2523
  /**
2140
2524
  * Delete the database
2141
2525
  * @param databaseName
2142
2526
  */
2143
- export async function dropDatabase(databaseName) {
2144
- if (!databases[databaseName]) throw new Error('Database does not exist');
2145
- const dbTables = databases[databaseName];
2146
- let rootStore;
2147
-
2148
- // Hold the per-database restore lock across the entire drop so its file deletion can never
2149
- // interleave with a restore's purge-and-copy on the same directory — a destroy landing after a
2150
- // restore's copy would gut a "successful" restore, and vice versa. Restore takes the same lock
2151
- // (before writing its marker), so both operations serialize on this one primitive rather than on
2152
- // a check-then-act marker probe. Released in the finally below.
2153
- const restoreLocks: RestoreLock[] = [];
2527
+ export async function dropDatabase(databaseName, requestedRootPaths: Iterable<string> = []) {
2528
+ if (!databases[databaseName]) {
2529
+ const rootPaths = new Set(requestedRootPaths);
2530
+ if (rootPaths.size === 0) throw new Error('Database does not exist');
2531
+ return resumeIncompleteDatabaseDrop(databaseName, rootPaths);
2532
+ }
2533
+ const { databaseNames, dbTables, rootStores } = collectDatabaseGraph(databaseName);
2534
+
2535
+ const dropLocks: DatabaseDropLock[] = [];
2536
+ let releaseDerivedIndexActivation: (() => void) | undefined;
2537
+ let destructiveWorkStarted = false;
2538
+ let dropCompleted = false;
2154
2539
  try {
2155
- for (const tableName in dbTables) {
2156
- const table = dbTables[tableName];
2157
- rootStore = table.primaryStore.rootStore;
2158
- if (rootStore instanceof RocksDatabase) lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
2540
+ if (rootStores.size === 0) {
2541
+ rootStores.add(openDatabaseRoot({ database: databaseName }, { allowPreparedDrop: true }));
2542
+ }
2543
+ const blobStorageByRootPath = new Map(
2544
+ [...rootStores].map((rootStore) => [
2545
+ rootStore.path,
2546
+ {
2547
+ databaseName: rootStore.databaseName ?? databaseName,
2548
+ paths: getRootBlobPathsForDB(rootStore as RootDatabase),
2549
+ },
2550
+ ])
2551
+ );
2552
+ for (const rootPath of new Set([...requestedRootPaths, ...blobStorageByRootPath.keys()])) {
2553
+ const blobStorage = blobStorageByRootPath.get(rootPath);
2554
+ const blobDatabaseName = blobStorage?.databaseName ?? inferDropBlobDatabaseName(databaseName, rootPath);
2555
+ lockDatabaseForDrop(
2556
+ rootPath,
2557
+ databaseName,
2558
+ dropLocks,
2559
+ blobDatabaseName,
2560
+ blobStorage?.paths ?? getBlobPathsForDatabaseName(blobDatabaseName)
2561
+ );
2562
+ }
2563
+ const openedRootPaths = new Set([...rootStores].map((rootStore) => rootStore.path));
2564
+ const detachedRootPaths = dropLocks.map((lock) => lock.dbPath).filter((rootPath) => !openedRootPaths.has(rootPath));
2565
+ const detachedRootPathSet = new Set(detachedRootPaths);
2566
+ const detachedRootHasForeignHandle = () =>
2567
+ registryStatus().some(
2568
+ (entry) =>
2569
+ detachedRootPathSet.has(entry.path) && entry.refCount > 0 && !incompleteDatabaseDropStores.has(entry.path)
2570
+ );
2571
+ if (detachedRootHasForeignHandle()) throw new DatabaseClosingError(databaseName);
2572
+ releaseDerivedIndexActivation = await settleDatabaseDerivedIndexes(dbTables, true, rootStores);
2573
+ destructiveWorkStarted = true;
2574
+ for (const rootStore of rootStores) {
2159
2575
  lmdbDatabaseEnvs.delete(rootStore.path);
2160
2576
  rocksdbDatabaseEnvs.delete(rootStore.path);
2161
2577
  }
2162
2578
 
2163
- for (const tableName in dbTables) {
2164
- databaseEventsEmitter.emit('dropTable', tableName, databaseName);
2579
+ for (const [tableName, table] of Object.entries(dbTables)) {
2580
+ databaseEventsEmitter.emit('dropTable', (table as any).tableName ?? tableName, (table as any).databaseName);
2165
2581
  }
2166
2582
 
2167
- if (databaseName === 'data') {
2168
- for (const tableName in tables) {
2169
- delete tables[tableName];
2583
+ for (const name of databaseNames) {
2584
+ if (name === 'data') {
2585
+ for (const tableName in tables) {
2586
+ delete tables[tableName];
2587
+ }
2588
+ delete tables[DEFINED_TABLES];
2170
2589
  }
2171
- delete tables[DEFINED_TABLES];
2590
+ delete databases[name];
2591
+ definedDatabases?.delete(name);
2592
+ databaseEventsEmitter.emit('dropDatabase', name);
2172
2593
  }
2173
- delete databases[databaseName];
2174
2594
 
2175
- databaseEventsEmitter.emit('dropDatabase', databaseName);
2595
+ const closedStores = new Set<any>();
2596
+ for (const table of Object.values(dbTables) as any[]) {
2597
+ if (!(table?.primaryStore?.rootStore instanceof RocksDatabase)) continue;
2598
+ for (const store of [...Object.values(table.indices || {}), table.primaryStore] as any[]) {
2599
+ if (!store || closedStores.has(store)) continue;
2600
+ closedStores.add(store);
2601
+ await store.close?.();
2602
+ }
2603
+ }
2176
2604
 
2177
- if (rootStore) {
2178
- // awaited: retirement stops the loop admitting work, the barrier is what says the pass that was
2179
- // already running has released the stores this is about to close and unlink
2605
+ for (const rootStore of rootStores) {
2180
2606
  await rootStore.auditStore?.stopAuditCleanup?.();
2181
2607
  removeStorageReclamation(rootStore.path);
2182
2608
  if (rootStore.status === 'open') {
2183
2609
  if (rootStore instanceof RocksDatabase) {
2184
- rootStore.close();
2185
- rootStore.destroy();
2610
+ await (rootStore as any).dbisDb?.close?.();
2611
+ await rootStore.close();
2612
+ await rootStore.destroy();
2186
2613
  } else {
2187
2614
  await rootStore.close();
2188
2615
  await unlink(rootStore.path);
2189
2616
  }
2190
2617
  }
2191
- } else {
2192
- rootStore = database({ database: databaseName, table: null });
2193
- // a tableless database resolves its root store here rather than in the loop above, so take
2194
- // the drop lock now (still before any destructive step)
2195
- if (rootStore instanceof RocksDatabase) lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
2196
- await rootStore.auditStore?.stopAuditCleanup?.();
2197
- removeStorageReclamation(rootStore.path);
2198
- if (rootStore instanceof RocksDatabase) {
2199
- rootStore.close();
2200
- rootStore.destroy();
2201
- } else if (rootStore.status === 'open') {
2202
- await rootStore.close();
2203
- await unlink(rootStore.path);
2204
- }
2205
2618
  }
2619
+ if (detachedRootHasForeignHandle()) throw new DatabaseClosingError(databaseName);
2620
+ for (const rootPath of detachedRootPaths) await rm(rootPath, { recursive: true, force: true });
2206
2621
 
2207
- await deleteRootBlobPathsForDB(rootStore);
2622
+ await deleteBlobPaths(dropLocks.flatMap((lock) => lock.blobPaths));
2623
+ while (dropLocks.length > 0) completeDatabaseDrop(dropLocks.shift()!);
2624
+ dropCompleted = true;
2208
2625
  } finally {
2209
- for (const lock of restoreLocks) releaseRestoreLock(lock);
2626
+ if (destructiveWorkStarted) {
2627
+ for (const rootStore of rootStores) databasePaths.delete(rootStore as RootDatabase);
2628
+ for (const rootStore of rootStores) {
2629
+ if (databaseDropMarkerPresent(rootStore.path)) incompleteDatabaseDropStores.set(rootStore.path, rootStore);
2630
+ }
2631
+ if (!dropCompleted) {
2632
+ permanentlySuspendDatabaseCommits(rootStores);
2633
+ for (const rootStore of rootStores) permanentlySuspendDerivedIndexActivation(rootStore);
2634
+ }
2635
+ }
2636
+ releaseDerivedIndexActivation?.();
2637
+ releaseDatabaseDropLocks(dropLocks, destructiveWorkStarted);
2210
2638
  }
2211
2639
  }
2212
2640
 
2213
- /**
2214
- * Close a database's store handles on the current thread and unregister it, without
2215
- * touching its files. Used by the restore_backup flow: every thread must release its handles so
2216
- * `backups.restore()` can purge and rewrite the (fully closed) database directory. A subsequent
2217
- * `resetDatabases()`/`getDatabases()` rescan reloads it (or skips it while a restore is in
2218
- * progress, per the restore marker checks in the scan).
2219
- *
2220
- * An LMDB database is closed by closing its environment, which releases every dbi in it, so every
2221
- * other database alias sharing that environment is closed with it.
2222
- */
2223
- export function closeDatabase(databaseName: string): boolean {
2224
- const dbTables = databases[databaseName];
2225
- if (!dbTables) return false;
2226
- const rootStores = new Set<any>();
2227
- const closeStore = (store: any, description: string) => {
2641
+ const databaseDropPreparationTasks = new Map<string, { id: string; task: Promise<void>; rootPaths: string[] }>();
2642
+
2643
+ function addPhysicalDatabaseRoots(directory: string, rootPaths: Set<string>): void {
2644
+ if (!existsSync(directory)) return;
2645
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
2646
+ if (entry.name === RESTORE_META_DIR || entry.name === BRANCH_ROOT_DIR || entry.name.endsWith(MIGRATING_DIR_SUFFIX))
2647
+ continue;
2648
+ const rootPath = join(directory, entry.name);
2649
+ if (entry.isFile()) {
2650
+ if (extname(entry.name).toLowerCase() === '.mdb') rootPaths.add(rootPath);
2651
+ continue;
2652
+ }
2653
+ if (!entry.isDirectory()) continue;
2228
2654
  try {
2229
- store?.close?.();
2655
+ const files = readdirSync(rootPath, { withFileTypes: true });
2656
+ if (
2657
+ files.find((file) => file.name === 'CURRENT')?.isFile() &&
2658
+ files.some((file) => file.name.startsWith('MANIFEST-'))
2659
+ )
2660
+ rootPaths.add(rootPath);
2230
2661
  } catch (error) {
2231
- logger.warn(`Error closing ${description} while closing database ${databaseName}:`, error);
2662
+ if (!('code' in error && error.code === 'ENOENT')) throw error;
2232
2663
  }
2664
+ }
2665
+ }
2666
+
2667
+ function databaseDropRecoveryGraphPaths(databaseName: string, incompleteRootPaths: string[]): string[] {
2668
+ const rootPaths = new Set(incompleteRootPaths);
2669
+ if (incompleteRootPaths.length === 0) return [];
2670
+ const databaseConfig = (envGet(CONFIG_PARAMS.DATABASES) || {})[databaseName];
2671
+ if (databaseConfig?.path) addPhysicalDatabaseRoots(databaseConfig.path, rootPaths);
2672
+ addPhysicalDatabaseRoots(join(getBaseSchemaPath(), databaseName), rootPaths);
2673
+ for (const [tableName, tableConfig] of Object.entries(databaseConfig?.tables || {}) as [string, any][]) {
2674
+ if (!tableConfig?.path) continue;
2675
+ for (const rootPath of [join(tableConfig.path, tableName), join(tableConfig.path, `${tableName}.mdb`)]) {
2676
+ if (existsSync(rootPath)) rootPaths.add(rootPath);
2677
+ }
2678
+ }
2679
+ try {
2680
+ const storageRoot = resolveDatabaseStorageRoot(databaseName);
2681
+ for (const rootPath of [join(storageRoot, databaseName), join(storageRoot, `${databaseName}.mdb`)]) {
2682
+ if (existsSync(rootPath)) rootPaths.add(rootPath);
2683
+ }
2684
+ } catch {}
2685
+ return [...rootPaths];
2686
+ }
2687
+
2688
+ function incompleteDatabaseDropPaths(databaseName: string): string[] {
2689
+ const directories = new Set<string>();
2690
+ try {
2691
+ directories.add(resolveDatabaseStorageRoot(databaseName));
2692
+ } catch {}
2693
+ directories.add(join(getBaseSchemaPath(), databaseName));
2694
+ const databaseConfig = (envGet(CONFIG_PARAMS.DATABASES) || {})[databaseName];
2695
+ if (databaseConfig?.path) directories.add(databaseConfig.path);
2696
+ for (const tableConfig of Object.values(databaseConfig?.tables || {}) as any[]) {
2697
+ if (tableConfig?.path) directories.add(tableConfig.path);
2698
+ }
2699
+ const rootPaths = new Set<string>();
2700
+ for (const directory of directories) {
2701
+ for (const drop of scanBlockedDatabaseDrops(directory)) {
2702
+ if (drop.databaseName === databaseName) rootPaths.add(drop.rootPath);
2703
+ }
2704
+ }
2705
+ return [...rootPaths];
2706
+ }
2707
+
2708
+ export function databaseDropRecoveryPending(databaseName: string): boolean {
2709
+ return incompleteDatabaseDropPaths(databaseName).length > 0 || incompleteDatabaseClosePaths(databaseName).length > 0;
2710
+ }
2711
+
2712
+ export function databaseDropPreparationTargets(databaseName: string): {
2713
+ rootPaths: string[];
2714
+ } {
2715
+ getDatabases();
2716
+ const incompleteRootPaths = [
2717
+ ...new Set([...incompleteDatabaseDropPaths(databaseName), ...incompleteDatabaseClosePaths(databaseName)]),
2718
+ ];
2719
+ const recoveryRootPaths = databaseDropRecoveryGraphPaths(databaseName, incompleteRootPaths);
2720
+ if (!databases[databaseName]) {
2721
+ if (recoveryRootPaths.length > 0) return { rootPaths: recoveryRootPaths };
2722
+ throw new Error(`Database '${databaseName}' does not exist`);
2723
+ }
2724
+ const graph = collectDatabaseGraph(databaseName);
2725
+ if (graph.rootStores.size === 0) graph.rootStores.add(openDatabaseRoot({ database: databaseName }));
2726
+ return {
2727
+ rootPaths: [...new Set([...recoveryRootPaths, ...[...graph.rootStores].map((rootStore) => rootStore.path)])],
2233
2728
  };
2234
- for (const tableName in dbTables) {
2235
- const table: any = dbTables[tableName];
2236
- if (!table?.primaryStore) continue;
2237
- if (table.primaryStore.rootStore) rootStores.add(table.primaryStore.rootStore);
2729
+ }
2730
+
2731
+ export function prepareDatabaseDrop(
2732
+ databaseName: string,
2733
+ preparationId: string,
2734
+ ownerThreadId: number,
2735
+ requestedRootPaths: Iterable<string> = []
2736
+ ): Promise<void> {
2737
+ const existing = databaseDropPreparationTasks.get(databaseName);
2738
+ if (existing?.id === preparationId) return existing.task;
2739
+ const rootPaths = new Set(requestedRootPaths);
2740
+ if (databases[databaseName]) {
2741
+ const graph = collectDatabaseGraph(databaseName);
2742
+ for (const rootStore of graph.rootStores) rootPaths.add(rootStore.path);
2238
2743
  }
2239
- // a database with no tables (an empty schema, or one whose tables were all dropped) still holds
2240
- // an open root store, tracked only on the defined-database entry rather than any table — include
2241
- // it so its handles are released too (the Set dedupes it against the per-table root stores above)
2242
- const definedRoot = (definedDatabases?.get(databaseName) as any)?.rootStore;
2243
- if (definedRoot) rootStores.add(definedRoot);
2244
- // before any table store closes, so no further pass is admitted. This is synchronous, so it cannot
2245
- // await the drain barrier stopAuditCleanup() returns; what covers it is the in-pass status checks,
2246
- // plus the fact that its production callers reach it only for RocksDB databases, whose pass is one
2247
- // synchronous purgeLogs() call with nothing suspended mid-removal.
2248
- for (const rootStore of rootStores) rootStore.auditStore?.stopAuditCleanup?.();
2744
+ const names: string[] = [];
2745
+ for (const name of Object.keys(databases)) {
2746
+ const definedRoot = (definedDatabases?.get(name) as any)?.rootStore;
2747
+ if (definedRoot && rootPaths.has(definedRoot.path)) {
2748
+ names.push(name);
2749
+ continue;
2750
+ }
2751
+ const tables = databases[name];
2752
+ if (
2753
+ Object.values(tables).some((table: any) => {
2754
+ const rootStore = table?.primaryStore?.rootStore;
2755
+ return rootStore && rootPaths.has(rootStore.path);
2756
+ })
2757
+ )
2758
+ names.push(name);
2759
+ }
2760
+ const paths = [...rootPaths];
2761
+ claimDatabaseDropPreparations(paths, preparationId, ownerThreadId, databaseName);
2762
+ const task = (async () => {
2763
+ const failures: unknown[] = [];
2764
+ for (const rootPath of paths) {
2765
+ try {
2766
+ await retryIncompleteDatabaseClose(rootPath);
2767
+ } catch (error) {
2768
+ failures.push(error);
2769
+ }
2770
+ }
2771
+ for (const name of names) {
2772
+ try {
2773
+ await closeDatabase(name, { requireClosed: true });
2774
+ } catch (error) {
2775
+ failures.push(error);
2776
+ }
2777
+ }
2778
+ for (const rootPath of paths) {
2779
+ if (!incompleteDatabaseDropStores.has(rootPath)) continue;
2780
+ try {
2781
+ await destroyIncompleteDatabaseRoot(rootPath);
2782
+ } catch (error) {
2783
+ failures.push(error);
2784
+ }
2785
+ }
2786
+ if (failures.length > 0)
2787
+ throw new AggregateError(failures, `Could not prepare database '${databaseName}' for drop`);
2788
+ })();
2789
+ databaseDropPreparationTasks.set(databaseName, { id: preparationId, task, rootPaths: paths });
2790
+ for (const rootPath of paths) trackDatabaseDropPreparationTask(rootPath, preparationId, task);
2791
+ return task;
2792
+ }
2793
+
2794
+ export async function completeDatabaseDropPreparation(
2795
+ databaseName: string,
2796
+ preparationId: string,
2797
+ requestedRootPaths: Iterable<string> = []
2798
+ ): Promise<void> {
2799
+ const existing = databaseDropPreparationTasks.get(databaseName);
2800
+ if (existing?.id === preparationId) {
2801
+ try {
2802
+ await existing.task;
2803
+ } catch {
2804
+ // Preparation already reported this failure to the strict broadcaster.
2805
+ }
2806
+ databaseDropPreparationTasks.delete(databaseName);
2807
+ }
2808
+ releaseDatabaseDropPreparations(existing?.rootPaths ?? requestedRootPaths, preparationId);
2809
+ }
2810
+
2811
+ async function closeDatabaseStores(
2812
+ databaseName: string,
2813
+ dbTables: Record<string, any>,
2814
+ rootStores: Set<any>,
2815
+ requireClosed: boolean
2816
+ ): Promise<unknown[]> {
2817
+ const rootStorePaths: string[] = [];
2818
+ const closeFailures: unknown[] = [];
2819
+ const closeStore = async (store: any, description: string) => {
2820
+ if (!store || store.status === 'closed') return true;
2821
+ try {
2822
+ await store.close?.();
2823
+ return true;
2824
+ } catch (error) {
2825
+ logger.warn(`Error closing ${description} while closing database ${databaseName}:`, error);
2826
+ closeFailures.push(error);
2827
+ return false;
2828
+ }
2829
+ };
2249
2830
  const lmdbRootStores = new Set([...rootStores].filter((rootStore) => !(rootStore instanceof RocksDatabase)));
2250
2831
  for (const tableName in dbTables) {
2251
2832
  const table: any = dbTables[tableName];
2252
- // a dbi closed natively after its environment closed (through another alias sharing it) runs
2253
- // mdb_dbi_close against the freed environment
2254
2833
  if (!table?.primaryStore || lmdbRootStores.has(table.primaryStore.rootStore)) continue;
2834
+ const tableIdentity = `${table.databaseName ?? databaseName}.${table.tableName ?? tableName}`;
2255
2835
  for (const indexName in table.indices || {}) {
2256
- closeStore(table.indices[indexName], `index ${tableName}.${indexName}`);
2836
+ await closeStore(table.indices[indexName], `index ${tableIdentity}.${indexName}`);
2257
2837
  }
2258
- closeStore(table.primaryStore, `table ${tableName}`);
2838
+ await closeStore(table.primaryStore, `table ${tableIdentity}`);
2259
2839
  }
2260
2840
  for (const rootStore of rootStores) {
2261
2841
  removeStorageReclamation(rootStore.path);
2842
+ databasePaths.delete(rootStore as RootDatabase);
2843
+ let rootClosed = rootStore.status !== 'open';
2262
2844
  if (!lmdbRootStores.has(rootStore)) {
2263
- closeStore(rootStore.dbisDb, 'attributes store');
2264
- closeStore(rootStore, 'root store');
2265
- } else if (rootStore.status === 'open') closeStore(rootStore, 'root store');
2266
- lmdbDatabaseEnvs.delete(rootStore.path);
2267
- rocksdbDatabaseEnvs.delete(rootStore.path);
2268
- }
2269
- unregisterDatabase(databaseName);
2270
- for (const aliasName of Object.keys(databases)) {
2271
- for (const rootStore of lmdbRootStores) {
2272
- if (databaseUsesRootStore(aliasName, rootStore)) {
2273
- closeDatabase(aliasName);
2845
+ await closeStore(rootStore.dbisDb, 'attributes store');
2846
+ rootClosed = await closeStore(rootStore, 'root store');
2847
+ } else if (!rootClosed) rootClosed = await closeStore(rootStore, 'root store');
2848
+ if (rootClosed || !requireClosed) rootStorePaths.push(rootStore.path);
2849
+ }
2850
+ for (const path of rootStorePaths) {
2851
+ lmdbDatabaseEnvs.delete(path);
2852
+ rocksdbDatabaseEnvs.delete(path);
2853
+ }
2854
+ return closeFailures;
2855
+ }
2856
+
2857
+ /**
2858
+ * Close a database's store handles on this thread and unregister every alias sharing those roots,
2859
+ * without touching files. Restore uses this before rewriting the closed directory. With
2860
+ * `requireClosed`, any handle failure rejects and leaves the affected wrappers permanently fenced
2861
+ * for an explicit retry. Closing an LMDB environment closes every DBI and alias that shares it.
2862
+ */
2863
+ export async function closeDatabase(
2864
+ databaseName: string,
2865
+ { requireClosed = false }: { requireClosed?: boolean } = {}
2866
+ ): Promise<boolean> {
2867
+ if (!databases[databaseName]) return false;
2868
+ const { databaseNames, dbTables, rootStores } = collectDatabaseGraph(databaseName);
2869
+ const releaseDerivedIndexActivation = await settleDatabaseDerivedIndexes(dbTables, false, rootStores);
2870
+ let databaseClosed = false;
2871
+ let handleCloseStarted = false;
2872
+ try {
2873
+ await Promise.all([...rootStores].map((rootStore) => rootStore.auditStore?.stopAuditCleanup?.()));
2874
+ handleCloseStarted = true;
2875
+ const closeFailures = await closeDatabaseStores(databaseName, dbTables, rootStores, requireClosed);
2876
+ if (requireClosed && closeFailures.length > 0) {
2877
+ for (const rootStore of rootStores) {
2878
+ lmdbDatabaseEnvs.delete(rootStore.path);
2879
+ rocksdbDatabaseEnvs.delete(rootStore.path);
2880
+ }
2881
+ for (const name of databaseNames) unregisterDatabase(name);
2882
+ rememberIncompleteDatabaseClose(
2883
+ databaseNames,
2884
+ [...rootStores].map((rootStore) => rootStore.path),
2885
+ async () => {
2886
+ const retryFailures = await closeDatabaseStores(databaseName, dbTables, rootStores, true);
2887
+ if (retryFailures.length > 0)
2888
+ throw new AggregateError(retryFailures, `Could not finish closing database graph '${databaseName}'`);
2889
+ }
2890
+ );
2891
+ throw new AggregateError(
2892
+ closeFailures,
2893
+ `Could not close database graph '${[...databaseNames].join("', '")}' for destructive DDL`
2894
+ );
2895
+ }
2896
+ for (const name of databaseNames) unregisterDatabase(name);
2897
+ databaseClosed = true;
2898
+ return true;
2899
+ } finally {
2900
+ if (handleCloseStarted && !databaseClosed) {
2901
+ permanentlySuspendDatabaseCommits(rootStores);
2902
+ for (const rootStore of rootStores) permanentlySuspendDerivedIndexActivation(rootStore);
2903
+ } else if (!databaseClosed && !handleCloseStarted) {
2904
+ resumeTableMaintenance(dbTables);
2905
+ for (const table of Object.values(dbTables)) refreshDerivedIndexes(table);
2906
+ }
2907
+ releaseDerivedIndexActivation();
2908
+ }
2909
+ }
2910
+
2911
+ function collectDatabaseGraph(databaseName: string): {
2912
+ databaseNames: Set<string>;
2913
+ dbTables: Record<string, any>;
2914
+ rootStores: Set<any>;
2915
+ } {
2916
+ const databaseNames = new Set([databaseName]);
2917
+ const processedNames = new Set<string>();
2918
+ const rootStores = new Set<any>();
2919
+ const tableSet = new Set<any>();
2920
+ for (;;) {
2921
+ for (const name of databaseNames) {
2922
+ if (processedNames.has(name)) continue;
2923
+ processedNames.add(name);
2924
+ const tables = databases[name];
2925
+ for (const tableName in tables) {
2926
+ const table = tables[tableName];
2927
+ tableSet.add(table);
2928
+ if (table?.primaryStore?.rootStore) rootStores.add(table.primaryStore.rootStore);
2929
+ }
2930
+ const definedRoot = (definedDatabases?.get(name) as any)?.rootStore;
2931
+ if (definedRoot) rootStores.add(definedRoot);
2932
+ }
2933
+ let foundAlias = false;
2934
+ for (const aliasName of Object.keys(databases)) {
2935
+ if (databaseNames.has(aliasName)) continue;
2936
+ for (const rootStore of rootStores) {
2937
+ if (!databaseUsesRootStore(aliasName, rootStore)) continue;
2938
+ databaseNames.add(aliasName);
2939
+ foundAlias = true;
2274
2940
  break;
2275
2941
  }
2276
2942
  }
2943
+ if (!foundAlias) break;
2277
2944
  }
2278
- return true;
2945
+ return {
2946
+ databaseNames,
2947
+ dbTables: Object.fromEntries([...tableSet].map((table, index) => [index, table])),
2948
+ rootStores,
2949
+ };
2279
2950
  }
2280
2951
 
2281
2952
  function databaseUsesRootStore(databaseName: string, rootStore: any): boolean {
2282
2953
  if ((definedDatabases?.get(databaseName) as any)?.rootStore === rootStore) return true;
2283
2954
  const dbTables = databases[databaseName];
2955
+ if (!dbTables) return false;
2284
2956
  for (const tableName in dbTables) {
2285
2957
  if ((dbTables[tableName] as any)?.primaryStore?.rootStore === rootStore) return true;
2286
2958
  }
2287
2959
  return false;
2288
2960
  }
2289
2961
 
2962
+ function databaseUsesPreparedRoot(databaseName: string): boolean {
2963
+ const definedRoot = (definedDatabases?.get(databaseName) as any)?.rootStore;
2964
+ if (definedRoot && databaseDropPrepared(definedRoot.path)) return true;
2965
+ const dbTables = databases[databaseName];
2966
+ for (const tableName in dbTables) {
2967
+ const rootStore = (dbTables[tableName] as any)?.primaryStore?.rootStore;
2968
+ if (rootStore && databaseDropPrepared(rootStore.path)) return true;
2969
+ }
2970
+ return false;
2971
+ }
2972
+
2290
2973
  function unregisterDatabase(databaseName: string): void {
2291
2974
  const definedDatabase = definedDatabases?.get(databaseName);
2292
2975
  if (definedDatabase) (definedDatabase as any).rootStore = undefined;
@@ -2300,41 +2983,105 @@ function unregisterDatabase(databaseName: string): void {
2300
2983
  }
2301
2984
 
2302
2985
  /**
2303
- * Close every RocksDB (user) database this thread has open, releasing its native handles.
2986
+ * Close every user database this thread has open, releasing its native handles.
2304
2987
  *
2305
- * rocksdb-js's registry is process-global across worker threads, and a thread that exits WITHOUT
2306
- * closing leaks its handles (the process-global refCount never drops), while the only alternative,
2307
- * `shutdown()`, tears down rocksdb for the entire process. So a worker thread that opens databases
2308
- * and then exits — notably a job worker (jobProcess), which opens the whole database graph via
2309
- * `getDatabases()` and exits when the job finishes — must close its handles explicitly, or those
2310
- * handles linger process-wide (and, e.g., block an online `restore_backup` from confirming the
2311
- * database is closed). The `system` database is intentionally left open: it is non-enumerable here
2988
+ * A job worker opens the database graph and must prove every handle is closed before its exit can
2989
+ * satisfy destructive-DDL preparation. RocksDB handles otherwise leak in the process-global
2990
+ * registry; an open LMDB handle can likewise prevent deletion on Windows. The `system` database is
2991
+ * intentionally left open: it is non-enumerable here
2312
2992
  * (skipped by the loop), is never restored online, and the exiting worker may still touch the job
2313
2993
  * table during teardown. Best-effort: closing failures are swallowed inside `closeDatabase`.
2314
2994
  *
2315
2995
  * Branches are invisible to the loop below but hold handles from the same registry, so this — the
2316
2996
  * thread's one teardown entry point — closes them too.
2317
2997
  */
2318
- export function closeLoadedDatabases(): void {
2319
- closeBranchDatabases();
2998
+ export async function closeLoadedDatabases({ requireClosed = false }: { requireClosed?: boolean } = {}): Promise<void> {
2999
+ const closeFailures: unknown[] = [];
3000
+ try {
3001
+ await closeBranchDatabases(requireClosed);
3002
+ } catch (error) {
3003
+ closeFailures.push(error);
3004
+ }
2320
3005
  // snapshot the names first: closeDatabase() deletes from `databases` as it goes
2321
3006
  for (const databaseName of Object.keys(databases)) {
2322
3007
  const dbTables = databases[databaseName];
2323
3008
  if (!dbTables) continue;
2324
- let isRocks = false;
2325
- for (const tableName in dbTables) {
2326
- if (dbTables[tableName]?.primaryStore?.rootStore instanceof RocksDatabase) {
2327
- isRocks = true;
2328
- break;
2329
- }
3009
+ try {
3010
+ await closeDatabase(databaseName, { requireClosed });
3011
+ } catch (error) {
3012
+ logger.warn(`Error closing database ${databaseName} during worker teardown`, error);
3013
+ closeFailures.push(error);
2330
3014
  }
2331
- // a tableless database exposes no table root store, so also check the defined-database
2332
- // entry — otherwise its open root store would leak on worker exit
2333
- if (!isRocks && (definedDatabases?.get(databaseName) as any)?.rootStore instanceof RocksDatabase) {
2334
- isRocks = true;
3015
+ }
3016
+ if (requireClosed && closeFailures.length > 0)
3017
+ throw new AggregateError(closeFailures, 'Could not close every database during worker teardown');
3018
+ }
3019
+
3020
+ async function settleTableMaintenance(dbTables: Record<string, any>, deadline: number): Promise<void> {
3021
+ await Promise.all(Object.values(dbTables).map((table: any) => table?.closeMaintenance?.(deadline)));
3022
+ }
3023
+
3024
+ function resumeTableMaintenance(dbTables: Record<string, any>): void {
3025
+ for (const table of Object.values(dbTables)) table?.resumeMaintenance?.();
3026
+ }
3027
+
3028
+ async function settleDatabaseDerivedIndexes(
3029
+ dbTables: Record<string, any>,
3030
+ dropping: boolean,
3031
+ additionalRootStores: Iterable<object> = []
3032
+ ): Promise<() => void> {
3033
+ const rootStores = new Set<object>(additionalRootStores);
3034
+ for (const tableName in dbTables) {
3035
+ const rootStore = dbTables[tableName]?.primaryStore?.rootStore;
3036
+ if (rootStore) rootStores.add(rootStore);
3037
+ }
3038
+ const activationReleases = [...rootStores].map(suspendDerivedIndexActivation);
3039
+ const commitSuspension = suspendDatabaseCommits(rootStores);
3040
+ const deadline = Date.now() + getDatabaseCommitDrainTimeoutMilliseconds();
3041
+ const databaseName = ([...rootStores][0] as any)?.databaseName ?? 'unknown';
3042
+ let lifecycleReleased = false;
3043
+ const releaseLifecycle = () => {
3044
+ if (lifecycleReleased) return;
3045
+ lifecycleReleased = true;
3046
+ commitSuspension.release();
3047
+ for (const release of activationReleases) release();
3048
+ };
3049
+ const attachments = new Map<any, any[]>();
3050
+ for (const tableName in dbTables) {
3051
+ const table = dbTables[tableName];
3052
+ const attachment = table?.derivedIndexRuntime;
3053
+ if (!attachment) continue;
3054
+ let tables = attachments.get(attachment);
3055
+ if (!tables) attachments.set(attachment, (tables = []));
3056
+ tables.push(table);
3057
+ }
3058
+ const entries = [...attachments];
3059
+ try {
3060
+ await commitSuspension.waitForDrain({ deadline, databaseName });
3061
+ await settleInterruptedDropRetirements(rootStores);
3062
+ await settleTableMaintenance(dbTables, deadline);
3063
+ if (attachments.size === 0) return releaseLifecycle;
3064
+ const results = await Promise.allSettled(entries.map(async ([attachment]) => attachment.close(dropping)));
3065
+ const failures = results
3066
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
3067
+ .map((result) => result.reason);
3068
+ if (failures.length === 1) throw failures[0];
3069
+ if (failures.length)
3070
+ throw new AggregateError(failures, 'Could not settle derived indexes before closing the database');
3071
+ } catch (error) {
3072
+ resumeTableMaintenance(dbTables);
3073
+ releaseLifecycle();
3074
+ for (const [attachment, tables] of entries) {
3075
+ if (dropping) attachment.completeDrop?.(false);
3076
+ for (const table of tables) refreshDerivedIndexes(table);
2335
3077
  }
2336
- if (isRocks) closeDatabase(databaseName);
3078
+ throw error;
2337
3079
  }
3080
+ for (const [attachment, tables] of entries) {
3081
+ if (dropping) attachment.completeDrop?.();
3082
+ for (const table of tables) if (table.derivedIndexRuntime === attachment) table.derivedIndexRuntime = undefined;
3083
+ }
3084
+ return releaseLifecycle;
2338
3085
  }
2339
3086
  // HNSW_NO_AUTOVERSION kill-switch: when set, a NEW index initializes as legacy rather than
2340
3087
  // versioned. process.env values are strings, so a bare truthiness check would treat "0"/"false"
@@ -2809,6 +3556,8 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2809
3556
  let fullTextValuesForPersistence: unknown;
2810
3557
  let fullTextPersistencePending = false;
2811
3558
  let activeFullTextIndexes: FullTextDefinition[] | undefined;
3559
+ let fullTextIndexGenerationMap: FullTextIndexGenerations = Object.create(null);
3560
+ let fullTextIndexRetirementNames: string[] | undefined;
2812
3561
  let restoreFullTextLiveState: (() => void) | undefined;
2813
3562
  let armFullTextLiveStateRestore: (() => void) | undefined;
2814
3563
  const attributesToIndex = [];
@@ -2857,8 +3606,10 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2857
3606
  let persistedPrimary = persistedPrimaryDescriptor(Table.dbisDB);
2858
3607
  let persistedFullTextValues = persistedPrimary.descriptor?.fullTextIndexes;
2859
3608
  const incomingFullTextValues = fullTextIndexesExplicit ? fullTextIndexes : [];
3609
+ let fullTextValidationAttributes: any[] | undefined;
2860
3610
  if (
2861
3611
  (persistedFullTextValues !== undefined && (rootStore instanceof RocksDatabase || fullTextIndexesExplicit)) ||
3612
+ persistedPrimary.descriptor?.fullTextIndexRetirements !== undefined ||
2862
3613
  Table.fullTextIndexes?.length > 0 ||
2863
3614
  (fullTextIndexesExplicit && (!Array.isArray(incomingFullTextValues) || incomingFullTextValues.length > 0))
2864
3615
  ) {
@@ -2868,6 +3619,9 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2868
3619
  persistedFullTextValues = persistedPrimary.descriptor?.fullTextIndexes;
2869
3620
  }
2870
3621
  const originalAttributes = Table.attributes.slice();
3622
+ fullTextIndexRetirementNames = persistedFullTextIndexNames(
3623
+ persistedPrimary.descriptor?.fullTextIndexRetirements
3624
+ );
2871
3625
  const relationshipAttributes = originalAttributes.filter((attribute: any) => attribute.relationship);
2872
3626
  const originalMetadata = {
2873
3627
  description: Table.description,
@@ -2886,14 +3640,19 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2886
3640
  Object.assign(Table, originalMetadata);
2887
3641
  Table.properties = projectAttributesToProperties(restored);
2888
3642
  const restoredPrimary = persistedPrimaryDescriptor(Table.dbisDB).descriptor;
2889
- Table.fullTextIndexes =
3643
+ const restoredFullTextIndexes =
2890
3644
  rootStore instanceof RocksDatabase && restoredPrimary?.audit === true
2891
3645
  ? readPersistedFullTextDefinitions(restoredPrimary?.fullTextIndexes, restored, fullTextWarning)
2892
3646
  : [];
3647
+ Table.fullTextIndexes = restoredFullTextIndexes;
3648
+ Table.fullTextIndexGenerations = persistedFullTextIndexGenerations(
3649
+ restoredPrimary?.fullTextIndexGenerations,
3650
+ restoredFullTextIndexes
3651
+ );
3652
+ Table.fullTextIndexRetirements = persistedFullTextIndexNames(restoredPrimary?.fullTextIndexRetirements);
2893
3653
  Table.schemaVersion++;
2894
3654
  Table.updatedAttributes();
2895
- void Table.derivedIndexRuntime?.close();
2896
- Table.derivedIndexRuntime = attachDerivedIndexes(Table);
3655
+ refreshDerivedIndexes(Table);
2897
3656
  };
2898
3657
  };
2899
3658
  const durableAttributes = catalogAttributes(Table.dbisDB);
@@ -2909,6 +3668,10 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2909
3668
  if (durablePrimary) validationAttributes.unshift(durablePrimary);
2910
3669
  }
2911
3670
  }
3671
+ fullTextValidationAttributes = validationAttributes;
3672
+ // Before the full-text branches can write an interim descriptor; the check on the merged list
3673
+ // below stays authoritative.
3674
+ assertDerivedFieldOwnership(validationAttributes as any[]);
2912
3675
 
2913
3676
  const persistedAudit = persistedPrimary.descriptor?.audit;
2914
3677
  const durableAudit = persistedAudit === true;
@@ -2947,21 +3710,59 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2947
3710
  `Table '${databaseName}.${tableName}' must enable audit logging before using @fullText because its transaction log is the derived-index recovery source`,
2948
3711
  400
2949
3712
  );
2950
- const interim = safeInterimFullTextDefinitions(persistedFullTextValues, validationAttributes, compiled);
2951
3713
  const pinAudit = compiled.length > 0 && persistedPrimary.descriptor?.audit !== true;
2952
- if (pinAudit || !definitionsEqual(interim, persistedFullTextValues)) {
3714
+ const requestedNames = new Set(compiled.map(({ name }) => name));
3715
+ const durableDefinitions = readPersistedFullTextDefinitions(
3716
+ persistedFullTextValues,
3717
+ durableAttributes,
3718
+ fullTextWarning
3719
+ );
3720
+ const removedNames = durableDefinitions.map(({ name }) => name).filter((name) => !requestedNames.has(name));
3721
+ const invalidatedNames = durableDefinitions
3722
+ .filter(({ name }) => requestedNames.has(name))
3723
+ .filter((definition) => {
3724
+ try {
3725
+ compileFullTextDefinitions([definition], validationAttributes);
3726
+ return false;
3727
+ } catch (error) {
3728
+ if (error instanceof ClientError) return true;
3729
+ throw error;
3730
+ }
3731
+ })
3732
+ .map(({ name }) => name);
3733
+ const transitionRetirements = new Set([...removedNames, ...invalidatedNames]);
3734
+ if (pinAudit || transitionRetirements.size > 0) {
2953
3735
  const interimPrimary = { ...persistedPrimary.descriptor };
2954
3736
  if (pinAudit) interimPrimary.audit = true;
2955
- if (interim.length > 0) interimPrimary.fullTextIndexes = interim;
2956
- else delete interimPrimary.fullTextIndexes;
3737
+ if (transitionRetirements.size > 0) {
3738
+ const interimDefinitions = durableDefinitions.filter(({ name }) => requestedNames.has(name));
3739
+ if (interimDefinitions.length > 0) {
3740
+ interimPrimary.fullTextIndexes = interimDefinitions;
3741
+ interimPrimary.fullTextIndexGenerations = persistedFullTextIndexGenerations(
3742
+ persistedPrimary.descriptor?.fullTextIndexGenerations,
3743
+ interimDefinitions
3744
+ );
3745
+ } else {
3746
+ delete interimPrimary.fullTextIndexes;
3747
+ delete interimPrimary.fullTextIndexGenerations;
3748
+ }
3749
+ const retirements = new Set(
3750
+ persistedFullTextIndexNames(persistedPrimary.descriptor?.fullTextIndexRetirements)
3751
+ );
3752
+ for (const name of transitionRetirements) retirements.add(name);
3753
+ for (const name of requestedNames) if (!transitionRetirements.has(name)) retirements.delete(name);
3754
+ fullTextIndexRetirementNames = [...retirements].sort();
3755
+ if (fullTextIndexRetirementNames.length > 0)
3756
+ interimPrimary.fullTextIndexRetirements = fullTextIndexRetirementNames.map((name) => ({ name }));
3757
+ else delete interimPrimary.fullTextIndexRetirements;
3758
+ }
2957
3759
  armFullTextLiveStateRestore?.();
2958
3760
  Table.dbisDB.put(persistedPrimary.key, interimPrimary);
2959
3761
  if (pinAudit && Table.audit !== true) Table.enableAuditing();
2960
- Table.fullTextIndexes = rootStore instanceof RocksDatabase && finalAudit ? interim : [];
2961
3762
  hasChanges = true;
2962
3763
  }
2963
3764
  fullTextValuesForPersistence = compiled;
2964
- fullTextPersistencePending = !definitionsEqual(compiled, interim);
3765
+ fullTextPersistencePending = !definitionsEqual(compiled, persistedFullTextValues);
2965
3766
  activeFullTextIndexes = compiled;
2966
3767
  } else {
2967
3768
  const retained = retainFullTextDefinitions(
@@ -2979,6 +3780,34 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
2979
3780
  activeFullTextIndexes = rootStore instanceof RocksDatabase && finalAudit ? retained : [];
2980
3781
  }
2981
3782
  }
3783
+ if (activeFullTextIndexes !== undefined) {
3784
+ const durableGenerationDefinitions = readPersistedFullTextDefinitions(
3785
+ persistedFullTextValues,
3786
+ fullTextValidationAttributes ?? Table.attributes,
3787
+ fullTextWarning
3788
+ );
3789
+ const finalGenerationDefinitions = readPersistedFullTextDefinitions(
3790
+ fullTextValuesForPersistence ?? persistedFullTextValues,
3791
+ fullTextValidationAttributes ?? Table.attributes,
3792
+ fullTextWarning
3793
+ );
3794
+ fullTextIndexGenerationMap = reconcileFullTextIndexGenerations(
3795
+ durableGenerationDefinitions,
3796
+ persistedPrimary.descriptor?.fullTextIndexGenerations,
3797
+ finalGenerationDefinitions,
3798
+ createFullTextIndexGeneration
3799
+ );
3800
+ assertFullTextActivationSupported(rootStore, databaseName, tableName, activeFullTextIndexes);
3801
+ if (
3802
+ activeFullTextIndexes.length > 0 &&
3803
+ rootStore instanceof RocksDatabase &&
3804
+ fullTextClearInProgress(rootStore, Table.tableId)
3805
+ )
3806
+ throw new ClientError(
3807
+ `Cannot activate @fullText on '${databaseName}.${tableName}' while Table.clear() is in progress`,
3808
+ 409
3809
+ );
3810
+ }
2982
3811
  // it table already exists, get the split segments setting
2983
3812
  if (splitSegments == undefined) splitSegments = Table.splitSegments;
2984
3813
  if (origin === 'cluster') {
@@ -3016,6 +3845,9 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3016
3845
  );
3017
3846
  if (existingPrimary) attributes = [existingPrimary, ...attributes];
3018
3847
  }
3848
+ // On the complete list (a peer's fields merged, an omitted primary key inherited) and before
3849
+ // it replaces the live one or reaches the catalog, so a refused declaration changes nothing.
3850
+ assertDerivedFieldOwnership(attributes as any[]);
3019
3851
  armFullTextLiveStateRestore?.();
3020
3852
  Table.attributes.splice(0, Table.attributes.length, ...attributes);
3021
3853
  // Re-assert from the live declaration so a stale value on disk (replicated event,
@@ -3031,6 +3863,7 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3031
3863
  // undefined means a non-schema caller (add_attribute, cluster schema events) — don't clobber
3032
3864
  if (cacheControl !== undefined) Table.cacheControl = cacheControl;
3033
3865
  } else {
3866
+ if (Array.isArray(attributes)) assertDerivedFieldOwnership(attributes as any[]);
3034
3867
  if (fullTextIndexesExplicit) {
3035
3868
  if (origin === 'cluster') {
3036
3869
  const merged = mergePeerFullTextDefinitions([], fullTextIndexes, attributes, fullTextWarning);
@@ -3064,8 +3897,21 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3064
3897
  primaryKeyAttribute.schemaDefined = schemaDefined;
3065
3898
  // Old readers treat every attribute row as live schema, so relationships stay on the ignored primary descriptor.
3066
3899
  if (relationshipDefinitions) primaryKeyAttribute.relationships = relationshipDefinitions;
3067
- if (Array.isArray(fullTextValuesForPersistence) && fullTextValuesForPersistence.length > 0)
3900
+ if (Array.isArray(fullTextValuesForPersistence) && fullTextValuesForPersistence.length > 0) {
3901
+ const durableFullTextIndexes = readPersistedFullTextDefinitions(
3902
+ fullTextValuesForPersistence,
3903
+ attributes,
3904
+ fullTextWarning
3905
+ );
3906
+ fullTextIndexGenerationMap = reconcileFullTextIndexGenerations(
3907
+ [],
3908
+ undefined,
3909
+ durableFullTextIndexes,
3910
+ createFullTextIndexGeneration
3911
+ );
3068
3912
  primaryKeyAttribute.fullTextIndexes = fullTextValuesForPersistence;
3913
+ primaryKeyAttribute.fullTextIndexGenerations = fullTextIndexGenerationMap;
3914
+ }
3069
3915
  // can't change compression after the fact (except threshold), so save only when we create the table
3070
3916
  primaryKeyAttribute.compression = getDefaultCompression();
3071
3917
  if (trackDeletes) primaryKeyAttribute.trackDeletes = true;
@@ -3117,6 +3963,11 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3117
3963
  markInternalDbiNonVersioned(attributesDbi);
3118
3964
 
3119
3965
  exclusiveLock(); // get an exclusive lock on the database so we can verify that we are the only thread creating the table (and assigning the table id)
3966
+ if (rootStore instanceof RocksDatabase && fullTextRetirementInProgress(rootStore, tableName))
3967
+ throw new ClientError(
3968
+ `Cannot create '${databaseName}.${tableName}' while its previous full-text storage is being retired`,
3969
+ 409
3970
+ );
3120
3971
  const existingTableMeta = (attributesDbi as any).getSync(dbiName);
3121
3972
  if (existingTableMeta && !existingTableMeta.dropping) {
3122
3973
  // table was created while we were setting up; the lock is not reentrant, so release
@@ -3133,7 +3984,11 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3133
3984
  // create below starts from a clean slate; treating the tombstoned
3134
3985
  // entry as an existing table would recurse forever on the stale
3135
3986
  // catalog row.
3136
- completeInterruptedDrop(rootStore, attributesDbi, databaseName, tableName);
3987
+ if (!completeInterruptedDrop(rootStore, attributesDbi, databaseName, tableName))
3988
+ throw new ClientError(
3989
+ `Cannot create '${databaseName}.${tableName}' while its interrupted full-text drop is being retired`,
3990
+ 409
3991
+ );
3137
3992
  // This resolves the drop without ever going through the schema-load
3138
3993
  // reconcile below, which is the only other place that returns a spent
3139
3994
  // budget. Without clearing it here too, a table that gets dropped again
@@ -3188,6 +4043,8 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3188
4043
  indices: {},
3189
4044
  attributes,
3190
4045
  fullTextIndexes: activeFullTextIndexes ?? [],
4046
+ fullTextIndexGenerations: fullTextIndexGenerationMap,
4047
+ fullTextIndexRetirements: fullTextIndexRetirementNames ?? [],
3191
4048
  schemaDefined,
3192
4049
  dbisDB: attributesDbi,
3193
4050
  description,
@@ -3433,9 +4290,13 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3433
4290
  JSON.stringify(attributeDescriptor.elements) !== JSON.stringify(attribute.elements) ||
3434
4291
  // Include `embed` so a source/model change refreshes the embed registry.
3435
4292
  JSON.stringify(attributeDescriptor.embed) !== JSON.stringify(attribute.embed);
3436
- // any metadata difference (drives persistence)
4293
+ // any metadata difference (drives persistence). `decide` is compared here and not in
4294
+ // `commonChanged`: a changed directive refreshes the decide registry, and a stored decision
4295
+ // does not depend on the model, so an indexed decision attribute must not rebuild its index.
3437
4296
  const changed =
3438
- commonChanged || JSON.stringify(attributeDescriptor?.indexed) !== JSON.stringify(attribute.indexed);
4297
+ commonChanged ||
4298
+ JSON.stringify(attributeDescriptor?.indexed) !== JSON.stringify(attribute.indexed) ||
4299
+ JSON.stringify(attributeDescriptor?.decide) !== JSON.stringify(attribute.decide);
3439
4300
  // structure-affecting difference (drives reindex) — ignores search-only option changes and
3440
4301
  // representation-only differences (key order, string-vs-number) via canonicalIndexKey
3441
4302
  const indexOptionsStructurallyChanged =
@@ -3599,14 +4460,39 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3599
4460
  attributesDbi.put(dbiKey, attribute);
3600
4461
  }
3601
4462
  }
3602
- if (fullTextPersistencePending && !deferredPrimaryRow) {
4463
+ if (
4464
+ !deferredPrimaryRow &&
4465
+ (fullTextPersistencePending ||
4466
+ (rootStore instanceof RocksDatabase &&
4467
+ activeFullTextIndexes !== undefined &&
4468
+ Array.isArray(fullTextValuesForPersistence)))
4469
+ ) {
3603
4470
  const { key, descriptor } = persistedPrimaryDescriptor(attributesDbi);
3604
4471
  if (descriptor && !tableIsDropping(descriptor, key)) {
3605
4472
  const updatedPrimary = { ...descriptor };
3606
- if (Array.isArray(fullTextValuesForPersistence) && fullTextValuesForPersistence.length > 0)
4473
+ if (Array.isArray(fullTextValuesForPersistence) && fullTextValuesForPersistence.length > 0) {
3607
4474
  updatedPrimary.fullTextIndexes = fullTextValuesForPersistence;
3608
- else delete updatedPrimary.fullTextIndexes;
3609
- if (!definitionsEqual(descriptor.fullTextIndexes, updatedPrimary.fullTextIndexes)) {
4475
+ updatedPrimary.fullTextIndexGenerations = fullTextIndexGenerationMap;
4476
+ } else {
4477
+ delete updatedPrimary.fullTextIndexes;
4478
+ delete updatedPrimary.fullTextIndexGenerations;
4479
+ }
4480
+ const activeNames = new Set(persistedFullTextIndexNames(updatedPrimary.fullTextIndexes));
4481
+ const retirements = new Set(persistedFullTextIndexNames(descriptor.fullTextIndexRetirements));
4482
+ for (const name of persistedFullTextIndexNames(descriptor.fullTextIndexes))
4483
+ if (!activeNames.has(name)) retirements.add(name);
4484
+ for (const name of activeNames) retirements.delete(name);
4485
+ fullTextIndexRetirementNames = [...retirements].sort();
4486
+ if (fullTextIndexRetirementNames.length > 0)
4487
+ updatedPrimary.fullTextIndexRetirements = fullTextIndexRetirementNames.map((name) => ({ name }));
4488
+ else delete updatedPrimary.fullTextIndexRetirements;
4489
+ if (
4490
+ !definitionsEqual(descriptor.fullTextIndexes, updatedPrimary.fullTextIndexes) ||
4491
+ JSON.stringify(descriptor.fullTextIndexGenerations ?? {}) !==
4492
+ JSON.stringify(updatedPrimary.fullTextIndexGenerations ?? {}) ||
4493
+ JSON.stringify(descriptor.fullTextIndexRetirements ?? []) !==
4494
+ JSON.stringify(updatedPrimary.fullTextIndexRetirements ?? [])
4495
+ ) {
3610
4496
  exclusiveLock();
3611
4497
  attributesDbi.put(key, updatedPrimary);
3612
4498
  hasChanges = true;
@@ -3674,7 +4560,11 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3674
4560
  } finally {
3675
4561
  releaseLock();
3676
4562
  }
3677
- if (activeFullTextIndexes !== undefined) Table.fullTextIndexes = activeFullTextIndexes;
4563
+ if (activeFullTextIndexes !== undefined) {
4564
+ Table.fullTextIndexes = activeFullTextIndexes;
4565
+ Table.fullTextIndexGenerations = fullTextIndexGenerationMap;
4566
+ Table.fullTextIndexRetirements = fullTextIndexRetirementNames ?? [];
4567
+ }
3678
4568
  if (hasChanges || refreshRelationshipAttributes) Table.schemaVersion++;
3679
4569
  if (hasChanges || refreshRelationshipAttributes || refreshedLiveAttributes) Table.updatedAttributes();
3680
4570
  logger.trace(`${tableName} table loading, running index`);
@@ -3691,8 +4581,7 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
3691
4581
  signalling.signalSchemaChange(
3692
4582
  new SchemaEventMsg(process.pid, 'schema-change', Table.databaseName, Table.tableName, undefined, branchPath)
3693
4583
  );
3694
- void Table.derivedIndexRuntime?.close();
3695
- Table.derivedIndexRuntime = attachDerivedIndexes(Table);
4584
+ refreshDerivedIndexes(Table);
3696
4585
 
3697
4586
  Table.origin = origin;
3698
4587
  // scope-private: replication and other global subscribers must not learn of a branch class
@@ -4537,8 +5426,34 @@ function scheduleGenerationReclaim(rootStore: RocksDatabase, attributesDbi, data
4537
5426
  state.delay = Math.min(state.delay * 2, 60_000);
4538
5427
  }
4539
5428
 
5429
+ const interruptedDropRetirements = new WeakMap<object, Set<Promise<void>>>();
5430
+
5431
+ function trackInterruptedDropRetirement(rootStore: object, task: Promise<void>): void {
5432
+ let tasks = interruptedDropRetirements.get(rootStore);
5433
+ if (!tasks) interruptedDropRetirements.set(rootStore, (tasks = new Set()));
5434
+ tasks.add(task);
5435
+ const remove = () => {
5436
+ tasks.delete(task);
5437
+ };
5438
+ task.then(remove, remove);
5439
+ }
5440
+
5441
+ async function settleInterruptedDropRetirements(rootStores: Iterable<object>): Promise<void> {
5442
+ for (;;) {
5443
+ const tasks = [...rootStores].flatMap((rootStore) => [...(interruptedDropRetirements.get(rootStore) ?? [])]);
5444
+ if (tasks.length === 0) return;
5445
+ await Promise.allSettled(tasks);
5446
+ }
5447
+ }
5448
+
4540
5449
  /** Finishes the durable logical state of a drop that stopped after writing its tombstone. */
4541
- function completeInterruptedDrop(rootStore, attributesDbi, databaseName: string, tableName: string) {
5450
+ function completeInterruptedDrop(
5451
+ rootStore,
5452
+ attributesDbi,
5453
+ databaseName: string,
5454
+ tableName: string,
5455
+ fullTextRetired = false
5456
+ ): boolean {
4542
5457
  logger.debug(`Completing interrupted drop of table ${databaseName}.${tableName}`);
4543
5458
  const catalogRows = [...attributesDbi.getRange({ start: tableName + '/', end: tableName + '0' })] as Array<{
4544
5459
  key: string;
@@ -4548,6 +5463,40 @@ function completeInterruptedDrop(rootStore, attributesDbi, databaseName: string,
4548
5463
  const primaryEntry =
4549
5464
  (bareEntry?.value?.isPrimaryKey ? bareEntry : undefined) ?? catalogRows.find(({ value }) => value?.isPrimaryKey);
4550
5465
  const tombstoneEntry = bareEntry ?? primaryEntry;
5466
+ if (rootStore instanceof RocksDatabase && !fullTextRetired) {
5467
+ const names = [
5468
+ ...new Set([
5469
+ ...persistedFullTextIndexNames(primaryEntry?.value?.fullTextIndexes),
5470
+ ...persistedFullTextIndexNames(primaryEntry?.value?.fullTextIndexRetirements),
5471
+ ]),
5472
+ ];
5473
+ if (names.length > 0) {
5474
+ const releaseRetirement = acquireFullTextRetirementFence(rootStore, tableName);
5475
+ if (releaseRetirement) {
5476
+ const retirement = (async () => {
5477
+ try {
5478
+ const retired = await retireFullTextIndexes(
5479
+ { databaseName, tableName, primaryStore: { rootStore } },
5480
+ names.map((name) => ({ name })),
5481
+ () => rootStore.status === 'open' && !databaseCommitsSuspended(rootStore)
5482
+ );
5483
+ if (!retired || rootStore.status === 'closed' || databaseCommitsSuspended(rootStore)) return;
5484
+ await withUpdateAttributesLockNonBlocking(
5485
+ rootStore,
5486
+ `complete interrupted drop of '${databaseName}.${tableName}'`,
5487
+ () => completeInterruptedDrop(rootStore, attributesDbi, databaseName, tableName, true)
5488
+ );
5489
+ } catch (error) {
5490
+ logger.warn(`Could not retire full-text storage for interrupted drop ${databaseName}.${tableName}`, error);
5491
+ } finally {
5492
+ releaseRetirement();
5493
+ }
5494
+ })();
5495
+ trackInterruptedDropRetirement(rootStore, retirement);
5496
+ }
5497
+ return false;
5498
+ }
5499
+ }
4551
5500
  if (rootStore instanceof RocksDatabase) {
4552
5501
  const tombstone = tombstoneEntry?.value;
4553
5502
  const generation = tombstone?.generation ?? primaryEntry?.value?.generation;
@@ -4598,6 +5547,7 @@ function completeInterruptedDrop(rootStore, attributesDbi, databaseName: string,
4598
5547
  (attributesDbi as any).removeSync(key);
4599
5548
  }
4600
5549
  if (tombstoneEntry) (attributesDbi as any).removeSync(tombstoneEntry.key);
5550
+ return true;
4601
5551
  }
4602
5552
 
4603
5553
  export function dropTableMeta({ table: tableName, database: databaseName }) {