@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
@@ -26,6 +26,7 @@ import {
26
26
  } from './longLivedTransactions.ts';
27
27
 
28
28
  const trackedTxns = new Set<DatabaseTransaction>();
29
+ const readTransactionOwners = new WeakMap<ReadTransaction, DatabaseTransaction>();
29
30
  // Read options for a rotated generation's native transactions; shared because they never vary.
30
31
  const SNAPSHOT_FREE = Object.freeze({ disableSnapshot: true });
31
32
  // Logical transactions the monitor supervises for their WRITES, kept apart from trackedTxns because the
@@ -292,6 +293,82 @@ export function transactionOpenTooLongError(): ServerError {
292
293
  );
293
294
  }
294
295
 
296
+ class ReadSnapshotExpiredError extends ServerError {
297
+ constructor() {
298
+ super('Read scan snapshot expired; retry the read without replaying previously committed writes', 503);
299
+ this.name = 'ReadSnapshotExpiredError';
300
+ }
301
+ }
302
+
303
+ export function getReadTransactionGuard(transaction: ReadTransaction): (() => void) | undefined {
304
+ const owner = readTransactionOwners.get(transaction);
305
+ if (!owner) return;
306
+ return function checkActive() {
307
+ if (owner.timedOut) throw transactionOpenTooLongError();
308
+ if (owner.transaction !== transaction) {
309
+ throw new ReadSnapshotExpiredError();
310
+ }
311
+ };
312
+ }
313
+
314
+ export function trackReadRange(transaction: ReadTransaction, createRange: () => any): any {
315
+ const owner = readTransactionOwners.get(transaction);
316
+ if (!owner) return createRange();
317
+ const checkActive = getReadTransactionGuard(transaction)!;
318
+ checkActive();
319
+ const range = createRange();
320
+ const iterate = range.iterate;
321
+ range.iterate = function (options) {
322
+ const iterator = iterate.call(this, options);
323
+ let done = false;
324
+ // Closing the underlying iterator is the one step here that can throw for a reason the caller
325
+ // must not see: `next()` only reaches it once the snapshot is already gone, which is the
326
+ // likeliest moment for the native layer to object, and an error from cleanup would replace the
327
+ // named 503 with exactly the raw iterator error this wrapper exists to stop surfacing. The
328
+ // closure reference rather than `this` so a destructured `next` still cleans up.
329
+ const wrapper = {
330
+ [Symbol.iterator]() {
331
+ return this;
332
+ },
333
+ next() {
334
+ if (done) return { done: true, value: undefined };
335
+ try {
336
+ checkActive();
337
+ owner.rangeReadActive = true;
338
+ const result = iterator.next();
339
+ done = result.done === true;
340
+ return result;
341
+ } catch (error) {
342
+ closeQuietly();
343
+ throw error;
344
+ }
345
+ },
346
+ return(value?: any) {
347
+ if (!done) {
348
+ done = true;
349
+ iterator.return?.(value);
350
+ }
351
+ return { done: true, value };
352
+ },
353
+ throw(error) {
354
+ // Not delegated to `iterator.throw`: it closes and rethrows the same error anyway, and
355
+ // delegating after the close below would run it against an iterator already closed.
356
+ closeQuietly();
357
+ throw error;
358
+ },
359
+ };
360
+ function closeQuietly() {
361
+ try {
362
+ wrapper.return();
363
+ } catch {
364
+ // the error being propagated is the actionable one
365
+ }
366
+ }
367
+ return wrapper;
368
+ };
369
+ return range;
370
+ }
371
+
295
372
  type MaybePromise<T> = T | Promise<T>;
296
373
 
