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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/bin/cliOperations.ts +12 -0
  2. package/bin/restart.ts +66 -6
  3. package/components/Application.ts +1134 -112
  4. package/components/OptionsWatcher.ts +368 -102
  5. package/components/Scope.ts +20 -1
  6. package/components/componentLoader.ts +43 -4
  7. package/components/deploymentOperations.ts +4 -1
  8. package/components/deploymentRecorder.ts +9 -2
  9. package/components/operations.js +284 -52
  10. package/components/operationsValidation.js +49 -2
  11. package/components/packageComponent.ts +25 -1
  12. package/components/requestRestart.ts +11 -0
  13. package/config/RootConfigWatcher.ts +191 -37
  14. package/config/configReadRetry.ts +62 -0
  15. package/config/configUtils.ts +78 -26
  16. package/config/parseConfigFile.ts +34 -0
  17. package/config/readConfigFileSync.ts +44 -0
  18. package/config/watcherArming.ts +59 -0
  19. package/config-root.schema.json +4 -0
  20. package/dataLayer/harperBridge/ResourceBridge.ts +28 -2
  21. package/dist/bin/cliOperations.js +13 -0
  22. package/dist/bin/cliOperations.js.map +1 -1
  23. package/dist/bin/restart.js +42 -6
  24. package/dist/bin/restart.js.map +1 -1
  25. package/dist/components/Application.d.ts +104 -9
  26. package/dist/components/Application.js +954 -102
  27. package/dist/components/Application.js.map +1 -1
  28. package/dist/components/OptionsWatcher.d.ts +4 -1
  29. package/dist/components/OptionsWatcher.js +378 -104
  30. package/dist/components/OptionsWatcher.js.map +1 -1
  31. package/dist/components/Scope.js +15 -1
  32. package/dist/components/Scope.js.map +1 -1
  33. package/dist/components/componentLoader.js +35 -3
  34. package/dist/components/componentLoader.js.map +1 -1
  35. package/dist/components/deploymentOperations.js +4 -1
  36. package/dist/components/deploymentOperations.js.map +1 -1
  37. package/dist/components/deploymentRecorder.d.ts +4 -2
  38. package/dist/components/deploymentRecorder.js +1 -0
  39. package/dist/components/deploymentRecorder.js.map +1 -1
  40. package/dist/components/operations.d.ts +28 -0
  41. package/dist/components/operations.js +263 -54
  42. package/dist/components/operations.js.map +1 -1
  43. package/dist/components/operationsValidation.js +48 -2
  44. package/dist/components/operationsValidation.js.map +1 -1
  45. package/dist/components/packageComponent.js +24 -0
  46. package/dist/components/packageComponent.js.map +1 -1
  47. package/dist/components/requestRestart.d.ts +1 -0
  48. package/dist/components/requestRestart.js +7 -0
  49. package/dist/components/requestRestart.js.map +1 -1
  50. package/dist/config/RootConfigWatcher.d.ts +2 -0
  51. package/dist/config/RootConfigWatcher.js +189 -35
  52. package/dist/config/RootConfigWatcher.js.map +1 -1
  53. package/dist/config/configReadRetry.d.ts +8 -0
  54. package/dist/config/configReadRetry.js +62 -0
  55. package/dist/config/configReadRetry.js.map +1 -0
  56. package/dist/config/configUtils.d.ts +10 -9
  57. package/dist/config/configUtils.js +63 -27
  58. package/dist/config/configUtils.js.map +1 -1
  59. package/dist/config/parseConfigFile.d.ts +4 -0
  60. package/dist/config/parseConfigFile.js +35 -0
  61. package/dist/config/parseConfigFile.js.map +1 -0
  62. package/dist/config/readConfigFileSync.d.ts +1 -0
  63. package/dist/config/readConfigFileSync.js +47 -0
  64. package/dist/config/readConfigFileSync.js.map +1 -0
  65. package/dist/config/watcherArming.d.ts +15 -0
  66. package/dist/config/watcherArming.js +59 -0
  67. package/dist/config/watcherArming.js.map +1 -0
  68. package/dist/dataLayer/harperBridge/ResourceBridge.js +21 -2
  69. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  70. package/dist/index.d.ts +1 -0
  71. package/dist/index.js +4 -1
  72. package/dist/index.js.map +1 -1
  73. package/dist/json/systemSchema.json +3 -0
  74. package/dist/resources/DatabaseTransaction.d.ts +25 -0
  75. package/dist/resources/DatabaseTransaction.js +224 -18
  76. package/dist/resources/DatabaseTransaction.js.map +1 -1
  77. package/dist/resources/LMDBTransaction.d.ts +2 -1
  78. package/dist/resources/LMDBTransaction.js +22 -3
  79. package/dist/resources/LMDBTransaction.js.map +1 -1
  80. package/dist/resources/PrimaryRocksDatabase.js +22 -6
  81. package/dist/resources/PrimaryRocksDatabase.js.map +1 -1
  82. package/dist/resources/RecordEncoder.d.ts +1 -1
  83. package/dist/resources/RecordEncoder.js +21 -5
  84. package/dist/resources/RecordEncoder.js.map +1 -1
  85. package/dist/resources/Resource.js +97 -13
  86. package/dist/resources/Resource.js.map +1 -1
  87. package/dist/resources/ResourceInterface.d.ts +8 -0
  88. package/dist/resources/RocksIndexStore.js +2 -1
  89. package/dist/resources/RocksIndexStore.js.map +1 -1
  90. package/dist/resources/RocksTransactionLogStore.d.ts +10 -0
  91. package/dist/resources/RocksTransactionLogStore.js +104 -33
  92. package/dist/resources/RocksTransactionLogStore.js.map +1 -1
  93. package/dist/resources/Table.d.ts +58 -7
  94. package/dist/resources/Table.js +1095 -346
  95. package/dist/resources/Table.js.map +1 -1
  96. package/dist/resources/analytics/write.js +10 -3
  97. package/dist/resources/analytics/write.js.map +1 -1
  98. package/dist/resources/auditStore.d.ts +170 -0
  99. package/dist/resources/auditStore.js +457 -11
  100. package/dist/resources/auditStore.js.map +1 -1
  101. package/dist/resources/dataLoader.js +3 -4
  102. package/dist/resources/dataLoader.js.map +1 -1
  103. package/dist/resources/databases.d.ts +16 -13
  104. package/dist/resources/databases.js +624 -176
  105. package/dist/resources/databases.js.map +1 -1
  106. package/dist/resources/derivedIndexRegistry.d.ts +5 -0
  107. package/dist/resources/derivedIndexRegistry.js +68 -0
  108. package/dist/resources/derivedIndexRegistry.js.map +1 -0
  109. package/dist/resources/derivedIndexRuntime.d.ts +215 -0
  110. package/dist/resources/derivedIndexRuntime.js +2027 -0
  111. package/dist/resources/derivedIndexRuntime.js.map +1 -0
  112. package/dist/resources/graphql.js +3 -2
  113. package/dist/resources/graphql.js.map +1 -1
  114. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +102 -9
  115. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +848 -39
  116. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  117. package/dist/resources/indexes/hnswDerivedIndex.d.ts +67 -0
  118. package/dist/resources/indexes/hnswDerivedIndex.js +464 -0
  119. package/dist/resources/indexes/hnswDerivedIndex.js.map +1 -0
  120. package/dist/resources/indexes/hnswPlaneBinding.d.ts +65 -0
  121. package/dist/resources/indexes/hnswPlaneBinding.js +91 -0
  122. package/dist/resources/indexes/hnswPlaneBinding.js.map +1 -0
  123. package/dist/resources/nodeIdMapping.d.ts +5 -0
  124. package/dist/resources/nodeIdMapping.js +49 -0
  125. package/dist/resources/nodeIdMapping.js.map +1 -1
  126. package/dist/resources/recordLock.d.ts +47 -4
  127. package/dist/resources/recordLock.js +138 -7
  128. package/dist/resources/recordLock.js.map +1 -1
  129. package/dist/resources/recordLockCoordinator.d.ts +557 -0
  130. package/dist/resources/recordLockCoordinator.js +2565 -0
  131. package/dist/resources/recordLockCoordinator.js.map +1 -0
  132. package/dist/resources/replayLogs.js +5 -0
  133. package/dist/resources/replayLogs.js.map +1 -1
  134. package/dist/resources/replicatedApplyFailure.d.ts +16 -0
  135. package/dist/resources/replicatedApplyFailure.js +63 -0
  136. package/dist/resources/replicatedApplyFailure.js.map +1 -0
  137. package/dist/resources/scheduler/scheduler.js +3 -3
  138. package/dist/resources/scheduler/scheduler.js.map +1 -1
  139. package/dist/resources/search.d.ts +10 -4
  140. package/dist/resources/search.js +160 -40
  141. package/dist/resources/search.js.map +1 -1
  142. package/dist/resources/tracked.d.ts +5 -1
  143. package/dist/resources/tracked.js +74 -23
  144. package/dist/resources/tracked.js.map +1 -1
  145. package/dist/security/jsLoader.js +6 -4
  146. package/dist/security/jsLoader.js.map +1 -1
  147. package/dist/server/REST.js +33 -2
  148. package/dist/server/REST.js.map +1 -1
  149. package/dist/server/http.d.ts +5 -1
  150. package/dist/server/http.js +34 -2
  151. package/dist/server/http.js.map +1 -1
  152. package/dist/server/serverHelpers/Headers.d.ts +2 -0
  153. package/dist/server/serverHelpers/Headers.js +6 -0
  154. package/dist/server/serverHelpers/Headers.js.map +1 -1
  155. package/dist/server/serverHelpers/NodeAdapterResponse.d.ts +48 -0
  156. package/dist/server/serverHelpers/NodeAdapterResponse.js +220 -0
  157. package/dist/server/serverHelpers/NodeAdapterResponse.js.map +1 -0
  158. package/dist/server/serverHelpers/Request.d.ts +5 -10
  159. package/dist/server/serverHelpers/Request.js +38 -136
  160. package/dist/server/serverHelpers/Request.js.map +1 -1
  161. package/dist/server/serverHelpers/contentTypes.d.ts +2 -0
  162. package/dist/server/serverHelpers/contentTypes.js +189 -15
  163. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  164. package/dist/server/serverHelpers/serverUtilities.js +96 -17
  165. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  166. package/dist/server/storageReclamation.js +1 -1
  167. package/dist/server/storageReclamation.js.map +1 -1
  168. package/dist/server/threads/isolatedApplications.d.ts +47 -0
  169. package/dist/server/threads/isolatedApplications.js +171 -0
  170. package/dist/server/threads/isolatedApplications.js.map +1 -0
  171. package/dist/server/threads/logRotationTransport.d.ts +1 -0
  172. package/dist/server/threads/logRotationTransport.js +33 -0
  173. package/dist/server/threads/logRotationTransport.js.map +1 -0
  174. package/dist/server/threads/manageThreads.d.ts +64 -6
  175. package/dist/server/threads/manageThreads.js +261 -12
  176. package/dist/server/threads/manageThreads.js.map +1 -1
  177. package/dist/server/threads/socketRouter.d.ts +1 -0
  178. package/dist/server/threads/socketRouter.js +196 -13
  179. package/dist/server/threads/socketRouter.js.map +1 -1
  180. package/dist/server/threads/threadServer.js +30 -7
  181. package/dist/server/threads/threadServer.js.map +1 -1
  182. package/dist/utility/errors/hdbError.d.ts +24 -0
  183. package/dist/utility/errors/hdbError.js +58 -1
  184. package/dist/utility/errors/hdbError.js.map +1 -1
  185. package/dist/utility/hdbTerms.d.ts +2 -0
  186. package/dist/utility/hdbTerms.js +2 -0
  187. package/dist/utility/hdbTerms.js.map +1 -1
  188. package/dist/utility/logging/harper_logger.js +217 -38
  189. package/dist/utility/logging/harper_logger.js.map +1 -1
  190. package/dist/utility/logging/logGenerationCoordinator.d.ts +35 -0
  191. package/dist/utility/logging/logGenerationCoordinator.js +184 -0
  192. package/dist/utility/logging/logGenerationCoordinator.js.map +1 -0
  193. package/dist/utility/logging/logRotation.d.ts +46 -0
  194. package/dist/utility/logging/logRotation.js +365 -0
  195. package/dist/utility/logging/logRotation.js.map +1 -0
  196. package/dist/utility/logging/logRotator.d.ts +1 -1
  197. package/dist/utility/logging/logRotator.js +172 -92
  198. package/dist/utility/logging/logRotator.js.map +1 -1
  199. package/dist/utility/npmUtilities.js +6 -4
  200. package/dist/utility/npmUtilities.js.map +1 -1
  201. package/dist/utility/watcherFallback.d.ts +0 -45
  202. package/dist/utility/watcherFallback.js +1 -125
  203. package/dist/utility/watcherFallback.js.map +1 -1
  204. package/dist/validation/configValidator.js +6 -3
  205. package/dist/validation/configValidator.js.map +1 -1
  206. package/index.ts +6 -0
  207. package/json/systemSchema.json +3 -0
  208. package/npm-shrinkwrap.json +131 -41
  209. package/package.json +10 -3
  210. package/resources/DESIGN.md +124 -19
  211. package/resources/DatabaseTransaction.ts +230 -17
  212. package/resources/LMDBTransaction.ts +21 -3
  213. package/resources/PrimaryRocksDatabase.ts +20 -7
  214. package/resources/RecordEncoder.ts +25 -5
  215. package/resources/Resource.ts +97 -13
  216. package/resources/ResourceInterface.ts +8 -0
  217. package/resources/RocksIndexStore.ts +2 -1
  218. package/resources/RocksTransactionLogStore.ts +111 -31
  219. package/resources/Table.ts +1224 -393
  220. package/resources/analytics/write.ts +10 -3
  221. package/resources/auditStore.ts +460 -11
  222. package/resources/dataLoader.ts +3 -4
  223. package/resources/databases.ts +610 -146
  224. package/resources/derivedIndexRegistry.ts +56 -0
  225. package/resources/derivedIndexRuntime.ts +2292 -0
  226. package/resources/graphql.ts +3 -2
  227. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +905 -46
  228. package/resources/indexes/hnswDerivedIndex.ts +531 -0
  229. package/resources/indexes/hnswPlaneBinding.ts +174 -0
  230. package/resources/nodeIdMapping.ts +50 -0
  231. package/resources/recordLock.ts +173 -7
  232. package/resources/recordLockCoordinator.ts +3043 -0
  233. package/resources/replayLogs.ts +5 -0
  234. package/resources/replicatedApplyFailure.ts +77 -0
  235. package/resources/scheduler/scheduler.ts +4 -4
  236. package/resources/search.ts +169 -49
  237. package/resources/tracked.ts +73 -22
  238. package/security/jsLoader.ts +6 -4
  239. package/server/DESIGN.md +11 -0
  240. package/server/REST.ts +36 -3
  241. package/server/http.ts +34 -2
  242. package/server/serverHelpers/Headers.ts +5 -1
  243. package/server/serverHelpers/NodeAdapterResponse.ts +221 -0
  244. package/server/serverHelpers/Request.ts +33 -131
  245. package/server/serverHelpers/contentTypes.ts +188 -15
  246. package/server/serverHelpers/serverUtilities.ts +143 -24
  247. package/server/storageReclamation.ts +2 -2
  248. package/server/threads/isolatedApplications.ts +157 -0
  249. package/server/threads/logRotationTransport.ts +40 -0
  250. package/server/threads/manageThreads.js +254 -12
  251. package/server/threads/socketRouter.ts +217 -11
  252. package/server/threads/threadServer.js +30 -7
  253. package/studio/web/assets/{Chat-BnCBegQz.js → Chat-D3j-1yY1.js} +1 -1
  254. package/studio/web/assets/{FloatingChat-CoDW1ySS.js → FloatingChat-BxJGYcfB.js} +3 -3
  255. package/studio/web/assets/{apiToken-Bwk5BLXW.js → apiToken-CT55oWOe.js} +1 -1
  256. package/studio/web/assets/{applications-DHxGi7JH.js → applications-D9Ct9_vm.js} +1 -1
  257. package/studio/web/assets/{cssMode-s0cWI-_M.js → cssMode-DV8H7VwA.js} +1 -1
  258. package/studio/web/assets/{editor-DNcRHK54.js → editor-uatc0unt.js} +1 -1
  259. package/studio/web/assets/{html-Bdssedlg.js → html-Bm6D6paN.js} +1 -1
  260. package/studio/web/assets/{htmlMode-CoDlJ3fw.js → htmlMode-CEn7tpLG.js} +1 -1
  261. package/studio/web/assets/{index-D6sxmFLR.js → index-BIXW6Pu4.js} +5 -5
  262. package/studio/web/assets/{index.lazy-tmU5BS8s.js → index.lazy-UI7L-Vrk.js} +1 -1
  263. package/studio/web/assets/{javascript-B8meVSTH.js → javascript-CJ0G3AFZ.js} +1 -1
  264. package/studio/web/assets/{jsonMode-DpIPd35T.js → jsonMode-DQADAYEa.js} +1 -1
  265. package/studio/web/assets/{languageServices-C_5FMJzQ.js → languageServices-CAQJXWcI.js} +1 -1
  266. package/studio/web/assets/{lspLanguageFeatures-BIzNBzPK.js → lspLanguageFeatures-CCQ8P5sY.js} +1 -1
  267. package/studio/web/assets/{notifications-CQf18QKb.js → notifications-BbxTU6Aw.js} +1 -1
  268. package/studio/web/assets/{notifications-CvZivSbh.js → notifications-Cvb3P1lB.js} +1 -1
  269. package/studio/web/assets/{profile-DdOwtntb.js → profile-Yyb7gsvL.js} +1 -1
  270. package/studio/web/assets/{regions-n69fwagr.js → regions-OgjGHlU5.js} +1 -1
  271. package/studio/web/assets/{register-PfWTCXWB.js → register-6qwNEOY3.js} +2 -2
  272. package/studio/web/assets/{setComponentFile-Bg6O7X0S.js → setComponentFile-BilDMtgB.js} +1 -1
  273. package/studio/web/assets/{setup-CUx_aUDl.js → setup-J6qJ7OIU.js} +2 -2
  274. package/studio/web/assets/{status-D7BVKqX9.js → status-0RWGcfyD.js} +1 -1
  275. package/studio/web/assets/{toggleHighContrast-DBSyXzMr.js → toggleHighContrast-BIn-vErT.js} +1 -1
  276. package/studio/web/assets/{tsMode-BByKCjBS.js → tsMode-DgUXku4d.js} +1 -1
  277. package/studio/web/assets/{typescript-DDLnLpw9.js → typescript-C9orXcsM.js} +1 -1
  278. package/studio/web/assets/{useEntityRestURL-31CHGaHk.js → useEntityRestURL-BEoXXbUB.js} +1 -1
  279. package/studio/web/assets/{workers-pR3jRY9D.js → workers-JVzSDmgx.js} +1 -1
  280. package/studio/web/assets/{xml-2iRnMhQO.js → xml-Cq-S8S4X.js} +1 -1
  281. package/studio/web/assets/{yaml-Bf92gJpd.js → yaml-sfoRdh1M.js} +1 -1
  282. package/studio/web/index.html +1 -1
  283. package/utility/errors/hdbError.ts +54 -0
  284. package/utility/hdbTerms.ts +2 -0
  285. package/utility/logging/harper_logger.ts +209 -30
  286. package/utility/logging/logGenerationCoordinator.ts +196 -0
  287. package/utility/logging/logRotation.ts +367 -0
  288. package/utility/logging/logRotator.ts +196 -91
  289. package/utility/npmUtilities.ts +6 -4
  290. package/utility/watcherFallback.ts +0 -122
  291. package/validation/configValidator.ts +6 -3
