@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
@@ -157,6 +157,10 @@ function sendAnalytics() {
157
157
  analyticsStart ||= performance.now();
158
158
  sendAnalyticsTimeout = setTimeout(async () => {
159
159
  sendAnalyticsTimeout = null;
160
+ // Rotate before the first yield below: a sample recorded mid-flush would otherwise be added to
161
+ // an entry already reported, and dropped with it.
162
+ const reportingActions = activeActions;
163
+ activeActions = new Map();
160
164
  const period = performance.now() - analyticsStart;
161
165
  analyticsStart = 0;
162
166
  const metrics = [];
@@ -166,7 +170,7 @@ function sendAnalytics() {
166
170
  threadId,
167
171
  metrics,
168
172
  };
169
- for (const [_name, action] of activeActions) {
173
+ for (const [_name, action] of reportingActions) {
170
174
  if (action.values) {
171
175
  const values = action.values.subarray(0, (action.values as any).index);
172
176
  values.sort();
@@ -239,7 +243,6 @@ function sendAnalytics() {
239
243
  for (const listener of analyticsListeners) {
240
244
  listener(metrics);
241
245
  }
242
- activeActions = new Map();
243
246
  if (parentPort)
244
247
  parentPort.postMessage({
245
248
  type: ANALYTICS_REPORT_TYPE,
@@ -904,7 +907,11 @@ async function aggregation(fromPeriod, toPeriod = 60000) {
904
907
  const taskQueueLatency = (async () => {
905
908
  const start = performance.now();
906
909
  // measure how long it takes to enqueue and get a callback from a simple/fast task:
907
- await stat(getLogFilePath());
910
+ // The result is discarded — only the round trip is being measured — so a missing file is not a
911
+ // failure. It rejects whenever the probe races a rotation, which the write-path size guard
912
+ // makes a routine event rather than a once-a-minute one, and an unhandled rejection here takes
913
+ // the process down over a latency measurement.
914
+ await stat(getLogFilePath()).catch(() => {});
908
915
  const delay = performance.now() - start;
909
916
  if (delay > 5000) {
910
917
  log.warn?.('Unusually high task queue latency on the main thread of ' + Math.round(delay) + 'ms');
@@ -2,7 +2,7 @@ import { readKey, writeKey } from 'ordered-binary';
2
2
  import { initSync, get as envGet } from '../utility/environment/environmentManager.ts';
3
3
  import { AUDIT_STORE_NAME } from '../utility/lmdb/terms.ts';
4
4
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
5
- import { getWorkerIndex, getWorkerCount } from '../server/threads/manageThreads.js';
5
+ import { getWorkerIndex, ownsStoreMaintenance } from '../server/threads/manageThreads.js';
6
6
  import { convertToMS } from '../utility/common_utils.ts';
7
7
  import { LAST_TIMESTAMP_PLACEHOLDER, HAS_STRUCTURE_UPDATE, PENDING_LOCAL_TIME } from './RecordEncoder.ts';
8
8
  import * as harperLogger from '../utility/logging/harper_logger.ts';
@@ -10,6 +10,7 @@ import { getRecordAtTime } from './crdt.ts';
10
10
  import { decodeFromDatabase } from './blob.ts';
11
11
  import { onStorageReclamation } from '../server/storageReclamation.ts';
12
12
  import { RocksDatabase } from '@harperfast/rocksdb-js';
13
+ import { asBinary } from 'lmdb';
13
14
  import { RocksTransactionLogStore } from './RocksTransactionLogStore.ts';
14
15
  import { isReadOnlyMode } from './databases.ts';
15
16
 
@@ -35,6 +36,8 @@ initSync();
35
36
  export type AuditRecord = {
36
37
  version: number; // the record's own version: LWW ordering, @updatedTime, ETag
37
38
  txnLogKey: number; // position in the origin's transaction log
39
+ /** Physical transaction log that yielded this entry, populated only when requested by the reader. */
40
+ logName?: string;
38
41
  type: string;
39
42
  encodedRecord?: Buffer;
40
43
  extendedType?: number;
@@ -124,6 +127,31 @@ let warnedUndecodableHeader = false;
124
127
  const warnedPendingPreviousVersion = new Set<string>();
125
128
  const FLOAT_TARGET = new Float64Array(1);
126
129
  const FLOAT_BUFFER = new Uint8Array(FLOAT_TARGET.buffer);
130
+ /**
131
+ * Key of this database's audit retention floor — the staleness horizon `getAuditFloor` reports.
132
+ * Its *presence* is what marks the floor trustworthy, which is why it is not the `last-removed`
133
+ * marker above: that one is written after its removals and by only one of the five prune paths, so a
134
+ * value found there cannot be told apart from one carrying the write-ahead and monotonicity
135
+ * guarantees `raiseAuditFloor` provides. The two coexist deliberately and answer different
136
+ * questions.
137
+ */
138
+ const AUDIT_FLOOR_KEY = Symbol.for('audit-floor');
139
+ // The epoch `establishAuditFloor` stamped, never raised or removed. Its PRESENCE marks the floor as
140
+ // unverified provenance — a guess bounded by what survived, blind to history a legacy prune removed
141
+ // before tracking began — until a database generation (#2451) retires the mark. No comparison does:
142
+ // a later prune certifies only what it removed, so `floor > bootstrap` says nothing about the older
143
+ // gap. The value records how far the guess reached, for that repair. See `establishAuditFloor`.
144
+ const AUDIT_FLOOR_BOOTSTRAP_KEY = Symbol.for('audit-floor-bootstrap');
145
+ /**
146
+ * The floor's own eight bytes, deliberately NOT the FLOAT_TARGET/FLOAT_BUFFER pair the `last-removed`
147
+ * marker uses: decoding a floor writes into its buffer on every read, including the pre-check on each
148
+ * prune, while `updateLastRemoved` hands the shared buffer to an async `put` it has not yet consumed.
149
+ * Sharing them would let a floor read rewrite a marker still in flight.
150
+ */
151
+ const FLOOR_TARGET = new Float64Array(1);
152
+ const FLOOR_BUFFER = new Uint8Array(FLOOR_TARGET.buffer);
153
+ /** No trustworthy floor: the highest possible floor, so every cursor compares as stale. */
154
+ const AUDIT_FLOOR_UNKNOWN = Infinity;
127
155
 
128
156
  /** Last resort on a detached path: a failing log sink must not itself become an unhandled rejection. */
129
157
  function warnContained(message: string, error: unknown) {
@@ -162,6 +190,7 @@ export function openAuditStore(rootStore) {
162
190
  }
163
191
  rootStore.auditStore = auditStore;
164
192
  auditStore.rootStore = rootStore;
193
+ establishAuditFloor(auditStore);
165
194
  auditStore.tableStores = [];
166
195
  const deleteCallbacks = [];
167
196
  auditStore.addDeleteRemovalCallback = function (tableId, table, callback) {
@@ -235,25 +264,39 @@ export function openAuditStore(rootStore) {
235
264
  let lastKey: any;
236
265
  try {
237
266
  if (isRocksAuditStore) {
238
- auditStore.rootStore.purgeLogs({
239
- before: Date.now() - auditRetention / (1 + passCleanupPriority * passCleanupPriority),
240
- });
267
+ const before = retentionCutoff(1 + passCleanupPriority * passCleanupPriority);
268
+ raiseAuditFloor(auditStore, before);
269
+ auditStore.rootStore.purgeLogs({ before });
241
270
  } else {
242
271
  // Driven explicitly rather than with for-of: this loop suspends on the awaits below, and a
243
272
  // close landing mid-pass closes the env under it. for-of calls next() before the body, so a
244
273
  // check inside the body advances the cursor first — the guard has to precede every next().
274
+ // remove up until the audit retention time, reducing audit retention time if cleanup is higher priority
275
+ const end = retentionCutoff(1 + passCleanupPriority * passCleanupPriority);
276
+ // Probe before raising, so an idle database does not write a floor transaction on every
277
+ // pass forever. `end` is fixed and audit keys only move forward, so an empty probe means
278
+ // the loop below finds nothing either.
245
279
  const entries = auditStore
246
280
  .getRange({
247
281
  start: 1, // must not be zero or it will be interpreted as null and overlap with symbols in search
248
282
  snapshot: false,
249
- end: Date.now() - auditRetention / (1 + passCleanupPriority * passCleanupPriority), // remove up until the audit retention time, reducing audit retention time if cleanup is higher priority
283
+ end,
250
284
  })
251
285
  [Symbol.iterator]();
252
286
  try {
287
+ // Raised off the first eligible entry rather than a separate probe range: one cursor,
288
+ // so a pass over an idle database writes no floor at all, and nothing else observing
289
+ // this range sees an extra advance. Still strictly before any removal — see
290
+ // raiseAuditFloor.
291
+ let floorRaised = false;
253
292
  while (!cleanupStopped && !storeClosing()) {
254
293
  const entry = entries.next();
255
294
  if (entry.done) break;
256
295
  const auditRecord = entry.value;
296
+ if (!floorRaised) {
297
+ raiseAuditFloor(auditStore, end);
298
+ floorRaised = true;
299
+ }
257
300
  try {
258
301
  // awaited so a rejection (not just a synchronous throw) is caught here instead of
259
302
  // escaping as an unhandled rejection once a later iteration's promise replaces this one
@@ -328,7 +371,7 @@ export function openAuditStore(rootStore) {
328
371
  if (
329
372
  !cleanupStopped &&
330
373
  !storeClosing() &&
331
- (!isRocksAuditStore || (getWorkerIndex() === getWorkerCount() - 1 && !pendingCleanupResolve))
374
+ (!isRocksAuditStore || (ownsStoreMaintenance(rootStore.path) && !pendingCleanupResolve))
332
375
  ) {
333
376
  scheduleAuditCleanup();
334
377
  }
@@ -364,7 +407,7 @@ export function openAuditStore(rootStore) {
364
407
  pendingCleanupResolve = null;
365
408
  return lastCleanupResolution ?? Promise.resolve();
366
409
  };
367
- if (getWorkerIndex() === getWorkerCount() - 1) {
410
+ if (ownsStoreMaintenance(rootStore.path)) {
368
411
  scheduleAuditCleanup();
369
412
  }
370
413
  if (getWorkerIndex() === 0 && !timestampErrored) {
@@ -435,11 +478,377 @@ export function getLastRemoved(auditStore) {
435
478
  return FLOAT_TARGET[0];
436
479
  }
437
480
  }
481
+ /**
482
+ * Read the recorded floor, normalizing anything we cannot trust to AUDIT_FLOOR_UNKNOWN. Reads bytes
483
+ * rather than going through the store's value decoder, which would read these eight raw float bytes
484
+ * as an audit entry (and as msgpack on RocksDB). Callers get a number in every case: a NaN or
485
+ * negative floor read as a number would make one of `cursor >= floor` / `cursor < floor` report
486
+ * safety, and which of the two a consumer writes must not decide whether corrupt metadata fails
487
+ * closed.
488
+ */
489
+ function decodeAuditFloor(stored: any): number {
490
+ // RocksDB's getBinarySync is typed to also return a length; anything that is not exactly the
491
+ // eight bytes we write is metadata written by something else.
492
+ if (stored?.byteLength !== 8) return AUDIT_FLOOR_UNKNOWN;
493
+ FLOOR_BUFFER.set(stored);
494
+ const floor = FLOOR_TARGET[0];
495
+ // `Object.is` for -0, which passes `>= 0` and would then read as a permissive zero — every cursor
496
+ // safe — from bytes with the sign bit set that nothing here writes. raiseAuditFloor rejects the
497
+ // same value as a cutoff; the read side has to agree or corrupt metadata fails open.
498
+ if (!Number.isFinite(floor) || floor < 0 || Object.is(floor, -0)) return AUDIT_FLOOR_UNKNOWN;
499
+ return floor;
500
+ }
501
+
502
+ /**
503
+ * Did the floor write land? A record has to be PRESENT, not merely decode to the value we wrote:
504
+ * `decodeAuditFloor(undefined)` is the unknown sentinel too, so on a floorless store — where the
505
+ * resolver writes exactly that sentinel — comparing decoded values alone reported a commit for a
506
+ * write that never happened, and the caller pruned with nothing persisted.
507
+ */
508
+ function floorWriteLanded(stored: any, floor: number): boolean {
509
+ return stored !== undefined && decodeAuditFloor(stored) === floor;
510
+ }
511
+
512
+ /** Own eight bytes per write: the store must never be handed a live view of the reused module buffer. */
513
+ function encodeAuditFloor(floor: number): Uint8Array {
514
+ FLOOR_TARGET[0] = floor;
515
+ return FLOOR_BUFFER.slice();
516
+ }
517
+
518
+ /**
519
+ * Read-modify-write the floor under one store transaction. `resolve` receives the recorded floor
520
+ * (AUDIT_FLOOR_UNKNOWN when there is none) and returns the value to store, or undefined to leave it
521
+ * alone. `key` selects the record: the floor itself, or the bootstrap-provenance record beside it,
522
+ * which wants the same verified commit rather than a second write path.
523
+ *
524
+ * The transaction is the point: several paths advance the floor from different workers, and two
525
+ * unsynchronized read-then-writes can interleave so the lower cutoff lands last — a floor below
526
+ * history the higher one already removed. Read-only mode writes nothing, and prunes nothing.
527
+ */
528
+ function updateAuditFloor(
529
+ auditStore: any,
530
+ resolve: (current: number, recorded: boolean) => number | undefined,
531
+ key: symbol = AUDIT_FLOOR_KEY
532
+ ): void {
533
+ // A legacy `auditPath` layout is opened as its own standalone LMDB root (databases.ts) and has no
534
+ // `.rootStore`, so it owns the transaction itself.
535
+ const transactionOwner = auditStore?.rootStore ?? auditStore;
536
+ if (!transactionOwner?.transactionSync)
537
+ throw new Error('Cannot record the audit retention floor: this database has no audit store');
538
+ // Both branches read their own write back and report `false` on mismatch, and the caller demands an
539
+ // explicit `true`. Both halves are load-bearing: a RocksDB transactionSync returns undefined for a
540
+ // swallowed abort rather than throwing (see RecordEncoder.saveStructures), and a write that fails
541
+ // without throwing is otherwise indistinguishable from one that landed — a caller pruning against a
542
+ // floor never recorded. Reads inside a write transaction see their own writes on both engines, so
543
+ // the read-back observes what commit will make durable.
544
+ const committed =
545
+ auditStore instanceof RocksTransactionLogStore
546
+ ? transactionOwner.transactionSync(
547
+ (txn) => {
548
+ const stored = txn.getBinarySync(key);
549
+ const floor = resolve(decodeAuditFloor(stored), stored !== undefined);
550
+ if (floor !== undefined) {
551
+ txn.putSync(key, asBinary(encodeAuditFloor(floor)));
552
+ if (!floorWriteLanded(txn.getBinarySync(key), floor)) return false;
553
+ }
554
+ return true;
555
+ },
556
+ { retryOnBusy: true }
557
+ )
558
+ : transactionOwner.transactionSync(() => {
559
+ const stored = auditStore.getBinary(key);
560
+ const floor = resolve(decodeAuditFloor(stored), stored !== undefined);
561
+ // `put` rather than `putSync`, and inside the transaction: lmdb's putSync is
562
+ // `put(...) === SYNC_PROMISE_SUCCESS`, so it drops whatever put returns, and a rejected put
563
+ // would leak with no owner. Within a write transaction put writes synchronously and returns
564
+ // an already-resolved sentinel, so the value is visible immediately either way and this
565
+ // only takes ownership of the failure case.
566
+ // asBinary: a legacy standalone audit root's encoder has no Uint8Array passthrough, so raw
567
+ // bytes would reach createAuditEntry and throw. This bypasses both encoders.
568
+ if (floor !== undefined) {
569
+ auditStore
570
+ .put(key, asBinary(encodeAuditFloor(floor)))
571
+ ?.catch?.((error) => warnContained('Error writing the audit retention floor', error));
572
+ if (!floorWriteLanded(auditStore.getBinary(key), floor)) return false;
573
+ }
574
+ return true;
575
+ });
576
+ if (committed !== true) throw new Error('The audit retention floor transaction did not commit');
577
+ }
578
+
579
+ /**
580
+ * The bound a prune should use — and record — in place of an unbounded cutoff.
581
+ *
582
+ * `Infinity` is a legitimate thing for a caller to *mean* ("remove all of it") and a ruinous thing to
583
+ * store: it is the unknown sentinel, and the sentinel is absorbing. `raiseAuditFloor`'s lock-free
584
+ * pre-check skips any present record no cutoff exceeds, and `establishAuditFloor` skips any store that
585
+ * has one, so a floor at `Infinity` never comes back down — for the whole database, including sibling
586
+ * tables whose own history was never touched. One `deleteHistory(Infinity)` would otherwise retire the
587
+ * accessor for that database permanently (#2458).
588
+ *
589
+ * So bound it by what exists: strictly above the newest key currently in the log, and never below the
590
+ * clock. Nothing already written can escape that bound, and a caller that passes it as the prune's
591
+ * range end as well as its floor cannot remove anything the floor does not cover — which is what makes
592
+ * the write-ahead ordering hold without an infinite bound. An entry written *after* this returns is
593
+ * simply not history the call asked to remove.
594
+ *
595
+ * `Infinity` is only the extreme case. Any cutoff above this bound is the same defect by degree: a
596
+ * finite year-2286 bound (`Date.now() * 1000`, or a bare `'9999999999999'`) is equally unreachable
597
+ * and equally permanent, and entries written after the prune then land *below* the recorded floor,
598
+ * so the floor's promise — nothing after it was pruned — is false about history that is still there.
599
+ * Every cutoff is therefore clamped, not just the unbounded one.
600
+ *
601
+ * Non-numbers, NaN, negatives and `-0` fall through unchanged to `raiseAuditFloor`, which rejects
602
+ * them: the numeric ones are ordered keys the prune range would honor, not bounds anyone meant, and a
603
+ * non-number is guarded explicitly because `>` would otherwise coerce it into a bound the floor
604
+ * accepts. `cutoff > bound` rather than `Math.min` keeps NaN falling through too.
605
+ *
606
+ * On RocksDB `getKeys` is unimplemented and returns `[]`, so the bound reduces to `Date.now()`. A
607
+ * key above the clock therefore survives a purge that asked for it on that engine. That is the safe
608
+ * direction: the entry is kept and the floor stays honest, where the alternative removes history the
609
+ * floor does not cover.
610
+ */
611
+ export function boundedAuditPruneEnd(auditStore: any, cutoff: number): number {
612
+ // Non-numbers pass through untouched for `raiseAuditFloor` to reject. `>` coerces, so without this
613
+ // a numeric STRING ('9999999999999', 'Infinity') or a future Date compares true against the bound
614
+ // and comes back AS the bound — a number raiseAuditFloor accepts — turning a type error into a
615
+ // whole-log prune.
616
+ if (typeof cutoff !== 'number') return cutoff;
617
+ let bound = Date.now();
618
+ for (const newest of auditStore.getKeys({ reverse: true, limit: 1 })) {
619
+ if (typeof newest === 'number' && newest >= bound) bound = newest + 1;
620
+ }
621
+ return cutoff > bound ? bound : cutoff;
622
+ }
623
+
624
+ /**
625
+ * Raise the floor to `cutoff`, the exclusive lower bound of the history a prune is about to make
626
+ * unreachable.
627
+ *
628
+ * **Call this before removing anything.** A floor written after the removal is lost if the process
629
+ * dies in between, and the surviving lower floor then certifies a cursor whose history is gone.
630
+ * Ordering it first also covers a prune that removes less than `cutoff` spans (a RocksDB purge with
631
+ * no whole droppable file, a retention pass stopping at MAX_DELETES_PER_CLEANUP): over-reporting costs
632
+ * one unnecessary resync, under-reporting loses data silently. The retention paths bound that
633
+ * over-report at the configured horizon (`Date.now() - auditRetention`, never below 0); the two
634
+ * operator-supplied bounds have no ceiling of their own and go through `boundedAuditPruneEnd` first,
635
+ * since a bound above everything reachable would be recorded verbatim and never come down.
636
+ *
637
+ * Throws if the floor cannot be persisted, which is why it is called first — the throw is what stops
638
+ * the prune from proceeding unrecorded. Never lowers the floor, so a narrower prune cannot undo a
639
+ * wider one, and a store whose floor is unknown stays unknown rather than being talked down to a
640
+ * cutoff that says nothing about the history it has already lost.
641
+ */
642
+ export function raiseAuditFloor(auditStore: any, cutoff: number): void {
643
+ // Throw rather than no-op on a bound we will not store: audit keys are raw float64, so NaN and
644
+ // negatives (sign bit set) sort ABOVE every real timestamp and a range ending there spans the whole
645
+ // log — declining the floor silently would leave the prune deleting everything. `-0` and a non-number
646
+ // slip past a naive `< 0` check but are still ordered keys the range honors, so they are rejected too.
647
+ // Infinity is accepted and stored, decoding back to "unknown": every production caller clamps before
648
+ // reaching here (`boundedAuditPruneEnd`, the bridge's 400), so it stays reachable only for a caller
649
+ // that genuinely cannot bound its prune.
650
+ if (typeof cutoff !== 'number' || Number.isNaN(cutoff) || cutoff < 0 || Object.is(cutoff, -0))
651
+ throw new Error(`Invalid audit prune bound: ${String(cutoff)}`);
652
+ // Read-only mode does not exempt a prune from recording its floor; it means the prune must not
653
+ // happen. Only scheduleAuditCleanup and purgeAgedLogs check read-only themselves, so for
654
+ // deleteHistory and the whole-database purge this throw is the guard.
655
+ if (isReadOnlyMode()) throw new Error('Cannot record the audit retention floor: the database is read-only');
656
+ // Lock-free pre-check, getBinary-guarded so this optimization never decides the error a store with
657
+ // no audit store reports. Most calls cannot move the floor (a RocksDB reclamation pass on an idle
658
+ // database, a cutoff below one a wider prune already set), and taking the env write lock to
659
+ // discover that serializes every worker's boot and reclamation on it. The in-transaction guards
660
+ // below stay authoritative.
661
+ // Skips only the case it can prove is a no-op: a record that exists and already sits at or above
662
+ // the cutoff. An absent record is NOT decided here — the presence question is settled inside the
663
+ // transaction below, because another worker's establishAuditFloor can land between this read and
664
+ // that write.
665
+ if (auditStore?.getBinary) {
666
+ const stored = auditStore.getBinary(AUDIT_FLOOR_KEY);
667
+ if (stored !== undefined && !(cutoff > decodeAuditFloor(stored))) return;
668
+ }
669
+ updateAuditFloor(auditStore, (current, recorded) => {
670
+ // Still no record, and we are about to prune: persist the unknown sentinel. Leaving no marker
671
+ // lets the next open stamp a FINITE epoch, and a prune bound above that epoch (a future
672
+ // `endTime`, or a rolled-back clock) then certifies cursors whose history this prune deleted.
673
+ // Unknown is the honest value, because a store with no record may have been pruned before this
674
+ // run too.
675
+ if (!recorded) return AUDIT_FLOOR_UNKNOWN;
676
+ // A record appeared while we were getting here, so this is an ordinary monotonic raise: pruning
677
+ // to `cutoff` against a floor left below it is exactly the silent gap this function prevents.
678
+ return cutoff > current ? cutoff : undefined;
679
+ });
680
+ }
681
+
682
+ /**
683
+ * Give a database a trustworthy floor if it has none: the current time, as a one-time resync epoch.
684
+ *
685
+ * The floor record's *presence* is the trust marker, so a store without one is a store whose
686
+ * retention history we cannot account for. It may have been pruned by a version that recorded no
687
+ * floor; it may be the empty audit store an LMDB→RocksDB migration deliberately leaves behind
688
+ * (`bin/copyDb.ts` does not migrate it, so the records and their resumable cursors outlive their
689
+ * history); or it may be a database restored from a table-scoped backup taken without
690
+ * `include_audit`, which carries records but no audit DBI. Cursors from before this moment are
691
+ * therefore reported stale — not because we know they are, but because we do not know they are not.
692
+ *
693
+ * There is no "brand new store, use a permissive baseline" case: creating the audit DBI proves only
694
+ * that the DBI was absent, which the audit-less backup above also produces. Being conservative on a
695
+ * genuinely new database costs nothing, since its entries are all written after this instant.
696
+ *
697
+ * In read-only mode nothing is written and the floor stays unknown — the fail-closed answer for a
698
+ * process that cannot record what it does not know.
699
+ *
700
+ * **The epoch is also recorded under its own key, so this bootstrap is repairable.** The epoch is a
701
+ * guess bounded by surviving state, and surviving state cannot see history a selective prune already
702
+ * removed (see the clock note below). The record marks the store as one that carried a guess, and
703
+ * preserves the value guessed — the two facts a later release needs to raise such a floor to
704
+ * something it can stand behind. See "Audit retention floor" in DESIGN.md for the full reading.
705
+ *
706
+ * **The mark is the signal, not a comparison against the floor.** A store carrying this record has an
707
+ * unverified pre-tracking window for as long as it exists, however far the floor has since moved: a
708
+ * prune raising the floor above the epoch certifies only what that prune removed, and says nothing
709
+ * about history removed before tracking began — which may sit above the epoch, since that is exactly
710
+ * the case the guess cannot see. Retiring the mark takes a database generation (harper#2451), not a
711
+ * floor that has climbed past it.
712
+ *
713
+ * Ordering. The provenance record is written **first**, so a crash between the two writes leaves a
714
+ * record with no floor — which the next open retries, since the early return above tests the floor.
715
+ * The epoch actually stamped is always read back from the record rather than taken from this call's
716
+ * own `Date.now()`, so a worker whose record lost the race adopts the winner's value and the two
717
+ * always agree. Re-adopting an older record is sound: nothing was pruned in the meantime, or a prune
718
+ * would have written the floor this function returns early on.
719
+ */
720
+ export function establishAuditFloor(auditStore: any): void {
721
+ if (isReadOnlyMode()) return;
722
+ // Every read and write in here is inside the try: the contract is that a database open never fails
723
+ // over this metadata, and a throwing getBinary/getKeys would escape to initStores just as a
724
+ // throwing write would.
725
+ try {
726
+ // Absence of the record, not `getAuditFloor() === AUDIT_FLOOR_UNKNOWN`: a record that exists but
727
+ // decodes to unknown — corrupt bytes, or the Infinity a prune-everything stored — is already the
728
+ // fail-closed answer, and stamping over it would LOWER a floor that is never supposed to lower.
729
+ // The check is repeated inside the transaction (the `recorded` argument), because between this
730
+ // read and that write another worker's prune can store exactly such a value; this read only keeps
731
+ // the common case — a floor already established, every worker, every database, every boot — off
732
+ // the env write lock.
733
+ if (auditStore.getBinary(AUDIT_FLOOR_KEY) !== undefined) return;
734
+ // Not bare Date.now(): a clock that has rolled back would bootstrap a floor BELOW history this
735
+ // database may already have pruned, certifying a stale cursor. The newest retained entry is a
736
+ // lower bound the clock cannot argue with — everything at or above it is demonstrably still here —
737
+ // so take whichever is later.
738
+ //
739
+ // It narrows that hole; it does not close it, because the bound covers what SURVIVES rather than
740
+ // what existed. A legacy `deleteHistory` removes one table's entries below its endTime out of the
741
+ // shared log, so a table whose entries were the newest and all fell below that bound leaves the
742
+ // log's newest survivor being a sibling's OLDER entry — removed history above every surviving key.
743
+ // A clock rolled back to between the two then stamps an epoch below entries that are gone, and a
744
+ // cursor in that window resumes over the gap (#2458). Also unclosed:
745
+ // RocksTransactionLogStore.getKeys() is unimplemented, so on that engine this reduces to
746
+ // Date.now() outright.
747
+ //
748
+ // Neither is a reason to refuse to stamp — the unknown sentinel is absorbing, so that would make
749
+ // every upgraded deployment fail closed forever. They are the reason
750
+ // the guess is RECORDED as a guess: written first, so it cannot be lost behind a floor that
751
+ // outlives it, and left in place afterwards so the repair reading stays available.
752
+ let fresh = Date.now();
753
+ for (const newest of auditStore.getKeys({ reverse: true, limit: 1 })) {
754
+ if (typeof newest === 'number' && newest > fresh) fresh = newest;
755
+ }
756
+ // A READABLE record is kept, so the epoch read back below is whatever landed first and the floor
757
+ // always matches it. An unreadable one is replaced: unlike the floor, where a present record may
758
+ // be a deliberate AUDIT_FLOOR_UNKNOWN and overwriting it would lower a floor, this record is only
759
+ // a comparison basis, and undecodable bytes carry nothing worth keeping. Declining to replace
760
+ // them pinned the store's floor to unknown forever — the resolver would skip the write on every
761
+ // later open, the read back would fail identically, and no retry could ever succeed, which is the
762
+ // fail-closed-forever state this bootstrap exists to avoid.
763
+ updateAuditFloor(
764
+ auditStore,
765
+ (current, recorded) => (recorded && Number.isFinite(current) ? undefined : fresh),
766
+ AUDIT_FLOOR_BOOTSTRAP_KEY
767
+ );
768
+ const epoch = decodeAuditFloor(auditStore.getBinary(AUDIT_FLOOR_BOOTSTRAP_KEY));
769
+ // Never stamp a floor whose provenance cannot be read: that is the one state a later repair cannot
770
+ // act on. Unreachable in principle, since `updateAuditFloor` throws if its write did not land.
771
+ if (!Number.isFinite(epoch)) return;
772
+ updateAuditFloor(auditStore, (_current, recorded) => (recorded ? undefined : epoch));
773
+ } catch (error) {
774
+ // An unrecorded floor already reads as unknown, which is the fail-closed answer; aborting startup
775
+ // instead would turn a metadata failure into an outage. The next open retries.
776
+ warnContained('Error initializing the audit retention floor', error);
777
+ }
778
+ }
779
+
780
+ /**
781
+ * The floor of this database's retained audit history: every audit entry at or after the returned
782
+ * time is still retained, so a consumer whose last-processed audit-log cursor is `>=` it can resume
783
+ * incrementally, and one below it must resync — it may have lost nothing, but the floor cannot
784
+ * certify it either way. Returns `Infinity` when
785
+ * the floor is unknown, which fails closed — no cursor compares as safe.
786
+ *
787
+ * **One exception, and it is the only one: history removed before the floor existed.** Every prune
788
+ * that runs with a floor recorded is covered — it raises the floor first, so it cannot remove an entry
789
+ * the floor does not cover. But the first open stamps a floor from what *survives*
790
+ * (`establishAuditFloor`), and a legacy `Table.deleteHistory` that removed a table's newest entries,
791
+ * followed by a clock rollback, leaves that stamp below history that is gone; a cursor in the window
792
+ * is then certified over the gap. So the guarantee is one-directional for tracked prunes and silent
793
+ * about untracked ones. Only a database generation can close that (harper#2451).
794
+ *
795
+ * The time domain is the audit-log key: what `subscribe`'s events carry as `localTime` and what MQTT
796
+ * durable sessions persist as `startTime`, so those compare against the floor directly.
797
+ * **`getHistory` is not in that domain** — it reports each entry's origin `version` under the name
798
+ * `localTime`, which a backdated or replicated write makes differ from the audit-log key. A cursor
799
+ * saved from `getHistory` is not comparable to this floor.
800
+ *
801
+ * **Database-scoped**, and deliberately conservative: the audit store is per-database and its
802
+ * entries carry a `tableId`, so a per-table floor would need a scan for the first entry matching
803
+ * that table. For a valid cursor, `cursor >= floor` therefore means no entry of *any* table in the
804
+ * database was removed *after* the cursor. What it never promises is anything below the FLOOR — that
805
+ * history is exactly what a prune takes. Below the *cursor* is not the same set: for a cursor strictly
806
+ * above the floor, `[floor, cursor)` sits below the cursor and is still covered by the guarantee. `Table.deleteHistory`
807
+ * prunes one table out of that shared log and raises the whole database's floor, which can overstate
808
+ * the floor for its siblings.
809
+ *
810
+ * **A moment-in-time observation.** Retention can advance between this call and whatever the caller
811
+ * does with the answer, so a check-then-resume sequence has a window where the floor moves under it.
812
+ * Closing that requires validating the cursor inside the resume itself (harper#2448); until then a
813
+ * lost race degrades to the truncation that happens today, never to anything worse.
814
+ *
815
+ * **On RocksDB the floor tracks the configured horizon, not retained reality.** That branch purges at
816
+ * whole-log-file granularity, so it cannot know before the fact which entries a purge will drop, and
817
+ * the floor has to be written first — so every retention pass advances it to
818
+ * `Date.now() - auditRetention / (1 + priority²)` whether or not a file was dropped. Entries below
819
+ * that horizon are routinely still on disk, and a consumer holding a cursor among them is told to
820
+ * resync. Conservative in the one safe direction, and the reason the LMDB branch (which can see a
821
+ * single eligible entry) instead raises off the first one it finds.
822
+ *
823
+ * **Not covered: copying a database's state without its history.** `restore_backup` replaces a
824
+ * database with the backup's, floor and all, and a RocksDB checkpoint (a branch database) copies the
825
+ * floor record but no transaction logs — so in both cases a cursor from after the copy point sits
826
+ * above a floor that is present, and therefore trusted, for history that is not there. Nothing here
827
+ * can detect that on its own, and it is not the audit log's problem alone: the same copy rolls back
828
+ * record versions and per-node replication sequence state, so making this one field honest while
829
+ * those stay stale would not give a consumer a coherent answer. It needs a database-level epoch —
830
+ * harper#2451.
831
+ */
832
+ export function getAuditFloor(auditStore: any): number {
833
+ return decodeAuditFloor(auditStore.getBinary(AUDIT_FLOOR_KEY));
834
+ }
438
835
  export function setAuditRetention(retentionTime, defaultDelay = DEFAULT_AUDIT_CLEANUP_DELAY) {
439
836
  auditRetention = retentionTime;
440
837
  DEFAULT_AUDIT_CLEANUP_DELAY = defaultDelay;
441
838
  }
442
839
 
840
+ /**
841
+ * The retention cutoff a prune uses: `Date.now() - auditRetention`, scaled down by `divisor` for a
842
+ * higher-priority pass, and never below 0. A retention above ~55.7 years (or `Infinity`, to keep
843
+ * logs indefinitely) would otherwise go negative, and a negative bound is not "nothing eligible" —
844
+ * `raiseAuditFloor` rejects it, so every boot purge and retention pass would warn and the floor
845
+ * would never be raised on that install. At 0 the pass is a harmless no-op: nothing sits before
846
+ * the epoch, and a floor that already exists is never lowered to it.
847
+ */
848
+ function retentionCutoff(divisor = 1): number {
849
+ return Math.max(0, Date.now() - auditRetention / divisor);
850
+ }
851
+
443
852
  /**
444
853
  * One-shot purge of transaction-log files already older than the audit retention window,
445
854
  * intended to run during startup/recovery before transaction-log replay. The steady-state
@@ -452,7 +861,10 @@ export function setAuditRetention(retentionTime, defaultDelay = DEFAULT_AUDIT_CL
452
861
  export function purgeAgedLogs(rootStore: RocksDatabase): string[] {
453
862
  // Mirror the read-only guard in scheduleAuditCleanup: never delete log files in read-only mode.
454
863
  if (isReadOnlyMode()) return [];
455
- return rootStore.purgeLogs({ before: Date.now() - auditRetention });
864
+ const before = retentionCutoff();
865
+ // The audit store is reachable this early because initStores opens it before replayLogs runs this.
866
+ raiseAuditFloor((rootStore as any).auditStore, before);
867
+ return rootStore.purgeLogs({ before });
456
868
  }
457
869
 
458
870
  const HAS_RECORD = 16;
@@ -467,13 +879,31 @@ const STRUCTURES = 7;
467
879
  // Whole-table "reload" marker: a control entry (no record) signalling that a table was bulk-reloaded
468
880
  // and subscribers should re-read it. Used after a copyApply base copy, whose per-row snapshot writes
469
881
  // carry no audit entry (harper-pro#489). The entry type lives in the low nibble of the action byte
470
- // (decoded via `action & 0xf`); 1–7 are the record actions above, 8 is reload, leaving 9–15 free for
471
- // future actions. Markers are always written LOCAL_ONLY so an unknown type never reaches a peer.
882
+ // (decoded via `action & 0xf`); 1–7 are the record actions above, 8 is reload, 9 is eviction, leaving 10–15 free for
883
+ // future actions. Reload markers are always written LOCAL_ONLY so an unknown type never reaches a
884
+ // peer; the lock control entries below deliberately are not, and rely on the capability gate instead.
472
885
  const RELOAD = 8;
886
+ const EVICT = 9;
473
887
  export const ACTION_32_BIT = 14;
474
888
  export const ACTION_64_BIT = 15;
475
889
  /** Used to indicate we have received a remote local time update */
476
890
  export const REMOTE_SEQUENCE_UPDATE = 11;
891
+ /**
892
+ * Cluster record-lock coordination (harper#483 Phase 1). This replicates — unlike the reload marker
893
+ * it is NOT `LOCAL_ONLY` — and carries a control payload rather than a record, so it is written with
894
+ * `recordId: null`: an entry sharing a real record's `(version, tableId, recordId, nodeId)` would be
895
+ * returned by `RocksTransactionLogStore.getSync` ahead of that record's own audit entry and make
896
+ * `_writeUpdate`'s keyed dedup drop the holder's write.
897
+ *
898
+ * Two entries exist: the release, and the barrier — a replicated no-op a member commits on request
899
+ * so its own log position can serve as the §7.2 recovery fence (harper#2625). Nibbles 9 and 10
900
+ * briefly held `lockRequest`/`lockGrant` for the Ricart–Agrawala arbitration rule that
901
+ * `docs/record-lock-ownership.md` replaces; that rule never shipped enabled, so they were retired
902
+ * rather than migrated — and 9 has since been taken by eviction. 10 is spare; 14/15 are the width
903
+ * flags.
904
+ */
905
+ export const LOCK_RELEASE = 12;
906
+ export const LOCK_BARRIER = 13;
477
907
  export const HAS_CURRENT_RESIDENCY_ID = 512;
478
908
  export const HAS_PREVIOUS_RESIDENCY_ID = 1024;
479
909
  export const HAS_ORIGINATING_OPERATION = 2048;
@@ -507,8 +937,14 @@ const EVENT_TYPES = {
507
937
  [STRUCTURES]: 'structures',
508
938
  reload: RELOAD,
509
939
  [RELOAD]: 'reload',
940
+ evict: EVICT,
941
+ [EVICT]: 'evict',
510
942
  remoteSequenceUpdate: REMOTE_SEQUENCE_UPDATE,
511
943
  [REMOTE_SEQUENCE_UPDATE]: 'remoteSequenceUpdate',
944
+ lockRelease: LOCK_RELEASE | HAS_RECORD,
945
+ [LOCK_RELEASE]: 'lockRelease',
946
+ lockBarrier: LOCK_BARRIER | HAS_RECORD,
947
+ [LOCK_BARRIER]: 'lockBarrier',
512
948
  };
513
949
  /**
514
950
  * The LMDB audit entry states the presence of its leading 8-byte previousVersion field with that
@@ -553,6 +989,17 @@ function isDecodableAction(action: number) {
553
989
  LOCAL_ONLY;
554
990
  return (action & 0xf) !== 0 && !(action & ~knownActionFlags);
555
991
  }
992
+
993
+ /**
994
+ * Cluster lock coordination entries. They ride the replicated audit stream but describe no record,
995
+ * so every consumer that surfaces audit entries as record activity — subscriber fan-out, the
996
+ * `startTime` replay, the `previousCount` backfill, the replicated-event sink — must exclude them.
997
+ * An equality chain rather than a Set: this runs once per audit entry on the replay and fan-out
998
+ * paths, where the common answer is false on the first comparison.
999
+ */
1000
+ export function isLockControlType(type: unknown): boolean {
1001
+ return type === 'lockRelease' || type === 'lockBarrier';
1002
+ }
556
1003
  const ORIGINATING_OPERATIONS = {
557
1004
  insert: 1,
558
1005
  update: 2,
@@ -836,10 +1283,11 @@ export function readAuditEntry(buffer: Uint8Array, start = 0, end = undefined):
836
1283
  const usernameEnd = (decoder.position += length);
837
1284
  let value: any;
838
1285
  return {
839
- // The entry type is the low nibble of the action byte (1–7 record actions, 8 reload, 9–15
1286
+ // The entry type is the low nibble of the action byte (1–7 record actions, 8 reload, 9 eviction, 10–15
840
1287
  // reserved); the flag bits (HAS_RECORD, HAS_PARTIAL_RECORD, …) sit above it. `& 0xf` is
841
1288
  // identical to the historical `& 7` for every pre-reload entry (bit 3 was always clear).
842
1289
  type: EVENT_TYPES[action & 0xf],
1290
+ logName: undefined,
843
1291
  tableId,
844
1292
  nodeId,
845
1293
  get recordId() {
@@ -956,6 +1404,7 @@ function corruptEntry(buffer: Uint8Array, start: number, end: number | undefined
956
1404
  function createCorruptAuditSentinel(buffer: Uint8Array, start: number, end: number | undefined): AuditRecord {
957
1405
  return {
958
1406
  type: undefined,
1407
+ logName: undefined,
959
1408
  tableId: undefined,
960
1409
  nodeId: undefined,
961
1410
  recordId: undefined,