297
374
  export type CommitOptions = {
@@ -309,6 +386,11 @@ type ReadTransaction = (LMDBTransaction | RocksTransaction) & {
309
386
  isCommitted?: boolean;
310
387
  };
311
388
 
389
+ export type WriteGeneration = {
390
+ closed: boolean;
391
+ internalWrites: number;
392
+ };
393
+
312
394
  export type TransactionWrite = {
313
395
  key: Id;
314
396
  store: any; // using any here because of circular dependency and complex RootDatabaseKind
@@ -381,8 +463,30 @@ export type TransactionWrite = {
381
463
  // this settles (fire-and-forget from the if-branch), so Table.save()'s lock-writable path awaits
382
464
  // it to ensure the write is durable before resolving to the caller.
383
465
  innerCommit?: MaybePromise<CommitResolution>;
466
+ // the commit derives stored state (folds, index diffs, residency) from its base entry, so
467
+ // save() must reload that base through the committing transaction's snapshot
468
+ reloadCommitBase?: boolean;
469
+ writeGeneration?: WriteGeneration;
470
+ instanceClosed?: boolean;
384
471
  };
385
472
 
473
+ export function closeWriteInstance(operation: TransactionWrite | null | undefined): void {
474
+ if (operation && !operation.instanceClosed) {
475
+ operation.instanceClosed = true;
476
+ if (operation.writeGeneration) operation.writeGeneration.closed = true;
477
+ }
478
+ }
479
+
480
+ export function validateWrite(operation: TransactionWrite, txnTime: number, transaction: DatabaseTransaction): any {
481
+ const generation = operation.writeGeneration;
482
+ if (generation) generation.internalWrites++;
483
+ try {
484
+ return operation.validate?.(txnTime, transaction);
485
+ } finally {
486
+ if (generation) generation.internalWrites--;
487
+ }
488
+ }
489
+
386
490
  export function getAppliedWriteVersion(recordVersion: number | undefined, txnLogKey: number): number {
387
491
  return recordVersion == null ? txnLogKey : Math.min(recordVersion, txnLogKey);
388
492
  }
@@ -431,6 +535,7 @@ export class DatabaseTransaction implements Transaction {
431
535
  this.#scopeOwned = options?.scopeOwned === true;
432
536
  }
433
537
  writes: TransactionWrite[] = []; // the set of writes to commit if the conditions are met
538
+ ownedWrites?: WeakSet<TransactionWrite>;
434
539
  // the last staged write per store and key, used to chain repeat writes to the same key (linkWrite)
435
540
  declare writesByKey?: Map<any, Map<unknown, TransactionWrite>>;
436
541
  completions: Promise<void>[] = []; // the set of outstanding async operations to complete
@@ -521,6 +626,15 @@ export class DatabaseTransaction implements Transaction {
521
626
  declare commitChainHead?: DatabaseTransaction;
522
627
  // O(1) lookup in recordLockFor; only lock() handles are registered here (no gate handles).
523
628
  declare recordLocks?: Map<any, Map<unknown, RecordLockHandle>>;
629
+ /**
630
+ * Whether any staged write was made through a record lock handle. The commit-time lease fence
631
+ * below is skipped entirely when this is false, which is every transaction in a core-only
632
+ * deployment: without it a bulk transaction of 100,000 plain writes pays 100,000 property checks
633
+ * before submission, on a path that is supposed to be untouched when no lock is involved. It
634
+ * latches rather than tracking a count, because a write whose handle was released or expired after
635
+ * staging is exactly what the fence exists to catch.
636
+ */
637
+ declare hasLeaseProtectedWrite?: boolean;
524
638
  // Tracks in-flight acquireRecordKey calls so concurrent lock() calls for the same key in one
525
639
  // link (e.g. Promise.all([T.lock(id), T.lock(id)])) can coalesce rather than self-block.
526
640
  declare pendingLocks?: Map<any, Map<unknown, Promise<RecordLockHandle>>>;
@@ -534,8 +648,9 @@ export class DatabaseTransaction implements Transaction {
534
648
  }
535
649
  }
536
650
 
537
- getReadTxn(disableSnapshot?: boolean): ReadTransaction {
538
- this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
651
+ rangeReadActive = false;
652
+
653
+ renewReadTimeout(): void {
539
654
  // The limit is an IDLE limit. Writes always re-arm it (see addWrite), but reads only do so
540
655
  // while no uncommitted writes are held: staged writes hold write intents that other writers'
541
656
  // coordinated-retry commits park on, so a handler that wrote once and then only reads — an
@@ -548,6 +663,25 @@ export class DatabaseTransaction implements Transaction {
548
663
  if ((this.writes.length === 0 && !this.next) || this.open !== TRANSACTION_STATE.OPEN || !this.hasPendingWrites()) {
549
664
  this.timeout = Math.max(txnExpiration, this.timeoutBudget);
550
665
  }
666
+ }
667
+
668
+ // Each engine keeps its own expiration; LMDBTransaction overrides this with its own.
669
+ renewIdleTimeout(): void {
670
+ this.timeout = Math.max(txnExpiration, this.timeoutBudget ?? 0);
671
+ }
672
+
673
+ // The links after this one wait for its native commit before their own commit() is entered; each
674
+ // hop starts with a full idle window and a stalled native commit is still bounded by one.
675
+ renewChainForNativeCommit(): void {
676
+ for (let txn: DatabaseTransaction = this; txn; txn = txn.next) {
677
+ if (txn.timedOut || txn.open === TRANSACTION_STATE.CLOSED) continue;
678
+ txn.renewIdleTimeout();
679
+ }
680
+ }
681
+
682
+ getReadTxn(disableSnapshot?: boolean): ReadTransaction {
683
+ this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
684
+ this.renewReadTimeout();
551
685
  if (this.transaction) {
552
686
  if ((this.transaction as any).openTimer) (this.transaction as any).openTimer = 0;
553
687
  return this.transaction;
@@ -582,6 +716,8 @@ export class DatabaseTransaction implements Transaction {
582
716
  // Monitor state is not ownership state: it stays with `trackedTxns.add` in getReadTxn().
583
717
  private attachOwnedTransaction(transaction: RocksTransactionWithRetry): void {
584
718
  this.transaction = transaction;
719
+ readTransactionOwners.set(transaction, this);
720
+ this.rangeReadActive = false;
585
721
  this.readTxnsUsed = 1;
586
722
  this.baseReadRefConsumed = false;
587
723
  this.handleOpenedAt = performance.now();
@@ -623,6 +759,7 @@ export class DatabaseTransaction implements Transaction {
623
759
  trackedTxns.delete(this);
624
760
  this.endWriteSupervision();
625
761
  this.transaction = null;
762
+ this.rangeReadActive = false;
626
763
  this.readTxnsUsed = 0;
627
764
  this.readTxnRefCount = 0;
628
765
  this.handleOpenedAt = 0;
@@ -721,6 +858,7 @@ export class DatabaseTransaction implements Transaction {
721
858
  detachWrite(operation: TransactionWrite): void {
722
859
  const index = this.writes.indexOf(operation);
723
860
  if (index > -1) this.writes[index] = null;
861
+ this.ownedWrites?.delete(operation);
724
862
  if (operation.key === undefined) return;
725
863
  const writesForStore = this.writesByKey?.get(operation.store);
726
864
  if (!writesForStore) return;
@@ -728,7 +866,7 @@ export class DatabaseTransaction implements Transaction {
728
866
  // Membership, not `stagedIn`, which every commit handler clears and so cannot tell a takeover from a
729
867
  // write done in place; a prior already taken over must not become this transaction's basis again.
730
868
  let prior = operation.priorWrite;
731
- while (prior && !this.writes.includes(prior)) prior = prior.priorWrite;
869
+ while (prior && !this.ownedWrites?.has(prior)) prior = prior.priorWrite;
732
870
  const tail = writesForStore.get(keyId);
733
871
  if (tail === operation) {
734
872
  if (prior) writesForStore.set(keyId, prior);
@@ -757,9 +895,9 @@ export class DatabaseTransaction implements Transaction {
757
895
  if (!storeMap) return undefined;
758
896
  const h = storeMap.get(keyId);
759
897
  if (!h) return undefined;
760
- if (!h.released) return h;
761
- // Prune released handles so they don't accumulate; an expired handle checking re-entrancy
762
- // would otherwise be seen as the holder and incorrectly granted access.
898
+ if (!h.isExpired()) return h;
899
+ // Prune released/expired handles so they don't accumulate; a handle past its deadline checking
900
+ // re-entrancy would otherwise be seen as the holder and incorrectly granted access.
763
901
  storeMap.delete(keyId);
764
902
  return undefined;
765
903
  }
@@ -818,9 +956,16 @@ export class DatabaseTransaction implements Transaction {
818
956
  // fire after a commit or abort, and routing it back here would revive a write this transaction
819
957
  // already rolled back — whose blobs abort() has reclaimed. Cleared here, save() resolves the
820
958
  // context's current transaction as it did before `stagedIn` existed.
821
- for (const write of this.writes) if (write?.stagedIn === this) write.stagedIn = undefined;
959
+ for (const write of this.writes) {
960
+ if (write?.stagedIn === this) write.stagedIn = undefined;
961
+ if (write) this.ownedWrites?.delete(write);
962
+ }
822
963
  this.writes = [];
823
964
  this.writesByKey = undefined;
965
+ // The commit-fence latch belongs to the discarded batch. A reused transaction that once staged
966
+ // a locked write would otherwise re-scan every write of every later unlocked batch. Assigned
967
+ // only when it was set, so an ordinary transaction never gains the property at all.
968
+ if (this.hasLeaseProtectedWrite) this.hasLeaseProtectedWrite = false;
824
969
  }
825
970
 
826
971
  /**
@@ -956,6 +1101,7 @@ export class DatabaseTransaction implements Transaction {
956
1101
  this.writeTick = monitorTick;
957
1102
  this.linkWrite(operation);
958
1103
  this.writes.push(operation);
1104
+ (this.ownedWrites ??= new WeakSet()).add(operation);
959
1105
  operation.stagedIn = this;
960
1106
  // Hold this write back while any earlier same-key write has not run — out of staging order both
961
1107
  // diff against the pre-transaction record (harper#2211, DESIGN.md). The whole chain, not just the
@@ -987,15 +1133,26 @@ export class DatabaseTransaction implements Transaction {
987
1133
  const lockHandle = operation.lockHandle;
988
1134
  // Guard: a write staged through an expired or released lock handle must not land.
989
1135
  // The handle's lease timer already unlocked the native key; another holder may have taken it.
990
- if (lockHandle && (lockHandle.expired || lockHandle.released)) {
991
- // Remove the operation from the staged set so subsequent writes on this context do not
992
- // re-throw 409 due to a stale null-saved entry sitting in this.writes.
993
- const failedIdx = this.writes.indexOf(operation);
994
- if (failedIdx > -1) this.writes[failedIdx] = null;
1136
+ // isExpired() re-evaluates the deadline here rather than trusting the timer to have run: a
1137
+ // holder whose event loop stalled past its lease would otherwise commit in the window between
1138
+ // the deadline and its own timer callback, after peers had already granted the key onward.
1139
+ //
1140
+ // A RE-save is judged by the lease alone. `released` belongs to the first stage — a caller that
1141
+ // unlocked and then saved is writing through a handle it gave back — but an operation already
1142
+ // staged was staged while the lock was held, and an `unlock()` inside the lease leaves it valid
1143
+ // (the same rule the pre-submit fence applies below). Without the distinction,
1144
+ // `lock(); save(); unlock(); commit()` succeeded normally and threw 409 only when a conflict
1145
+ // retry or an open read iterator forced the replay to re-save it.
1146
+ if (operation.saved ? lockHandle?.isLeaseExpired() : lockHandle?.isExpired()) {
1147
+ // Through `detachWrite`, which also repairs the per-key chain: dropping it from `writes` alone
1148
+ // leaves `writesByKey` pointing at a write that was refused, so a caller that catches this 409
1149
+ // and stages the same key again would take the rejected operation as its merge basis.
1150
+ this.detachWrite(operation);
995
1151
  throw lockNotHeldError(lockHandle);
996
1152
  }
997
1153
  // Lock-write timestamp rules.
998
1154
  if (lockHandle) {
1155
+ this.hasLeaseProtectedWrite = true;
999
1156
  if (this.open === TRANSACTION_STATE.CLOSED || this.saveCommits) {
1000
1157
  // CLOSED path (second+ write per ImmediateTransaction cycle) OR the first write in
1001
1158
  // an ImmediateTransaction (open=OPEN until commit sets it CLOSED, but saveCommits
@@ -1054,22 +1211,44 @@ export class DatabaseTransaction implements Transaction {
1054
1211
  (transaction as RocksTransactionWithRetry).isRetry = true;
1055
1212
  }
1056
1213
  if (!txnTime) txnTime = this.timestamp = transaction.getTimestamp();
1214
+ if (!operation.saved && operation.pendingPriorWrite) {
1215
+ const pendingWrites = [];
1216
+ for (let pending = operation.pendingPriorWrite; pending;) {
1217
+ if (!pending.saved && this.ownedWrites?.has(pending)) pendingWrites.push(pending);
1218
+ pending = pending.pendingPriorWrite !== undefined ? pending.pendingPriorWrite : pending.priorWrite;
1219
+ }
1220
+ for (let index = pendingWrites.length - 1; index >= 0; index--)
1221
+ this.save(pendingWrites[index], transaction, false, options);
1222
+ operation.pendingPriorWrite = null;
1223
+ }
1057
1224
  // `txnTime` is this transaction's timestamp — the key its entries take in the per-origin log.
1058
1225
  // A write applied from elsewhere carries the origin's record version too, and that is what the
1059
1226
  // record is stored at; the two coincide for every locally-originated write. Gated on the apply
1060
1227
  // flags so an ordinary write never reads the property (harper#2412).
1061
1228
  const writeVersion =
1062
1229
  this.sourceApply || this.isReplay ? getAppliedWriteVersion(operation.recordVersion, txnTime) : txnTime;
1063
- if (reloadEntry || operation.entry === undefined) {
1064
- operation.entry = operation.store.getEntry(operation.key, { transaction });
1230
+ // A base that feeds stored state must come from this transaction's snapshot, never the
1231
+ // cross-worker cache vouch (stale when a resequenced write reused a version). That closes the
1232
+ // lost-update window only when this transaction holds a snapshot to validate the later Put
1233
+ // against; a snapshot-free transaction (this.snapshotFree, after a mid-scope-commit rotation)
1234
+ // has no snapshot for rocksdb-js to validate the Put against, so it only narrows the window to
1235
+ // the read-to-put span rather than closing it (open follow-up, tracked in the PR description).
1236
+ // Replays keep their pre-read base — their convergence contract is the replay pass itself.
1237
+ const reloadsCommitBase = operation.reloadCommitBase && !operation.saved && !this.isReplay;
1238
+ if (reloadEntry || operation.entry === undefined || reloadsCommitBase) {
1239
+ const uncachedRead = (!!operation.reloadCommitBase && !this.isReplay) || reloadEntry;
1240
+ operation.entry = operation.store.getEntry(operation.key, { transaction, uncachedRead });
1065
1241
  }
1066
1242
  if (!operation.saved) {
1067
- operation.saved = true;
1068
1243
  // immediately execute in this transaction
1069
- if ((operation.validate?.(writeVersion, this) as any) === false) {
1244
+ const validated = validateWrite(operation, writeVersion, this);
1245
+ if ((validated as any) === false) {
1246
+ operation.saved = true;
1070
1247
  operation.commit = () => {}; // noop if we try again
1248
+ closeWriteInstance(operation);
1071
1249
  return;
1072
1250
  }
1251
+ operation.saved = true;
1073
1252
  let result: Promise<void> = operation.before?.() as Promise<void>;
1074
1253
  if (result?.then) this.stageCompletion(result);
1075
1254
  result = operation.beforeIntermediate?.() as Promise<void>;
@@ -1077,7 +1256,12 @@ export class DatabaseTransaction implements Transaction {
1077
1256
  }
1078
1257
  if (lockHandle || this.recordLocks) operation.trackRecordVersion = true;
1079
1258
  if (operation.trackRecordVersion) operation.recordVersionApplied = false;
1080
- const completion = operation.commit(writeVersion, operation.entry, this.retries > 0, transaction) as Promise<void>;
1259
+ let completion: Promise<void>;
1260
+ try {
1261
+ completion = operation.commit(writeVersion, operation.entry, this.retries > 0, transaction) as Promise<void>;
1262
+ } finally {
1263
+ closeWriteInstance(operation);
1264
+ }
1081
1265
  if (operation.trackRecordVersion)
1082
1266
  operation.appliedRecordVersion = operation.recordVersionApplied ? writeVersion : undefined;
1083
1267
  if (typeof completion?.then === 'function') this.stageCompletion(completion);
@@ -1149,6 +1333,7 @@ export class DatabaseTransaction implements Transaction {
1149
1333
  // the local is empty: a truthy one is what the loop staged into, and the retained-handle
1150
1334
  // and replay branches below deliberately commit a handle other than this.transaction.
1151
1335
  if (!transaction) transaction = this.transaction;
1336
+ if (!options.transaction && this.writes.some((write) => write)) this.renewChainForNativeCommit();
1152
1337
  this.open = TRANSACTION_STATE.CLOSED;
1153
1338
  // RocksTransaction.commit() resolves with RETRY_NOW_VALUE (a number) under
1154
1339
  // coordinatedRetry, or void on a normal commit/abort.
@@ -1234,6 +1419,30 @@ export class DatabaseTransaction implements Transaction {
1234
1419
  if (transaction) {
1235
1420
  this.writes = this.writes.filter((write) => write); // filter out removed entries
1236
1421
  if (this.writes.length > 0) {
1422
+ // Commit retries can construct fresh ranges on this live handle after read ownership ends.
1423
+ readTransactionOwners.delete(transaction);
1424
+ // Re-fence before submitting: the loop above skips operations already marked saved, so
1425
+ // save()'s own check cannot see a holder that stalled between staging and commit. The
1426
+ // retry/replay path re-saves every operation and is fenced there instead. Lease expiry
1427
+ // only: an unlock() inside the lease leaves the staged write valid, an elapsed lease
1428
+ // does not, whether or not the caller also unlocked.
1429
+ for (let i = 0; this.hasLeaseProtectedWrite && i < this.writes.length; i++) {
1430
+ const lapsed = this.writes[i].lockHandle;
1431
+ if (!lapsed?.isLeaseExpired()) continue;
1432
+ try {
1433
+ transaction.abort();
1434
+ } catch {}
1435
+ // Every other terminal exit from commit() runs the logical cleanup too. Throwing
1436
+ // straight out would strand the OTHER locks this transaction holds, its staged
1437
+ // blobs, and the context's back-reference to a CLOSED transaction — and
1438
+ // transaction()'s onComplete has no rejection path to run it later.
1439
+ try {
1440
+ this.abort();
1441
+ } catch (error) {
1442
+ harperLogger.debug?.('cleaning up a transaction whose record lock lapsed', error);
1443
+ }
1444
+ throw lockNotHeldError(lapsed);
1445
+ }
1237
1446
  // The transaction was created with coordinatedRetry:true (see
1238
1447
  // getReadTxn), so commit() can resolve to RETRY_NOW_VALUE. That
1239
1448
  // sentinel (a number) is why commitResolution is typed
@@ -2032,6 +2241,10 @@ function startMonitoringTxns() {
2032
2241
  reportNow: number,
2033
2242
  reportBudget: LongLivedHolderReportBudget
2034
2243
  ) {
2244
+ if (txn.rangeReadActive) {
2245
+ txn.rangeReadActive = false;
2246
+ txn.renewReadTimeout();
2247
+ }
2035
2248
  reportIfLongLived(txn, reportThresholdMs, reportNow, reportBudget);
2036
2249
  {
2037
2250
  const commitChainHead = txn.commitChainHead ?? txn;
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  DatabaseTransaction,
3
+ closeWriteInstance,
4
+ validateWrite,
3
5
  shouldSpareCommitPhase,
4
6
  transactionOpenTooLongError,
5
7
  type CommitOptions,
@@ -37,10 +39,14 @@ export class LMDBTransaction extends DatabaseTransaction {
37
39
  declare overloadChecked: boolean;
38
40
  open = TRANSACTION_STATE.OPEN;
39
41
 
42
+ renewIdleTimeout(): void {
43
+ this.timeout = Math.max(txnExpiration, this.timeoutBudget ?? 0);
44
+ }
45
+
40
46
  getReadTxn(): any {
41
47
  // used optimistically
42
48
  this.readTxnRefCount = (this.readTxnRefCount || 0) + 1;
43
- this.timeout = Math.max(txnExpiration, this.timeoutBudget ?? 0); // reset the timeout
49
+ this.renewIdleTimeout();
44
50
  if (this.stale) this.stale = false;
45
51
  if (this.readTxn) {
46
52
  if ((this.readTxn as any).openTimer) (this.readTxn as any).openTimer = 0;
@@ -113,12 +119,14 @@ export class LMDBTransaction extends DatabaseTransaction {
113
119
 
114
120
  this.linkWrite(operation);
115
121
  this.writes.push(operation); // standard path, add to current transaction
122
+ (this.ownedWrites ??= new WeakSet()).add(operation);
116
123
  operation.stagedIn = this;
117
124
  }
118
125
 
119
126
  removeWrite(operation: TransactionWrite) {
120
127
  const index = this.writes.indexOf(operation);
121
128
  if (index > -1) this.writes[index] = null;
129
+ this.ownedWrites?.delete(operation);
122
130
  }
123
131
 
124
132
  /**
@@ -140,7 +148,11 @@ export class LMDBTransaction extends DatabaseTransaction {
140
148
  this.validated = this.writes.length;
141
149
  for (let i = start; i < this.validated; i++) {
142
150
  const write = this.writes[i];
143
- write?.validate?.(this.timestamp, this);
151
+ try {
152
+ if (write) validateWrite(write, this.timestamp, this);
153
+ } finally {
154
+ closeWriteInstance(write);
155
+ }
144
156
  }
145
157
  let hasBefore;
146
158
  for (let i = start; i < this.validated; i++) {
@@ -194,6 +206,7 @@ export class LMDBTransaction extends DatabaseTransaction {
194
206
  throw error;
195
207
  }
196
208
  }
209
+ if (!retries && this.writes.some((write) => write)) this.renewChainForNativeCommit();
197
210
  // release the read snapshot so we don't keep it open longer than necessary
198
211
  if (!retries) this.doneReadTxn();
199
212
  this.open = options?.doneWriting ? TRANSACTION_STATE.LINGERING : TRANSACTION_STATE.OPEN;
@@ -203,7 +216,12 @@ export class LMDBTransaction extends DatabaseTransaction {
203
216
  let writeIndex = 0;
204
217
  this.writes = this.writes.filter((write) => write); // filter out removed entries
205
218
  const doWrite = (write) => {
206
- const completion = write.commit(txnTime, write.entry, retries);
219
+ let completion;
220
+ try {
221
+ completion = write.commit(txnTime, write.entry, retries);
222
+ } finally {
223
+ closeWriteInstance(write);
224
+ }
207
225
  if (typeof completion?.then === 'function') {
208
226
  // the aggregating Promise.all is attached a turn or more later (after the conditional batch
209
227
  // or the exclusive transaction resolves), so handle rejection here to keep the gap from
@@ -1,9 +1,10 @@
1
+ import { trackReadRange } from './DatabaseTransaction.ts';
1
2
  import { RocksDatabase, type RocksDatabaseOptions, constants, type Store, Transaction } from '@harperfast/rocksdb-js';
2
3
 
3
4
  const FRESH_VERSION_FLAG = constants.FRESH_VERSION_FLAG;
4
5
  import { WeakLRUCache } from 'weak-lru-cache';
5
6
  import { when } from '../utility/when.ts';
6
- import { assignStoredFields, entryMap, METADATA, type Entry } from './RecordEncoder.ts';
7
+ import { assignStoredFields, entryMap, METADATA, VERSION_REUSED, type Entry } from './RecordEncoder.ts';
7
8
 
8
9
  /**
9
10
  * RocksDatabase subclass that owns all primary-store behaviour for Harper tables:
@@ -121,6 +122,13 @@ export class PrimaryRocksDatabase extends RocksDatabase {
121
122
  */
122
123
  getEntry(id: any, options?: any): any {
123
124
  this.readCount++;
125
+ // Commit-path base reads must reflect the caller's transaction snapshot; the cache vouch
126
+ // answers "latest committed" — the wrong question there, and wrong outright for a version a
127
+ // resequenced write reused — so read the store directly, touching neither cache nor VT.
128
+ if (options?.uncachedRead) {
129
+ if (options.async) return when(super.get(id, options), (result) => this.#processEntry(result, id));
130
+ return this.#processEntry(super.getSync(id, options), id);
131
+ }
124
132
  const cache = this.#cache;
125
133
  // The cache stores the record *value* (weakly, via setValue) rather than
126
134
  // the Entry: a WeakRef-wrapped value lets the LRFU expirer release it once
@@ -158,11 +166,16 @@ export class PrimaryRocksDatabase extends RocksDatabase {
158
166
  if (cache && cachedValue !== undefined) cache.delete(id);
159
167
  return undefined;
160
168
  }
161
- // Only object values can be weakly cached and mapped back to their Entry;
162
- // primitive/empty values fall through uncached (no fast path, still correct).
163
- if (entry.version != null && cache && entry.value != null && typeof entry.value === 'object') {
164
- entryMap.set(entry.value, entry);
165
- cache.setValue(id, entry.value, (entry.size ?? 0) >> 10);
169
+ if (entry.version != null && cache) {
170
+ // its version no longer identifies its value, so drop any copy already held
171
+ if (entry.metadataFlags & VERSION_REUSED) {
172
+ if (cachedValue !== undefined) cache.delete(id);
173
+ } else if (entry.value != null && typeof entry.value === 'object') {
174
+ // Only object values can be weakly cached and mapped back to their Entry;
175
+ // primitive/empty values fall through uncached (no fast path, still correct).
176
+ entryMap.set(entry.value, entry);
177
+ cache.setValue(id, entry.value, (entry.size ?? 0) >> 10);
178
+ }
166
179
  }
167
180
  return entry;
168
181
  });
@@ -178,7 +191,7 @@ export class PrimaryRocksDatabase extends RocksDatabase {
178
191
  }
179
192
 
180
193
  getRange(options?: any): any {
181
- const iterable = super.getRange(options);
194
+ const iterable = trackReadRange(options?.transaction, () => super.getRange(options));
182
195
  if (options?.valuesForKey) return iterable.map((v: any) => v?.value);
183
196
  if (options?.values === false || options?.onlyCount) return iterable;
184
197
  if (!this.#enc.isRocksDB) return iterable;
@@ -30,7 +30,7 @@ import {
30
30
  } from './blob.ts';
31
31
  import { getThisNodeId } from './nodeIdMapping.ts';
32
32
  import { recordAction } from './analytics/write.ts';
33
- import { RocksDatabase } from '@harperfast/rocksdb-js';
33
+ import { constants, RocksDatabase } from '@harperfast/rocksdb-js';
34
34
  import { when } from '../utility/when.ts';
35
35
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
36
36
  import * as envMngr from '../utility/environment/environmentManager.js';
@@ -124,7 +124,26 @@ export const HAS_NODE_ID = 64;
124
124
  export const PENDING_LOCAL_TIME = 1;
125
125
  export const HAS_STRUCTURE_UPDATE = 0x100;
126
126
  export const HAS_ADDITIONAL_AUDIT_REFS = 0x80;
127
- export const VERSION_NOT_UNIQUE_FLAG = 0x10000;
127
+ // A resequenced write keeps the (newer) version it merged onto, so one version identifies two
128
+ // different stored values and version equality proves nothing. The metadata word this is set in is
129
+ // the same header word the VerificationTable reads at value offset 8 — ACTION_32_BIT is the tag
130
+ // byte its predicate requires — so the bit is what stops the native layer vouching for the version.
131
+ export const VERSION_REUSED = constants.VERSION_NOT_UNIQUE_FLAG;
132
+ // The bit is persisted in every resequenced record, so a rocksdb-js that moved it down onto one of
133
+ // the flags above (or up into the tag byte) would silently change what records already on disk
134
+ // mean: it must stay a single bit strictly between them.
135
+ if (
136
+ typeof VERSION_REUSED !== 'number' ||
137
+ (VERSION_REUSED & (VERSION_REUSED - 1)) !== 0 ||
138
+ VERSION_REUSED < 0x10000 ||
139
+ VERSION_REUSED > 0x800000
140
+ )
141
+ throw new Error(
142
+ `rocksdb-js VERSION_NOT_UNIQUE_FLAG (${VERSION_REUSED}) is not a single bit in the range Harper record metadata reserves for it — requires @harperfast/rocksdb-js >= 2.8.0`
143
+ );
144
+ function versionIsReused(newVersion: number, existingEntry: { version?: number } | undefined): boolean {
145
+ return existingEntry?.version != null && newVersion <= existingEntry.version;
146
+ }
128
147
 
129
148
  const TRACKED_WRITE_TYPES = new Set(['put', 'patch', 'delete', 'message', 'publish']);
130
149
  // For now we use this as the private property mechanism for mapping records to entries.
@@ -885,11 +904,12 @@ export function recordUpdater(store, tableId, auditStore) {
885
904
  : NO_TIMESTAMP;
886
905
  const expiresAt = options?.expiresAt;
887
906
  if (expiresAt >= 0) assignMetadata |= HAS_EXPIRATION;
888
- if (isRocksDB && record !== undefined && existingEntry?.version != null && newVersion <= existingEntry.version) {
889
- assignMetadata = Math.max(assignMetadata, 0) | VERSION_NOT_UNIQUE_FLAG;
890
- }
891
907
  metadataInNextEncoding = assignMetadata;
892
908
  expiresAtNextEncoding = expiresAt;
909
+ // Math.max normalizes the -1 "no metadata word" sentinel to 0 first: OR-ing the flag into
910
+ // -1 directly would stay -1 and silently drop the metadata word (and the flag with it).
911
+ if (isRocksDB && record !== undefined && versionIsReused(newVersion, existingEntry))
912
+ metadataInNextEncoding = Math.max(metadataInNextEncoding, 0) | VERSION_REUSED;
893
913
  const putOptions: {
894
914
  version: number;
895
915
  instructedWrite?: boolean;