@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
@@ -26,7 +26,7 @@ import type {
26
26
  import type { User } from '../security/user.ts';
27
27
  import lmdbProcessRows from '../dataLayer/harperBridge/lmdbBridge/lmdbUtility/lmdbProcessRows.js';
28
28
  import { Resource, SEARCH_AUTHORIZATION, transformForSelect } from './Resource.ts';
29
- import { when, promiseNormalize } from '../utility/when.ts';
29
+ import { settleBeforeDeadline, when, promiseNormalize } from '../utility/when.ts';
30
30
  import {
31
31
  DatabaseTransaction,
32
32
  ImmediateTransaction,
@@ -36,6 +36,9 @@ import {
36
36
  TRANSACTION_STATE,
37
37
  writeKeyId,
38
38
  closeWriteInstance,
39
+ databaseCommitsSuspended,
40
+ commitTrackedRocksTransaction,
41
+ getDatabaseCommitDrainTimeoutMilliseconds,
39
42
  type WriteGeneration,
40
43
  } from './DatabaseTransaction.ts';
41
44
  import {
@@ -50,8 +53,11 @@ import {
50
53
  import { getThisNodeName } from '../server/nodeName.ts';
51
54
  import * as envMngr from '../utility/environment/environmentManager.ts';
52
55
  import { addSubscription } from './transactionBroadcast.ts';
56
+ import { databaseDropPrepared } from './databaseDropPreparation.ts';
53
57
  import {
54
58
  DerivedIndexLagError,
59
+ DatabaseClosingError,
60
+ DatabaseDrainTimeoutError,
55
61
  handleHDBError,
56
62
  ClientError,
57
63
  ServerError,
@@ -73,6 +79,7 @@ import {
73
79
  sweepDroppedTableBlobs,
74
80
  storeNameFor,
75
81
  storeNamesFor,
82
+ isReadOnlyMode,
76
83
  } from './databases.ts';
77
84
  import { notifyReplicatedApplyFailure } from './replicatedApplyFailure.ts';
78
85
  import {
@@ -117,7 +124,13 @@ import {
117
124
  isLockControlType,
118
125
  isAuditEntryWrite,
119
126
  } from './auditStore.ts';
120
- import { derivedIndexWriteRejection, hasDerivedIndexRegistration } from './derivedIndexRegistry.ts';
127
+ import {
128
+ acquireFullTextClearFence,
129
+ acquireFullTextRetirementFence,
130
+ derivedIndexWriteRejection,
131
+ hasDerivedIndexRegistration,
132
+ waitForFullTextClear,
133
+ } from './derivedIndexRegistry.ts';
121
134
  import {
122
135
  decodeLockControlPayload,
123
136
  encodeLockControlPayload,
@@ -127,7 +140,22 @@ import {
127
140
  LockCoordinator,
128
141
  type LockControlEntry,
129
142
  } from './recordLockCoordinator.ts';
130
- import { buildEmbedBefore, createDefaultEmbedder, type EmbedAttribute, type Embedder } from './models/embedHook.ts';
143
+ import {
144
+ assertDerivedFieldOwnership,
145
+ buildEmbedBefore,
146
+ combineWriteHooks,
147
+ type WriteHook,
148
+ createDefaultEmbedder,
149
+ type EmbedAttribute,
150
+ type Embedder,
151
+ } from './models/embedHook.ts';
152
+ import {
153
+ buildDecideBefore,
154
+ createDefaultDecider,
155
+ type DecideAttribute,
156
+ type DecideConfig,
157
+ type Decider,
158
+ } from './models/decideHook.ts';
131
159
  import { autoCast, autoCastBooleanStrict } from '../utility/common_utils.ts';
132
160
  import {
133
161
  recordUpdater,
@@ -157,7 +185,11 @@ import { RocksDatabase, Transaction as RocksTransaction } from '@harperfast/rock
157
185
  import { LMDBTransaction, ImmediateTransaction as ImmediateLMDBTransaction } from './LMDBTransaction';
158
186
  import { contentTypes } from '../server/serverHelpers/contentTypes';
159
187
  import { type JsonSchemaFragment, projectAttributesToProperties } from './jsonSchemaTypes.ts';
160
- import { type FullTextDefinition } from './fullTextSchema.ts';
188
+ import {
189
+ persistedFullTextIndexNames,
190
+ type FullTextDefinition,
191
+ type FullTextIndexGenerations,
192
+ } from './fullTextSchema.ts';
161
193
 
162
194
  const { sortBy } = lodash;
163
195
  const { validateAttribute } = lmdbProcessRows;
@@ -178,6 +210,7 @@ export type Attribute = {
178
210
  resolve?: any;
179
211
  computedFromExpression?: any;
180
212
  embed?: { source: string; model: string };
213
+ decide?: DecideConfig;
181
214
  version?: any;
182
215
  properties?: Array<Attribute>;
183
216
  elements?: Attribute;
@@ -294,12 +327,38 @@ export function releaseUpdateAttributesLock(rootStore: RocksDatabase) {
294
327
  rootStore.unlock(updateAttributesLockKey);
295
328
  }
296
329
 
297
- export function withUpdateAttributesLock<Callback extends () => unknown>(
330
+ async function acquireUpdateAttributesLockAsync(
331
+ rootStore: RocksDatabase,
332
+ scopeDescription: string,
333
+ timeout = UPDATE_ATTRIBUTES_LOCK_TIMEOUT
334
+ ): Promise<void> {
335
+ if (rootStore.tryLock(updateAttributesLockKey)) return;
336
+ const startTime = performance.now();
337
+ let waitTime = 1;
338
+ while (!rootStore.tryLock(updateAttributesLockKey)) {
339
+ const elapsed = performance.now() - startTime;
340
+ if (elapsed >= timeout) {
341
+ throw new UpdateAttributesLockTimeoutError(
342
+ `Timed out after ${Math.round(elapsed)}ms waiting for the exclusive '${UPDATE_ATTRIBUTES_LOCK}' lock on ${scopeDescription}; the lock holder did not release it before the deadline, so this schema/attribute update cannot proceed`
343
+ );
344
+ }
345
+ await new Promise((resolve) => setTimeout(resolve, Math.min(waitTime, timeout - elapsed)));
346
+ if (waitTime < 16) waitTime *= 2;
347
+ }
348
+ const waited = performance.now() - startTime;
349
+ if (waited >= UPDATE_ATTRIBUTES_LOCK_SLOW_WAIT)
350
+ try {
351
+ logger.warn?.(
352
+ `Acquired the exclusive '${UPDATE_ATTRIBUTES_LOCK}' lock on ${scopeDescription} after waiting ${Math.round(waited)}ms`
353
+ );
354
+ } catch {}
355
+ }
356
+
357
+ function runWithUpdateAttributesLock<Callback extends () => unknown>(
298
358
  rootStore: RocksDatabase,
299
359
  scopeDescription: string,
300
360
  callback: Callback & (ReturnType<Callback> extends PromiseLike<unknown> ? never : unknown)
301
361
  ): ReturnType<Callback> {
302
- acquireUpdateAttributesLock(rootStore, scopeDescription);
303
362
  try {
304
363
  const result = callback();
305
364
  if (typeof (result as any)?.then === 'function') {
@@ -318,6 +377,27 @@ export function withUpdateAttributesLock<Callback extends () => unknown>(
318
377
  releaseUpdateAttributesLock(rootStore);
319
378
  }
320
379
  }
380
+
381
+ export function withUpdateAttributesLock<Callback extends () => unknown>(
382
+ rootStore: RocksDatabase,
383
+ scopeDescription: string,
384
+ callback: Callback & (ReturnType<Callback> extends PromiseLike<unknown> ? never : unknown)
385
+ ): ReturnType<Callback> {
386
+ acquireUpdateAttributesLock(rootStore, scopeDescription);
387
+ return runWithUpdateAttributesLock(rootStore, scopeDescription, callback);
388
+ }
389
+
390
+ export function withUpdateAttributesLockNonBlocking<Callback extends () => unknown>(
391
+ rootStore: RocksDatabase,
392
+ scopeDescription: string,
393
+ callback: Callback & (ReturnType<Callback> extends PromiseLike<unknown> ? never : unknown)
394
+ ): ReturnType<Callback> | Promise<ReturnType<Callback>> {
395
+ if (rootStore.tryLock(updateAttributesLockKey))
396
+ return runWithUpdateAttributesLock(rootStore, scopeDescription, callback);
397
+ return acquireUpdateAttributesLockAsync(rootStore, scopeDescription).then(() =>
398
+ runWithUpdateAttributesLock(rootStore, scopeDescription, callback)
399
+ );
400
+ }
321
401
  // Tolerate a redundant column family drop. Drops are broadcast to every worker
322
402
  // thread and each holds its own handle to the same underlying family, so a
323
403
  // concurrent worker may already have dropped it; the storage engine reports
@@ -677,7 +757,11 @@ export function makeTable(options) {
677
757
  cacheControl,
678
758
  isBranch,
679
759
  fullTextIndexes = [],
760
+ fullTextIndexGenerations = Object.create(null),
761
+ fullTextIndexRetirements = [],
680
762
  } = options;
763
+ const tableRootStore = primaryStore.rootStore;
764
+ const tableRootPath = tableRootStore.path;
681
765
  let { expirationMS: expirationMs, evictionMS: evictionMs, audit, trackDeletes } = options;
682
766
  // Set when the TTL exists only on this thread: either application code configured it at runtime, or
683
767
  // an isolated application's schema was declared here. Hydrating persisted metadata does not set it:
@@ -702,6 +786,9 @@ export function makeTable(options) {
702
786
  let hasSourceGet: any;
703
787
  let primaryKeyAttribute: Attribute | undefined;
704
788
  let lastEvictionCompletion: Promise<void> = Promise.resolve();
789
+ let recordExpirationCompletion: Promise<void> = Promise.resolve();
790
+ const maintenanceCommits = new Set<Promise<unknown>>();
791
+ let maintenanceClosed = false;
705
792
  let droppingTable = false;
706
793
  let createdTimeProperty: Attribute | undefined,
707
794
  updatedTimeProperty: Attribute | undefined,
@@ -721,8 +808,8 @@ export function makeTable(options) {
721
808
  let cleanupInterval = 86400000;
722
809
  let cleanupPriority = 0;
723
810
  let lastCleanupInterval: number | undefined;
724
- let cleanupTimer: NodeJS.Timeout;
725
- let recordExpirationInterval: NodeJS.Timeout;
811
+ let cleanupTimer: NodeJS.Timeout | undefined;
812
+ let recordExpirationInterval: NodeJS.Timeout | undefined;
726
813
  // a reclamation pass awaits a scheduled cleanup, which only settles from its timer
727
814
  const pendingCleanupResolvers = new Set<() => void>();
728
815
  let disposed = false;
@@ -903,9 +990,10 @@ export function makeTable(options) {
903
990
  }
904
991
  return { txnLogKey: version, nodeId };
905
992
  }
906
- // Canonical-source applies (sourceApply), replay and replication notifications are never shed;
907
- // dropping one would advance the source cursor past a write that never landed.
993
+ // Teardown rejects every producer; source/replay paths only bypass derived-index lag shedding.
908
994
  function assertDerivedIndexAdmission(options: any, transaction: any) {
995
+ if (databaseDropPrepared(tableRootPath) || databaseCommitsSuspended(tableRootStore))
996
+ throw new DatabaseClosingError(databaseName, !transaction?.root && !transaction?.snapshotFree);
909
997
  if (options?.isNotification || transaction?.sourceApply || transaction?.isReplay) return;
910
998
  const reason = derivedIndexWriteRejection(auditStore, tableId);
911
999
  if (reason) throw new DerivedIndexLagError(reason);
@@ -931,6 +1019,19 @@ export function makeTable(options) {
931
1019
  error.statusCode = 404;
932
1020
  throw error;
933
1021
  }
1022
+ function currentFullTextDescriptor(): { key: string; descriptor: any } | undefined {
1023
+ const namedKey = `${tableName}/${primaryKey}`;
1024
+ const namedDescriptor = (dbisDb as any).getSync(namedKey);
1025
+ const key = namedDescriptor?.isPrimaryKey ? namedKey : `${tableName}/`;
1026
+ const descriptor = (dbisDb as any).getSync(key);
1027
+ if (
1028
+ !descriptor ||
1029
+ (descriptor.tableId != null && descriptor.tableId !== tableId) ||
1030
+ descriptor.generation !== tableGeneration
1031
+ )
1032
+ return;
1033
+ return { key, descriptor };
1034
+ }
934
1035
  class TableResource<Record extends object = any> extends Resource<Record> {
935
1036
  #record: any; // the stored/frozen record from the database and stored in the cache (should not be modified directly)
936
1037
  #changes: any; // the changes to the record that have been made (should not be modified directly)
@@ -981,12 +1082,51 @@ export function makeTable(options) {
981
1082
  static derivedIndexRuntime:
982
1083
  | {
983
1084
  close(dropping?: boolean): Promise<void>;
1085
+ fullTextDefinitions?(): readonly FullTextDefinition[];
1086
+ matchesCurrent?(): boolean;
984
1087
  restoreAfterFailedDrop?(): typeof TableResource.derivedIndexRuntime;
1088
+ retireAfterConfirmedDrop?(definitions?: readonly Pick<FullTextDefinition, 'name'>[]): Promise<boolean>;
985
1089
  completeDrop?(dropped?: boolean): void;
986
1090
  }
987
1091
  | undefined;
988
1092
  static audit = audit;
989
1093
  static fullTextIndexes: FullTextDefinition[] = fullTextIndexes;
1094
+ static fullTextIndexGenerations: FullTextIndexGenerations = fullTextIndexGenerations;
1095
+ static fullTextIndexRetirements: string[] = fullTextIndexRetirements;
1096
+ static hasCurrentFullTextIndexRetirements(names: readonly string[]): boolean | Promise<boolean> {
1097
+ if (names.length === 0 || !(primaryStore.rootStore instanceof RocksDatabase)) return false;
1098
+ return withUpdateAttributesLockNonBlocking(
1099
+ primaryStore.rootStore,
1100
+ `verify full-text retirement for '${databaseName}.${tableName}'`,
1101
+ () => {
1102
+ const current = currentFullTextDescriptor();
1103
+ if (!current) return false;
1104
+ const pending = new Set(persistedFullTextIndexNames(current.descriptor.fullTextIndexRetirements));
1105
+ return names.every((name) => pending.has(name));
1106
+ }
1107
+ );
1108
+ }
1109
+ static completeFullTextIndexRetirements(names: readonly string[]): void | Promise<void> {
1110
+ if (names.length === 0 || !(primaryStore.rootStore instanceof RocksDatabase)) return;
1111
+ const completed = new Set(names);
1112
+ return withUpdateAttributesLockNonBlocking(
1113
+ primaryStore.rootStore,
1114
+ `complete full-text retirement for '${databaseName}.${tableName}'`,
1115
+ () => {
1116
+ const current = currentFullTextDescriptor();
1117
+ if (!current) return;
1118
+ const { key, descriptor } = current;
1119
+ const remaining = persistedFullTextIndexNames(descriptor.fullTextIndexRetirements).filter(
1120
+ (name) => !completed.has(name)
1121
+ );
1122
+ const updated = { ...descriptor };
1123
+ if (remaining.length > 0) updated.fullTextIndexRetirements = remaining.map((name) => ({ name }));
1124
+ else delete updated.fullTextIndexRetirements;
1125
+ (dbisDb as any).putSync(key, updated);
1126
+ this.fullTextIndexRetirements = remaining;
1127
+ }
1128
+ );
1129
+ }
990
1130
  static databasePath = databasePath;
991
1131
  static databaseName = databaseName;
992
1132
  static attributes = attributes;
@@ -1010,6 +1150,9 @@ export function makeTable(options) {
1010
1150
  static userEmbedders: { [name: string]: Embedder } = {};
1011
1151
  static userSetEmbedders: Set<string> = new Set();
1012
1152
  static embedAttributes: EmbedAttribute[] = (attributes as any[]).filter((a) => a?.embed);
1153
+ static userDeciders: { [name: string]: Decider } = {};
1154
+ static userSetDeciders: Set<string> = new Set();
1155
+ static decideAttributes: DecideAttribute[] = (attributes as any[]).filter((a) => a?.decide);
1013
1156
  static source?: typeof TableResource;
1014
1157
  declare static sourceOptions: any;
1015
1158
  declare static intermediateSource: boolean;
@@ -1382,7 +1525,11 @@ export function makeTable(options) {
1382
1525
  } catch {}
1383
1526
  throw error;
1384
1527
  }
1385
- return seqTransaction.commit().catch((error) => {
1528
+ return commitTrackedRocksTransaction(
1529
+ seqTransaction,
1530
+ dbisDb,
1531
+ (primaryStore as any).rootStore
1532
+ ).catch((error) => {
1386
1533
  // A rejected commit leaves the handle open too, so release it here as well —
1387
1534
  // same reason as the staging failure above, and the same shape as the
1388
1535
  // eviction paths' commit failures (see evict/commitItems below).
@@ -1616,6 +1763,11 @@ export function makeTable(options) {
1616
1763
  request: Context,
1617
1764
  resourceOptions?: any
1618
1765
  ): Promise<TableResource<Record>> | TableResource<Record> {
1766
+ if (databaseDropPrepared(tableRootPath) || databaseCommitsSuspended(tableRootStore))
1767
+ throw new DatabaseClosingError(
1768
+ databaseName,
1769
+ !(request as any)?.transaction?.root && !(request as any)?.transaction?.snapshotFree
1770
+ );
1619
1771
  const resource: TableResource = super.getResource(target, request, resourceOptions) as any;
1620
1772
  if (this.loadAsInstance !== false) {
1621
1773
  return resource._loadRecord(target, request, resourceOptions);
@@ -2040,28 +2192,46 @@ export function makeTable(options) {
2040
2192
  // same-name recreate must not race an owner still applying to the old generation.
2041
2193
  const derivedIndexRuntime = TableResource.derivedIndexRuntime;
2042
2194
  const restoreDerivedIndexesAfterFailedDrop = () => {
2195
+ TableResource.resumeMaintenance();
2043
2196
  try {
2044
- TableResource.derivedIndexRuntime = derivedIndexRuntime?.restoreAfterFailedDrop?.();
2197
+ const restored = derivedIndexRuntime?.restoreAfterFailedDrop?.();
2198
+ if (TableResource.derivedIndexRuntime === derivedIndexRuntime) TableResource.derivedIndexRuntime = restored;
2045
2199
  } catch (restoreError) {
2046
- TableResource.derivedIndexRuntime = undefined;
2200
+ if (TableResource.derivedIndexRuntime === derivedIndexRuntime) TableResource.derivedIndexRuntime = undefined;
2047
2201
  logger.error?.(
2048
2202
  `Could not restore derived indexes after failed drop of ${databaseName}.${TableResource.tableName}`,
2049
2203
  restoreError
2050
2204
  );
2051
2205
  }
2052
2206
  };
2207
+ try {
2208
+ await TableResource.closeMaintenance();
2209
+ } catch (error) {
2210
+ TableResource.resumeMaintenance();
2211
+ throw error;
2212
+ }
2053
2213
  try {
2054
2214
  await derivedIndexRuntime?.close(true);
2055
2215
  } catch (error) {
2056
2216
  restoreDerivedIndexesAfterFailedDrop();
2057
2217
  throw error;
2058
2218
  }
2219
+ let releaseFullTextRetirementFence: (() => void) | undefined;
2220
+ const releaseFullTextRetirement = () => {
2221
+ const release = releaseFullTextRetirementFence;
2222
+ releaseFullTextRetirementFence = undefined;
2223
+ release?.();
2224
+ };
2059
2225
  const abortStaleDrop = () => {
2226
+ releaseFullTextRetirement();
2060
2227
  derivedIndexRuntime?.completeDrop?.(false);
2061
2228
  TableResource.derivedIndexRuntime = undefined;
2062
2229
  TableResource.cleanup();
2063
2230
  if (databases[databaseName]?.[tableName] === TableResource) delete databases[databaseName][tableName];
2064
2231
  };
2232
+ let fullTextDefinitionsForRetirement: Array<Pick<FullTextDefinition, 'name'>> = [
2233
+ ...TableResource.fullTextIndexes,
2234
+ ];
2065
2235
  let dropIdentityConfirmed = databaseName !== databasePath;
2066
2236
  let primaryCatalogKey = TableResource.tableName + '/';
2067
2237
  let storeGeneration: string | undefined;
@@ -2092,6 +2262,29 @@ export function makeTable(options) {
2092
2262
  return false;
2093
2263
  dropGeneration = primaryMeta.dropGeneration;
2094
2264
  storeGeneration = primaryMeta.generation;
2265
+ const durableFullTextDefinitions =
2266
+ rootStore instanceof RocksDatabase
2267
+ ? [
2268
+ ...persistedFullTextIndexNames(primaryMeta.fullTextIndexes),
2269
+ ...persistedFullTextIndexNames(primaryMeta.fullTextIndexRetirements),
2270
+ ].map((name) => ({ name }))
2271
+ : [];
2272
+ const attachedFullTextDefinitions = derivedIndexRuntime?.fullTextDefinitions?.() ?? [];
2273
+ const definitionsByName = new Map(
2274
+ [...durableFullTextDefinitions, ...attachedFullTextDefinitions].map((definition) => [
2275
+ definition.name,
2276
+ definition,
2277
+ ])
2278
+ );
2279
+ fullTextDefinitionsForRetirement = [...definitionsByName.values()];
2280
+ if (fullTextDefinitionsForRetirement.length > 0 && !releaseFullTextRetirementFence) {
2281
+ releaseFullTextRetirementFence = acquireFullTextRetirementFence(rootStore, TableResource.tableName);
2282
+ if (!releaseFullTextRetirementFence)
2283
+ throw new ClientError(
2284
+ `Cannot drop '${databaseName}.${TableResource.tableName}' while its full-text storage is being retired`,
2285
+ 409
2286
+ );
2287
+ }
2095
2288
  if (primaryMeta.dropping) return true;
2096
2289
  primaryMeta.dropping = true;
2097
2290
  // Stamps this drop's identity so the interrupted-drop retry budget in
@@ -2123,11 +2316,13 @@ export function makeTable(options) {
2123
2316
  if (typeof tombstoneWrite?.then === 'function') await tombstoneWrite;
2124
2317
  }
2125
2318
  } catch (error) {
2319
+ releaseFullTextRetirement();
2126
2320
  restoreDerivedIndexesAfterFailedDrop();
2127
2321
  throw error;
2128
2322
  }
2129
2323
  }
2130
2324
  if (!dropIdentityConfirmed) {
2325
+ releaseFullTextRetirement();
2131
2326
  abortStaleDrop();
2132
2327
  return;
2133
2328
  }
@@ -2150,12 +2345,19 @@ export function makeTable(options) {
2150
2345
  try {
2151
2346
  if (!dropGeneration)
2152
2347
  throw new Error(`Cannot drop ${databaseName}.${tableName}: its catalog tombstone has no drop generation`);
2153
- await retireRocksStores(storeGeneration, dropGeneration);
2348
+ const retired = await retireRocksStores(storeGeneration, dropGeneration);
2349
+ if (!retired) {
2350
+ derivedIndexRuntime?.completeDrop?.();
2351
+ releaseFullTextRetirement();
2352
+ return;
2353
+ }
2154
2354
  } catch (error) {
2355
+ releaseFullTextRetirement();
2155
2356
  derivedIndexRuntime?.completeDrop?.();
2156
2357
  throw error;
2157
2358
  }
2158
2359
  derivedIndexRuntime?.completeDrop?.();
2360
+ releaseFullTextRetirement();
2159
2361
  return;
2160
2362
  }
2161
2363
  try {
@@ -2165,6 +2367,7 @@ export function makeTable(options) {
2165
2367
  }
2166
2368
  }
2167
2369
  } catch (error) {
2370
+ releaseFullTextRetirement();
2168
2371
  derivedIndexRuntime?.completeDrop?.();
2169
2372
  throw error;
2170
2373
  }
@@ -2206,6 +2409,7 @@ export function makeTable(options) {
2206
2409
  removed = removeTombstonedCatalog();
2207
2410
  if (removed) await dbisDb.committed;
2208
2411
  } catch (error) {
2412
+ releaseFullTextRetirement();
2209
2413
  derivedIndexRuntime?.completeDrop?.();
2210
2414
  throw error;
2211
2415
  }
@@ -2226,22 +2430,41 @@ export function makeTable(options) {
2226
2430
  await primaryStore.close();
2227
2431
  fs.unlinkSync(primaryStore.path);
2228
2432
  } catch (error) {
2433
+ releaseFullTextRetirement();
2229
2434
  derivedIndexRuntime?.completeDrop?.();
2230
2435
  throw error;
2231
2436
  }
2232
2437
  }
2233
- derivedIndexRuntime?.completeDrop?.();
2234
- const message: any = new SchemaEventMsg(process.pid, OPERATIONS_ENUM.DROP_TABLE, databaseName, tableName);
2235
- message.dropTableId = tableId;
2236
- signalling.signalSchemaChange(message);
2438
+ try {
2439
+ const message: any = new SchemaEventMsg(process.pid, OPERATIONS_ENUM.DROP_TABLE, databaseName, tableName);
2440
+ message.dropTableId = tableId;
2441
+ await signalling.signalSchemaChange(message);
2442
+ await retireFullTextStorage();
2443
+ derivedIndexRuntime?.completeDrop?.();
2444
+ } finally {
2445
+ releaseFullTextRetirement();
2446
+ }
2447
+
2448
+ async function retireFullTextStorage(): Promise<boolean> {
2449
+ if (fullTextDefinitionsForRetirement.length === 0) return true;
2450
+ if (derivedIndexRuntime?.retireAfterConfirmedDrop)
2451
+ return derivedIndexRuntime.retireAfterConfirmedDrop(fullTextDefinitionsForRetirement);
2452
+ else {
2453
+ const { retireFullTextIndexes } = await import('./derivedIndexes.ts');
2454
+ return retireFullTextIndexes(TableResource, fullTextDefinitionsForRetirement);
2455
+ }
2456
+ }
2237
2457
 
2238
- async function retireRocksStores(generation: string | undefined, dropGeneration: string) {
2458
+ async function retireRocksStores(generation: string | undefined, dropGeneration: string): Promise<boolean> {
2239
2459
  const releaseDropMark = markDropInProgress(dropGeneration);
2240
2460
  try {
2241
2461
  const message: any = new SchemaEventMsg(process.pid, OPERATIONS_ENUM.DROP_TABLE, databaseName, tableName);
2242
2462
  message.dropGeneration = dropGeneration;
2243
2463
  message.dropTableId = tableId;
2244
2464
  await signalling.signalSchemaChange(message);
2465
+ // Keep the tombstone's native index names durable until every peer writer
2466
+ // has stopped and the wrapper has retired their storage.
2467
+ if (!(await retireFullTextStorage())) return false;
2245
2468
  const removed = withUpdateAttributesLock(rootStore, `table '${databaseName}.${tableName}'`, () => {
2246
2469
  const stores = storeNamesFor(dbisDb, tableName, generation);
2247
2470
  const retiredStores = recordRetiredGeneration(
@@ -2294,6 +2517,7 @@ export function makeTable(options) {
2294
2517
  const settled = await settlePhysicalDrops(rootStore, label);
2295
2518
  await sweepDroppedTableBlobs(primaryStore, label);
2296
2519
  if (!settled) finishDroppedTableBlobSweep(rootStore, primaryStore, label);
2520
+ return true;
2297
2521
  } finally {
2298
2522
  releaseDropMark();
2299
2523
  }
@@ -2955,6 +3179,7 @@ export function makeTable(options) {
2955
3179
  * Evicting a record will remove it from a caching table. This is not considered a canonical data change, and it is assumed that retrieving this record from the source will still yield the same record, this is only removing the local copy of the record.
2956
3180
  */
2957
3181
  static evict(id, existingRecord, existingVersion) {
3182
+ if (maintenanceClosed) return Promise.resolve();
2958
3183
  let entry;
2959
3184
  const lmdbTransaction = txnForContext({ transaction: new DatabaseTransaction() });
2960
3185
  let transaction = lmdbTransaction.getReadTxn();
@@ -3001,23 +3226,27 @@ export function makeTable(options) {
3001
3226
  // a plain resolution object rather than a promise — return the store's write promises instead, so
3002
3227
  // the caller gets a real thenable that resolves once the removal is durable.
3003
3228
  (lmdbTransaction as any).commit();
3004
- return Promise.resolve(lmdbCompletion).catch((error) => {
3005
- logger.warn?.('Error evicting record', id, error);
3006
- });
3229
+ return trackMaintenanceCommit(
3230
+ Promise.resolve(lmdbCompletion).catch((error) => {
3231
+ logger.warn?.('Error evicting record', id, error);
3232
+ })
3233
+ );
3007
3234
  }
3008
3235
  // RocksDB: eviction writes went directly into the raw transaction via options; commit it directly,
3009
3236
  // as DatabaseTransaction.commit() would abort it (no tracked writes). The raw commit bypasses
3010
3237
  // DatabaseTransaction's ERR_BUSY retry, so a concurrent-write conflict rejects here — swallow it
3011
3238
  // (abandon the eviction) and log anything unexpected, rather than letting it crash the process.
3012
- return (transaction as any).commit().catch((error) => {
3013
- // The commit failed, so the read-snapshot/transaction handle is still open — release it, as the
3014
- // batched-eviction path does on its own commit failures. committed===true skips the finally abort.
3015
- try {
3016
- (transaction as any).abort();
3017
- } catch {}
3018
- if (error?.code === 'ERR_BUSY') logger.trace?.('Abandoned eviction of busy record', id);
3019
- else logger.warn?.('Error evicting record', id, error);
3020
- });
3239
+ return trackMaintenanceCommit(
3240
+ commitTrackedRocksTransaction(transaction as RocksTransaction, primaryStore).catch((error) => {
3241
+ // The commit failed, so the read-snapshot/transaction handle is still open — release it, as the
3242
+ // batched-eviction path does on its own commit failures. committed===true skips the finally abort.
3243
+ try {
3244
+ (transaction as any).abort();
3245
+ } catch {}
3246
+ if (error?.code === 'ERR_BUSY') logger.trace?.('Abandoned eviction of busy record', id);
3247
+ else logger.warn?.('Error evicting record', id, error);
3248
+ })
3249
+ );
3021
3250
  } finally {
3022
3251
  if (!committed) {
3023
3252
  // Skip path or thrown error: abort instead of committing so we don't apply
@@ -4386,22 +4615,33 @@ export function makeTable(options) {
4386
4615
  },
4387
4616
  };
4388
4617
  this.#savingOperation = write;
4389
- // `@embed` hook must run before `addWrite` so the embedder's vector is on the
4390
- // record when `commit` runs. (The txn `before` slot runs after commit, which
4391
- // suits blob writes but not embedding, where the vector must be present at commit.)
4392
- // Known limitation of this write-time placement (a validate-time alternative was
4393
- // tried and reverted as a Harper-foreign pattern): the embedder sees this write's
4394
- // payload, before table validation — so a write that later fails validation still
4395
- // calls the backend, and a tracked-instance mutation (update(id,{}); row.source=…;
4396
- // save()) that sets the source via accessors after update() won't re-embed. A
4397
- // resource-layer re-embed is the proper fix; tracked as a follow-up.
4398
- const embedBefore = buildEmbedBefore(
4399
- recordUpdate,
4400
- context,
4401
- options,
4402
- TableResource.embedAttributes,
4403
- TableResource.userEmbedders
4404
- );
4618
+ // The hooks run before `addWrite` so the derived values are on the record at commit (the
4619
+ // txn `before` slot runs after commit). They see the payload before table validation, and a
4620
+ // tracked-instance mutation that sets the source via accessors after update() is not seen.
4621
+ let modelHooksBefore: WriteHook | undefined;
4622
+ try {
4623
+ modelHooksBefore =
4624
+ (TableResource.embedAttributes.length || TableResource.decideAttributes.length) && !isReadOnlyMode()
4625
+ ? combineWriteHooks(
4626
+ buildEmbedBefore(
4627
+ recordUpdate,
4628
+ context,
4629
+ options,
4630
+ TableResource.embedAttributes,
4631
+ TableResource.userEmbedders
4632
+ ),
4633
+ buildDecideBefore(
4634
+ recordUpdate,
4635
+ context,
4636
+ options,
4637
+ TableResource.decideAttributes,
4638
+ TableResource.userDeciders
4639
+ )
4640
+ )
4641
+ : undefined;
4642
+ } catch (err) {
4643
+ return Promise.reject(err);
4644
+ }
4405
4645
  const proceed = (): any => {
4406
4646
  // On a source/replication apply (`isNotification`), the record's already-saved blobs were
4407
4647
  // received out-of-band for THIS write, so track them for skip/abort cleanup (harper-pro#406).
@@ -4414,7 +4654,7 @@ export function makeTable(options) {
4414
4654
  );
4415
4655
  return transaction.addWrite(write as any);
4416
4656
  };
4417
- return embedBefore ? embedBefore().then(proceed) : proceed();
4657
+ return modelHooksBefore ? modelHooksBefore((context as any)?.signal).then(proceed) : proceed();
4418
4658
  }
4419
4659
 
4420
4660
  async delete(target: RequestTargetOrId): Promise<boolean> {
@@ -5684,7 +5924,10 @@ export function makeTable(options) {
5684
5924
  // messages during this loop because the snapshot:false cursor will pick them up itself.
5685
5925
  pendingRealTimeQueue = null;
5686
5926
  dropDuringReplay = true;
5687
-
5927
+ // subscription.startTime is the resume cursor (exclusive) and RocksDB gives every record of a
5928
+ // transaction the same txnLogKey, so it only moves to a key once all of that key's records are
5929
+ // handled; an early return leaves it before a partly delivered transaction.
5930
+ let handledTxnLogKey: number | undefined;
5688
5931
  try {
5689
5932
  for (const auditRecord of auditStore.getRange({
5690
5933
  start: startTime,
@@ -5698,17 +5941,22 @@ export function makeTable(options) {
5698
5941
  }
5699
5942
  if (auditRecord.tableId !== tableId || auditRecord.type === 'evict') continue;
5700
5943
  if (isLockControlType(auditRecord.type)) continue;
5944
+ if (handledTxnLogKey !== undefined && auditRecord.txnLogKey !== handledTxnLogKey) {
5945
+ subscription!.startTime = handledTxnLogKey;
5946
+ }
5701
5947
  const id = auditRecord.recordId;
5702
- subscription!.startTime = auditRecord.txnLogKey;
5703
5948
  if (thisId == null || isDescendantId(thisId, id)) {
5704
5949
  const event = eventFromAudit(id, auditRecord, auditRecord.txnLogKey);
5705
- if (!event) continue;
5706
- if (!send(event)) return;
5707
- if (subscription.queue?.length > EVENT_HIGH_WATER_MARK) {
5708
- if ((await subscription.waitForDrain()) === false) return;
5950
+ if (event) {
5951
+ if (!send(event)) return;
5952
+ if (subscription.queue?.length > EVENT_HIGH_WATER_MARK) {
5953
+ if ((await subscription.waitForDrain()) === false) return;
5954
+ }
5709
5955
  }
5710
5956
  }
5957
+ handledTxnLogKey = auditRecord.txnLogKey;
5711
5958
  }
5959
+ if (handledTxnLogKey !== undefined) subscription!.startTime = handledTxnLogKey;
5712
5960
  } finally {
5713
5961
  // replay is done, we can start sending real-time messages again
5714
5962
  dropDuringReplay = false;
@@ -6683,13 +6931,24 @@ export function makeTable(options) {
6683
6931
  static updatedAttributes() {
6684
6932
  // Refresh on every call: schema reload mutates `attributes` in place, so the
6685
6933
  // class-construction snapshot would otherwise go stale.
6934
+ // Declarations are refused before they are saved; here a descriptor an earlier build
6935
+ // accepted must still load, so a violation is logged and the table keeps working.
6936
+ try {
6937
+ assertDerivedFieldOwnership(this.attributes as any[]);
6938
+ } catch (error) {
6939
+ console.error(`Derived attributes of table "${tableName}" conflict: ${(error as Error).message}`);
6940
+ }
6686
6941
  this.embedAttributes = (this.attributes as any[]).filter((a) => a?.embed);
6942
+ this.decideAttributes = (this.attributes as any[]).filter((a) => a?.decide);
6687
6943
  expiresAtProperty = this.attributes.find((attribute) => attribute.expiresAt);
6688
- // Drop registry entries for attributes that are no longer `@embed`, so a dropped
6689
- // directive doesn't leave a stale embedder or block a default refresh on re-add.
6944
+ // Drop registry entries for attributes that are no longer `@embed` / `@decide`, so a dropped
6945
+ // directive doesn't leave a stale hook or block a default refresh on re-add.
6690
6946
  const embedNames = new Set(this.embedAttributes.map((a) => a.name));
6691
6947
  for (const name of Object.keys(this.userEmbedders)) if (!embedNames.has(name)) delete this.userEmbedders[name];
6692
6948
  for (const name of this.userSetEmbedders) if (!embedNames.has(name)) this.userSetEmbedders.delete(name);
6949
+ const decideNames = new Set(this.decideAttributes.map((a) => a.name));
6950
+ for (const name of Object.keys(this.userDeciders)) if (!decideNames.has(name)) delete this.userDeciders[name];
6951
+ for (const name of this.userSetDeciders) if (!decideNames.has(name)) this.userSetDeciders.delete(name);
6693
6952
  propertyResolvers = this.propertyResolvers = {
6694
6953
  $id: (object, context, entry) => ({ value: entry.key }),
6695
6954
  $updatedtime: (object, context, entry) => entry.version,
@@ -6726,9 +6985,12 @@ export function makeTable(options) {
6726
6985
  const computed = attribute.computed;
6727
6986
  // Register the default embedder unless an author override is set. Sits outside
6728
6987
  // the resolver chain below so `@embed` fields still flow through auto-HNSW indexing.
6729
- if (attribute.embed && !TableResource.userSetEmbedders.has(attribute.name)) {
6988
+ if (attribute.embed && !this.userSetEmbedders.has(attribute.name)) {
6730
6989
  this.userEmbedders[attribute.name] = createDefaultEmbedder(attribute.embed);
6731
6990
  }
6991
+ if (attribute.decide && !this.userSetDeciders.has(attribute.name)) {
6992
+ this.userDeciders[attribute.name] = createDefaultDecider(attribute.decide);
6993
+ }
6732
6994
  if (relationship) {
6733
6995
  if (attribute.indexed) {
6734
6996
  console.error(
@@ -6960,6 +7222,26 @@ export function makeTable(options) {
6960
7222
  this.userEmbedders[attribute_name] = embedder;
6961
7223
  this.userSetEmbedders.add(attribute_name);
6962
7224
  }
7225
+ /**
7226
+ * Override the default decider for a `@decide` attribute. Return `{ value, probability }`
7227
+ * to store at the attribute and its confidence attribute, or `null` to clear both. The
7228
+ * value must be one the directive allows, and the probability is required when the
7229
+ * directive names a confidence attribute. Like an embedder, the decider receives the write
7230
+ * payload, not the post-merge record, and a `signal` that aborts when a sibling hook fails.
7231
+ */
7232
+ static setDecideAttribute(attribute_name: string, decider: Decider): void {
7233
+ const attribute = findAttribute(attributes, attribute_name);
7234
+ if (!attribute) {
7235
+ console.error(`The attribute "${attribute_name}" does not exist in the table "${tableName}"`);
7236
+ return;
7237
+ }
7238
+ if (!attribute.decide) {
7239
+ console.error(`The attribute "${attribute_name}" is not declared with @decide in the table "${tableName}"`);
7240
+ return;
7241
+ }
7242
+ this.userDeciders[attribute_name] = decider;
7243
+ this.userSetDeciders.add(attribute_name);
7244
+ }
6963
7245
  static async deleteHistory(endTime = 0, cleanupDeletedRecords = false): Promise<number> {
6964
7246
  const maxConcurrentRemovals = isRocksDB ? MAX_CONCURRENT_HISTORY_REMOVALS : MAX_CONCURRENT_LMDB_HISTORY_REMOVALS;
6965
7247
  const inFlightRemovals = new Set<Promise<void>>();
@@ -7136,31 +7418,99 @@ export function makeTable(options) {
7136
7418
  } while (count < 1000 && nextVersion);
7137
7419
  return history.reverse();
7138
7420
  }
7139
- static clear() {
7140
- // clear the primary store and every secondary index dbi (same pattern used by
7141
- // runIndexing when rebuilding from scratch), so clear() doesn't leave stale
7142
- // index entries pointing at records that no longer exist.
7143
- const promises = [primaryStore.clear()];
7144
- for (const key in indices) {
7145
- const index = indices[key];
7146
- index.customIndex?.resetDerivedStorage?.();
7147
- promises.push(index.clearAsync ? index.clearAsync() : index.clear());
7148
- }
7149
- return Promise.all(promises);
7421
+ static async clear() {
7422
+ const rootStore = primaryStore.rootStore;
7423
+ const assertNoFullTextDeclaration = () => {
7424
+ const namedPrimaryDescriptor = (dbisDb as any).getSync(`${tableName}/${primaryKey}`);
7425
+ const primaryDescriptor = namedPrimaryDescriptor?.isPrimaryKey
7426
+ ? namedPrimaryDescriptor
7427
+ : (dbisDb as any).getSync(`${tableName}/`);
7428
+ if (
7429
+ TableResource.fullTextIndexes.length > 0 ||
7430
+ (rootStore instanceof RocksDatabase &&
7431
+ persistedFullTextIndexNames(primaryDescriptor?.fullTextIndexes).length > 0)
7432
+ )
7433
+ throw new ClientError(
7434
+ `Table.clear() is not supported on full-text table '${databaseName}.${tableName}' until whole-table invalidation is crash-safe`,
7435
+ 501
7436
+ );
7437
+ };
7438
+ let releaseFullTextClearFence: (() => void) | undefined;
7439
+ let waitForExistingClear: Promise<boolean> | undefined;
7440
+ let admission: void | Promise<void>;
7441
+ if (rootStore instanceof RocksDatabase) {
7442
+ admission = withUpdateAttributesLockNonBlocking(rootStore, `clear table '${databaseName}.${tableName}'`, () => {
7443
+ assertNoFullTextDeclaration();
7444
+ releaseFullTextClearFence = acquireFullTextClearFence(rootStore, tableId);
7445
+ if (!releaseFullTextClearFence) waitForExistingClear = waitForFullTextClear(rootStore, tableId);
7446
+ });
7447
+ } else assertNoFullTextDeclaration();
7448
+ const startClear = () => {
7449
+ if (waitForExistingClear) return waitForExistingClear.then(() => TableResource.clear());
7450
+ // clear the primary store and every secondary index dbi (same pattern used by
7451
+ // runIndexing when rebuilding from scratch), so clear() doesn't leave stale
7452
+ // index entries pointing at records that no longer exist.
7453
+ const promises = [];
7454
+ const settleStartedClears = (synchronousFailure?: { error: unknown }) =>
7455
+ Promise.allSettled(promises)
7456
+ .then((results) => {
7457
+ if (synchronousFailure) throw synchronousFailure.error;
7458
+ const values = [];
7459
+ for (const result of results) {
7460
+ if (result.status === 'rejected') throw result.reason;
7461
+ values.push(result.value);
7462
+ }
7463
+ return values;
7464
+ })
7465
+ .finally(releaseFullTextClearFence);
7466
+ try {
7467
+ promises.push(primaryStore.clear());
7468
+ for (const key in indices) {
7469
+ const index = indices[key];
7470
+ index.customIndex?.resetDerivedStorage?.();
7471
+ promises.push(index.clearAsync ? index.clearAsync() : index.clear());
7472
+ }
7473
+ return settleStartedClears();
7474
+ } catch (error) {
7475
+ if (promises.length > 0) return settleStartedClears({ error });
7476
+ releaseFullTextClearFence?.();
7477
+ throw error;
7478
+ }
7479
+ };
7480
+ return admission instanceof Promise ? admission.then(startClear) : startClear();
7150
7481
  }
7151
7482
  /** Release everything makeTable() registered process-wide; the class must not be used afterwards. */
7152
7483
  static cleanup() {
7153
7484
  disposed = true;
7154
7485
  TableResource.getResource = unavailableResource;
7155
- TableResource.derivedIndexRuntime
7486
+ void TableResource.derivedIndexRuntime
7156
7487
  ?.close()
7157
7488
  .catch((error) => logger.warn?.(`Derived index shutdown failed for ${databaseName}.${tableName}`, error));
7158
- clearTimeout(cleanupTimer);
7159
- settlePendingCleanup();
7160
- clearInterval(recordExpirationInterval);
7489
+ stopMaintenance();
7161
7490
  deleteCallbackHandle?.remove();
7162
7491
  removeStorageReclamationHandler(primaryStore.path, reclamationHandler);
7163
7492
  }
7493
+ static async closeMaintenance(deadline?: number): Promise<void> {
7494
+ const startedAt = Date.now();
7495
+ const defaultTimeout = getDatabaseCommitDrainTimeoutMilliseconds();
7496
+ const timeoutMilliseconds = deadline === undefined ? defaultTimeout : Math.max(0, deadline - startedAt);
7497
+ const resolvedDeadline = deadline ?? startedAt + timeoutMilliseconds;
7498
+ stopMaintenance();
7499
+ const timeoutError = () => new DatabaseDrainTimeoutError(databaseName, timeoutMilliseconds);
7500
+ await settleBeforeDeadline([lastEvictionCompletion, recordExpirationCompletion], resolvedDeadline, timeoutError);
7501
+ for (;;) {
7502
+ const pending = [...maintenanceCommits];
7503
+ if (pending.length === 0) return;
7504
+ await settleBeforeDeadline(pending, resolvedDeadline, timeoutError);
7505
+ }
7506
+ }
7507
+ static resumeMaintenance(): void {
7508
+ if (disposed || !maintenanceClosed) return;
7509
+ maintenanceClosed = false;
7510
+ lastCleanupInterval = undefined;
7511
+ if (expirationScanScheduled || evictionMs) scheduleCleanup();
7512
+ if (expiresAtProperty && !recordExpirationInterval) runRecordExpirationEviction();
7513
+ }
7164
7514
  static _readTxnForContext(context) {
7165
7515
  return txnForContext(context).getReadTxn();
7166
7516
  }
@@ -7178,7 +7528,9 @@ export function makeTable(options) {
7178
7528
  },
7179
7529
  () => {
7180
7530
  throw new ServerError('Service unavailable, exceeded request queue limit for resolving cache record', 503);
7181
- }
7531
+ },
7532
+ undefined,
7533
+ `cache resolution for ${tableName}`
7182
7534
  );
7183
7535
 
7184
7536
  try {
@@ -7803,7 +8155,8 @@ export function makeTable(options) {
7803
8155
  replacingVersion: existingVersion,
7804
8156
  // Once dropTable() has started, no new source-fill write may begin; still resolve the
7805
8157
  // caller's read with fresh source data, just don't cache it into a table that's going away.
7806
- noCacheStore: droppingTable,
8158
+ // No write can commit on a read-only node, so a fill there is served but never staged.
8159
+ noCacheStore: droppingTable || isReadOnlyMode(),
7807
8160
  source: null,
7808
8161
  transaction: undefined,
7809
8162
  expiresAt: undefined,
@@ -8145,19 +8498,25 @@ export function makeTable(options) {
8145
8498
  }
8146
8499
  },
8147
8500
  };
8148
- // The cache-from-source write bypasses `_writeUpdate`, so wire the embed hook here
8149
- // too (always the originating node). It runs after the client GET has resolved with
8150
- // fresh source data, so it's a background commit: an embedder failure aborts the cache
8151
- // write via the outer error handler (row re-embeds next read) and never reaches the
8152
- // caller. Source-resolution errors are handled earlier, with the stale-data fallback.
8153
- const embedBefore = buildEmbedBefore(
8154
- updatedRecord,
8155
- sourceContext,
8156
- undefined,
8157
- TableResource.embedAttributes,
8158
- TableResource.userEmbedders
8501
+ // The fill is shared by every reader, so it takes no request signal; a hook failure aborts
8502
+ // only the cache write.
8503
+ const modelHooksBefore = combineWriteHooks(
8504
+ buildEmbedBefore(
8505
+ updatedRecord,
8506
+ sourceContext,
8507
+ undefined,
8508
+ TableResource.embedAttributes,
8509
+ TableResource.userEmbedders
8510
+ ),
8511
+ buildDecideBefore(
8512
+ updatedRecord,
8513
+ sourceContext,
8514
+ undefined,
8515
+ TableResource.decideAttributes,
8516
+ TableResource.userDeciders
8517
+ )
8159
8518
  );
8160
- if (embedBefore) await embedBefore();
8519
+ if (modelHooksBefore) await modelHooksBefore();
8161
8520
  if (droppingTable) {
8162
8521
  // Re-check right before staging the write: dropTable() may have started
8163
8522
  // while we were awaiting the embed step above (harper#1381).
@@ -8195,6 +8554,19 @@ export function makeTable(options) {
8195
8554
  throw new ClientError('Can not specify replication confirmation without super user permissions', 403);
8196
8555
  return true;
8197
8556
  }
8557
+ function trackMaintenanceCommit<T>(commit: Promise<T>): Promise<T> {
8558
+ const tracked = commit.finally(() => maintenanceCommits.delete(tracked));
8559
+ maintenanceCommits.add(tracked);
8560
+ return tracked;
8561
+ }
8562
+ function stopMaintenance() {
8563
+ maintenanceClosed = true;
8564
+ clearTimeout(cleanupTimer);
8565
+ cleanupTimer = undefined;
8566
+ settlePendingCleanup();
8567
+ clearInterval(recordExpirationInterval);
8568
+ recordExpirationInterval = undefined;
8569
+ }
8198
8570
  // RocksDB-only: coalesces eviction/tombstone removals into shared transactions so the cleanup
8199
8571
  // scan pays one commit per batch instead of one per record. Descriptors hold only the decoded
8200
8572
  // primary key and the version seen during the scan (both stable primitives — the scanned record
@@ -8254,7 +8626,7 @@ export function makeTable(options) {
8254
8626
  return;
8255
8627
  }
8256
8628
  try {
8257
- await transaction.commit();
8629
+ await commitTrackedRocksTransaction(transaction, primaryStore);
8258
8630
  return;
8259
8631
  } catch (error: any) {
8260
8632
  try {
@@ -8303,7 +8675,7 @@ export function makeTable(options) {
8303
8675
  }
8304
8676
  function scheduleCleanup(priority?: number): Promise<void> | void {
8305
8677
  // a reclamation run may still hold this class's handler after cleanup(); a promise here would never settle
8306
- if (disposed) return;
8678
+ if (disposed || maintenanceClosed) return;
8307
8679
  let runImmediately = false;
8308
8680
  if (priority) {
8309
8681
  // run immediately if there is a big increase in priority
@@ -8312,7 +8684,9 @@ export function makeTable(options) {
8312
8684
  }
8313
8685
  // Periodically evict expired records and deleted records searching for records who expiresAt timestamp is before now
8314
8686
  if (cleanupInterval === lastCleanupInterval && !runImmediately) return;
8315
- lastCleanupInterval = cleanupInterval;
8687
+ // Left unrecorded by a thread with no worker index yet: under threads: 0 the main thread loads tables
8688
+ // before it becomes worker 0, and its next call for this interval has to arm the scan then.
8689
+ if (getWorkerIndex() !== undefined) lastCleanupInterval = cleanupInterval;
8316
8690
  if (ownsStoreMaintenance(primaryStore.path) || (ttlConfiguredByApplication && isDedicatedWorker())) {
8317
8691
  // run on the last thread so we aren't overloading lower-numbered threads
8318
8692
  if (cleanupTimer) clearTimeout(cleanupTimer);
@@ -8340,12 +8714,13 @@ export function makeTable(options) {
8340
8714
  ? Date.now()
8341
8715
  : Math.ceil((Date.now() - startOfYear.getTime()) / nextInterval) * nextInterval + startOfYear.getTime();
8342
8716
  const startNextTimer = (nextScheduled) => {
8343
- if (disposed) return;
8717
+ if (disposed || maintenanceClosed) return;
8344
8718
  logger.trace?.(`Scheduled next cleanup scan at ${new Date(nextScheduled)}`);
8345
8719
  // noinspection JSVoidFunctionReturnValueUsed
8346
8720
  cleanupTimer = setTimeout(
8347
8721
  () =>
8348
8722
  (lastEvictionCompletion = lastEvictionCompletion.then(async () => {
8723
+ if (disposed || maintenanceClosed) return;
8349
8724
  // schedule the next run for when the next cleanup interval should occur (or now if it is in the past)
8350
8725
  startNextTimer(Math.max(nextScheduled + cleanupInterval, Date.now()));
8351
8726
  const rootStore = primaryStore.rootStore;
@@ -8399,6 +8774,7 @@ export function makeTable(options) {
8399
8774
  versions: true,
8400
8775
  lazy: true, // only want to access metadata most of the time
8401
8776
  })) {
8777
+ if (maintenanceClosed) break;
8402
8778
  const { key, value: record, version, expiresAt, metadataFlags } = entry;
8403
8779
  // if there is no auditing cleanup and we are tracking deletion, need to do cleanup of
8404
8780
  // these deletion entries (LMDB audit cleanup has its own scheduled job for this)
@@ -8430,6 +8806,7 @@ export function makeTable(options) {
8430
8806
  }
8431
8807
  await rest();
8432
8808
  }
8809
+ await Promise.all(outstandingCleanupOperations.filter(Boolean));
8433
8810
  if (batcher) await batcher.drain();
8434
8811
  logger.debug?.(`Finished cleanup scan for ${tableName}, evicted ${count} entries`);
8435
8812
  } catch (error) {
@@ -8457,40 +8834,56 @@ export function makeTable(options) {
8457
8834
  // Periodically evict expired records, searching for records who expiresAt timestamp is before now
8458
8835
  if (ownsStoreExpiration(primaryStore.path) || (ttlConfiguredByApplication && isDedicatedWorker())) {
8459
8836
  // we want to run the pruning of expired records on only one thread so we don't have conflicts in evicting
8460
- recordExpirationInterval = setInterval(async () => {
8837
+ recordExpirationInterval = setInterval(() => {
8461
8838
  // go through each database and table and then search for expired entries
8462
8839
  // find any entries that are set to expire before now
8463
8840
  // updatedAttributes() clears expiresAtProperty when a live redeclaration drops the directive,
8464
8841
  // and there is nothing left for this interval to scan by
8465
- if (disposed || runningRecordExpiration || !expiresAtProperty) return;
8842
+ if (disposed || maintenanceClosed || runningRecordExpiration || !expiresAtProperty) return;
8466
8843
  runningRecordExpiration = true;
8467
- try {
8844
+ recordExpirationCompletion = (async () => {
8468
8845
  const expiresAtName = expiresAtProperty.name;
8469
8846
  const index = indices[expiresAtName];
8470
8847
  if (!index) throw new Error(`expiresAt attribute ${expiresAtProperty} must be indexed`);
8471
- for (const key of index.getRange({
8848
+ const inFlight = new Set<Promise<unknown>>();
8849
+ const trackInFlight = (operation: Promise<unknown>) => {
8850
+ const tracked = operation.finally(() => inFlight.delete(tracked));
8851
+ inFlight.add(tracked);
8852
+ if (inFlight.size >= 50) return Promise.race(inFlight);
8853
+ };
8854
+ expirationScan: for (const key of index.getRange({
8472
8855
  start: true,
8473
8856
  values: false,
8474
8857
  end: Date.now(),
8475
8858
  snapshot: false,
8476
8859
  })) {
8477
8860
  for (const id of index.getValues(key)) {
8861
+ if (maintenanceClosed) break expirationScan;
8478
8862
  const recordEntry = primaryStore.getEntry(id);
8479
8863
  if (!recordEntry?.value) {
8480
8864
  // cleanup the index if the record is gone
8481
- primaryStore.ifVersion(id, recordEntry?.version, () => index.remove(key, id));
8865
+ const repair = trackMaintenanceCommit(
8866
+ Promise.resolve(primaryStore.ifVersion(id, recordEntry?.version, () => index.remove(key, id))).catch(
8867
+ (error) => logger.warn?.('Error removing stale expiration index entry', id, error)
8868
+ )
8869
+ );
8870
+ const backpressure = trackInFlight(repair);
8871
+ if (backpressure) await backpressure;
8482
8872
  } else if (recordEntry.value[expiresAtName] < Date.now()) {
8483
8873
  // make sure the record hasn't changed and won't change while removing
8484
- TableResource.evict(id, recordEntry.value, recordEntry.version);
8874
+ const eviction = TableResource.evict(id, recordEntry.value, recordEntry.version);
8875
+ if (eviction) {
8876
+ const backpressure = trackInFlight(eviction);
8877
+ if (backpressure) await backpressure;
8878
+ }
8485
8879
  }
8486
8880
  }
8487
8881
  await rest();
8488
8882
  }
8489
- } catch (error) {
8490
- logger.error?.('Error in evicting old records', error);
8491
- } finally {
8492
- runningRecordExpiration = false;
8493
- }
8883
+ await Promise.all(inFlight);
8884
+ })()
8885
+ .catch((error) => logger.error?.('Error in evicting old records', error))
8886
+ .finally(() => (runningRecordExpiration = false));
8494
8887
  }, RECORD_PRUNING_INTERVAL).unref();
8495
8888
  }
8496
8889
  }