@@ -16,7 +16,7 @@ import { Script } from 'node:vm';
16
16
  import { randomUUID } from 'node:crypto';
17
17
  import { performance } from 'node:perf_hooks';
18
18
  import { getIndexedValues, getNextMonotonicTime } from '../utility/lmdb/commonUtility.ts';
19
- import { getThisNodeId, exportIdMapping } from './nodeIdMapping.ts';
19
+ import { getThisNodeId, exportIdMapping, getNodeNameForId } from './nodeIdMapping.ts';
20
20
  import lodash from 'lodash';
21
21
  import { ExtendedIterable, SKIP } from '@harperfast/extended-iterable';
22
22
  import type {
@@ -41,6 +41,8 @@ import {
41
41
  isReleasedTransaction,
42
42
  TRANSACTION_STATE,
43
43
  writeKeyId,
44
+ closeWriteInstance,
45
+ type WriteGeneration,
44
46
  } from './DatabaseTransaction.ts';
45
47
  import {
46
48
  acquireRecordKey,
@@ -49,21 +51,27 @@ import {
49
51
  resolveLockOptions,
50
52
  type RecordLockHandle,
51
53
  type RecordLockOptions,
54
+ type ResolvedRecordLockOptions,
52
55
  } from './recordLock.ts';
56
+ import { getThisNodeName } from '../server/nodeName.ts';
53
57
  import * as envMngr from '../utility/environment/environmentManager.ts';
54
58
  import { addSubscription } from './transactionBroadcast.ts';
55
59
  import {
60
+ DerivedIndexLagError,
56
61
  handleHDBError,
57
62
  ClientError,
58
63
  ServerError,
59
64
  AccessViolation,
60
65
  ValidationError,
61
66
  UpdateAttributesLockTimeoutError,
67
+ LockUnavailableError,
68
+ appendErrorContext,
62
69
  type ValidationIssue,
63
70
  } from '../utility/errors/hdbError.ts';
64
71
  import * as signalling from '../utility/signalling.ts';
65
72
  import { SchemaEventMsg, UserEventMsg } from '../server/threads/itc.js';
66
73
  import { databases, table } from './databases.ts';
74
+ import { notifyReplicatedApplyFailure } from './replicatedApplyFailure.ts';
67
75
  import {
68
76
  searchByIndex,
69
77
  findAttribute,
@@ -76,11 +84,44 @@ import {
76
84
  } from './search.ts';
77
85
  import { logger } from '../utility/logging/logger.ts';
78
86
  import { isStaticResourceInstance } from './staticResourceDispatch.ts';
79
- import { Addition, assignTrackedAccessors, updateAndFreeze, hasChanges, GenericTrackedObject } from './tracked.ts';
87
+ import {
88
+ Addition,
89
+ assignTrackedAccessors,
90
+ updateAndFreeze,
91
+ hasChanges,
92
+ GenericTrackedObject,
93
+ ASSERT_TRACKED_WRITABLE,
94
+ GET_TRACKED_WRITE_GENERATION,
95
+ } from './tracked.ts';
80
96
  import { transaction, contextStorage } from './transaction.ts';
81
97
  import { MAXIMUM_KEY, writeKey, compareKeys } from 'ordered-binary';
82
- import { getWorkerIndex, getWorkerCount } from '../server/threads/manageThreads.js';
83
- import { HAS_BLOBS, auditRetention, removeAuditEntry } from './auditStore.ts';
98
+ import {
99
+ getWorkerIndex,
100
+ applicationWorkerIndex,
101
+ ownsStoreMaintenance,
102
+ ownsStoreExpiration,
103
+ runsApplicationCodeSingletons,
104
+ isDedicatedWorker,
105
+ } from '../server/threads/manageThreads.js';
106
+ import {
107
+ HAS_BLOBS,
108
+ LOCAL_ONLY,
109
+ auditRetention,
110
+ removeAuditEntry,
111
+ raiseAuditFloor,
112
+ boundedAuditPruneEnd,
113
+ isLockControlType,
114
+ } from './auditStore.ts';
115
+ import { derivedIndexWriteRejection, hasDerivedIndexRegistration } from './derivedIndexRegistry.ts';
116
+ import {
117
+ decodeLockControlPayload,
118
+ encodeLockControlPayload,
119
+ getClusterLockTransport,
120
+ isClusterLockRequired,
121
+ setLockCoordinatorResolver,
122
+ LockCoordinator,
123
+ type LockControlEntry,
124
+ } from './recordLockCoordinator.ts';
84
125
  import { buildEmbedBefore, createDefaultEmbedder, type EmbedAttribute, type Embedder } from './models/embedHook.ts';
85
126
  import { autoCast, autoCastBooleanStrict } from '../utility/common_utils.ts';
86
127
  import {
@@ -146,6 +187,9 @@ type MaybePromise<T> = T | Promise<T>;
146
187
 
147
188
  const NULL_WITH_TIMESTAMP = new Uint8Array(9);
148
189
  NULL_WITH_TIMESTAMP[8] = 0xc0; // null
190
+ const sourceWriteTypes = new Set(['put', 'patch', 'delete', 'publish', 'message', 'invalidate', 'relocate']);
191
+ const isSourceWriteType = (type: string) => sourceWriteTypes.has(type);
192
+ const SOURCE_APPLY_POSITION = Symbol('sourceApplyPosition');
149
193
  const UNCACHEABLE_TIMESTAMP = Infinity; // we use this when dynamic content is accessed that we can't safely cache, and this prevents earlier timestamps from change the "last" modification
150
194
  const MAX_DATE_TIMESTAMP = 8.64e15;
151
195
  const RECORD_PRUNING_INTERVAL = 60000; // one minute
@@ -173,6 +217,20 @@ const MAX_COUNT_PAGE = 10_000;
173
217
  // How often the exact-count drain yields to the macrotask queue (must be a power of two for the bit-mask
174
218
  // check). Keeps a large scan from monopolizing the event loop without adding a yield per row.
175
219
  const COUNT_YIELD_INTERVAL = 2_048;
220
+ // Smallest forward sample `getRecordCount` will extrapolate a record rate from; below it the scan runs
221
+ // to completion and reports an exact count.
222
+ const MIN_ESTIMATOR_SAMPLE = 1_000;
223
+ // Budget intervals the forward scan may spend before it must estimate rather than keep scanning.
224
+ const MAX_ESTIMATE_CHECKPOINTS = 20;
225
+ // A store estimate's `count`, or 0 when the store answered with a shape that cannot be trusted --
226
+ // DESIGN.md's invariant for this API family is that such an answer degrades rather than poisons.
227
+ function usableCount(estimate: any): number {
228
+ const { count, confidence } = estimate ?? {};
229
+ // `confidence` needs its own finiteness check, not just the range: `null >= 0 && null <= 1` is true
230
+ if (!Number.isFinite(count) || count < 0 || !Number.isFinite(confidence) || confidence < 0 || confidence > 1)
231
+ return 0;
232
+ return count;
233
+ }
176
234
  envMngr.initSync();
177
235
  const LMDB_PREFETCH_WRITES = envMngr.get(CONFIG_PARAMS.STORAGE_PREFETCHWRITES);
178
236
  const LOCK_TIMEOUT = 10000;
@@ -491,6 +549,24 @@ function contextArgument(context: unknown): any {
491
549
  return resolved instanceof DatabaseTransaction ? { transaction: resolved } : resolved;
492
550
  }
493
551
 
552
+ /** The cluster round never ran for a node-scoped handle, so no peer ever deferred to it. */
553
+ function scopeViolation(
554
+ handle: RecordLockHandle,
555
+ resolved: ResolvedRecordLockOptions,
556
+ databaseName: string
557
+ ): ClientError | undefined {
558
+ if (resolved.scope !== 'cluster' || handle.clusterTsR !== undefined) return undefined;
559
+ // The same predicate lock() fails closed on, not the transport alone: a coalesced caller re-checks
560
+ // this after its wait, and a transport unregistered during that wait leaves the database still
561
+ // clustered while the lookup answers undefined. Only the implicit Phase 0 case falls through.
562
+ if (!resolved.scopeRequested && !isClusterLockRequired(databaseName) && !getClusterLockTransport(databaseName))
563
+ return undefined;
564
+ return new ClientError(
565
+ 'This transaction already holds a node-scoped lock on this record, so a cluster-scoped lock cannot be taken on top of it',
566
+ 409
567
+ );
568
+ }
569
+
494
570
  /** Distinguishes bare lock options from a record target (id, URL, {id:...}). */
495
571
  function isPlainOptions(value: unknown): boolean {
496
572
  return (
@@ -502,6 +578,18 @@ function isPlainOptions(value: unknown): boolean {
502
578
  );
503
579
  }
504
580
 
581
+ // Lets a transport push a received control entry straight to the right coordinator without
582
+ // importing Table (which would be a cycle through databases.ts).
583
+ setLockCoordinatorResolver(
584
+ (database: string, tableName: string) => (databases as any)[database]?.[tableName]?.lockCoordinator,
585
+ (database: string, tableName: string) => (databases as any)[database]?.[tableName]?.admittingCoordinator,
586
+ (database: string, tableName: string) => {
587
+ const Table = (databases as any)[database]?.[tableName];
588
+ if (typeof Table?.writeLockControlEntry !== 'function') return undefined;
589
+ return (entry: LockControlEntry) => Table.writeLockControlEntry(entry);
590
+ }
591
+ );
592
+
505
593
  export function makeTable(options) {
506
594
  const {
507
595
  primaryKey,
@@ -523,6 +611,11 @@ export function makeTable(options) {
523
611
  isBranch,
524
612
  } = options;
525
613
  let { expirationMS: expirationMs, evictionMS: evictionMs, audit, trackDeletes } = options;
614
+ // Set when the TTL exists only on this thread: either application code configured it at runtime, or
615
+ // an isolated application's schema was declared here. Hydrating persisted metadata does not set it:
616
+ // dedicated workers open unrelated shared tables too, whose scan remains owned by the pool.
617
+ let ttlConfiguredByApplication = false;
618
+ let ttlFromLoad = false; // true only around the creation-time call below
526
619
  evictionMs ??= 0;
527
620
  // Eviction without explicit expiration means expiration:0. Apply at construction so
528
621
  // describe_all sees it on every worker, not just ones that ran setTTLExpiration.
@@ -532,6 +625,9 @@ export function makeTable(options) {
532
625
  if (!attributes) attributes = [];
533
626
  if (!properties) properties = projectAttributesToProperties(attributes);
534
627
  const updateRecord = recordUpdater(primaryStore, tableId, auditStore);
628
+ // Created on first cluster-scoped lock() or first arriving control entry, and only while a
629
+ // transport is registered for this database.
630
+ let lockCoordinator: LockCoordinator | undefined;
535
631
  let warnedNullSourcePut = false; // latched: one warn per table per worker (see _writeUpdate)
536
632
  let warnedFutureSourceVersion = false; // likewise (see getFromSource)
537
633
  let sourceLoad: any; // if a source has a load function (replicator), record it here
@@ -563,7 +659,7 @@ export function makeTable(options) {
563
659
  let nonPrefetchSequence = 2;
564
660
  let cleanupInterval = 86400000;
565
661
  let cleanupPriority = 0;
566
- let lastCleanupInterval: number;
662
+ let lastCleanupInterval: number | undefined;
567
663
  let cleanupTimer: NodeJS.Timeout;
568
664
  let recordExpirationInterval: NodeJS.Timeout;
569
665
  // a reclamation pass awaits a scheduled cleanup, which only settles from its timer
@@ -746,6 +842,29 @@ export function makeTable(options) {
746
842
  }
747
843
  return { txnLogKey: version, nodeId };
748
844
  }
845
+ // Canonical-source applies (sourceApply), replay and replication notifications are never shed;
846
+ // dropping one would advance the source cursor past a write that never landed.
847
+ function assertDerivedIndexAdmission(options: any, transaction: any) {
848
+ if (options?.isNotification || transaction?.sourceApply || transaction?.isReplay) return;
849
+ const reason = derivedIndexWriteRejection(auditStore, tableId);
850
+ if (reason) throw new DerivedIndexLagError(reason);
851
+ }
852
+ function stageDerivedIndexEviction(transaction: RocksTransaction, id: Id, version: number) {
853
+ if (!hasDerivedIndexRegistration(auditStore, tableId)) return;
854
+ const nodeId = getThisNodeId(auditStore) ?? 0;
855
+ auditStore.put(
856
+ null,
857
+ {
858
+ type: 'evict',
859
+ tableId,
860
+ recordId: id,
861
+ version,
862
+ nodeId,
863
+ extendedType: LOCAL_ONLY,
864
+ },
865
+ { transaction, nodeId }
866
+ );
867
+ }
749
868
  class TableResource<Record extends object = any> extends Resource<Record> {
750
869
  #record: any; // the stored/frozen record from the database and stored in the cache (should not be modified directly)
751
870
  #changes: any; // the changes to the record that have been made (should not be modified directly)
@@ -754,7 +873,17 @@ export function makeTable(options) {
754
873
  #savingOperation?: any; // operation for the record is currently being saved
755
874
  #lockHandle?: RecordLockHandle; // the record lock acquired by lock() — scoped or hold
756
875
  #lockWritable?: boolean; // set by #reloadLocked to let save() stage lock-writable updates
876
+ #writeGeneration?: WriteGeneration;
757
877
  declare getProperty: (name: string) => any;
878
+ [ASSERT_TRACKED_WRITABLE](generation = this.#writeGeneration): void {
879
+ if (!generation) return;
880
+ if (generation.internalWrites > 0) return;
881
+ if (generation !== this.#writeGeneration || generation.closed)
882
+ throw new ClientError('Can not modify an update instance after it has been saved; call update() again', 409);
883
+ }
884
+ [GET_TRACKED_WRITE_GENERATION](): WriteGeneration {
885
+ return (this.#writeGeneration ??= { closed: false, internalWrites: 0 });
886
+ }
758
887
 
759
888
  /**
760
889
  * Shared guard: if this instance is lock-writable but the handle is gone (expired or
@@ -762,13 +891,15 @@ export function makeTable(options) {
762
891
  * in addition to the save() path. Every lock-writable instance carries its own handle in
763
892
  * #lockHandle (scoped and hold alike), so we never need to search the registry here.
764
893
  */
765
- #assertLiveHandle(id: Id): void {
894
+ #assertLiveHandle(id: Id, allowClosed = false): void {
895
+ if (!allowClosed && this.#writeGeneration?.closed && writeKeyId(id) === writeKeyId(this.getId()))
896
+ this[ASSERT_TRACKED_WRITABLE]();
766
897
  if (!this.#lockWritable) return;
767
898
  const handle = this.#lockHandle!;
768
899
  // Off-key writes through the same resource instance are ordinary; only guard the
769
900
  // exact key the lock was acquired for.
770
901
  if (handle.keyId !== writeKeyId(id)) return;
771
- if (handle.expired || handle.released) {
902
+ if (handle.isExpired()) {
772
903
  throw lockNotHeldError(handle);
773
904
  }
774
905
  }
@@ -780,6 +911,13 @@ export function makeTable(options) {
780
911
  static tableName = tableName;
781
912
  static tableId = tableId;
782
913
  static indices = indices;
914
+ static derivedIndexRuntime:
915
+ | {
916
+ close(dropping?: boolean): Promise<void>;
917
+ restoreAfterFailedDrop?(): typeof TableResource.derivedIndexRuntime;
918
+ completeDrop?(dropped?: boolean): void;
919
+ }
920
+ | undefined;
783
921
  static audit = audit;
784
922
  static databasePath = databasePath;
785
923
  static databaseName = databaseName;
@@ -866,8 +1004,65 @@ export function makeTable(options) {
866
1004
  (async () => {
867
1005
  let userRoleUpdate = false;
868
1006
  let lastSequenceId;
1007
+ let pendingApplyFailures: Promise<void> | undefined;
1008
+ const reportDroppedWrite = (event, context, error) => {
1009
+ const position =
1010
+ event === context ? context[SOURCE_APPLY_POSITION] : (event.timestamp ?? context[SOURCE_APPLY_POSITION]);
1011
+ const notification = notifyReplicatedApplyFailure(
1012
+ databaseName,
1013
+ {
1014
+ nodeId: event.nodeId ?? context.nodeId,
1015
+ table: event.table ?? context.table,
1016
+ localTime: event.localTime ?? context.localTime,
1017
+ },
1018
+ position,
1019
+ error,
1020
+ tableName
1021
+ );
1022
+ pendingApplyFailures = pendingApplyFailures
1023
+ ? Promise.all([pendingApplyFailures, notification]).then(noop)
1024
+ : notification;
1025
+ return notification;
1026
+ };
1027
+ /** Cluster lock coordination entries (harper#483 Phase 1) describe no record. */
1028
+ const applyLockControlEvent = (event, context) => {
1029
+ const entry = decodeLockControlPayload(event.type, event.value);
1030
+ if (!entry) {
1031
+ logger.warn?.('discarding a malformed record lock control entry from', event.nodeId, event.type);
1032
+ return reportDroppedWrite(event, context, new Error('Malformed record lock control entry'));
1033
+ }
1034
+ const target = event.table ? databases[databaseName]?.[event.table] : TableResource;
1035
+ try {
1036
+ // The audit header's nodeId is the origin, translated on receive and preserved across
1037
+ // relays. The payload's own names are peer-supplied and prove nothing. Rebuild the id
1038
+ // map on a miss rather than waiting out the negative-cache window: a dropped release
1039
+ // leaves the key's home holding its grant until the delegation's own deadline, and
1040
+ // control entries are far too rare to drive the store.
1041
+ //
1042
+ // Inside the guard, not before it: that rebuild reads the audit store, and a throw
1043
+ // there would escape this sink and stall the apply loop for every later entry — the §8
1044
+ // rule that a receive boundary settles its callers and keeps admission closed.
1045
+ const author = getNodeNameForId(auditStore, event.nodeId, true);
1046
+ if (!author) {
1047
+ logger.warn?.('discarding a record lock control entry whose origin node could not be resolved');
1048
+ return reportDroppedWrite(event, context, new Error('Record lock control origin could not be resolved'));
1049
+ }
1050
+ // The coordinator getter fails closed on an unusable node identity. That is right for
1051
+ // an acquire and wrong here: rejecting out of this sink stalls the apply loop for
1052
+ // every later entry rather than dropping one.
1053
+ // `admittingCoordinator`, because `lockCoordinator` answers undefined while a transport
1054
+ // is momentarily unregistered — and this sink runs off the replication stream, not off
1055
+ // that transport. Dropping a peer's clean-handoff release there leaves the home holding
1056
+ // its grant for the delegation's whole deadline.
1057
+ target?.admittingCoordinator?.applyEntry(entry, author, event.timestamp);
1058
+ } catch (error) {
1059
+ logger.warn?.('dropping a record lock control entry: the coordinator is unavailable', error);
1060
+ return reportDroppedWrite(event, context, error);
1061
+ }
1062
+ };
869
1063
  // perform the write of an individual write event
870
1064
  const writeUpdate = async (event, context) => {
1065
+ if (isLockControlType(event.type)) return applyLockControlEvent(event, context);
871
1066
  const value = event.value;
872
1067
  const Table = event.table ? databases[databaseName][event.table] : TableResource;
873
1068
  if (
@@ -899,6 +1094,14 @@ export function makeTable(options) {
899
1094
  async: true,
900
1095
  };
901
1096
  const id = event.id;
1097
+ if (!isSourceWriteType(event.type)) {
1098
+ logger.error?.('Unknown operation', event.type, event.id);
1099
+ const notification = reportDroppedWrite(event, context, new Error('Unknown source operation'));
1100
+ if (event.finished) await event.finished;
1101
+ return notification;
1102
+ }
1103
+ if (Table && event.type === 'put' && value == null && !shouldRevalidateEvents)
1104
+ await reportDroppedWrite(event, context, new Error('Source-applied put has no record content'));
902
1105
  const resource: TableResource = await Table.getResource(id, context, options);
903
1106
  if (event.finished) await event.finished;
904
1107
  switch (event.type) {
@@ -919,13 +1122,18 @@ export function makeTable(options) {
919
1122
  return resource._writeInvalidate(id, value, options);
920
1123
  case 'relocate':
921
1124
  return resource._writeRelocate(id, options);
922
- default:
923
- logger.error?.('Unknown operation', event.type, event.id);
924
1125
  }
925
1126
  };
926
1127
 
927
1128
  /** Keeps the writes to any one key in arrival order; see DESIGN.md (harper#2211). */
928
1129
  const stageWrite = (event, context) => {
1130
+ // A grant must not queue behind whatever the key it names is doing.
1131
+ if (
1132
+ isLockControlType(event.type) ||
1133
+ !isSourceWriteType(event.type) ||
1134
+ (event.type === 'put' && event.value == null && !shouldRevalidateEvents)
1135
+ )
1136
+ return writeUpdate(event, context);
929
1137
  let chainKey: string | undefined;
930
1138
  try {
931
1139
  const Table = event.table ? databases[databaseName][event.table] : TableResource;
@@ -965,14 +1173,18 @@ export function makeTable(options) {
965
1173
  omitCurrent: true,
966
1174
  };
967
1175
  const subscribeOnThisThread = source.subscribeOnThisThread
968
- ? source.subscribeOnThisThread(getWorkerIndex(), subscriptionOptions)
969
- : getWorkerIndex() === 0;
1176
+ ? source.subscribeOnThisThread(applicationWorkerIndex(), subscriptionOptions)
1177
+ : runsApplicationCodeSingletons(); // set up by the defining application's code, so it runs where that code does
970
1178
  const subscription = hasSubscribe && subscribeOnThisThread && (await source.subscribe?.(subscriptionOptions));
971
1179
  if (subscription) {
972
1180
  let txnInProgress;
973
1181
  // we listen for events by iterating through the async iterator provided by the subscription
974
1182
  for await (const event of subscription) {
1183
+ let failureEvent = event;
1184
+ let failurePosition: number | undefined;
1185
+ let applied = false;
975
1186
  try {
1187
+ failurePosition = event?.timestamp;
976
1188
  if (!event || typeof event !== 'object') {
977
1189
  logger.error?.('Bad subscription event', event);
978
1190
  continue;
@@ -980,6 +1192,13 @@ export function makeTable(options) {
980
1192
  const firstWrite = event.type === 'transaction' ? event.writes[0] : event;
981
1193
  if (!firstWrite) {
982
1194
  logger.error?.('Bad subscription event', event);
1195
+ await notifyReplicatedApplyFailure(
1196
+ databaseName,
1197
+ event,
1198
+ failurePosition,
1199
+ new Error('Subscription transaction has no writes'),
1200
+ tableName
1201
+ );
983
1202
  continue;
984
1203
  }
985
1204
  event.source = source;
@@ -988,11 +1207,16 @@ export function makeTable(options) {
988
1207
  // there is no re-subscribe / sequence-id-resume path to recover it. Mark the context so the
989
1208
  // commit retries such conflicts without a cap (see DatabaseTransaction commit).
990
1209
  event.sourceApply = true;
1210
+ event[SOURCE_APPLY_POSITION] = failurePosition;
991
1211
  if (event.type === 'end_txn') {
992
1212
  // Capture the in-progress transaction in a stable local: the loop variable is reset
993
1213
  // once this transaction completes (below), but the seq-id closure and the commit await
994
1214
  // still need to reference it afterward.
995
1215
  const committingTxn = txnInProgress;
1216
+ if (committingTxn) {
1217
+ failureEvent = committingTxn;
1218
+ failurePosition = committingTxn[SOURCE_APPLY_POSITION];
1219
+ }
996
1220
  committingTxn?.resolve();
997
1221
  let updateRecordedSequenceId: () => MaybePromise<void>;
998
1222
  if (event.localTime && lastSequenceId !== event.localTime) {
@@ -1082,6 +1306,7 @@ export function makeTable(options) {
1082
1306
  let committed;
1083
1307
  try {
1084
1308
  committed = committingTxn ? await committingTxn.committed : undefined;
1309
+ applied = true;
1085
1310
  if (event.onCommit) {
1086
1311
  // the onCommit callback can be async and carry associated work (e.g. blob
1087
1312
  // transfer); wait for it too before recording the sequence id. Pass the commit
@@ -1115,6 +1340,13 @@ export function makeTable(options) {
1115
1340
  // than rethrow) so the current beginTxn still starts a fresh transaction with
1116
1341
  // correct boundaries instead of having its writes applied as standalone ones.
1117
1342
  logger.error?.('source-applied transaction commit failed during apply', error);
1343
+ await notifyReplicatedApplyFailure(
1344
+ databaseName,
1345
+ txnInProgress,
1346
+ txnInProgress[SOURCE_APPLY_POSITION],
1347
+ error,
1348
+ tableName
1349
+ );
1118
1350
  } finally {
1119
1351
  // Clear it regardless of outcome so a rejected commit isn't re-awaited on the
1120
1352
  // next beginTxn (which would brick the apply loop).
@@ -1197,6 +1429,7 @@ export function makeTable(options) {
1197
1429
  // standalone write: backpressure on the commit before pulling the next event,
1198
1430
  // and pass the commit resolution through to the callback.
1199
1431
  const committed = commitResolution ? await commitResolution : undefined;
1432
+ applied = true;
1200
1433
  await event.onCommit(committed);
1201
1434
  }
1202
1435
  } else if (commitResolution && !txnInProgress) {
@@ -1205,6 +1438,14 @@ export function makeTable(options) {
1205
1438
  }
1206
1439
  } catch (error) {
1207
1440
  logger.error?.('error in subscription handler', error);
1441
+ if (!applied)
1442
+ await notifyReplicatedApplyFailure(databaseName, failureEvent, failurePosition, error, tableName);
1443
+ } finally {
1444
+ while (pendingApplyFailures) {
1445
+ const notification = pendingApplyFailures;
1446
+ pendingApplyFailures = undefined;
1447
+ await notification;
1448
+ }
1208
1449
  }
1209
1450
  }
1210
1451
  }
@@ -1498,24 +1739,51 @@ export function makeTable(options) {
1498
1739
  * This also informs the scheduling for record eviction.
1499
1740
  * @param opts Time in seconds until records expire, or an options object with `expiration`, `eviction`,
1500
1741
  * and `scanInterval` (all in seconds, all optional). Number form preserves any previously configured
1501
- * eviction/scanInterval; object form replaces all three.
1742
+ * eviction/scanInterval; object form replaces all three. An internal schema ownership-only call with
1743
+ * none of those values preserves the settings already loaded from the catalog.
1502
1744
  */
1503
- static setTTLExpiration(opts: number | { expiration?: number; eviction?: number; scanInterval?: number }) {
1745
+ static setTTLExpiration(
1746
+ opts:
1747
+ | number
1748
+ | {
1749
+ expiration?: number;
1750
+ eviction?: number;
1751
+ scanInterval?: number;
1752
+ fromSchema?: boolean;
1753
+ isolatedApplicationOwner?: boolean;
1754
+ }
1755
+ ) {
1504
1756
  if (opts == null || (typeof opts !== 'number' && typeof opts !== 'object'))
1505
1757
  throw new Error('Invalid expiration value type');
1758
+ const declaredHere = typeof opts === 'object' && opts.fromSchema;
1759
+ const isolatedApplicationOwner = declaredHere && opts.isolatedApplicationOwner;
1760
+ const preserveLoadedConfiguration =
1761
+ declaredHere && opts.expiration === undefined && opts.eviction === undefined && opts.scanInterval === undefined;
1762
+ if (((!ttlFromLoad && !declaredHere) || isolatedApplicationOwner) && !ttlConfiguredByApplication) {
1763
+ ttlConfiguredByApplication = true;
1764
+ // the scan owner may have changed with this: re-evaluate even if the interval did not
1765
+ lastCleanupInterval = undefined;
1766
+ }
1506
1767
  if (typeof opts === 'number') {
1507
1768
  expirationMs = opts * 1000;
1508
- } else {
1769
+ } else if (!preserveLoadedConfiguration) {
1509
1770
  // `??` so an explicit 0 is treated as the user's chosen value, not as "missing"
1510
1771
  expirationMs = (opts.expiration ?? 0) * 1000;
1511
1772
  evictionMs = (opts.eviction ?? 0) * 1000;
1512
1773
  cleanupInterval = (opts.scanInterval ?? 0) * 1000;
1513
1774
  }
1514
1775
  if (expirationMs < 0) throw new Error('Expiration can not be negative');
1515
- // default to one quarter of the total expiration+eviction window
1516
- cleanupInterval = cleanupInterval || (expirationMs + evictionMs) / 4;
1517
- expirationScanScheduled = true;
1518
- scheduleCleanup();
1776
+ if (!preserveLoadedConfiguration) {
1777
+ // default to one quarter of the total expiration+eviction window
1778
+ cleanupInterval = cleanupInterval || (expirationMs + evictionMs) / 4;
1779
+ expirationScanScheduled = true;
1780
+ }
1781
+ // Re-evaluate an existing table-level scan after an ownership-only declaration, but do not
1782
+ // create the default daily cleanup timer for a table that has only an @expiresAt field.
1783
+ if (!preserveLoadedConfiguration || expirationScanScheduled || evictionMs) scheduleCleanup();
1784
+ // @expiresAt has its own interval rather than the cleanup timer above. Arm it whenever a live
1785
+ // declaration introduces the attribute, including after this application already claimed TTL.
1786
+ if (expiresAtProperty && !recordExpirationInterval) runRecordExpirationEviction();
1519
1787
  }
1520
1788
 
1521
1789
  static getResidencyRecord(id: Id) {
@@ -1622,6 +1890,43 @@ export function makeTable(options) {
1622
1890
  static async dropTable() {
1623
1891
  TableResource.assertSchemaMutable('drop a table');
1624
1892
  const rootStore = primaryStore.rootStore;
1893
+ if (
1894
+ databaseName === databasePath &&
1895
+ rootStore instanceof RocksDatabase &&
1896
+ (dbisDb as any).put !== (dbisDb as any).putSync
1897
+ )
1898
+ throw new Error(
1899
+ `Cannot drop ${databaseName}.${TableResource.tableName}: the catalog store's put is asynchronous, so the drop tombstone cannot be made durable before the column families are dropped`
1900
+ );
1901
+ // Release post-commit derived-index delivery before any destructive work: the runner's
1902
+ // backend must have quiesced before its stores and native file are destroyed, and a
1903
+ // same-name recreate must not race an owner still applying to the old generation.
1904
+ const derivedIndexRuntime = TableResource.derivedIndexRuntime;
1905
+ const restoreDerivedIndexesAfterFailedDrop = () => {
1906
+ try {
1907
+ TableResource.derivedIndexRuntime = derivedIndexRuntime?.restoreAfterFailedDrop?.();
1908
+ } catch (restoreError) {
1909
+ TableResource.derivedIndexRuntime = undefined;
1910
+ logger.error?.(
1911
+ `Could not restore derived indexes after failed drop of ${databaseName}.${TableResource.tableName}`,
1912
+ restoreError
1913
+ );
1914
+ }
1915
+ };
1916
+ try {
1917
+ await derivedIndexRuntime?.close(true);
1918
+ } catch (error) {
1919
+ restoreDerivedIndexesAfterFailedDrop();
1920
+ throw error;
1921
+ }
1922
+ const abortStaleDrop = () => {
1923
+ derivedIndexRuntime?.completeDrop?.(false);
1924
+ TableResource.derivedIndexRuntime = undefined;
1925
+ TableResource.cleanup();
1926
+ if (databases[databaseName]?.[tableName] === TableResource) delete databases[databaseName][tableName];
1927
+ };
1928
+ let dropIdentityConfirmed = databaseName !== databasePath;
1929
+ let primaryCatalogKey = TableResource.tableName + '/';
1625
1930
  if (databaseName === databasePath) {
1626
1931
  // Persist a drop tombstone on the primary catalog entry BEFORE any
1627
1932
  // destructive work. If the process dies or a column family drop fails
@@ -1629,10 +1934,19 @@ export function makeTable(options) {
1629
1934
  // the next startup (or a same-name create) completes the drop via
1630
1935
  // completeInterruptedDrop in databases.ts instead of resurrecting
1631
1936
  // the table.
1632
- const primaryCatalogKey = TableResource.tableName + '/';
1937
+ let tombstoneWrite: any;
1633
1938
  const writeTombstone = () => {
1634
- const primaryMeta = (dbisDb as any).getSync(primaryCatalogKey);
1635
- if (!primaryMeta || primaryMeta.dropping) return;
1939
+ let primaryMeta = (dbisDb as any).getSync(primaryCatalogKey);
1940
+ if (!primaryMeta && primaryKey) {
1941
+ const legacyPrimaryKey = `${TableResource.tableName}/${primaryKey}`;
1942
+ const legacyPrimaryMeta = (dbisDb as any).getSync(legacyPrimaryKey);
1943
+ if (legacyPrimaryMeta?.isPrimaryKey) {
1944
+ primaryCatalogKey = legacyPrimaryKey;
1945
+ primaryMeta = legacyPrimaryMeta;
1946
+ }
1947
+ }
1948
+ if (!primaryMeta || (primaryMeta.tableId != null && primaryMeta.tableId !== tableId)) return false;
1949
+ if (primaryMeta.dropping) return true;
1636
1950
  primaryMeta.dropping = true;
1637
1951
  // Stamps this drop's identity so the interrupted-drop retry budget in
1638
1952
  // databases.ts can be scoped to THIS drop rather than the table name: a
@@ -1642,30 +1956,34 @@ export function makeTable(options) {
1642
1956
  // the budget by generation instead makes the new drop's tombstone carry
1643
1957
  // its own fresh key regardless of what any worker last observed.
1644
1958
  primaryMeta.dropGeneration = randomUUID();
1645
- return (dbisDb as any).put(primaryCatalogKey, primaryMeta);
1959
+ tombstoneWrite = (dbisDb as any).put(primaryCatalogKey, primaryMeta);
1960
+ return true;
1646
1961
  };
1647
- if (rootStore instanceof RocksDatabase) {
1648
- // withUpdateAttributesLock's locked section cannot be held across an await, so a durable
1649
- // tombstone depends on put being rebound to putSync for RocksDB primary stores (see
1650
- // createOpenDBIObject). Check that BEFORE writing anything: a tombstone left behind by a
1651
- // refused drop would delete the table on the next load.
1652
- if ((dbisDb as any).put !== (dbisDb as any).putSync)
1653
- throw new Error(
1654
- `Cannot drop ${databaseName}.${TableResource.tableName}: the catalog store's put is asynchronous, so the drop tombstone cannot be made durable before the column families are dropped`
1962
+ try {
1963
+ if (rootStore instanceof RocksDatabase) {
1964
+ // withUpdateAttributesLock's locked section cannot be held across an await, so a durable
1965
+ // tombstone depends on put being rebound to putSync for RocksDB primary stores.
1966
+ dropIdentityConfirmed = withUpdateAttributesLock(
1967
+ rootStore,
1968
+ `drop table '${databaseName}.${TableResource.tableName}'`,
1969
+ writeTombstone
1655
1970
  );
1656
- withUpdateAttributesLock(
1657
- rootStore,
1658
- `drop table '${databaseName}.${TableResource.tableName}'`,
1659
- writeTombstone
1660
- );
1661
- } else {
1662
- let tombstoneWrite;
1663
- rootStore.transactionSync(() => {
1664
- tombstoneWrite = writeTombstone();
1665
- });
1666
- if (typeof tombstoneWrite?.then === 'function') await tombstoneWrite;
1971
+ } else {
1972
+ rootStore.transactionSync(() => {
1973
+ dropIdentityConfirmed = writeTombstone();
1974
+ });
1975
+ if (typeof tombstoneWrite?.then === 'function') await tombstoneWrite;
1976
+ }
1977
+ } catch (error) {
1978
+ restoreDerivedIndexesAfterFailedDrop();
1979
+ throw error;
1667
1980
  }
1668
1981
  }
1982
+ if (!dropIdentityConfirmed) {
1983
+ abortStaleDrop();
1984
+ return;
1985
+ }
1986
+ TableResource.derivedIndexRuntime = undefined;
1669
1987
  // A get() against a sourcedFrom table resolves to its caller before the resolved
1670
1988
  // record's cache write has committed (see getFromSource) - the write lands "in the
1671
1989
  // background" for latency reasons. Flip this BEFORE removing the table from the
@@ -1678,7 +1996,7 @@ export function makeTable(options) {
1678
1996
  // family drops below. If a drop fails past this point the table stays
1679
1997
  // invisible, and the tombstone guarantees the drop completes on the
1680
1998
  // next startup (or on a same-name create).
1681
- delete databases[databaseName][tableName];
1999
+ if (databases[databaseName]?.[tableName] === TableResource) delete databases[databaseName][tableName];
1682
2000
  // The above stops new source-fill writes from starting, but a write from a get()
1683
2001
  // that already returned to its caller may still be in flight. Dropping the column
1684
2002
  // families out from under that write is a genuine invariant violation, not just a
@@ -1709,15 +2027,21 @@ export function makeTable(options) {
1709
2027
  ]);
1710
2028
  clearTimeout(timer);
1711
2029
  if (result === timedOut) {
2030
+ derivedIndexRuntime?.completeDrop?.();
1712
2031
  throw new Error(
1713
2032
  `dropTable() timed out after ${LOCK_TIMEOUT}ms waiting for ${pending.length} in-flight source-populated cache write(s) on ${tableName} to settle; refusing to drop the column families out from under a write that may still be staged. The drop tombstone is durable, so this will be retried on the next load.`
1714
2033
  );
1715
2034
  }
1716
2035
  }
1717
- for (const entry of primaryStore.getRange({ versions: true, snapshot: false, lazy: true })) {
1718
- if (entry.metadataFlags & HAS_BLOBS && entry.value) {
1719
- deleteBlobsInObject(entry.value);
2036
+ try {
2037
+ for (const entry of primaryStore.getRange({ versions: true, snapshot: false, lazy: true })) {
2038
+ if (entry.metadataFlags & HAS_BLOBS && entry.value) {
2039
+ deleteBlobsInObject(entry.value);
2040
+ }
1720
2041
  }
2042
+ } catch (error) {
2043
+ derivedIndexRuntime?.completeDrop?.();
2044
+ throw error;
1721
2045
  }
1722
2046
  if (databaseName === databasePath) {
1723
2047
  // part of a database.
@@ -1735,12 +2059,13 @@ export function makeTable(options) {
1735
2059
  // same-name create completes the interrupted drop and writes fresh
1736
2060
  // catalog rows, and clobbering those would orphan the new table.
1737
2061
  const removeTombstonedCatalog = () => {
1738
- const currentPrimary = (dbisDb as any).getSync(TableResource.tableName + '/');
1739
- if (!currentPrimary?.dropping) return false;
2062
+ const currentPrimary = (dbisDb as any).getSync(primaryCatalogKey);
2063
+ if (!currentPrimary?.dropping || (currentPrimary.tableId != null && currentPrimary.tableId !== tableId))
2064
+ return false;
1740
2065
  for (const attribute of attributes) {
1741
2066
  dbisDb.remove(TableResource.tableName + '/' + attribute.name);
1742
2067
  }
1743
- dbisDb.remove(TableResource.tableName + '/');
2068
+ dbisDb.remove(primaryCatalogKey);
1744
2069
  return true;
1745
2070
  };
1746
2071
  if (rootStore instanceof RocksDatabase) {
@@ -1752,47 +2077,88 @@ export function makeTable(options) {
1752
2077
  // completeInterruptedDrop does), never an awaited drop(), or a
1753
2078
  // concurrent create's wait would be stuck on a drop that the blocked
1754
2079
  // event loop can never resolve, burning its full deadline before failing.
1755
- const removed = withUpdateAttributesLock(rootStore, `table '${databaseName}.${tableName}'`, () => {
1756
- for (const attribute of attributes) {
1757
- const index = indices[attribute.name];
1758
- if (index)
1759
- try {
1760
- index.dropSync();
1761
- } catch (error) {
1762
- ignoreAlreadyDropped(error);
1763
- }
1764
- }
1765
- try {
1766
- primaryStore.dropSync();
1767
- } catch (error) {
1768
- ignoreAlreadyDropped(error);
1769
- }
1770
- return removeTombstonedCatalog();
1771
- });
1772
- if (removed) await dbisDb.committed;
2080
+ let removed: boolean;
2081
+ try {
2082
+ removed = withUpdateAttributesLock(rootStore, `table '${databaseName}.${tableName}'`, () => {
2083
+ const currentPrimary = (dbisDb as any).getSync(primaryCatalogKey);
2084
+ if (!currentPrimary?.dropping || (currentPrimary.tableId != null && currentPrimary.tableId !== tableId))
2085
+ return false;
2086
+ for (const attribute of attributes) {
2087
+ const index = indices[attribute.name];
2088
+ if (index)
2089
+ try {
2090
+ index.customIndex?.resetDerivedStorage?.();
2091
+ index.dropSync();
2092
+ } catch (error) {
2093
+ ignoreAlreadyDropped(error);
2094
+ }
2095
+ }
2096
+ try {
2097
+ primaryStore.dropSync();
2098
+ } catch (error) {
2099
+ ignoreAlreadyDropped(error);
2100
+ }
2101
+ return removeTombstonedCatalog();
2102
+ });
2103
+ if (removed) await dbisDb.committed;
2104
+ } catch (error) {
2105
+ derivedIndexRuntime?.completeDrop?.();
2106
+ throw error;
2107
+ }
2108
+ if (!removed) {
2109
+ abortStaleDrop();
2110
+ return;
2111
+ }
1773
2112
  } else {
1774
2113
  // LMDB: no shared column-family double-drop, and its engine lock is
1775
2114
  // transactional rather than this spin lock, so keep the awaited drop
1776
2115
  // plus the same tombstone-guarded catalog removal.
1777
- const drops = [];
1778
- for (const attribute of attributes) {
1779
- const index = indices[attribute.name];
1780
- if (index) drops.push(index.drop().catch(ignoreAlreadyDropped));
2116
+ let removed: boolean;
2117
+ try {
2118
+ const currentPrimary = (dbisDb as any).getSync(primaryCatalogKey);
2119
+ if (!currentPrimary?.dropping || (currentPrimary.tableId != null && currentPrimary.tableId !== tableId)) {
2120
+ abortStaleDrop();
2121
+ return;
2122
+ }
2123
+ const drops = [];
2124
+ for (const attribute of attributes) {
2125
+ const index = indices[attribute.name];
2126
+ if (index) {
2127
+ index.customIndex?.resetDerivedStorage?.();
2128
+ drops.push(index.drop().catch(ignoreAlreadyDropped));
2129
+ }
2130
+ }
2131
+ drops.push(primaryStore.drop().catch(ignoreAlreadyDropped));
2132
+ await Promise.all(drops);
2133
+ removed = removeTombstonedCatalog();
2134
+ if (removed) await dbisDb.committed;
2135
+ } catch (error) {
2136
+ derivedIndexRuntime?.completeDrop?.();
2137
+ throw error;
2138
+ }
2139
+ if (!removed) {
2140
+ abortStaleDrop();
2141
+ throw new Error(
2142
+ `Could not complete drop of ${databaseName}.${tableName}: a replacement table became current while the LMDB stores were being dropped`
2143
+ );
1781
2144
  }
1782
- drops.push(primaryStore.drop().catch(ignoreAlreadyDropped));
1783
- await Promise.all(drops);
1784
- if (removeTombstonedCatalog()) await dbisDb.committed;
1785
2145
  }
1786
2146
  } else {
1787
2147
  // legacy table per database. The store to retire is this table's own audit store: nothing
1788
2148
  // assigns `primaryStore.auditStore` — openAuditStore() assigns `rootStore.auditStore`, and
1789
2149
  // this is the reference makeTable() was handed. Awaited so a pass suspended mid-removal has
1790
2150
  // released the primary DBI before it is closed and unlinked.
1791
- await auditStore?.stopAuditCleanup?.();
1792
- removeStorageReclamation(primaryStore.path);
1793
- await primaryStore.close();
1794
- fs.unlinkSync(primaryStore.path);
2151
+ try {
2152
+ await auditStore?.stopAuditCleanup?.();
2153
+ removeStorageReclamation(primaryStore.path);
2154
+ await primaryStore.close();
2155
+ fs.unlinkSync(primaryStore.path);
2156
+ } catch (error) {
2157
+ derivedIndexRuntime?.completeDrop?.();
2158
+ throw error;
2159
+ }
1795
2160
  }
2161
+ derivedIndexRuntime?.completeDrop?.();
1796
2162
  signalling.signalSchemaChange(
1797
2163
  new SchemaEventMsg(process.pid, OPERATIONS_ENUM.DROP_TABLE, databaseName, tableName)
1798
2164
  );
@@ -1815,8 +2181,7 @@ export function makeTable(options) {
1815
2181
  records: './', // an href to the records themselves
1816
2182
  name: tableName,
1817
2183
  database: databaseName,
1818
- auditSize:
1819
- auditStore instanceof RocksDatabase ? auditStore.getKeysCount() : auditStore?.getStats().entryCount,
2184
+ auditSize: auditStore?.getStats().entryCount,
1820
2185
  attributes,
1821
2186
  recordCount: undefined,
1822
2187
  estimatedRecordRange: undefined,
@@ -2054,12 +2419,18 @@ export function makeTable(options) {
2054
2419
  } else {
2055
2420
  id = requestTargetToId(target);
2056
2421
  }
2422
+ if (this.#writeGeneration?.closed) {
2423
+ this.#changes = undefined;
2424
+ this.#writeGeneration = undefined;
2425
+ }
2426
+ this.#assertLiveHandle(id, true);
2057
2427
 
2058
2428
  const context = this.getContext();
2059
2429
  const envTxn = txnForContext(context);
2060
2430
  if (!envTxn) throw new Error('Can not update a table resource outside of a transaction');
2061
2431
  // record in the list of updating records so it can be written to the database when we commit
2062
- if (updates === false) {
2432
+ // `false` is the patch-cancel sentinel, not a record root — but only incrementally.
2433
+ if (updates === false && !fullUpdate) {
2063
2434
  // TODO: Remove from transaction
2064
2435
  return this;
2065
2436
  }
@@ -2102,15 +2473,25 @@ export function makeTable(options) {
2102
2473
  });
2103
2474
  }
2104
2475
  }
2105
- return when(this._writeUpdate(id, this.#changes, fullUpdate), () => this);
2476
+ // Keep absent changes distinguishable from an explicit empty patch: framework-created
2477
+ // post/publish updates do not necessarily mutate or save the instance.
2478
+ // A supplied root must reach validation as itself, not as the staged changes (harper#1298).
2479
+ const recordRoot = updates === undefined ? this.#changes : updates;
2480
+ return when(this._writeUpdate(id, recordRoot, fullUpdate), () => this);
2106
2481
  }
2107
2482
 
2108
2483
  /**
2109
2484
  * Save any changes into this instance to the current transaction
2110
2485
  */
2111
2486
  save() {
2112
- this.#assertLiveHandle(this.getId()); // a write through a released or expired lock never lands
2113
2487
  const operation = this.#savingOperation;
2488
+ if (
2489
+ !this.#lockWritable &&
2490
+ this.#writeGeneration?.closed &&
2491
+ (!operation || operation.writeGeneration === this.#writeGeneration)
2492
+ )
2493
+ return;
2494
+ this.#assertLiveHandle(operation?.key ?? this.getId()); // a write through a released or expired lock never lands
2114
2495
  if ((!operation || operation.dropped) && this.#lockWritable && this.#lockHandle?.hold) {
2115
2496
  // A held lock's record stages its update here rather than at lock() time: it is often
2116
2497
  // written after the acquiring transaction has already completed, which would have
@@ -2122,7 +2503,7 @@ export function makeTable(options) {
2122
2503
  // released between lock acquisition and this save(), throw 409 rather than silently
2123
2504
  // committing stale data. Every lock-writable instance carries its own handle.
2124
2505
  const saveHandle = this.#lockHandle!;
2125
- if (saveHandle.expired || saveHandle.released) {
2506
+ if (saveHandle.isExpired()) {
2126
2507
  throw lockNotHeldError(saveHandle);
2127
2508
  }
2128
2509
  const changes = this.#changes;
@@ -2170,12 +2551,21 @@ export function makeTable(options) {
2170
2551
  // resolve before that native commit actually settles. Chain on innerCommit (as the
2171
2552
  // lock-writable hold branch above already does) so callers awaiting save() see the
2172
2553
  // write durably land, not just the outer (possibly premature) resolution.
2173
- const result = this.#saveOperation(operation);
2554
+ let result;
2555
+ try {
2556
+ result = this.#saveOperation(operation);
2557
+ } catch (error) {
2558
+ if (!operation.saved) this.#savingOperation = operation;
2559
+ throw error;
2560
+ }
2174
2561
  const innerCommit = operation.innerCommit;
2175
2562
  return innerCommit ? when(innerCommit, () => result) : result;
2176
2563
  }
2177
2564
  }
2178
2565
  #saveOperation(operation: any) {
2566
+ // LMDB validates staged writes at transaction commit, so bind a lazy update to the
2567
+ // generation selected by save() before another update can replace its changes.
2568
+ operation.captureChanges?.();
2179
2569
  const transaction = txnForContext(this.getContext());
2180
2570
  const holder = operation.stagedIn;
2181
2571
  // never-drop-on-conflict lives on the transaction and would not travel with the write, so an
@@ -2195,13 +2585,26 @@ export function makeTable(options) {
2195
2585
  // merge and index diff would be relative to a record that may never land.
2196
2586
  operation.priorWrite = undefined;
2197
2587
  operation.deferSave = false;
2198
- return when(transaction.addWrite(operation), () => operation.promise ?? operation.result);
2588
+ const result = when(transaction.addWrite(operation), () => operation.promise ?? operation.result);
2589
+ this.#closeWriteChain(operation);
2590
+ return result;
2199
2591
  }
2200
2592
  const owner = holder ?? transaction;
2201
- if (owner.save) return owner.save(operation) || operation.promise || operation.result;
2593
+ if (owner.save) {
2594
+ const result = owner.save(operation) || operation.promise || operation.result;
2595
+ this.#closeWriteChain(operation);
2596
+ return result;
2597
+ }
2598
+ }
2599
+ #closeWriteChain(operation: any) {
2600
+ const owner = operation.stagedIn;
2601
+ for (let write = operation; write && !write.instanceClosed; write = write.priorWrite) {
2602
+ if (write === operation || owner?.ownedWrites?.has(write)) closeWriteInstance(write);
2603
+ }
2202
2604
  }
2203
2605
 
2204
2606
  addTo(property: any, value: any) {
2607
+ this[ASSERT_TRACKED_WRITABLE]();
2205
2608
  if (typeof value === 'number' || typeof value === 'bigint') {
2206
2609
  if (this.#savingOperation?.fullUpdate)
2207
2610
  (this as any).set(property, (+this.getProperty(property) || 0) + (value as any));
@@ -2255,6 +2658,7 @@ export function makeTable(options) {
2255
2658
  const context = this.getContext();
2256
2659
  checkValidId(id);
2257
2660
  const transaction = txnForContext(this.getContext());
2661
+ assertDerivedIndexAdmission(options, transaction);
2258
2662
  const write: any = {
2259
2663
  key: id,
2260
2664
  store: primaryStore,
@@ -2262,6 +2666,7 @@ export function makeTable(options) {
2262
2666
  entry: this.#entry,
2263
2667
  recordVersion: options?.version,
2264
2668
  lockHandle: this.#lockHandle && this.#lockHandle.keyId === writeKeyId(id) ? this.#lockHandle : undefined,
2669
+ reloadCommitBase: true,
2265
2670
  commit: (txnTime, existingEntry, _retry, transaction: any) => {
2266
2671
  const txnLogKey =
2267
2672
  isRocksDB && options?.version != null ? (transaction?.getTimestamp?.() ?? txnTime) : txnTime;
@@ -2313,6 +2718,7 @@ export function makeTable(options) {
2313
2718
  const context = this.getContext();
2314
2719
  checkValidId(id);
2315
2720
  const transaction = txnForContext(this.getContext());
2721
+ assertDerivedIndexAdmission(options, transaction);
2316
2722
  const write: any = {
2317
2723
  key: id,
2318
2724
  store: primaryStore,
@@ -2320,6 +2726,7 @@ export function makeTable(options) {
2320
2726
  entry: this.#entry,
2321
2727
  recordVersion: options?.version,
2322
2728
  lockHandle: this.#lockHandle && this.#lockHandle.keyId === writeKeyId(id) ? this.#lockHandle : undefined,
2729
+ reloadCommitBase: true,
2323
2730
  before:
2324
2731
  (this.constructor as any).source?.relocate && !(context as any)?.source
2325
2732
  ? (this.constructor as any).source.relocate.bind((this.constructor as any).source, id, undefined, context)
@@ -2428,9 +2835,8 @@ export function makeTable(options) {
2428
2835
  // if there is a resolution in-progress, abandon the eviction
2429
2836
  if (primaryStore.hasLock(id, entry.version)) return;
2430
2837
  }
2431
- // evictions never go in the audit log, so we can not record a deletion entry for the eviction
2432
- // as there is no corresponding audit entry and it would never get cleaned up. So we must simply
2433
- // removed the entry entirely, but first cleanup indices
2838
+ // Eviction is not a canonical delete. Indexed caching tables add a local-only control entry so
2839
+ // their derived indexes can remove the resident projection without exposing a delete event.
2434
2840
  let lmdbCompletion: MaybePromise<unknown>;
2435
2841
  if (primaryStore.ifVersion) {
2436
2842
  // lmdb: the index cleanup and the record removal are both version-guarded optimistic writes.
@@ -2443,6 +2849,7 @@ export function makeTable(options) {
2443
2849
  lmdbCompletion = Promise.all([indexCleanup, removal]);
2444
2850
  } else {
2445
2851
  updateIndices(id, existingRecord, null, options);
2852
+ stageDerivedIndexEviction(transaction as RocksTransaction, id, existingVersion);
2446
2853
  removeEntry(primaryStore, entry ?? primaryStore.getEntry(id), options);
2447
2854
  }
2448
2855
  committed = true;
@@ -2535,13 +2942,29 @@ export function makeTable(options) {
2535
2942
  }
2536
2943
  const id = target != null ? requestTargetToId(target as RequestTargetOrId) : this.getId();
2537
2944
  checkValidId(id);
2945
+ this.#assertLiveHandle(id);
2538
2946
  const resolved = resolveLockOptions(options);
2539
2947
  const context = this.getContext();
2540
2948
  const link = txnForContext(context);
2541
2949
  const keyId = writeKeyId(id);
2950
+ // Before the re-entrant paths, not after: a transaction that already holds this key
2951
+ // node-scoped would otherwise be handed that handle back for an explicit cluster request,
2952
+ // while the same request on a fresh key fails closed.
2953
+ if (
2954
+ resolved.scope === 'cluster' &&
2955
+ (resolved.scopeRequested || isClusterLockRequired(databaseName)) &&
2956
+ !getClusterLockTransport(databaseName)
2957
+ )
2958
+ return Promise.reject(
2959
+ new LockUnavailableError(
2960
+ `Cluster-scoped record locks are not available on ${databaseName}: no record lock transport is registered`
2961
+ )
2962
+ );
2542
2963
  const held = this.#lockHandle;
2543
- if (held && !held.released && !held.expired && held.keyId === keyId) {
2964
+ if (held && !held.isExpired() && held.keyId === keyId) {
2544
2965
  // Re-entrant: upgrade to hold if requested, then preserve staged changes.
2966
+ const violation = scopeViolation(held, resolved, databaseName);
2967
+ if (violation) return Promise.reject(violation);
2545
2968
  if (resolved.hold && !held.hold) {
2546
2969
  held.upgradeToHold(resolved.lease);
2547
2970
  // The scoped phase eagerly staged a TransactionWrite (see #reloadLocked); hold
@@ -2555,7 +2978,9 @@ export function makeTable(options) {
2555
2978
  return Promise.resolve(this.#reloadLocked(id, undefined, true));
2556
2979
  }
2557
2980
  const scoped = link.recordLockFor(primaryStore, keyId);
2558
- if (scoped && !scoped.released && !scoped.expired) {
2981
+ if (scoped && !scoped.isExpired()) {
2982
+ const violation = scopeViolation(scoped, resolved, databaseName);
2983
+ if (violation) return Promise.reject(violation);
2559
2984
  if (resolved.hold && !scoped.hold) {
2560
2985
  // Upgrade scoped → hold: flip the existing handle object to hold mode so every
2561
2986
  // instance that already references this handle stays valid. Retiring and creating a
@@ -2568,6 +2993,17 @@ export function makeTable(options) {
2568
2993
  // Already held with the same type: re-entrant return. Preserve any staged changes.
2569
2994
  return Promise.resolve(this.#reloadLocked(id, scoped, true));
2570
2995
  }
2996
+ // Cluster scope needs a registered transport. An EXPLICIT { scope: 'cluster' } without one is
2997
+ // a caller asking for a guarantee this node cannot make, so it fails closed rather than
2998
+ // silently returning the node-local lock; the default keeps Phase 0 behavior, which is what
2999
+ // a build with no replication has anyway.
3000
+ // The getter fails closed on an unusable node identity, and lock() answers with a promise.
3001
+ let coordinator: LockCoordinator | undefined;
3002
+ try {
3003
+ coordinator = resolved.scope === 'node' ? undefined : TableResource.lockCoordinator;
3004
+ } catch (error) {
3005
+ return Promise.reject(error as Error);
3006
+ }
2571
3007
  const key = lockAttemptKey(tableId, id);
2572
3008
  // Coalesce concurrent lock() calls for the same key inside one link so they don't
2573
3009
  // self-block: Promise.all([T.lock(id), T.lock(id)]) would otherwise have both calls
@@ -2580,6 +3016,10 @@ export function makeTable(options) {
2580
3016
  // The follower waits on the leader's acquisition, but only for its own timeout.
2581
3017
  let followerTimer: ReturnType<typeof setTimeout> | undefined;
2582
3018
  const followerTimedOut = Symbol('follower timeout');
3019
+ // Why the leader failed, so the follower can report that instead of inventing contention
3020
+ // when its own budget runs out. A leader 503 means the guarantee could not be established
3021
+ // at all; retrying is still right (the condition may clear) but 423 at the end is not.
3022
+ let leaderFailure: Error | undefined;
2583
3023
  const followerStart = Date.now();
2584
3024
  const followerDeadline = new Promise<never>((_, reject) => {
2585
3025
  followerTimer = setTimeout(() => reject(followerTimedOut), resolved.timeout).unref();
@@ -2593,14 +3033,25 @@ export function makeTable(options) {
2593
3033
  if (link.open === TRANSACTION_STATE.CLOSED && !link.saveCommits)
2594
3034
  throw new ServerError('Transaction was closed while waiting for a record lock', 500);
2595
3035
  const remaining = resolved.timeout - (Date.now() - followerStart);
2596
- if (remaining <= 0) throw new ClientError(`Record is locked and was not released in time`, 423);
2597
- return this.lock(target, { ...resolved, timeout: remaining }) as Promise<any>;
3036
+ if (remaining <= 0)
3037
+ throw leaderFailure ?? new ClientError(`Record is locked and was not released in time`, 423);
3038
+ // Carry the scope only if the caller named it: spreading the resolved options would turn
3039
+ // a defaulted 'cluster' into an explicit one, which is fail-closed when no transport is
3040
+ // registered.
3041
+ return this.lock(target, {
3042
+ lease: resolved.lease,
3043
+ timeout: remaining,
3044
+ hold: resolved.hold,
3045
+ scope: resolved.scopeRequested ? resolved.scope : undefined,
3046
+ }) as Promise<any>;
2598
3047
  };
2599
3048
  return Promise.race([pending, followerDeadline]).then(
2600
3049
  () => {
2601
3050
  clearTimeout(followerTimer);
2602
3051
  const acquired = link.recordLockFor(primaryStore, keyId);
2603
- if (acquired && !acquired.released && !acquired.expired) {
3052
+ if (acquired && !acquired.isExpired()) {
3053
+ const violation = scopeViolation(acquired, resolved, databaseName);
3054
+ if (violation) throw violation;
2604
3055
  if (resolved.hold && !acquired.hold) {
2605
3056
  detachScopedUpgradeWrite(link, keyId, acquired);
2606
3057
  acquired.upgradeToHold(resolved.lease);
@@ -2611,7 +3062,12 @@ export function makeTable(options) {
2611
3062
  },
2612
3063
  (error) => {
2613
3064
  clearTimeout(followerTimer);
3065
+ // A follower that simply ran out of its own wait was waiting on another caller in this
3066
+ // process, which is the contention 423 describes. But if the LEADER failed for a reason
3067
+ // that is not contention, that reason is the true one — keep it and report it if the
3068
+ // retries below also run out, rather than ending on a 423 for a key nobody held.
2614
3069
  if (error === followerTimedOut) throw new ClientError(`Record is locked and was not released in time`, 423);
3070
+ if (error instanceof LockUnavailableError) leaderFailure = error;
2615
3071
  return retryOnRemainingBudget();
2616
3072
  }
2617
3073
  );
@@ -2625,46 +3081,117 @@ export function makeTable(options) {
2625
3081
  resolved.lease,
2626
3082
  resolved.hold
2627
3083
  );
2628
- link.registerPendingLock(primaryStore, keyId, pendingPromise);
2629
- return pendingPromise.then(
2630
- (handle) => {
2631
- link.unregisterPendingLock(primaryStore, keyId);
2632
- if (link.open === TRANSACTION_STATE.CLOSED && !link.saveCommits) {
2633
- // The transaction was aborted while this call waited; nothing would ever release the handle.
3084
+ const clusterStart = performance.now();
3085
+ // What a follower waits on must span the cluster round and registration, not just the native
3086
+ // acquire. Waking it at the native hand-off leaves it in a window where the key is held but no
3087
+ // handle is registered, so it retries and parks on the leader's own lock for its full timeout
3088
+ // — inside a transaction that cannot finish until it gives up.
3089
+ const acquisition = pendingPromise.then(async (handle) => {
3090
+ const closedWhileWaiting = () => link.open === TRANSACTION_STATE.CLOSED && !link.saveCommits;
3091
+ if (closedWhileWaiting()) {
3092
+ // The transaction was aborted while this call waited; nothing would ever release the handle.
3093
+ handle.release();
3094
+ throw new ServerError('Transaction was closed while waiting for a record lock', 500);
3095
+ }
3096
+ // Anything that fails from here must give the native key back, or it becomes a lock this
3097
+ // caller does not know it owns.
3098
+ // Re-resolved, not the snapshot taken before `acquireRecordKey`: that wait can run the
3099
+ // caller's whole timeout, long enough for harper-pro to register the transport on this
3100
+ // worker. Using the snapshot would take the native key alone and hand back a node-scoped
3101
+ // handle while a peer that already had the transport is granted the same key.
3102
+ try {
3103
+ if (resolved.scope !== 'node') coordinator = TableResource.lockCoordinator ?? coordinator;
3104
+ } catch (error) {
3105
+ // The getter fails closed on an unusable node identity, and that has to reach the caller
3106
+ // the same way it does before the wait. Swallowing it let an implicit cluster lock fall
3107
+ // through to node-local authority — the one outcome failing closed exists to prevent —
3108
+ // because `coordinator` is still whatever it was, including undefined.
3109
+ handle.release();
3110
+ throw error as Error;
3111
+ }
3112
+ if (coordinator) {
3113
+ try {
3114
+ // Not a 423 when the budget is gone, and not a skip either: the native wait can consume
3115
+ // the whole timeout, and `acquire` with no wait left still admits from a live delegation
3116
+ // or a local grant without sending anything. Only if it cannot does the caller learn the
3117
+ // guarantee was unavailable — which is not the same as the key being held.
3118
+ const remaining = Math.max(0, resolved.timeout - (performance.now() - clusterStart));
3119
+ const round = await coordinator.acquire(id, resolved.lease, remaining);
3120
+ // Resolved through the getter rather than captured, so a transport swap between
3121
+ // acquisition and release reaches the coordinator that now owns the delegation.
3122
+ if (
3123
+ !handle.joinClusterRound(round.tsR, resolved.lease, round.mintedMono, () =>
3124
+ TableResource.admittingCoordinator?.release(id, round.admissionId)
3125
+ )
3126
+ ) {
3127
+ // The round completed inside its lease but the lease elapsed before the handle
3128
+ // could take it. The coordinator still holds it, and only this call knows the
3129
+ // hold was never handed out.
3130
+ // The getter, not the captured coordinator: after a transport swap the captured one no
3131
+ // longer owns this admission, so releasing through it would be a silent no-op.
3132
+ // `.then`, not `Promise.resolve(release())`: the call can throw synchronously, and that
3133
+ // throw would escape the catch and replace the 423 below with an internal error.
3134
+ Promise.resolve()
3135
+ .then(() => TableResource.admittingCoordinator?.release(id, round.admissionId))
3136
+ .catch(noop);
3137
+ // 503, not 423: the home granted this key to US and the lease elapsed before the handle
3138
+ // could take it, so nobody ever held it. The coordinator classifies the same thing the
3139
+ // same way — see its `timeout` denial.
3140
+ throw new LockUnavailableError(
3141
+ `A cluster record lock on ${databaseName}.${tableName} was granted after its lease had elapsed`
3142
+ );
3143
+ }
3144
+ // A recall must be able to fence a write this handle staged and then unlocked, so
3145
+ // the coordinator needs a way to revoke it — see LockCoordinator.registerAdmission.
3146
+ // The getter again: a swap during the acquisition moved this admission to the
3147
+ // successor, and registering on the predecessor would revoke a handle that is fine.
3148
+ TableResource.admittingCoordinator?.registerAdmission(round.admissionId, () => handle.revokeLease());
3149
+ } catch (error) {
3150
+ handle.release();
3151
+ throw error;
3152
+ }
3153
+ if (closedWhileWaiting()) {
2634
3154
  handle.release();
2635
3155
  throw new ServerError('Transaction was closed while waiting for a record lock', 500);
2636
3156
  }
2637
- link.registerRecordLock(handle);
2638
- if (link.saveCommits && (context as any)?.timestamp) handle.noteCandidateFloor((context as any).timestamp);
2639
- if (link.open === TRANSACTION_STATE.OPEN && !link.saveCommits) {
2640
- // Explicit transaction() (not ImmediateTransaction): pin the clock to
2641
- // acquiredAt when no writes have been staged yet. When writes already
2642
- // exist, leave the clock alone (ordering is best-effort; write held records
2643
- // in their own transaction for the guarantee). ImmediateTransaction is
2644
- // excluded (saveCommits=true) — its clock is never pinned in lock();
2645
- // each save() stamps from the handle's committed version floor instead.
2646
- if (link.writes.length === 0 && !link.timestamp) {
2647
- link.timestamp = handle.acquiredAt;
2648
- }
2649
- if (!resolved.hold && link.transaction) {
2650
- // Scoped lock: the read snapshot may predate the lock; drop it so the
2651
- // scope reads what it locked. Hold locks use acquiredAt directly and
2652
- // do not update the read snapshot.
2653
- // The timestamp guard matches DatabaseTransaction's own setTimestamp calls: a
2654
- // deferred update() write leaves the clock at 0, which rocksdb-js rejects.
2655
- if (link.writes.length === 0 && link.readTxnsUsed <= 1) {
2656
- link.releaseReadTxn();
2657
- link.snapshotFree = true;
2658
- } else if (link.timestamp) link.transaction.setTimestamp(link.timestamp);
2659
- }
3157
+ }
3158
+ link.registerRecordLock(handle);
3159
+ if (link.saveCommits && (context as any)?.timestamp) handle.noteCandidateFloor((context as any).timestamp);
3160
+ if (link.open === TRANSACTION_STATE.OPEN && !link.saveCommits) {
3161
+ // Explicit transaction() (not ImmediateTransaction): pin the clock to
3162
+ // acquiredAt when no writes have been staged yet. When writes already
3163
+ // exist, leave the clock alone (ordering is best-effort; write held records
3164
+ // in their own transaction for the guarantee). ImmediateTransaction is
3165
+ // excluded (saveCommits=true) — its clock is never pinned in lock();
3166
+ // each save() stamps from the handle's committed version floor instead.
3167
+ if (link.writes.length === 0 && !link.timestamp) {
3168
+ link.timestamp = handle.acquiredAt;
2660
3169
  }
2661
- // ImmediateTransaction: no clock pinning in lock(); save() stamps each write
2662
- // from the committed handle floor for both scoped and hold handles.
3170
+ if (!resolved.hold && link.transaction) {
3171
+ // Scoped lock: the read snapshot may predate the lock; drop it so the
3172
+ // scope reads what it locked. Hold locks use acquiredAt directly and
3173
+ // do not update the read snapshot.
3174
+ // The timestamp guard matches DatabaseTransaction's own setTimestamp calls: a
3175
+ // deferred update() write leaves the clock at 0, which rocksdb-js rejects.
3176
+ if (link.writes.length === 0 && link.readTxnsUsed <= 1) {
3177
+ link.releaseReadTxn();
3178
+ link.snapshotFree = true;
3179
+ } else if (link.timestamp) link.transaction.setTimestamp(link.timestamp);
3180
+ }
3181
+ }
3182
+ // ImmediateTransaction: no clock pinning in lock(); save() stamps each write
3183
+ // from the committed handle floor for both scoped and hold handles.
3184
+ return handle;
3185
+ });
3186
+ link.registerPendingLock(primaryStore, keyId, acquisition);
3187
+ return acquisition.then(
3188
+ (handle) => {
3189
+ link.unregisterPendingLock(primaryStore, keyId);
2663
3190
  return this.#reloadLocked(id, handle);
2664
3191
  },
2665
- (err) => {
3192
+ (error) => {
2666
3193
  link.unregisterPendingLock(primaryStore, keyId);
2667
- throw err;
3194
+ throw error;
2668
3195
  }
2669
3196
  );
2670
3197
  }
@@ -2871,6 +3398,7 @@ export function makeTable(options) {
2871
3398
  const context = this.getContext();
2872
3399
  const transaction = txnForContext(context);
2873
3400
  const replaying = transaction.isReplay === true;
3401
+ assertDerivedIndexAdmission(options, transaction);
2874
3402
  checkValidId(id);
2875
3403
  if (fullUpdate && recordUpdate == null && options?.isNotification) {
2876
3404
  // A source/replication-applied put must carry the record; these applies skip record
@@ -2888,6 +3416,16 @@ export function makeTable(options) {
2888
3416
  }
2889
3417
  return;
2890
3418
  }
3419
+ let captureChanges;
3420
+ if (recordUpdate === undefined) {
3421
+ let captured = false;
3422
+ captureChanges = () => {
3423
+ if (!captured) {
3424
+ captured = true;
3425
+ recordUpdate = this.#changes;
3426
+ }
3427
+ };
3428
+ }
2891
3429
  const entry = this.#entry ?? primaryStore.getEntry(id, { transaction: transaction.getReadTxn() });
2892
3430
  const writeToSource = () => {
2893
3431
  if (!(this.constructor as any).source || (context as any)?.source) return;
@@ -2907,12 +3445,20 @@ export function makeTable(options) {
2907
3445
  }
2908
3446
  };
2909
3447
 
3448
+ const receiverId = this.getId();
3449
+ const closesReceiver =
3450
+ !this.isCollection &&
3451
+ !isSearchTarget(receiverId) &&
3452
+ (id === receiverId || writeKeyId(id) === writeKeyId(receiverId));
2910
3453
  const write: any = {
2911
3454
  key: id,
2912
3455
  store: primaryStore,
2913
3456
  entry,
2914
3457
  nodeName: (context as any)?.nodeName,
2915
3458
  fullUpdate,
3459
+ chainsStagedState: true,
3460
+ // copy-apply rows keep their pre-read base: one read per row, healed by the post-copy replay
3461
+ reloadCommitBase: options?.isCopyApply !== true,
2916
3462
  deferSave: true,
2917
3463
  // the origin's record version on an applied write; absent for a locally-originated one
2918
3464
  recordVersion: options?.version,
@@ -2921,8 +3467,10 @@ export function makeTable(options) {
2921
3467
  // Only attach the hold handle when it covers exactly this key; off-key writes
2922
3468
  // are ordinary and must not carry an unrelated hold's handle.
2923
3469
  lockHandle: this.#lockHandle && this.#lockHandle.keyId === writeKeyId(id) ? this.#lockHandle : undefined,
3470
+ writeGeneration: !this.#lockWritable && closesReceiver ? this[GET_TRACKED_WRITE_GENERATION]() : undefined,
3471
+ captureChanges,
2924
3472
  validate: (txnTime, committedBy = transaction) => {
2925
- if (!recordUpdate) recordUpdate = this.#changes;
3473
+ write.captureChanges?.();
2926
3474
  if (fullUpdate || (recordUpdate && hasChanges(this.#changes === recordUpdate ? this : recordUpdate))) {
2927
3475
  if (!(context as any)?.source) {
2928
3476
  committedBy.checkOverloaded();
@@ -2973,10 +3521,13 @@ export function makeTable(options) {
2973
3521
  : txnTime;
2974
3522
  }
2975
3523
  if (createdTimeProperty) {
2976
- if (entry?.value) {
3524
+ // the reloaded commit base, not the pre-read one: a full PUT racing a create
3525
+ // would otherwise stamp a fresh created time over the real one
3526
+ const base = write.entry;
3527
+ if (base?.value) {
2977
3528
  if (fullUpdate || recordUpdate[createdTimeProperty.name]) {
2978
3529
  // make sure to retain original created time
2979
- recordUpdate[createdTimeProperty.name] = entry?.value[createdTimeProperty.name];
3530
+ recordUpdate[createdTimeProperty.name] = base.value[createdTimeProperty.name];
2980
3531
  }
2981
3532
  } else {
2982
3533
  // new entry, set created time
@@ -3495,8 +4046,8 @@ export function makeTable(options) {
3495
4046
  if (recordToStore && recordToStore.getRecord)
3496
4047
  throw new Error('Can not assign a record to a record, check for circular references');
3497
4048
  if (residencyId == undefined) {
3498
- if (entry?.residencyId)
3499
- (context as any).previousResidency = TableResource.getResidencyRecord(entry.residencyId);
4049
+ if (existingEntry?.residencyId)
4050
+ (context as any).previousResidency = TableResource.getResidencyRecord(existingEntry.residencyId);
3500
4051
  const residency = residencyFromFunction(TableResource.getResidency(recordToStore, context));
3501
4052
  if (residency) {
3502
4053
  if (!residency.includes(server.hostname)) {
@@ -3736,6 +4287,7 @@ export function makeTable(options) {
3736
4287
  this.#assertLiveHandle(id);
3737
4288
  const context = this.getContext();
3738
4289
  const transaction = txnForContext(context);
4290
+ assertDerivedIndexAdmission(options, transaction);
3739
4291
  checkValidId(id);
3740
4292
  const entry = this.#entry ?? primaryStore.getEntry(id, { transaction: transaction.getReadTxn() });
3741
4293
 
@@ -3744,6 +4296,7 @@ export function makeTable(options) {
3744
4296
  store: primaryStore,
3745
4297
  entry,
3746
4298
  chainsStagedState: true,
4299
+ reloadCommitBase: true,
3747
4300
  nodeName: (context as any)?.nodeName,
3748
4301
  recordVersion: options?.version,
3749
4302
  lockHandle: this.#lockHandle && this.#lockHandle.keyId === writeKeyId(id) ? this.#lockHandle : undefined,
@@ -3967,6 +4520,7 @@ export function makeTable(options) {
3967
4520
  // objects. Entries are small and shallow; the clone is cheap next to the query.
3968
4521
  conditions = cloneConditions(conditions);
3969
4522
  let orderAlignedCondition;
4523
+ let syntheticOrderCondition;
3970
4524
  const filtered = {};
3971
4525
 
3972
4526
  function prepareConditions(conditions: any[], operator: string) {
@@ -4105,7 +4659,7 @@ export function makeTable(options) {
4105
4659
  // if it is indexed, we add a pseudo-condition to align with the natural sort order of the index.
4106
4660
  // the primary key has no secondary index, but the primary store is itself keyed in
4107
4661
  // primary-key order, so scanning it is already aligned with the sort
4108
- orderAlignedCondition = { ...sort, comparator: 'sort' };
4662
+ orderAlignedCondition = syntheticOrderCondition = { ...sort, comparator: 'sort' };
4109
4663
  conditions.push(orderAlignedCondition);
4110
4664
  } else if (conditions.length === 0 && !target.allowFullScan)
4111
4665
  throw handleHDBError(
@@ -4116,7 +4670,13 @@ export function makeTable(options) {
4116
4670
  404
4117
4671
  );
4118
4672
  }
4119
- if (orderAlignedCondition) orderAlignedCondition.descending = Boolean(sort.descending);
4673
+ if (orderAlignedCondition) {
4674
+ orderAlignedCondition.descending = Boolean(sort.descending);
4675
+ if (orderAlignedCondition.maxIndexLagMilliseconds === undefined)
4676
+ orderAlignedCondition.maxIndexLagMilliseconds = sort.maxIndexLagMilliseconds;
4677
+ if (orderAlignedCondition.waitForIndexMilliseconds === undefined)
4678
+ orderAlignedCondition.waitForIndexMilliseconds = sort.waitForIndexMilliseconds;
4679
+ }
4120
4680
  }
4121
4681
  }
4122
4682
  conditions = orderConditions(conditions, operator);
@@ -4135,8 +4695,10 @@ export function makeTable(options) {
4135
4695
  };
4136
4696
  }
4137
4697
  } else {
4138
- // if we had to add an aligned condition that isn't first, we remove it and do ordering later
4139
- if (orderAlignedCondition) conditions.splice(conditions.indexOf(orderAlignedCondition), 1);
4698
+ // if we had to add an aligned condition that isn't first, we remove it and do ordering later —
4699
+ // only the one we added; a caller's own condition on the sort attribute is still a filter
4700
+ const syntheticIndex = syntheticOrderCondition ? conditions.indexOf(syntheticOrderCondition) : -1;
4701
+ if (syntheticIndex >= 0) conditions.splice(syntheticIndex, 1);
4140
4702
  postOrdering = sort;
4141
4703
  }
4142
4704
  }
@@ -4182,170 +4744,175 @@ export function makeTable(options) {
4182
4744
  boundRowFilter || typeof target.vectorFilter === 'function'
4183
4745
  ? { rowFilter: boundRowFilter, vectorFilter: target.vectorFilter }
4184
4746
  : undefined;
4185
- const entries = executeConditions(
4186
- conditions,
4187
- operator,
4188
- TableResource,
4189
- readTxn,
4190
- target,
4191
- context,
4192
- (results: any[], filters: Function[]) => transformToEntries(results, select, context, readTxn, filters),
4193
- filtered,
4194
- recordAccess
4195
- );
4196
- const ensure_loaded = (target as any).ensureLoaded !== false;
4197
- // The guards inside executeConditions evaluate the
4198
- // LOCAL record, but on a caching table transformEntryForSelect may then revalidate an
4199
- // expired/invalidated row from source and return a DIFFERENT record. The explicit row filter
4200
- // must hold on the record actually returned, so it is re-checked
4201
- // there, after materialization (the earlier evaluation stays as a prune that also bounds HNSW
4202
- // traversal). vectorFilter and condition filters intentionally keep the local-record
4203
- // semantics all query filters have on caching tables.
4204
- //
4205
- // A row that is past its TTL but not yet swept by the background eviction
4206
- // scan is still physically present. A write that is about to overwrite it
4207
- // anyway (e.g. the SQL engine locating UPDATE/DELETE targets) needs to see
4208
- // it as a match — the same leniency a direct by-id put/patch already gets,
4209
- // since those never run the ensureLoaded-gated freshness check this transform
4210
- // otherwise applies unconditionally to every read.
4211
- const includeExpired = (target as any).includeExpired === true;
4212
- const transformToRecord = TableResource.transformEntryForSelect(
4213
- select,
4214
- context,
4215
- readTxn,
4216
- filtered,
4217
- ensure_loaded,
4218
- true,
4219
- boundRowFilter,
4220
- includeExpired,
4221
- postOrdering
4222
- );
4223
- let results = TableResource.transformToOrderedSelect(
4224
- entries,
4225
- select,
4226
- postOrdering,
4227
- context,
4228
- readTxn,
4229
- transformToRecord
4230
- );
4231
- const offset = target.offset || 0;
4232
- const end = target.limit !== undefined ? offset + (target.limit as number) : undefined;
4233
- // `Prefer: count=` (REST pagination): materialize the requested page and attach a total record
4234
- // count so the HTTP layer can emit a Content-Range. `exact` drains the full matched set once,
4235
- // windowing the page in the same pass; `estimated` returns just the page plus a cheap planner/
4236
- // table estimate. Opt-in only — the default streaming path below is untouched.
4237
- //
4238
- // Requires a bounded page AND window. Counting is a pagination feature; both the limit and the
4239
- // offset must be finite, non-negative integers, the limit no larger than MAX_COUNT_PAGE, and the
4240
- // window (offset + limit) no larger than MAX_EXACT_COUNT_SCAN. Anything else — a missing/
4241
- // oversized/non-finite/negative limit or offset (a bare collection GET, limit(Infinity),
4242
- // limit(foo), limit(-5,10)) or a deep-page window past the scan budget — falls through to the
4243
- // normal streaming path with no count. This bounds the offset too: without it a huge offset would
4244
- // postpone the exact guardrail (which only engages past the page) until that offset was scanned.
4245
- const pageLimit = target.limit as number;
4246
- if (
4247
- target.count &&
4248
- Number.isInteger(pageLimit) &&
4249
- pageLimit >= 0 &&
4250
- pageLimit <= MAX_COUNT_PAGE &&
4251
- Number.isInteger(offset) &&
4252
- offset >= 0 &&
4253
- offset + pageLimit <= MAX_EXACT_COUNT_SCAN
4254
- ) {
4255
- const wantExact = target.count === 'exact';
4256
- const pageEnd = offset + pageLimit;
4257
- const countStart = performance.now();
4258
- // A custom-index (vector/HNSW) traversal returns a bounded, approximate candidate set whose size is
4259
- // chosen from `minResults` (offset + limit), so `scanned` over it tracks the requested page size, not
4260
- // the true match count — the same query at limit(5) vs limit(200) would otherwise advertise two
4261
- // different `count=exact` totals. Any query whose execution touches a custom index is affected: a
4262
- // custom-index sort (its aligned pseudo-condition lands in `conditions`), a custom-index threshold
4263
- // filter (an HNSW `lt`/`le` is the same minResults-widened traversal as a sort), or an opaque vector
4264
- // filter. Report the total as unavailable for those rather than advertising it as count=exact
4265
- // (mirroring how the estimated branch below bails to null for an opaque row/vector filter). A vector
4266
- // sort applied as in-memory post-ordering leaves no custom-index condition here and stays exact.
4267
- const touchesCustomIndex = (conds: any[]): boolean =>
4268
- conds.some((c: any) => {
4269
- if (!c) return false;
4270
- if (c.conditions) return touchesCustomIndex(c.conditions);
4271
- const attr = Array.isArray(c.attribute) ? c.attribute[0] : (c.attribute ?? c[0]);
4272
- return typeof attr === 'string' && Boolean(indices[attr]?.customIndex);
4273
- });
4274
- const approximateResultSet = typeof target.vectorFilter === 'function' || touchesCustomIndex(conditions);
4275
- return (async () => {
4276
- const page: any = [];
4277
- let scanned = 0;
4278
- let exact = true;
4279
- try {
4280
- for await (const record of results) {
4281
- if (scanned >= offset && scanned < pageEnd) page.push(record);
4282
- scanned++;
4283
- // A store whose async iterator settles synchronously (the common indexed-scan case) would
4284
- // otherwise let this drain spin as one uninterrupted microtask run, blocking the event loop
4285
- // for the whole count. Yield to the macrotask queue periodically so concurrent requests and
4286
- // I/O still make progress during a large exact scan.
4287
- if ((scanned & (COUNT_YIELD_INTERVAL - 1)) === 0) await new Promise((resolve) => setImmediate(resolve));
4288
- // The page window [offset, pageEnd) is always collected in full first — the guardrail
4289
- // only ever abandons the running TOTAL, never truncates the page body.
4290
- if (scanned >= pageEnd) {
4291
- // `estimated` needs nothing past the page; an approximate (vector) exact total is going to
4292
- // be reported unavailable anyway, so don't drain its tail for a number we won't publish.
4293
- if (!wantExact || approximateResultSet) break;
4294
- // `exact` keeps counting the tail, bounded by a row cap AND a time budget so a
4295
- // large match set can't turn a bounded page fetch into an unbounded scan.
4296
- if (scanned > MAX_EXACT_COUNT_SCAN || performance.now() - countStart > MAX_EXACT_COUNT_MS) {
4297
- exact = false;
4298
- break;
4747
+ try {
4748
+ const entries = executeConditions(
4749
+ conditions,
4750
+ operator,
4751
+ TableResource,
4752
+ readTxn,
4753
+ target,
4754
+ context,
4755
+ (results: any[], filters: Function[]) => transformToEntries(results, select, context, readTxn, filters),
4756
+ filtered,
4757
+ recordAccess
4758
+ );
4759
+ const ensure_loaded = (target as any).ensureLoaded !== false;
4760
+ // The guards inside executeConditions evaluate the
4761
+ // LOCAL record, but on a caching table transformEntryForSelect may then revalidate an
4762
+ // expired/invalidated row from source and return a DIFFERENT record. The explicit row filter
4763
+ // must hold on the record actually returned, so it is re-checked
4764
+ // there, after materialization (the earlier evaluation stays as a prune that also bounds HNSW
4765
+ // traversal). vectorFilter and condition filters intentionally keep the local-record
4766
+ // semantics all query filters have on caching tables.
4767
+ //
4768
+ // A row that is past its TTL but not yet swept by the background eviction
4769
+ // scan is still physically present. A write that is about to overwrite it
4770
+ // anyway (e.g. the SQL engine locating UPDATE/DELETE targets) needs to see
4771
+ // it as a match — the same leniency a direct by-id put/patch already gets,
4772
+ // since those never run the ensureLoaded-gated freshness check this transform
4773
+ // otherwise applies unconditionally to every read.
4774
+ const includeExpired = (target as any).includeExpired === true;
4775
+ const transformToRecord = TableResource.transformEntryForSelect(
4776
+ select,
4777
+ context,
4778
+ readTxn,
4779
+ filtered,
4780
+ ensure_loaded,
4781
+ true,
4782
+ boundRowFilter,
4783
+ includeExpired,
4784
+ postOrdering
4785
+ );
4786
+ let results = TableResource.transformToOrderedSelect(
4787
+ entries,
4788
+ select,
4789
+ postOrdering,
4790
+ context,
4791
+ readTxn,
4792
+ transformToRecord
4793
+ );
4794
+ const offset = target.offset || 0;
4795
+ const end = target.limit !== undefined ? offset + (target.limit as number) : undefined;
4796
+ // `Prefer: count=` (REST pagination): materialize the requested page and attach a total record
4797
+ // count so the HTTP layer can emit a Content-Range. `exact` drains the full matched set once,
4798
+ // windowing the page in the same pass; `estimated` returns just the page plus a cheap planner/
4799
+ // table estimate. Opt-in only — the default streaming path below is untouched.
4800
+ //
4801
+ // Requires a bounded page AND window. Counting is a pagination feature; both the limit and the
4802
+ // offset must be finite, non-negative integers, the limit no larger than MAX_COUNT_PAGE, and the
4803
+ // window (offset + limit) no larger than MAX_EXACT_COUNT_SCAN. Anything else — a missing/
4804
+ // oversized/non-finite/negative limit or offset (a bare collection GET, limit(Infinity),
4805
+ // limit(foo), limit(-5,10)) or a deep-page window past the scan budget — falls through to the
4806
+ // normal streaming path with no count. This bounds the offset too: without it a huge offset would
4807
+ // postpone the exact guardrail (which only engages past the page) until that offset was scanned.
4808
+ const pageLimit = target.limit as number;
4809
+ if (
4810
+ target.count &&
4811
+ Number.isInteger(pageLimit) &&
4812
+ pageLimit >= 0 &&
4813
+ pageLimit <= MAX_COUNT_PAGE &&
4814
+ Number.isInteger(offset) &&
4815
+ offset >= 0 &&
4816
+ offset + pageLimit <= MAX_EXACT_COUNT_SCAN
4817
+ ) {
4818
+ const wantExact = target.count === 'exact';
4819
+ const pageEnd = offset + pageLimit;
4820
+ const countStart = performance.now();
4821
+ // A custom-index (vector/HNSW) traversal returns a bounded, approximate candidate set whose size is
4822
+ // chosen from `minResults` (offset + limit), so `scanned` over it tracks the requested page size, not
4823
+ // the true match count — the same query at limit(5) vs limit(200) would otherwise advertise two
4824
+ // different `count=exact` totals. Any query whose execution touches a custom index is affected: a
4825
+ // custom-index sort (its aligned pseudo-condition lands in `conditions`), a custom-index threshold
4826
+ // filter (an HNSW `lt`/`le` is the same minResults-widened traversal as a sort), or an opaque vector
4827
+ // filter. Report the total as unavailable for those rather than advertising it as count=exact
4828
+ // (mirroring how the estimated branch below bails to null for an opaque row/vector filter). A vector
4829
+ // sort applied as in-memory post-ordering leaves no custom-index condition here and stays exact.
4830
+ const touchesCustomIndex = (conds: any[]): boolean =>
4831
+ conds.some((c: any) => {
4832
+ if (!c) return false;
4833
+ if (c.conditions) return touchesCustomIndex(c.conditions);
4834
+ const attr = Array.isArray(c.attribute) ? c.attribute[0] : (c.attribute ?? c[0]);
4835
+ return typeof attr === 'string' && Boolean(indices[attr]?.customIndex);
4836
+ });
4837
+ const approximateResultSet = typeof target.vectorFilter === 'function' || touchesCustomIndex(conditions);
4838
+ return (async () => {
4839
+ const page: any = [];
4840
+ let scanned = 0;
4841
+ let exact = true;
4842
+ try {
4843
+ for await (const record of results) {
4844
+ if (scanned >= offset && scanned < pageEnd) page.push(record);
4845
+ scanned++;
4846
+ // A store whose async iterator settles synchronously (the common indexed-scan case) would
4847
+ // otherwise let this drain spin as one uninterrupted microtask run, blocking the event loop
4848
+ // for the whole count. Yield to the macrotask queue periodically so concurrent requests and
4849
+ // I/O still make progress during a large exact scan.
4850
+ if ((scanned & (COUNT_YIELD_INTERVAL - 1)) === 0) await new Promise((resolve) => setImmediate(resolve));
4851
+ // The page window [offset, pageEnd) is always collected in full first — the guardrail
4852
+ // only ever abandons the running TOTAL, never truncates the page body.
4853
+ if (scanned >= pageEnd) {
4854
+ // `estimated` needs nothing past the page; an approximate (vector) exact total is going to
4855
+ // be reported unavailable anyway, so don't drain its tail for a number we won't publish.
4856
+ if (!wantExact || approximateResultSet) break;
4857
+ // `exact` keeps counting the tail, bounded by a row cap AND a time budget so a
4858
+ // large match set can't turn a bounded page fetch into an unbounded scan.
4859
+ if (scanned > MAX_EXACT_COUNT_SCAN || performance.now() - countStart > MAX_EXACT_COUNT_MS) {
4860
+ exact = false;
4861
+ break;
4862
+ }
4299
4863
  }
4300
4864
  }
4865
+ } finally {
4866
+ // We own the iteration here (no results.onDone consumer), so release the read
4867
+ // transaction unconditionally — including when the drain throws — or the snapshot leaks.
4868
+ txn.doneReadTxn();
4301
4869
  }
4302
- } finally {
4303
- // We own the iteration here (no results.onDone consumer), so release the read
4304
- // transaction unconditionally — including when the drain throws — or the snapshot leaks.
4305
- txn.doneReadTxn();
4306
- }
4307
- let total: number | null;
4308
- if (wantExact) {
4309
- // `scanned` is only an authoritative total when the iteration was exhaustive and deterministic;
4310
- // an approximate (vector/HNSW) result set is neither, so report the total as unavailable.
4311
- total = exact && !approximateResultSet ? scanned : null;
4312
- } else if (boundRowFilter || typeof target.vectorFilter === 'function') {
4313
- // An opaque row/vector filter shapes the result but isn't reflected in the index/condition
4314
- // estimate; guessing would both mislead and disclose cardinality the filter hides.
4315
- total = null;
4316
- } else if (!hasUserConditions) {
4317
- total = estimatedEntryCount(primaryStore);
4318
- } else {
4319
- // Estimate from the real conditions only — drop the planner's synthetic `sort`
4320
- // pseudo-condition, which otherwise contributes a bogus (entryCount/2) cardinality.
4321
- const est = estimateCondition(TableResource)({
4322
- conditions: conditions.filter((c: any) => c.comparator !== 'sort'),
4323
- operator: operator ? String(operator).toLowerCase() : 'and',
4324
- });
4325
- total = isFinite(est) ? Math.round(est) : null;
4326
- }
4327
- // For an estimate, never report a total below the last row actually returned — keeps the
4328
- // Content-Range valid (start-end/total) when an estimate undershoots a non-empty page.
4329
- // Exact totals are authoritative (and an empty page past the end must not be clamped up).
4330
- if (!wantExact && total != null && page.length > 0 && total < offset + page.length) {
4331
- total = offset + page.length;
4332
- }
4333
- page.recordCount = total;
4334
- page.recordCountExact = wantExact && exact && !approximateResultSet;
4335
- page.selectApplied = true;
4336
- page.getColumns = getColumns;
4337
- return page;
4338
- })() as any;
4339
- }
4340
- // apply any offset/limit after all the sorting and filtering
4341
- if (target.offset || target.limit !== undefined) results = results.slice(offset, end);
4342
- results.onDone = () => {
4343
- results.onDone = null; // ensure that it isn't called twice
4870
+ let total: number | null;
4871
+ if (wantExact) {
4872
+ // `scanned` is only an authoritative total when the iteration was exhaustive and deterministic;
4873
+ // an approximate (vector/HNSW) result set is neither, so report the total as unavailable.
4874
+ total = exact && !approximateResultSet ? scanned : null;
4875
+ } else if (boundRowFilter || typeof target.vectorFilter === 'function') {
4876
+ // An opaque row/vector filter shapes the result but isn't reflected in the index/condition
4877
+ // estimate; guessing would both mislead and disclose cardinality the filter hides.
4878
+ total = null;
4879
+ } else if (!hasUserConditions) {
4880
+ total = estimatedEntryCount(primaryStore);
4881
+ } else {
4882
+ // Estimate from the real conditions only — drop the planner's synthetic `sort`
4883
+ // pseudo-condition, which otherwise contributes a bogus (entryCount/2) cardinality.
4884
+ const est = estimateCondition(TableResource)({
4885
+ conditions: conditions.filter((c: any) => c.comparator !== 'sort'),
4886
+ operator: operator ? String(operator).toLowerCase() : 'and',
4887
+ });
4888
+ total = isFinite(est) ? Math.round(est) : null;
4889
+ }
4890
+ // For an estimate, never report a total below the last row actually returned — keeps the
4891
+ // Content-Range valid (start-end/total) when an estimate undershoots a non-empty page.
4892
+ // Exact totals are authoritative (and an empty page past the end must not be clamped up).
4893
+ if (!wantExact && total != null && page.length > 0 && total < offset + page.length) {
4894
+ total = offset + page.length;
4895
+ }
4896
+ page.recordCount = total;
4897
+ page.recordCountExact = wantExact && exact && !approximateResultSet;
4898
+ page.selectApplied = true;
4899
+ page.getColumns = getColumns;
4900
+ return page;
4901
+ })() as any;
4902
+ }
4903
+ // apply any offset/limit after all the sorting and filtering
4904
+ if (target.offset || target.limit !== undefined) results = results.slice(offset, end);
4905
+ results.onDone = () => {
4906
+ results.onDone = null; // ensure that it isn't called twice
4907
+ txn.doneReadTxn();
4908
+ };
4909
+ results.selectApplied = true;
4910
+ results.getColumns = getColumns;
4911
+ return results;
4912
+ } catch (error) {
4344
4913
  txn.doneReadTxn();
4345
- };
4346
- results.selectApplied = true;
4347
- results.getColumns = getColumns;
4348
- return results;
4914
+ throw error;
4915
+ }
4349
4916
  }
4350
4917
  /**
4351
4918
  * This is responsible for ordering and select()ing the attributes/properties from returned entries
@@ -4368,10 +4935,17 @@ export function makeTable(options) {
4368
4935
  if (sort) {
4369
4936
  // there might be some situations where we don't need to transform to entries for sorting, not sure
4370
4937
  entries = transformToEntries(entries, select, context, readTxn, null);
4371
- let ordered;
4938
+ // Sort keys are resolved as entries are collected, so comparison never dereferences a record: a
4939
+ // cached entry holds its record only weakly, and a re-read per comparison is what this avoids.
4940
+ const clauses: Sort[] = [];
4941
+ for (let order = sort; order; order = order.next) clauses.push(order);
4942
+ const clauseCount = clauses.length;
4372
4943
  // if we are doing post-ordering, we need to get records first, then sort them
4373
4944
  results.iterate = function (options: { async: boolean }) {
4374
- let sortedArrayIterator: IterableIterator<any>;
4945
+ let ordered: any[];
4946
+ let orderedKeys: any[][];
4947
+ let sortedPositions: number[];
4948
+ let sortedIndex: number;
4375
4949
  const dbIterator =
4376
4950
  options?.async && entries[Symbol.asyncIterator]
4377
4951
  ? entries[Symbol.asyncIterator]()
@@ -4381,25 +4955,33 @@ export function makeTable(options) {
4381
4955
  let enqueuedEntryForNextGroup: any;
4382
4956
  let lastGroupingValue: any;
4383
4957
  let firstEntry = true;
4384
- function createComparator(order: Sort) {
4385
- const nextComparator = order.next && createComparator(order.next);
4386
- const descending = order.descending;
4387
- return (entryA, entryB) => {
4388
- const a = getAttributeValue(entryA, order.attribute, context, order);
4389
- const b = getAttributeValue(entryB, order.attribute, context, order);
4390
- const diff = descending
4391
- ? compareKeys(convertToComparableKeys(b), convertToComparableKeys(a))
4392
- : compareKeys(convertToComparableKeys(a), convertToComparableKeys(b));
4393
- if (diff === 0) return nextComparator?.(entryA, entryB) || 0;
4394
- return diff;
4395
- };
4958
+ function collect(entry) {
4959
+ ordered.push(entry);
4960
+ for (let i = 0; i < clauseCount; i++) {
4961
+ const clause = clauses[i];
4962
+ orderedKeys[i].push(convertToComparableKeys(getAttributeValue(entry, clause.attribute, context, clause)));
4963
+ }
4964
+ }
4965
+ function comparePositions(positionA: number, positionB: number): number {
4966
+ for (let i = 0; i < clauseCount; i++) {
4967
+ const keys = orderedKeys[i];
4968
+ const diff = clauses[i].descending
4969
+ ? compareKeys(keys[positionB], keys[positionA])
4970
+ : compareKeys(keys[positionA], keys[positionB]);
4971
+ if (diff !== 0) return diff;
4972
+ }
4973
+ return 0;
4974
+ }
4975
+ function nextSorted(): IteratorResult<any> {
4976
+ if (sortedIndex < sortedPositions.length)
4977
+ return { done: false, value: ordered[sortedPositions[sortedIndex++]] };
4978
+ return { done: true, value: undefined };
4396
4979
  }
4397
- const comparator = createComparator(sort);
4398
4980
  return {
4399
4981
  async next() {
4400
4982
  let iteration: IteratorResult<any>;
4401
- if (sortedArrayIterator) {
4402
- iteration = sortedArrayIterator.next();
4983
+ if (sortedPositions) {
4984
+ iteration = nextSorted();
4403
4985
  if (iteration.done) {
4404
4986
  if (dbDone) {
4405
4987
  if (results.onDone) results.onDone();
@@ -4411,7 +4993,9 @@ export function makeTable(options) {
4411
4993
  };
4412
4994
  }
4413
4995
  ordered = [];
4414
- if (enqueuedEntryForNextGroup) ordered.push(enqueuedEntryForNextGroup);
4996
+ orderedKeys = [];
4997
+ for (let i = 0; i < clauseCount; i++) orderedKeys.push([]);
4998
+ if (enqueuedEntryForNextGroup) collect(enqueuedEntryForNextGroup);
4415
4999
  // need to load all the entries into ordered
4416
5000
  do {
4417
5001
  iteration = await dbIterator.next();
@@ -4442,17 +5026,17 @@ export function makeTable(options) {
4442
5026
  break;
4443
5027
  }
4444
5028
  }
4445
- // we store the value we will sort on, for fast sorting, and the entry so the records can be GC'ed if necessary
4446
- // before the sorting is completed
4447
- ordered.push(entry);
5029
+ collect(entry);
4448
5030
  }
4449
5031
  } while (true);
4450
5032
  if ((sort as any).isGrouped) {
4451
5033
  // TODO: Return grouped results
4452
5034
  }
4453
- ordered.sort(comparator);
4454
- sortedArrayIterator = ordered[Symbol.iterator]();
4455
- iteration = sortedArrayIterator.next();
5035
+ sortedPositions = [];
5036
+ for (let i = 0; i < ordered.length; i++) sortedPositions.push(i);
5037
+ sortedPositions.sort(comparePositions);
5038
+ sortedIndex = 0;
5039
+ iteration = nextSorted();
4456
5040
  if (!iteration.done)
4457
5041
  return {
4458
5042
  value: await transformToRecord.call(this, iteration.value),
@@ -4859,6 +5443,8 @@ export function makeTable(options) {
4859
5443
  if (dropDuringReplay) return;
4860
5444
  try {
4861
5445
  let type = auditRecord.type;
5446
+ // Ahead of the rawEvents branch, which forwards every type verbatim.
5447
+ if (isLockControlType(type)) return;
4862
5448
  let value;
4863
5449
  if (type === 'message' || request.rawEvents) {
4864
5450
  // we only send the full message, this are individual messages that can be sent out of order
@@ -4949,7 +5535,8 @@ export function makeTable(options) {
4949
5535
  await rest();
4950
5536
  if (!isActive()) return;
4951
5537
  }
4952
- if (auditRecord.tableId !== tableId) continue;
5538
+ if (auditRecord.tableId !== tableId || auditRecord.type === 'evict') continue;
5539
+ if (isLockControlType(auditRecord.type)) continue;
4953
5540
  const id = auditRecord.recordId;
4954
5541
  if (thisId == null || isDescendantId(thisId, id)) {
4955
5542
  const value = auditRecord.getValue(primaryStore, getFullRecord, auditRecord.txnLogKey);
@@ -4986,7 +5573,8 @@ export function makeTable(options) {
4986
5573
  if (!isActive()) return;
4987
5574
  }
4988
5575
  try {
4989
- if (auditRecord.tableId !== tableId) continue;
5576
+ if (auditRecord.tableId !== tableId || auditRecord.type === 'evict') continue;
5577
+ if (isLockControlType(auditRecord.type)) continue;
4990
5578
  const id = auditRecord.recordId;
4991
5579
  if (thisId == null || isDescendantId(thisId, id)) {
4992
5580
  // Bound entries INSPECTED for THIS scope, independent of `count` (entries
@@ -5411,6 +5999,115 @@ export function makeTable(options) {
5411
5999
  });
5412
6000
  });
5413
6001
  }
6002
+ /**
6003
+ * Write one cluster record-lock control entry (harper#483 Phase 1). Not local-only: replicating
6004
+ * it IS the send.
6005
+ *
6006
+ * `recordId` must stay null. An entry carrying the locked key would share
6007
+ * `(version, tableId, recordId, nodeId)` with the holder's own first write, which is stamped at
6008
+ * exactly `ts_R`, and `RocksTransactionLogStore.getSync` answers with the FIRST entry at a
6009
+ * timestamp and key — so `_writeUpdate`'s keyed dedup would find this one and drop that write.
6010
+ * The payload goes in as bytes rather than through `recordUpdater`, which would run it through
6011
+ * schema projection and the table's shared structure dictionary.
6012
+ */
6013
+ static writeLockControlEntry(entry: LockControlEntry): Promise<number | undefined> {
6014
+ const encodedRecord = encodeLockControlPayload(entry);
6015
+ const nodeId = getThisNodeId(auditStore) ?? 0;
6016
+ let position: number;
6017
+ // No entry pins its clock, the request included. `ts_R` is minted before the write, so pinning
6018
+ // to it can land the entry behind a peer's replication cursor if any write to this table
6019
+ // commits in between — the same hazard that rules it out for grants and releases, which are
6020
+ // written later still. The protocol reads `ts_R` from the payload, so the entry's own log key
6021
+ // never has to equal it.
6022
+ const context = {};
6023
+ return Promise.resolve(
6024
+ transaction(context as any, (txn: any) => {
6025
+ const tableTxn = txnForContext({ transaction: txn } as any);
6026
+ tableTxn.addWrite({
6027
+ key: null,
6028
+ store: primaryStore,
6029
+ skipReplicationConfirmation: true,
6030
+ commit: (txnTime: number, _existingEntry: any, _retry: any, nativeTransaction: any) => {
6031
+ position = txnTime;
6032
+ return auditStore[isRocksDB ? 'putSync' : 'put'](
6033
+ null,
6034
+ {
6035
+ version: txnTime,
6036
+ tableId,
6037
+ recordId: null,
6038
+ nodeId,
6039
+ type: entry.type,
6040
+ encodedRecord,
6041
+ extendedType: 0,
6042
+ // Zero, not the table's count: these bytes were packed by the private control `Packr`
6043
+ // and carry none of the table's structures. `RocksTransactionLogStore` raises the
6044
+ // per-(log, table) structure watermark from this field and flags the entry that does
6045
+ // it, so claiming the table's version would let a release take `HAS_STRUCTURE_UPDATE`
6046
+ // and leave the next real write at that version unflagged — a receiver that learns
6047
+ // structures only from flagged entries then decodes later records against a stale set
6048
+ // (harper#1348's class). A payload with no table structures cannot advance them.
6049
+ structureVersion: 0,
6050
+ },
6051
+ { instructedWrite: true, transaction: nativeTransaction, nodeId, viaNodeId: nodeId }
6052
+ );
6053
+ },
6054
+ });
6055
+ })
6056
+ ).then(() => position);
6057
+ }
6058
+ /**
6059
+ * The coordinator that holds this node's admissions, transport or not. Releasing and registering
6060
+ * go here rather than through `lockCoordinator`, which answers undefined while a transport is
6061
+ * momentarily unregistered — and a release dropped on that answer leaves the key's home holding
6062
+ * its grant until the delegation's own deadline.
6063
+ */
6064
+ static get admittingCoordinator(): LockCoordinator | undefined {
6065
+ return lockCoordinator;
6066
+ }
6067
+
6068
+ /**
6069
+ * This table's cluster lock coordinator, created on first use and only while a transport is
6070
+ * registered for the database. Nothing is allocated on the Phase 0 path.
6071
+ */
6072
+ static get lockCoordinator(): LockCoordinator | undefined {
6073
+ const transport = getClusterLockTransport(databaseName);
6074
+ if (!transport) {
6075
+ // Deliberately NOT closed. harper-pro unregisters without a standalone claim during a
6076
+ // reconnect, and closing here would drop this node's record of the delegations it has
6077
+ // issued as a home — so the next registration would start empty and could grant a key
6078
+ // whose delegate is still admitting. The coordinator keeps ticking, its grants expire on
6079
+ // their own deadlines, and `isClusterLockRequired` is what fails an acquire closed in the
6080
+ // meantime. A genuine standalone claim clears the requirement and the coordinator with it.
6081
+ if (!isClusterLockRequired(databaseName)) {
6082
+ lockCoordinator?.close();
6083
+ lockCoordinator = undefined;
6084
+ }
6085
+ return undefined;
6086
+ }
6087
+ if (lockCoordinator?.transport !== transport) {
6088
+ // The transport object changed, but this node's delegations and the handles they admitted
6089
+ // did not. The successor adopts that live authority in its constructor; the predecessor
6090
+ // is closed afterwards so nothing is dropped in between. See LockCoordinatorOptions.adopt.
6091
+ const predecessor = lockCoordinator;
6092
+ lockCoordinator = new LockCoordinator({
6093
+ database: databaseName,
6094
+ table: tableName,
6095
+ nodeId: getThisNodeName(),
6096
+ transport,
6097
+ adopt: predecessor,
6098
+ // Writing to the local transaction log IS the send, so a transport that only computes
6099
+ // the participant set gets core's writer.
6100
+ writeControl: transport.writeControl
6101
+ ? (entry: LockControlEntry) => transport.writeControl!(tableName, entry)
6102
+ : (entry: LockControlEntry) => TableResource.writeLockControlEntry(entry),
6103
+ keyIdOf: writeKeyId,
6104
+ nextTimestamp: () => (primaryStore as any).getMonotonicTimestamp(),
6105
+ grantableAfterMono: transport.grantableAfterMono,
6106
+ });
6107
+ predecessor?.close();
6108
+ }
6109
+ return lockCoordinator;
6110
+ }
5414
6111
  // #section: validation
5415
6112
  validate(record: any, patch?: boolean) {
5416
6113
  // Accumulate structured per-field issues so the 400 carries `{ path, code,
@@ -5652,82 +6349,174 @@ export function makeTable(options) {
5652
6349
  const exactCount = options?.exactCount;
5653
6350
  const TIME_LIMIT = options?.timeLimit ?? 1000 / 2; // one second time limit, enforced by seeing if we are halfway through at 500ms
5654
6351
  const start = performance.now();
5655
- // `entryCount` (the exact key count) is only needed once the scan blows the time budget --
5656
- // to decide whether to estimate and as the extrapolation base. On RocksDB it is a full
5657
- // key-only scan, so we defer it: tables that finish within budget (the common case) and
5658
- // `exact_count` requests never pay for it. `halfway`/`entryCount` stay 0 until first computed.
5659
6352
  let entryCount = 0;
5660
- let halfway = 0;
5661
- let counted = false;
5662
- let completeForExact = false;
6353
+ let remainderPhysical = 0;
6354
+ let estimator;
6355
+ // feature-detected per DESIGN.md's invariant for this API family; LMDB stores do not implement it
6356
+ const canEstimate =
6357
+ typeof primaryStore.createCountEstimator === 'function' && typeof primaryStore.estimateCount === 'function';
6358
+ let estimatorFailed = false;
6359
+ let warnedNoBase = false;
6360
+ let checkpoints = 0;
6361
+ let checkpointedEntries = 0;
5663
6362
  let recordCount = 0;
5664
6363
  let entriesScanned = 0;
6364
+ let lastKey;
5665
6365
  let limit: number;
5666
- for (const { value } of primaryStore.getRange({ start: true, lazy: true, snapshot: false })) {
6366
+ let nextCheckAt = start + TIME_LIMIT;
6367
+ for (const { key, value } of primaryStore.getRange({ start: true, lazy: true, snapshot: false })) {
5667
6368
  if (value != null) recordCount++;
5668
6369
  entriesScanned++;
6370
+ lastKey = key;
5669
6371
  await rest();
5670
- if (!exactCount && !completeForExact && performance.now() - start > TIME_LIMIT) {
5671
- if (!counted) {
5672
- counted = true;
5673
- entryCount = isRocksDB
5674
- ? primaryStore.getKeysCount({ start: undefined })
5675
- : primaryStore.getStats().entryCount;
5676
- halfway = Math.floor(entryCount / 2);
6372
+ // a table too small to reach the floor is small enough to finish exactly
6373
+ if (exactCount || entriesScanned < MIN_ESTIMATOR_SAMPLE) continue;
6374
+ const now = performance.now();
6375
+ if (now <= nextCheckAt) continue;
6376
+ nextCheckAt = now + TIME_LIMIT;
6377
+ checkpoints++;
6378
+ if (canEstimate && !estimatorFailed) {
6379
+ try {
6380
+ estimator ??= primaryStore.createCountEstimator({ start: true });
6381
+ estimator.advance(lastKey, entriesScanned - checkpointedEntries);
6382
+ checkpointedEntries = entriesScanned;
6383
+ entryCount = usableCount(estimator.estimate());
6384
+ } catch (error) {
6385
+ // a store closing concurrently -- drop_table can, while this scan is parked in a yield
6386
+ logger.debug?.('Count estimator unavailable, falling back to an exact scan', error);
6387
+ estimatorFailed = true;
6388
+ estimator = undefined;
6389
+ entryCount = 0;
5677
6390
  }
5678
- if (entriesScanned < halfway) {
5679
- // it is taking too long, so we will just take this sample and a sample from the end to estimate
5680
- limit = entriesScanned;
5681
- break;
6391
+ } else if (!canEstimate) {
6392
+ // `canEstimate` false is not the same as "LMDB": a RocksDB store whose native module predates
6393
+ // the estimator API lands here too, and `RocksDatabase.getStats()` carries no `entryCount`.
6394
+ try {
6395
+ const stats = primaryStore.getStats?.();
6396
+ entryCount = Number.isFinite(stats?.entryCount) && stats.entryCount > 0 ? stats.entryCount : 0;
6397
+ } catch {
6398
+ entryCount = 0;
5682
6399
  }
5683
- // Past the halfway point already: finishing the scan for an exact count is cheaper
5684
- // than estimating. Set the flag so we stop re-evaluating the budget on each remaining iteration.
5685
- completeForExact = true;
6400
+ }
6401
+ if (!entryCount && canEstimate && !estimatorFailed) {
6402
+ // Range estimates are block-granular and can report 0 for a store whose entries are still
6403
+ // in the memtable. Without a base the escape cannot fire at all, so fall back to the
6404
+ // whole-store property rather than silently walking the table.
6405
+ try {
6406
+ const wholeStore = primaryStore.getEstimatedKeyCount();
6407
+ entryCount = Number.isFinite(wholeStore) && wholeStore > 0 ? wholeStore : 0;
6408
+ } catch {
6409
+ entryCount = 0;
6410
+ }
6411
+ if (!entryCount && !warnedNoBase) {
6412
+ warnedNoBase = true;
6413
+ logger.debug?.(`No usable key-count estimate for ${tableName}; counting records by full scan`);
6414
+ }
6415
+ }
6416
+ // Zero is "no usable base": degrade to the exact scan. The checkpoint ceiling is what stops a
6417
+ // base that keeps undershooting from holding the halfway test false forever and walking the
6418
+ // whole table; the reverse sample is bounded by `limit` in turn.
6419
+ if (
6420
+ entryCount > 0 &&
6421
+ (checkpoints >= MAX_ESTIMATE_CHECKPOINTS || entriesScanned < Math.floor(entryCount / 2))
6422
+ ) {
6423
+ if (canEstimate) {
6424
+ try {
6425
+ const remaining = primaryStore.estimateCount({ start: lastKey, exclusiveStart: true });
6426
+ // widened by its own reported untrustworthiness: block-granular, so it can land below
6427
+ // the live count it is meant to bound
6428
+ const remainingCount = usableCount(remaining);
6429
+ remainderPhysical = remainingCount > 0 ? remainingCount * (2 - remaining.confidence) : 0;
6430
+ } catch {
6431
+ remainderPhysical = 0;
6432
+ }
6433
+ // A zero or unusable remainder is valid -- entries still in the memtable read as none
6434
+ // through range statistics -- but it would leave `baseMax` resting on the sampled ends
6435
+ // alone. The whole-store property is a separate, non-range source, so fall back to it.
6436
+ if (!remainderPhysical) {
6437
+ try {
6438
+ const wholeStore = primaryStore.getEstimatedKeyCount();
6439
+ if (Number.isFinite(wholeStore)) remainderPhysical = Math.max(wholeStore - entriesScanned, 0);
6440
+ } catch {
6441
+ remainderPhysical = 0;
6442
+ }
6443
+ }
6444
+ }
6445
+ limit = entriesScanned;
6446
+ break;
5686
6447
  }
5687
6448
  }
5688
6449
  if (limit) {
5689
6450
  // in this case we are going to make an estimate of the table count using the first thousand
5690
6451
  // entries and last thousand entries
5691
6452
  const firstRecordCount = recordCount;
6453
+ const firstKey = lastKey;
5692
6454
  recordCount = 0;
5693
6455
  // Bound the reverse scan explicitly. The getRange `limit` option is honored by lmdb-js but
5694
6456
  // ignored by rocksdb-js; without this break the scan reads the whole table, so `recordRate`
5695
6457
  // blows up to ~entryCount/(2*limit) and the estimate scales with entryCount^2 -- the source
5696
6458
  // of the wildly inflated `record_count` (e.g. 20,000,000 for ~105k rows) on large RocksDB
5697
- // tables. The early-exit above guarantees limit < entryCount/2, so the two samples stay disjoint.
6459
+ // tables.
5698
6460
  let reverseScanned = 0;
5699
- for (const { value } of primaryStore.getRange({
6461
+ // Sized independently of the forward scan. `entriesScanned` is whatever the forward pass
6462
+ // covered before it escaped, and the checkpoint ceiling lets that run twenty budget
6463
+ // intervals when the base keeps undershooting; matching it here would read that same count
6464
+ // again and double the wall clock of the call this path exists to bound.
6465
+ const reverseLimit = Math.min(limit, MIN_ESTIMATOR_SAMPLE);
6466
+ // Disjointness is enforced against the forward scan's own last key rather than inferred from
6467
+ // the base, which is an estimate that can overshoot by more than 2x.
6468
+ let sampledWholeTable = false;
6469
+ for (const { key, value } of primaryStore.getRange({
5700
6470
  start: '\uffff',
5701
6471
  reverse: true,
5702
6472
  lazy: true,
5703
- limit,
6473
+ limit: reverseLimit,
5704
6474
  snapshot: false,
5705
6475
  })) {
6476
+ if (compareKeys(key, firstKey) <= 0) {
6477
+ sampledWholeTable = true;
6478
+ break;
6479
+ }
5706
6480
  if (value != null) recordCount++;
5707
6481
  reverseScanned++;
5708
6482
  await rest();
5709
- if (reverseScanned >= limit) break;
6483
+ if (reverseScanned >= reverseLimit) break;
5710
6484
  }
6485
+ // the samples met, so between them they covered every entry
6486
+ if (sampledWholeTable) return { recordCount: recordCount + firstRecordCount };
5711
6487
  // Use the actual entries sampled, not limit*2: the reverse scan can yield fewer than `limit`
5712
6488
  // (concurrent deletions under snapshot:false, or an overestimated entryCount), and counting
5713
6489
  // those un-scanned slots would inflate the denominator and underestimate the rate.
5714
- const sampleSize = limit + reverseScanned;
5715
- const recordRate = (recordCount + firstRecordCount) / sampleSize;
5716
- const variance =
5717
- Math.pow((recordCount - firstRecordCount + 1) / limit / 2, 2) + // variance between samples
5718
- (recordRate * (1 - recordRate)) / sampleSize;
5719
- const sd = Math.max(Math.sqrt(variance) * entryCount, 1);
5720
- const estimatedRecordCount = Math.round(recordRate * entryCount);
5721
- // TODO: This uses a normal/Wald interval, but a binomial confidence interval is probably better calculated using
5722
- // Wilson score interval or Agresti-Coull interval (I think the latter is a little easier to calculate/implement).
5723
- const lowerCiLimit = Math.max(estimatedRecordCount - 1.96 * sd, recordCount + firstRecordCount);
5724
- const upperCiLimit = Math.min(estimatedRecordCount + 1.96 * sd, entryCount);
5725
- let significantUnit = Math.pow(10, Math.round(Math.log10(sd)));
5726
- if (significantUnit > estimatedRecordCount) significantUnit = significantUnit / 10;
5727
- recordCount = Math.round(estimatedRecordCount / significantUnit) * significantUnit;
6490
+ const sampledRecords = recordCount + firstRecordCount;
6491
+ const recordRate = sampledRecords / (limit + reverseScanned);
6492
+ // Endpoints for the extrapolation base, spanning both ways an estimated base can be wrong:
6493
+ // every remaining entry superseded (only what was sampled is live) through every remaining
6494
+ // entry live (the uncalibrated physical count). Churn concentrated outside the sampled ends
6495
+ // calibrates to nothing, so an interval derived from the estimator's confidence would sit
6496
+ // narrowly around the wrong number. Both endpoints are themselves estimates on RocksDB, so
6497
+ // this is a widened heuristic interval, not a guaranteed bound on the live count.
6498
+ const baseMin = entriesScanned + reverseScanned;
6499
+ const baseMax = Math.max(entriesScanned + remainderPhysical, entryCount, baseMin);
6500
+ const estimatedRecordCount = Math.round(recordRate * Math.max(entryCount, baseMin));
6501
+ // The samples counted these directly, and the entries between them can only add; everything
6502
+ // outside the samples could be live. A statistical interval inside those endpoints would be
6503
+ // narrowest exactly where the ends are least representative of the middle -- sampled ends
6504
+ // that are all deletion entries give a rate of 0, collapsing an upper end to ~0 with live
6505
+ // rows in between -- so the endpoints are the evidence itself.
6506
+ const lower = sampledRecords;
6507
+ const upper = Math.round(baseMax);
6508
+ // Report only the precision the interval supports, but never so coarse a unit that the
6509
+ // estimate rounds away: `baseMax` is physical and can exceed a calibrated estimate by
6510
+ // orders of magnitude, which a single division cannot walk back.
6511
+ let significantUnit = Math.pow(10, Math.round(Math.log10(Math.max((upper - lower) / 2, 1))));
6512
+ while (significantUnit > estimatedRecordCount && significantUnit > 1) significantUnit /= 10;
6513
+ recordCount = Math.min(
6514
+ Math.max(Math.round(estimatedRecordCount / significantUnit) * significantUnit, lower),
6515
+ upper
6516
+ );
5728
6517
  return {
5729
6518
  recordCount,
5730
- estimatedRange: [Math.round(lowerCiLimit), Math.round(upperCiLimit)],
6519
+ estimatedRange: [lower, upper],
5731
6520
  };
5732
6521
  }
5733
6522
  return {
@@ -5741,6 +6530,7 @@ export function makeTable(options) {
5741
6530
  // Refresh on every call: schema reload mutates `attributes` in place, so the
5742
6531
  // class-construction snapshot would otherwise go stale.
5743
6532
  this.embedAttributes = (this.attributes as any[]).filter((a) => a?.embed);
6533
+ expiresAtProperty = this.attributes.find((attribute) => attribute.expiresAt);
5744
6534
  // Drop registry entries for attributes that are no longer `@embed`, so a dropped
5745
6535
  // directive doesn't leave a stale embedder or block a default refresh on re-add.
5746
6536
  const embedNames = new Set(this.embedAttributes.map((a) => a.name));
@@ -5810,7 +6600,7 @@ export function makeTable(options) {
5810
6600
  txnForContext(context).getReadTxn(),
5811
6601
  false,
5812
6602
  relatedTable,
5813
- false
6603
+ { allowFullScan: false }
5814
6604
  ) as any
5815
6605
  ).map((entry) => {
5816
6606
  if (entry && entry.key !== undefined) return entry;
@@ -6058,10 +6848,24 @@ export function makeTable(options) {
6058
6848
  }
6059
6849
  const drainRemovals = () => Promise.all(inFlightRemovals);
6060
6850
  let entriesDeleted = 0;
6851
+ // LMDB only: RocksTransactionLogStore.remove() is a no-op, so a RocksDB deleteHistory removes
6852
+ // nothing and must not claim it did.
6853
+ // A bound above everything reachable must not be recorded as the floor: the floor only rises
6854
+ // and a store with a record is never re-stamped, so it would never come down, for every table in
6855
+ // this database. `boundedAuditPruneEnd` clamps the cutoff to just above the newest key in the
6856
+ // log, and the scan below uses that same value as its range end, so the prune cannot remove an
6857
+ // entry the floor does not cover.
6858
+ let pruneEnd = endTime;
6859
+ if (!isRocksDB) {
6860
+ pruneEnd = boundedAuditPruneEnd(auditStore, endTime);
6861
+ raiseAuditFloor(auditStore, pruneEnd);
6862
+ }
6061
6863
  try {
6062
6864
  for (const auditRecord of auditStore.getRange({
6063
- start: 1, // must not be zero; see getHistory below for why
6064
- end: endTime,
6865
+ // must not be zero: 0 encodes to all zero bytes and so overlaps the symbol keys, as in
6866
+ // getHistory below
6867
+ start: 1,
6868
+ end: pruneEnd,
6065
6869
  })) {
6066
6870
  await rest(); // yield to other async operations
6067
6871
  if (auditRecord.tableId !== tableId) continue;
@@ -6088,7 +6892,7 @@ export function makeTable(options) {
6088
6892
  isRocksDB && version != null
6089
6893
  ? resolveAuditHead(key, version, entry.nodeId, entry.additionalAuditRefs).txnLogKey
6090
6894
  : localTime;
6091
- if (value === null && version != null && auditTime < endTime) {
6895
+ if (value === null && version != null && auditTime < pruneEnd) {
6092
6896
  const backpressure = queueRemoval(
6093
6897
  () => primaryStore.remove(key, version),
6094
6898
  'Error removing deleted record during deleteHistory'
@@ -6113,7 +6917,8 @@ export function makeTable(options) {
6113
6917
  end: endTime,
6114
6918
  })) {
6115
6919
  await rest(); // yield to other async operations
6116
- if (auditRecord.tableId !== tableId) continue;
6920
+ if (auditRecord.tableId !== tableId || auditRecord.type === 'evict' || isLockControlType(auditRecord.type))
6921
+ continue;
6117
6922
  yield {
6118
6923
  id: auditRecord.recordId,
6119
6924
  // Compatibility-facing LMDB history has always reported/grouped by record version.
@@ -6143,7 +6948,12 @@ export function makeTable(options) {
6143
6948
  let highestPreviousVersion = 0;
6144
6949
  const start = nextVersion - auditWindow;
6145
6950
  for (const auditRecord of auditStore.getRange({ start, end: nextVersion + 0.001 })) {
6146
- if (auditRecord.tableId === tableId && compareKeys(auditRecord.recordId, id) === 0) {
6951
+ if (
6952
+ auditRecord.tableId === tableId &&
6953
+ auditRecord.type !== 'evict' &&
6954
+ !isLockControlType(auditRecord.type) &&
6955
+ compareKeys(auditRecord.recordId, id) === 0
6956
+ ) {
6147
6957
  history.splice(insertionPoint, 0, {
6148
6958
  id: auditRecord.recordId,
6149
6959
  localTime: isRocksDB ? auditRecord.txnLogKey : auditRecord.version,
@@ -6179,6 +6989,7 @@ export function makeTable(options) {
6179
6989
  const promises = [primaryStore.clear()];
6180
6990
  for (const key in indices) {
6181
6991
  const index = indices[key];
6992
+ index.customIndex?.resetDerivedStorage?.();
6182
6993
  promises.push(index.clearAsync ? index.clearAsync() : index.clear());
6183
6994
  }
6184
6995
  return Promise.all(promises);
@@ -6186,6 +6997,7 @@ export function makeTable(options) {
6186
6997
  /** Release everything makeTable() registered process-wide; the class must not be used afterwards. */
6187
6998
  static cleanup() {
6188
6999
  disposed = true;
7000
+ void TableResource.derivedIndexRuntime?.close();
6189
7001
  clearTimeout(cleanupTimer);
6190
7002
  settlePendingCleanup();
6191
7003
  clearInterval(recordExpirationInterval);
@@ -6214,8 +7026,15 @@ export function makeTable(options) {
6214
7026
 
6215
7027
  try {
6216
7028
  TableResource.updatedAttributes(); // on creation, update accessors as well
6217
- if (expirationMs) TableResource.setTTLExpiration(expirationMs / 1000);
6218
- if (expiresAtProperty) runRecordExpirationEviction();
7029
+ if (expirationMs) {
7030
+ ttlFromLoad = true;
7031
+ try {
7032
+ TableResource.setTTLExpiration(expirationMs / 1000);
7033
+ } finally {
7034
+ ttlFromLoad = false;
7035
+ }
7036
+ }
7037
+ if (expiresAtProperty && !recordExpirationInterval) runRecordExpirationEviction();
6219
7038
  } catch (error) {
6220
7039
  TableResource.cleanup();
6221
7040
  throw error;
@@ -6980,25 +7799,34 @@ export function makeTable(options) {
6980
7799
  }
6981
7800
  resolve(resolvedEntry);
6982
7801
  } catch (error) {
6983
- error.message += ` while resolving record ${id} for ${tableName}`;
6984
- if (
6985
- existingRecord &&
6986
- (((error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED' || error.code === 'EAI_AGAIN') &&
6987
- !context?.mustRevalidate) ||
6988
- (context?.staleIfError &&
6989
- (error.statusCode === 500 ||
6990
- error.statusCode === 502 ||
6991
- error.statusCode === 503 ||
6992
- error.statusCode === 504)))
6993
- ) {
6994
- // these are conditions under which we can use stale data after an error
6995
- resolve({
6996
- key: id,
6997
- version: existingVersion,
6998
- value: existingRecord,
6999
- } as any);
7000
- logger.trace?.(error.message, '(returned stale record)');
7001
- } else reject(error);
7802
+ // A source may reject with anything at all, so deciding how to settle is itself
7803
+ // fallible: `message` is not assignable on every error (a DOMException from
7804
+ // AbortSignal.timeout), and a nullish rejection makes the reads below throw.
7805
+ // Leaving this promise unsettled hangs the caller forever, so every path here
7806
+ // has to end in resolve() or reject().
7807
+ try {
7808
+ appendErrorContext(error, ` while resolving record ${id} for ${tableName}`);
7809
+ if (
7810
+ existingRecord &&
7811
+ (((error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED' || error.code === 'EAI_AGAIN') &&
7812
+ !context?.mustRevalidate) ||
7813
+ (context?.staleIfError &&
7814
+ (error.statusCode === 500 ||
7815
+ error.statusCode === 502 ||
7816
+ error.statusCode === 503 ||
7817
+ error.statusCode === 504)))
7818
+ ) {
7819
+ // these are conditions under which we can use stale data after an error
7820
+ resolve({
7821
+ key: id,
7822
+ version: existingVersion,
7823
+ value: existingRecord,
7824
+ } as any);
7825
+ logger.trace?.((error as Error)?.message, '(returned stale record)');
7826
+ } else reject(error);
7827
+ } catch (settlingError) {
7828
+ reject(error ?? settlingError);
7829
+ }
7002
7830
  const resolveDuration = performance.now() - start;
7003
7831
  recordAction(resolveDuration, 'cache-resolution', tableName, null, 'fail');
7004
7832
  if (responseHeaders)
@@ -7238,6 +8066,7 @@ export function makeTable(options) {
7238
8066
  if (entry.value == null) continue; // already removed
7239
8067
  if (hasSourceGet && primaryStore.hasLock(item.key, entry.version)) continue; // resolution in progress
7240
8068
  updateIndices(item.key, entry.value, null, options);
8069
+ stageDerivedIndexEviction(transaction, item.key, entry.version);
7241
8070
  }
7242
8071
  removeEntry(primaryStore, entry, options);
7243
8072
  staged++;
@@ -7328,7 +8157,7 @@ export function makeTable(options) {
7328
8157
  // Periodically evict expired records and deleted records searching for records who expiresAt timestamp is before now
7329
8158
  if (cleanupInterval === lastCleanupInterval && !runImmediately) return;
7330
8159
  lastCleanupInterval = cleanupInterval;
7331
- if (getWorkerIndex() === getWorkerCount() - 1) {
8160
+ if (ownsStoreMaintenance(primaryStore.path) || (ttlConfiguredByApplication && isDedicatedWorker())) {
7332
8161
  // run on the last thread so we aren't overloading lower-numbered threads
7333
8162
  if (cleanupTimer) clearTimeout(cleanupTimer);
7334
8163
  if (!cleanupInterval) {
@@ -7470,12 +8299,14 @@ export function makeTable(options) {
7470
8299
  }
7471
8300
  function runRecordExpirationEviction() {
7472
8301
  // Periodically evict expired records, searching for records who expiresAt timestamp is before now
7473
- if (getWorkerIndex() === 0) {
8302
+ if (ownsStoreExpiration(primaryStore.path) || (ttlConfiguredByApplication && isDedicatedWorker())) {
7474
8303
  // we want to run the pruning of expired records on only one thread so we don't have conflicts in evicting
7475
8304
  recordExpirationInterval = setInterval(async () => {
7476
8305
  // go through each database and table and then search for expired entries
7477
8306
  // find any entries that are set to expire before now
7478
- if (disposed || runningRecordExpiration) return;
8307
+ // updatedAttributes() clears expiresAtProperty when a live redeclaration drops the directive,
8308
+ // and there is nothing left for this interval to scan by
8309
+ if (disposed || runningRecordExpiration || !expiresAtProperty) return;
7479
8310
  runningRecordExpiration = true;
7480
8311
  try {
7481
8312
  const expiresAtName = expiresAtProperty.name;