@harperfast/harper 5.3.0 → 5.3.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 (231) hide show
  1. package/bin/copyDb.ts +5 -1
  2. package/bin/deployRenderer.ts +3 -0
  3. package/components/Application.ts +66 -22
  4. package/components/DESIGN.md +89 -36
  5. package/components/componentLoader.ts +2 -0
  6. package/components/deploymentRecorder.ts +18 -1
  7. package/components/installFingerprint.ts +123 -0
  8. package/components/mcp/DESIGN.md +4 -4
  9. package/components/operations.js +29 -0
  10. package/config/DESIGN.md +5 -5
  11. package/dataLayer/DESIGN.md +37 -22
  12. package/dataLayer/backupRepository.ts +248 -0
  13. package/dataLayer/restoreMarker.ts +30 -75
  14. package/dataLayer/rocksdbBackup.ts +253 -110
  15. package/dist/bin/copyDb.js +3 -0
  16. package/dist/bin/copyDb.js.map +1 -1
  17. package/dist/bin/deployRenderer.js +3 -0
  18. package/dist/bin/deployRenderer.js.map +1 -1
  19. package/dist/components/Application.d.ts +5 -0
  20. package/dist/components/Application.js +51 -19
  21. package/dist/components/Application.js.map +1 -1
  22. package/dist/components/componentLoader.js +2 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/deploymentRecorder.js +18 -1
  25. package/dist/components/deploymentRecorder.js.map +1 -1
  26. package/dist/components/installFingerprint.d.ts +39 -0
  27. package/dist/components/installFingerprint.js +113 -0
  28. package/dist/components/installFingerprint.js.map +1 -0
  29. package/dist/components/operations.d.ts +2 -0
  30. package/dist/components/operations.js +31 -0
  31. package/dist/components/operations.js.map +1 -1
  32. package/dist/dataLayer/backupRepository.d.ts +33 -0
  33. package/dist/dataLayer/backupRepository.js +232 -0
  34. package/dist/dataLayer/backupRepository.js.map +1 -0
  35. package/dist/dataLayer/restoreMarker.d.ts +4 -1
  36. package/dist/dataLayer/restoreMarker.js +29 -68
  37. package/dist/dataLayer/restoreMarker.js.map +1 -1
  38. package/dist/dataLayer/rocksdbBackup.js +221 -112
  39. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/json/systemSchema.json +14 -0
  43. package/dist/resources/DatabaseTransaction.d.ts +140 -11
  44. package/dist/resources/DatabaseTransaction.js +1055 -464
  45. package/dist/resources/DatabaseTransaction.js.map +1 -1
  46. package/dist/resources/IterableEventQueue.d.ts +2 -1
  47. package/dist/resources/IterableEventQueue.js +16 -8
  48. package/dist/resources/IterableEventQueue.js.map +1 -1
  49. package/dist/resources/LMDBTransaction.d.ts +13 -2
  50. package/dist/resources/LMDBTransaction.js +219 -93
  51. package/dist/resources/LMDBTransaction.js.map +1 -1
  52. package/dist/resources/RecordEncoder.js +2 -3
  53. package/dist/resources/RecordEncoder.js.map +1 -1
  54. package/dist/resources/Resource.js +5 -6
  55. package/dist/resources/Resource.js.map +1 -1
  56. package/dist/resources/ResourceInterface.d.ts +3 -1
  57. package/dist/resources/ResourceInterface.js.map +1 -1
  58. package/dist/resources/RocksIndexStore.d.ts +2 -1
  59. package/dist/resources/RocksIndexStore.js +3 -0
  60. package/dist/resources/RocksIndexStore.js.map +1 -1
  61. package/dist/resources/Table.js +116 -12
  62. package/dist/resources/Table.js.map +1 -1
  63. package/dist/resources/analytics/write.js +6 -2
  64. package/dist/resources/analytics/write.js.map +1 -1
  65. package/dist/resources/auditStore.d.ts +71 -17
  66. package/dist/resources/auditStore.js +248 -64
  67. package/dist/resources/auditStore.js.map +1 -1
  68. package/dist/resources/branchDatabase.js +2 -0
  69. package/dist/resources/branchDatabase.js.map +1 -1
  70. package/dist/resources/databases.js +8 -2
  71. package/dist/resources/databases.js.map +1 -1
  72. package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
  73. package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
  74. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
  75. package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
  76. package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
  77. package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
  78. package/dist/resources/models/Models.d.ts +11 -0
  79. package/dist/resources/models/Models.js +95 -4
  80. package/dist/resources/models/Models.js.map +1 -1
  81. package/dist/resources/models/backendRegistry.d.ts +8 -0
  82. package/dist/resources/models/backendRegistry.js +15 -1
  83. package/dist/resources/models/backendRegistry.js.map +1 -1
  84. package/dist/resources/models/bootstrap.d.ts +2 -0
  85. package/dist/resources/models/bootstrap.js +6 -0
  86. package/dist/resources/models/bootstrap.js.map +1 -1
  87. package/dist/resources/models/calibration.d.ts +88 -0
  88. package/dist/resources/models/calibration.js +244 -0
  89. package/dist/resources/models/calibration.js.map +1 -0
  90. package/dist/resources/models/calibrationStore.d.ts +163 -0
  91. package/dist/resources/models/calibrationStore.js +986 -0
  92. package/dist/resources/models/calibrationStore.js.map +1 -0
  93. package/dist/resources/models/decisionStore.d.ts +24 -0
  94. package/dist/resources/models/decisionStore.js +45 -0
  95. package/dist/resources/models/decisionStore.js.map +1 -1
  96. package/dist/resources/models/generativeDecision.js +44 -15
  97. package/dist/resources/models/generativeDecision.js.map +1 -1
  98. package/dist/resources/models/types.d.ts +7 -0
  99. package/dist/resources/recordLock.d.ts +8 -5
  100. package/dist/resources/recordLock.js +6 -4
  101. package/dist/resources/recordLock.js.map +1 -1
  102. package/dist/resources/replayLogs.js +8 -1
  103. package/dist/resources/replayLogs.js.map +1 -1
  104. package/dist/resources/scheduler/engine.d.ts +4 -0
  105. package/dist/resources/scheduler/engine.js +30 -2
  106. package/dist/resources/scheduler/engine.js.map +1 -1
  107. package/dist/resources/search.d.ts +16 -4
  108. package/dist/resources/search.js +117 -13
  109. package/dist/resources/search.js.map +1 -1
  110. package/dist/resources/transaction.js +85 -5
  111. package/dist/resources/transaction.js.map +1 -1
  112. package/dist/resources/transactionBroadcast.d.ts +8 -0
  113. package/dist/resources/transactionBroadcast.js +65 -0
  114. package/dist/resources/transactionBroadcast.js.map +1 -1
  115. package/dist/security/keys.d.ts +1 -1
  116. package/dist/security/keys.js +170 -120
  117. package/dist/security/keys.js.map +1 -1
  118. package/dist/server/http.js +10 -0
  119. package/dist/server/http.js.map +1 -1
  120. package/dist/server/serverHelpers/Request.js +11 -2
  121. package/dist/server/serverHelpers/Request.js.map +1 -1
  122. package/dist/server/serverHelpers/contentTypes.js +8 -3
  123. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +38 -7
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/server/threads/manageThreads.d.ts +3 -3
  127. package/dist/server/threads/manageThreads.js +15 -13
  128. package/dist/server/threads/manageThreads.js.map +1 -1
  129. package/dist/server/threads/threadServer.d.ts +3 -0
  130. package/dist/server/threads/threadServer.js +24 -6
  131. package/dist/server/threads/threadServer.js.map +1 -1
  132. package/dist/upgrade/directives/5-3-0.js +9 -3
  133. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  134. package/dist/utility/durableFile.d.ts +36 -0
  135. package/dist/utility/durableFile.js +106 -0
  136. package/dist/utility/durableFile.js.map +1 -0
  137. package/dist/utility/errors/hdbError.d.ts +4 -0
  138. package/dist/utility/errors/hdbError.js +10 -1
  139. package/dist/utility/errors/hdbError.js.map +1 -1
  140. package/dist/utility/globalSchema.d.ts +9 -0
  141. package/dist/utility/hdbTerms.d.ts +1 -0
  142. package/dist/utility/hdbTerms.js +1 -0
  143. package/dist/utility/hdbTerms.js.map +1 -1
  144. package/dist/validation/configValidator.js +29 -0
  145. package/dist/validation/configValidator.js.map +1 -1
  146. package/index.ts +7 -0
  147. package/json/systemSchema.json +14 -0
  148. package/npm-shrinkwrap.json +22 -22
  149. package/package.json +2 -2
  150. package/resources/DESIGN.md +57 -272
  151. package/resources/DatabaseTransaction.ts +708 -87
  152. package/resources/FULL_TEXT.md +6 -2
  153. package/resources/IterableEventQueue.ts +14 -7
  154. package/resources/LMDBTransaction.ts +212 -84
  155. package/resources/RecordEncoder.ts +2 -3
  156. package/resources/Resource.ts +5 -6
  157. package/resources/ResourceInterface.ts +3 -1
  158. package/resources/RocksIndexStore.ts +5 -0
  159. package/resources/Table.ts +118 -15
  160. package/resources/analytics/write.ts +6 -2
  161. package/resources/audit-retention.md +203 -0
  162. package/resources/auditStore.ts +306 -84
  163. package/resources/branchDatabase.ts +2 -0
  164. package/resources/databases.ts +6 -2
  165. package/resources/indexes/DESIGN.md +120 -177
  166. package/resources/indexes/fullTextNativeBinding.ts +22 -3
  167. package/resources/indexes/fullTextQueryIndex.ts +274 -11
  168. package/resources/models/DESIGN.md +11 -3
  169. package/resources/models/Models.ts +127 -4
  170. package/resources/models/backendRegistry.ts +17 -0
  171. package/resources/models/bootstrap.ts +9 -1
  172. package/resources/models/calibration.ts +298 -0
  173. package/resources/models/calibrationStore.ts +1167 -0
  174. package/resources/models/decisionStore.ts +57 -0
  175. package/resources/models/generativeDecision.ts +54 -16
  176. package/resources/models/types.ts +6 -0
  177. package/resources/record-locks.md +155 -141
  178. package/resources/recordLock.ts +12 -6
  179. package/resources/replayLogs.ts +9 -2
  180. package/resources/scheduler/DESIGN.md +4 -3
  181. package/resources/scheduler/engine.ts +35 -2
  182. package/resources/search.ts +154 -16
  183. package/resources/transaction.ts +50 -5
  184. package/resources/transactionBroadcast.ts +69 -0
  185. package/security/DESIGN.md +9 -30
  186. package/security/keys.ts +175 -127
  187. package/server/DESIGN.md +70 -36
  188. package/server/http.ts +10 -0
  189. package/server/serverHelpers/Request.ts +10 -2
  190. package/server/serverHelpers/contentTypes.ts +5 -3
  191. package/server/serverHelpers/uwsServer.ts +34 -7
  192. package/server/threads/manageThreads.js +16 -14
  193. package/server/threads/threadServer.js +24 -6
  194. package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
  195. package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
  196. package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
  197. package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
  198. package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
  199. package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
  200. package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
  201. package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
  202. package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
  203. package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
  204. package/studio/web/assets/index-QLZw6xrU.css +1 -0
  205. package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
  206. package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
  207. package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
  208. package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
  209. package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
  210. package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
  211. package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
  212. package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
  213. package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
  214. package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
  215. package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
  216. package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
  217. package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
  218. package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
  219. package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
  220. package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
  221. package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
  222. package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
  223. package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
  224. package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
  225. package/studio/web/index.html +2 -2
  226. package/upgrade/directives/5-3-0.ts +10 -3
  227. package/utility/durableFile.ts +96 -0
  228. package/utility/errors/hdbError.ts +9 -0
  229. package/utility/hdbTerms.ts +1 -0
  230. package/validation/configValidator.ts +31 -0
  231. package/studio/web/assets/index-72cEH474.css +0 -1
@@ -1259,6 +1259,18 @@ function scopeViolation(
1259
1259
  );
1260
1260
  }
1261
1261
 
1262
+ function rescope(resolved: ResolvedRecordLockOptions, tableReplicates: boolean): ResolvedRecordLockOptions {
1263
+ if (resolved.scopeRequested) return resolved;
1264
+ const scope = tableReplicates ? 'cluster' : 'node';
1265
+ return scope === resolved.scope ? resolved : { ...resolved, scope };
1266
+ }
1267
+
1268
+ function transportUnavailable(databaseName: string): LockUnavailableError {
1269
+ return new LockUnavailableError(
1270
+ `Cluster-scoped record locks are not available on ${databaseName}: no record lock transport is registered`
1271
+ );
1272
+ }
1273
+
1262
1274
  /** Distinguishes bare lock options from a record target (id, URL, {id:...}). */
1263
1275
  function isPlainOptions(value: unknown): boolean {
1264
1276
  return (
@@ -1282,6 +1294,26 @@ setLockCoordinatorResolver(
1282
1294
  }
1283
1295
  );
1284
1296
 
1297
+ // Valid for one synchronous notify pass, in which a key's subscribers on this thread all receive the same
1298
+ // freshly decoded audit record; cleared after it so it pins no store or record once delivery is done.
1299
+ let memoizedEntryAuditRecord: any;
1300
+ let memoizedEntryStore: any;
1301
+ let memoizedEntryId: Id;
1302
+ let memoizedEntry: Entry | undefined;
1303
+ function clearEntryMemo() {
1304
+ memoizedEntryAuditRecord = memoizedEntryStore = memoizedEntryId = memoizedEntry = undefined;
1305
+ }
1306
+ function currentEntryForAudit(store: any, id: Id, auditRecord: any): Entry | undefined {
1307
+ if (auditRecord !== memoizedEntryAuditRecord || store !== memoizedEntryStore || id !== memoizedEntryId) {
1308
+ if (memoizedEntryAuditRecord === undefined) queueMicrotask(clearEntryMemo);
1309
+ memoizedEntry = store.getEntry(id);
1310
+ memoizedEntryAuditRecord = auditRecord;
1311
+ memoizedEntryStore = store;
1312
+ memoizedEntryId = id;
1313
+ }
1314
+ return memoizedEntry;
1315
+ }
1316
+
1285
1317
  export function makeTable(options): TableResourceClass {
1286
1318
  const {
1287
1319
  primaryKey,
@@ -1580,12 +1612,25 @@ export function makeTable(options): TableResourceClass {
1580
1612
  return;
1581
1613
  return { key, descriptor };
1582
1614
  }
1615
+ const COMMIT_BASE_METHODS = new Set(['put', 'patch', 'delete']);
1616
+ function entryBeforeWrite(loadedEntry: Entry | undefined, id: Id, transaction: any, reloadsCommitBase: boolean) {
1617
+ if (loadedEntry != null) return loadedEntry;
1618
+ if (isRocksDB && reloadsCommitBase) {
1619
+ // save() reads this write's base from the staging snapshot (harper#2259); the read handle is still opened
1620
+ // here because it is what gives the staged write coordinated conflict retries
1621
+ transaction.getReadTxn();
1622
+ return undefined;
1623
+ }
1624
+ return primaryStore.getEntry(id, { transaction: transaction.getReadTxn() });
1625
+ }
1583
1626
  class TableResource<Record extends object = any> extends Resource<Record> {
1584
1627
  #record: any; // the stored/frozen record from the database and stored in the cache (should not be modified directly)
1585
1628
  #changes: any; // the changes to the record that have been made (should not be modified directly)
1586
1629
  #version?: number; // version of the record
1587
1630
  #entry?: Entry; // the entry from the database
1588
1631
  #savingOperation?: any; // operation for the record is currently being saved
1632
+ #baseReadTxn?: any; // staging handle this instance's uncached pre-load read through
1633
+ #baseReadEntry?: Entry; // what that read returned, reusable as the commit base only while it is still #entry
1589
1634
  #lockHandle?: RecordLockHandle; // the record lock acquired by lock() — scoped or hold
1590
1635
  #lockWritable?: boolean; // set by #reloadLocked to let save() stage lock-writable updates
1591
1636
  #writeGeneration?: WriteGeneration;
@@ -2361,15 +2406,29 @@ export function makeTable(options): TableResourceClass {
2361
2406
  if (readTxn?.isDone) {
2362
2407
  throw new Error('You can not read from a transaction that has already been committed/aborted');
2363
2408
  }
2409
+ const readsCommitBase =
2410
+ isRocksDB &&
2411
+ COMMIT_BASE_METHODS.has(resourceOptions?.method) &&
2412
+ !resourceOptions?.ensureLoaded &&
2413
+ readTxn &&
2414
+ !(readTxn as any).snapshotDisabled;
2364
2415
  return loadLocalRecord(
2365
2416
  id,
2366
2417
  request,
2367
- { transaction: readTxn, ensureLoaded: resourceOptions?.ensureLoaded },
2418
+ {
2419
+ transaction: readTxn,
2420
+ ensureLoaded: resourceOptions?.ensureLoaded,
2421
+ uncachedRead: readsCommitBase || undefined,
2422
+ },
2368
2423
  sync,
2369
2424
  (entry) => {
2370
2425
  if (entry) {
2371
2426
  TableResource._updateResource(this, entry);
2372
2427
  } else this.#record = null;
2428
+ if (readsCommitBase) {
2429
+ this.#baseReadTxn = readTxn;
2430
+ this.#baseReadEntry = entry;
2431
+ }
2373
2432
  if (request.onlyIfCached) {
2374
2433
  // don't go into the loading from source condition, but HTTP spec says to
2375
2434
  // return 504 (rather than 404) if there is no content and the cache-control header
@@ -2401,6 +2460,14 @@ export function makeTable(options): TableResourceClass {
2401
2460
  throw error;
2402
2461
  }
2403
2462
  }
2463
+ // Reusable only for the key this instance read and while #entry is the entry that read returned: a source fill
2464
+ // or retry replaces #entry, and ensureLoaded() can evict it in place, so ensureLoaded() drops the receipt.
2465
+ #commitBaseTxn(id: Id) {
2466
+ const baseReadTxn = this.#baseReadTxn;
2467
+ if (!baseReadTxn || this.#baseReadEntry !== this.#entry) return;
2468
+ const receiverId = this.getId();
2469
+ if (Object.is(id, receiverId) || writeKeyId(id) === writeKeyId(receiverId)) return baseReadTxn;
2470
+ }
2404
2471
  static _updateResource(resource, entry) {
2405
2472
  resource.#entry = entry;
2406
2473
  resource.#record = entry?.value ?? null;
@@ -2412,6 +2479,7 @@ export function makeTable(options): TableResourceClass {
2412
2479
  * @returns
2413
2480
  */
2414
2481
  ensureLoaded() {
2482
+ this.#baseReadTxn = undefined;
2415
2483
  const loadedFromSource = ensureLoadedFromSource(
2416
2484
  (this.constructor as any).source,
2417
2485
  this.getId(),
@@ -3655,6 +3723,7 @@ export function makeTable(options): TableResourceClass {
3655
3723
  isRocksDB && audit && txnLogKey !== txnTime
3656
3724
  ? [{ version: txnLogKey, nodeId: options?.nodeId }]
3657
3725
  : undefined,
3726
+ localOnly: options?.localOnly,
3658
3727
  },
3659
3728
  'invalidate'
3660
3729
  );
@@ -3723,6 +3792,7 @@ export function makeTable(options): TableResourceClass {
3723
3792
  isRocksDB && audit && txnLogKey !== txnTime
3724
3793
  ? [{ version: txnLogKey, nodeId: options?.nodeId }]
3725
3794
  : undefined,
3795
+ localOnly: options?.localOnly,
3726
3796
  },
3727
3797
  'relocate',
3728
3798
  false,
@@ -3900,7 +3970,7 @@ export function makeTable(options): TableResourceClass {
3900
3970
  const id = target != null ? requestTargetToId(target as RequestTargetOrId) : this.getId();
3901
3971
  checkValidId(id);
3902
3972
  this.#assertLiveHandle(id);
3903
- const resolved = resolveLockOptions(options);
3973
+ const resolved = resolveLockOptions(options, TableResource.replicate !== false);
3904
3974
  const context = this.getContext();
3905
3975
  const link = txnForContext(context);
3906
3976
  const keyId = writeKeyId(id);
@@ -3912,11 +3982,7 @@ export function makeTable(options): TableResourceClass {
3912
3982
  (resolved.scopeRequested || isClusterLockRequired(databaseName)) &&
3913
3983
  !getClusterLockTransport(databaseName)
3914
3984
  )
3915
- return Promise.reject(
3916
- new LockUnavailableError(
3917
- `Cluster-scoped record locks are not available on ${databaseName}: no record lock transport is registered`
3918
- )
3919
- );
3985
+ return Promise.reject(transportUnavailable(databaseName));
3920
3986
  const held = this.#lockHandle;
3921
3987
  if (held && !held.isExpired() && held.keyId === keyId) {
3922
3988
  // Re-entrant: upgrade to hold if requested, then preserve staged changes.
@@ -4007,7 +4073,11 @@ export function makeTable(options): TableResourceClass {
4007
4073
  clearTimeout(followerTimer);
4008
4074
  const acquired = link.recordLockFor(primaryStore, keyId);
4009
4075
  if (acquired && !acquired.isExpired()) {
4010
- const violation = scopeViolation(acquired, resolved, databaseName);
4076
+ const violation = scopeViolation(
4077
+ acquired,
4078
+ rescope(resolved, TableResource.replicate !== false),
4079
+ databaseName
4080
+ );
4011
4081
  if (violation) throw violation;
4012
4082
  if (resolved.hold && !acquired.hold) {
4013
4083
  detachScopedUpgradeWrite(link, keyId, acquired);
@@ -4056,8 +4126,9 @@ export function makeTable(options): TableResourceClass {
4056
4126
  // caller's whole timeout, long enough for harper-pro to register the transport on this
4057
4127
  // worker. Using the snapshot would take the native key alone and hand back a node-scoped
4058
4128
  // handle while a peer that already had the transport is granted the same key.
4129
+ const current = rescope(resolved, TableResource.replicate !== false);
4059
4130
  try {
4060
- if (resolved.scope !== 'node') coordinator = TableResource.lockCoordinator ?? coordinator;
4131
+ coordinator = current.scope === 'cluster' ? (TableResource.lockCoordinator ?? coordinator) : undefined;
4061
4132
  } catch (error) {
4062
4133
  // The getter fails closed on an unusable node identity, and that has to reach the caller
4063
4134
  // the same way it does before the wait. Swallowing it let an implicit cluster lock fall
@@ -4066,6 +4137,15 @@ export function makeTable(options): TableResourceClass {
4066
4137
  handle.release();
4067
4138
  throw error as Error;
4068
4139
  }
4140
+ // The entry check cannot cover this: the wait is where the call became cluster-scoped.
4141
+ if (
4142
+ current.scope === 'cluster' &&
4143
+ !coordinator &&
4144
+ (current.scopeRequested || isClusterLockRequired(databaseName))
4145
+ ) {
4146
+ handle.release();
4147
+ throw transportUnavailable(databaseName);
4148
+ }
4069
4149
  if (coordinator) {
4070
4150
  try {
4071
4151
  // Not a 423 when the budget is gone, and not a skip either: the native wait can consume
@@ -4383,7 +4463,9 @@ export function makeTable(options): TableResourceClass {
4383
4463
  }
4384
4464
  };
4385
4465
  }
4386
- const entry = this.#entry ?? primaryStore.getEntry(id, { transaction: transaction.getReadTxn() });
4466
+ const reloadsCommitBase = options?.isCopyApply !== true;
4467
+ const entry = entryBeforeWrite(this.#entry, id, transaction, reloadsCommitBase);
4468
+ const baseReadTxn = this.#commitBaseTxn(id);
4387
4469
  const writeToSource = () => {
4388
4470
  if (!(this.constructor as any).source || (context as any)?.source) return;
4389
4471
  if (fullUpdate) {
@@ -4411,11 +4493,12 @@ export function makeTable(options): TableResourceClass {
4411
4493
  key: id,
4412
4494
  store: primaryStore,
4413
4495
  entry,
4496
+ baseReadTxn,
4414
4497
  nodeName: (context as any)?.nodeName,
4415
4498
  fullUpdate,
4416
4499
  chainsStagedState: true,
4417
4500
  // copy-apply rows keep their pre-read base: one read per row, healed by the post-copy replay
4418
- reloadCommitBase: options?.isCopyApply !== true,
4501
+ reloadCommitBase: reloadsCommitBase,
4419
4502
  deferSave: true,
4420
4503
  // the origin's record version on an applied write; absent for a locally-originated one
4421
4504
  recordVersion: options?.version,
@@ -5303,12 +5386,14 @@ export function makeTable(options): TableResourceClass {
5303
5386
  const transaction = txnForContext(context);
5304
5387
  assertDerivedIndexAdmission(options, transaction);
5305
5388
  checkValidId(id);
5306
- const entry = this.#entry ?? primaryStore.getEntry(id, { transaction: transaction.getReadTxn() });
5389
+ const entry = entryBeforeWrite(this.#entry, id, transaction, true);
5390
+ const baseReadTxn = this.#commitBaseTxn(id);
5307
5391
 
5308
5392
  const write: any = {
5309
5393
  key: id,
5310
5394
  store: primaryStore,
5311
5395
  entry,
5396
+ baseReadTxn,
5312
5397
  chainsStagedState: true,
5313
5398
  reloadCommitBase: true,
5314
5399
  nodeName: (context as any)?.nodeName,
@@ -5371,6 +5456,7 @@ export function makeTable(options): TableResourceClass {
5371
5456
  isRocksDB && audit && txnLogKey !== txnTime
5372
5457
  ? [{ version: txnLogKey, nodeId: options?.nodeId }]
5373
5458
  : undefined,
5459
+ localOnly: options?.localOnly,
5374
5460
  },
5375
5461
  'delete'
5376
5462
  );
@@ -5998,8 +6084,12 @@ export function makeTable(options): TableResourceClass {
5998
6084
  if (target.offset || target.limit !== undefined) results = results.slice(offset, end);
5999
6085
  results.onDone = () => {
6000
6086
  results.onDone = null; // ensure that it isn't called twice
6087
+ txn.unregisterReadIterator(results);
6001
6088
  txn.doneReadTxn();
6002
6089
  };
6090
+ // Recorded ownership: if the request dies before anything consumes these results, the
6091
+ // transaction closes them itself rather than leaving its read snapshot pinned.
6092
+ txn.registerReadIterator(results);
6003
6093
  results.selectApplied = true;
6004
6094
  results.getColumns = getColumns;
6005
6095
  return results;
@@ -6812,7 +6902,11 @@ export function makeTable(options): TableResourceClass {
6812
6902
  function failSubscription(error: any) {
6813
6903
  if (subscription.closed) return;
6814
6904
  harperLogger.error?.('Error in real-time subscription:', error);
6815
- subscription.close(error);
6905
+ try {
6906
+ subscription.close(error);
6907
+ } catch (listenerError) {
6908
+ harperLogger.error?.('Error in real-time subscription listener:', listenerError);
6909
+ }
6816
6910
  }
6817
6911
  function eventFromAudit(id: Id, auditRecord: any, localTime: number, beginTxn?: boolean) {
6818
6912
  let type = auditRecord.type;
@@ -6821,7 +6915,7 @@ export function makeTable(options): TableResourceClass {
6821
6915
  type === 'put' || type === 'patch' || type === 'delete' || type === 'invalidate' || type === 'relocate';
6822
6916
  if (isMutation && !includeSuperseded) {
6823
6917
  if (id === undefined) return;
6824
- const entry: Entry = primaryStore.getEntry(id);
6918
+ const entry = currentEntryForAudit(primaryStore, id, auditRecord);
6825
6919
  if (!entry || entry.version !== auditRecord.version) return;
6826
6920
  if (getFullRecord) {
6827
6921
  value = entry?.value;
@@ -7007,7 +7101,7 @@ export function makeTable(options): TableResourceClass {
7007
7101
  existingEntry?.value ?? null,
7008
7102
  existingEntry,
7009
7103
  txnTime,
7010
- 0,
7104
+ (existingEntry?.metadataFlags ?? 0) & LOCAL_ONLY,
7011
7105
  true,
7012
7106
  {
7013
7107
  user: (context as any)?.user,
@@ -7017,6 +7111,7 @@ export function makeTable(options): TableResourceClass {
7017
7111
  viaNodeId: options?.viaNodeId,
7018
7112
  transaction,
7019
7113
  tableToTrack: tableName,
7114
+ auditLocalOnly: options?.localOnly,
7020
7115
  },
7021
7116
  'message',
7022
7117
  false,
@@ -8684,6 +8779,13 @@ export function makeTable(options): TableResourceClass {
8684
8779
  // regardless of this state.
8685
8780
  transaction.next.open = TRANSACTION_STATE.CLOSED;
8686
8781
  }
8782
+ // A poison flag must travel with `open`, or a link created after the poisoning (a
8783
+ // handler touching this database for the first time post-poison) sees CLOSED but not
8784
+ // the reason, takes save()'s immediateCommit path, and commits on behalf of a request
8785
+ // that was supposed to have been cut off.
8786
+ if (transaction.timedOut) transaction.next.timedOut = true;
8787
+ if (transaction.disconnected) transaction.next.disconnected = true;
8788
+ if (transaction.postSubmitPoisoned) transaction.next.postSubmitPoisoned = true;
8687
8789
  transaction = transaction.next;
8688
8790
  transaction.db = primaryStore;
8689
8791
  return transaction;
@@ -8697,6 +8799,7 @@ export function makeTable(options): TableResourceClass {
8697
8799
  if (context) {
8698
8800
  context.transaction = transaction;
8699
8801
  if (context.timestamp) transaction.timestamp = context.timestamp;
8802
+ if (!context.sourceApply) transaction.requestSignal = context.signal;
8700
8803
  }
8701
8804
  return transaction;
8702
8805
  }
@@ -15,6 +15,7 @@ import * as fs from 'node:fs';
15
15
  import { getAnalyticsHostnameTable, nodeIds, stableNodeId } from './hostnames.ts';
16
16
  import { METRIC } from './metadata.ts';
17
17
  import { getTransactionQueueDepths, setCommitLatencyRecorder } from '../DatabaseTransaction.ts';
18
+ import { contextStorage } from '../transaction.ts';
18
19
  import { RocksDatabase, type TransactionLogStats } from '@harperfast/rocksdb-js';
19
20
 
20
21
  const log = forComponent('analytics').conditional;
@@ -126,7 +127,7 @@ export function recordAction(value: Value, metric: string, path?: string, method
126
127
  } else {
127
128
  recordNewAction(key, value, metric, path, method, type);
128
129
  }
129
- if (!sendAnalyticsTimeout) sendAnalytics();
130
+ if (!sendAnalyticsTimeout) contextStorage.exit(sendAnalytics);
130
131
  }
131
132
 
132
133
  server.recordAnalytics = recordAction;
@@ -151,7 +152,10 @@ export function addAnalyticsListener(callback) {
151
152
  const IDEAL_PERCENTILES = [0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 0.999, 1];
152
153
 
153
154
  /**
154
- * Periodically send analytics data back to the main thread for storage
155
+ * Periodically send analytics data back to the main thread for storage. Callers arm it through
156
+ * contextStorage.exit(): the first sample of a period comes from some request, but the flush and the
157
+ * scheduled tasks it starts are process-wide work, which under that request's context would write as
158
+ * its user and, once its client disconnected, have every write rejected by its aborted signal.
155
159
  */
156
160
  function sendAnalytics() {
157
161
  analyticsStart ||= performance.now();
@@ -0,0 +1,203 @@
1
+ # Audit retention and cleanup
2
+
3
+ Design notes for the audit/transaction-log retention lifecycle: the floor that makes a short replay
4
+ detectable, the self-rearming cleanup cadence, and the removal-loop rules. Indexed from the root
5
+ [DESIGN.md](../DESIGN.md). Moved out of `resources/DESIGN.md` intact (harper#2711) so that file stays
6
+ under its 1000-line budget; no note's content changed in the move.
7
+
8
+ ## Audit retention floor
9
+
10
+ `Table.subscribe`'s `startTime` replay just begins wherever the audit log now begins, so a consumer
11
+ resuming below the retention horizon is silently handed a short replay. The floor records what
12
+ pruning removed (harper#2447); it is internal, with deliberately no public accessor. **A resume is
13
+ not checked against it** but against the database generation (next section): this floor also steers
14
+ reconciliation, survives a restore, and absorbs at `Infinity`.
15
+
16
+ **The one consumer today is not a resume**: `Table.commit`'s out-of-order reconciliation reads the
17
+ floor before entering the audit walk (harper#2642). The walk terminates at the incoming write only by
18
+ reaching an audit entry at or below its version, so below the floor it cannot — it runs the whole
19
+ retained chain, one RocksDB end-of-log scan per step, to an outcome the floor already determines.
20
+ Two things that consumer does differently from a cursor check, and both are deliberate: it compares
21
+ the write's record version rather than a log key, because that is what the walk's own loop condition
22
+ compares; and it treats the `Infinity` unknown floor as **walk anyway** rather than as "not safe",
23
+ because here the conservative direction is to do the work, not to skip it. Below the floor a write
24
+ contributes only its commutative operations — a plain field's survival depends on what newer writes
25
+ did to that key, which is exactly what the pruned history no longer answers.
26
+
27
+ **The invariant: every path that prunes audit history raises the floor BEFORE removing anything.**
28
+ There are five, and the ordering is the whole guarantee — a floor written after the removal is lost
29
+ if the process dies in between, and the surviving lower floor then certifies a cursor whose history
30
+ is gone. Over-reporting (a floor covering more than the prune actually removed) costs a consumer one
31
+ unnecessary resync; under-reporting loses its data with no signal. So `raiseAuditFloor` is called
32
+ first and a throw from it is what stops the prune.
33
+
34
+ | Prune path | Engine |
35
+ | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
36
+ | `scheduleAuditCleanup` retention loop (`auditStore.ts`) | LMDB |
37
+ | `scheduleAuditCleanup` → `purgeLogs` | RocksDB |
38
+ | `purgeAgedLogs` (boot/recovery, called from `replayLogs.ts`) | RocksDB |
39
+ | `Table.deleteHistory` | LMDB (`RocksTransactionLogStore.remove()` is a no-op, so it must NOT raise) |
40
+ | `delete_transaction_logs_before` whole-database branch (`ResourceBridge.ts`) | RocksDB |
41
+
42
+ Things that are easy to get wrong here:
43
+
44
+ - **The floor cannot be derived from the surviving log.** For four of the five paths the oldest
45
+ surviving entry would do, because they prune a database-wide time prefix. `Table.deleteHistory`
46
+ removes one table's entries from a database-scoped log, so a sibling's entry survives _below_ the
47
+ newest entry it removed, and a floor taken from that survivor certifies cursors over removed history.
48
+ - **The record's presence is the trust marker.** `Symbol.for('audit-floor')` is not `last-removed`,
49
+ which the LMDB retention loop still maintains (#2338 — do not remove it): that one records where the
50
+ loop got to, after the fact, so a value there cannot be told apart from a write-ahead, verified floor.
51
+ - **A store with no floor record is one whose retention history we cannot account for** (the empty
52
+ audit store a migration leaves, a table-scoped backup without `include_audit`), so `openAuditStore`
53
+ stamps `max(Date.now(), newest retained key)` as a one-time resync epoch. There is no
54
+ permissive-baseline case: creating the audit DBI proves only that it was absent.
55
+ - **That epoch is a guess, and it is recorded as one.** Surviving state cannot see history a legacy
56
+ selective prune removed, so a rolled-back clock can stamp it below entries that are gone (#2458).
57
+ Refusing to stamp is worse — `AUDIT_FLOOR_UNKNOWN` is absorbing — so `establishAuditFloor` writes the
58
+ epoch under `Symbol.for('audit-floor-bootstrap')` first, then stamps the floor from that record.
59
+
60
+ **The record's presence is the signal; comparing it against the floor is not.** Worked example: a
61
+ v4-era `deleteHistory` removes tableA up to t=1000 while tableB's newest survivor is 900; a
62
+ rolled-back clock stamps bootstrap=floor=900; a later pass raises the floor to 950 — and a cursor at
63
+ 970 still sits over tableA's missing 950–1000. No timestamp can close that window, which is why
64
+ resumable positions are bound to a generation instead: a position naming one postdates tracking.
65
+
66
+ **Ordering:** the record is written first, so a crash between the two writes leaves a record with no
67
+ floor, which the next open retries. **Undecodable bytes are overwritten** (unlike the floor, where a
68
+ present record may be a deliberate unknown): keeping them pinned the store to unknown forever.
69
+
70
+ - **`getHistory` is not in the floor's time domain.** The floor is an audit-log key (`subscribe`'s
71
+ `localTime`); `getHistory` reports each entry's origin `version` under that name, which a backdated
72
+ or replicated write makes differ, so its cursors cannot be compared against the floor.
73
+ - **On RocksDB the floor tracks the configured retention horizon, not retained reality.** Whole-log-file
74
+ purge granularity means the branch cannot know which entries a purge will drop, and the floor is
75
+ written first, so each pass advances it to `Date.now() - auditRetention/(1+priority²)` whether a
76
+ file was dropped or not. Entries below that horizon are often still on disk, and a cursor among
77
+ them is told to resync — conservative in the safe direction only. LMDB can see a single eligible
78
+ entry, so it raises off the first one it finds instead.
79
+ - **A prune's clamped floor can sit past `Date.now() + 1`.** `boundedAuditPruneEnd` records `newest + 1`
80
+ when the newest log key is at or past the clock, and log keys are fractional `getNextMonotonicTime`
81
+ values, not `Date.now()`, so for the rest of that key's millisecond the floor exceeds the wall clock
82
+ by more than one. Bound it by the newest key, never the wall clock alone (`auditFloor.test.js`, the
83
+ far-future `deleteHistory` case — it failed twice on main that way).
84
+ - **Untrustworthy metadata resolves to `Infinity`, not to a number.** A wrong-length record, or eight
85
+ bytes decoding to NaN/negative, must not become a floor: `cursor < NaN` is false, so a consumer
86
+ spelling the check that way would read corrupt metadata as safe.
87
+ - **A copy keeps this floor honest for the log it carried.** A restore carries its log, so the floor
88
+ stands; a branch or migration carries none, so its stamp raises a finite floor to the copy's epoch.
89
+ Which history the database is belongs to the generation.
90
+
91
+ ---
92
+
93
+ ## Audit-entry removal loops must track every `removeAuditEntry()`/`removeEntry()` promise
94
+
95
+ `scheduleAuditCleanup` (`auditStore.ts`) and `Table.deleteHistory` (`Table.ts`, the LMDB path behind
96
+ `delete_transaction_logs_before`) both iterate a range of audit records and remove each one. Any loop
97
+ that removes audit/primary-store entries in a batch must attach a rejection handler to every removal
98
+ immediately and drain all tracked promises before returning — never stash a per-iteration promise in
99
+ an outer variable to await only the last one: an overwritten promise's rejection is never awaited or
100
+ caught, and surfaces later as an unhandled rejection with no log to explain it. `Table.deleteHistory`
101
+ allows up to 1,000 LMDB removals in flight
102
+ (ten for RocksDB) so storage writes batch without growing an unbounded pending
103
+ set. Live removals are tracked in a `Set`, and each one removes itself and wakes at most one parked
104
+ producer when it settles, so any completion releases the loop. In these removal loops, do not repeatedly
105
+ race the live set: each race attaches another reaction to every long-pending removal. Both phases drain
106
+ their tracked removals before settling, including when iteration throws. `scheduleAuditCleanup` remains
107
+ sequential because it is an automatic background loop, and it ends a pass at its first failed removal
108
+ rather than continuing past it: its `deleted` count (the backoff input) and the `last-removed` marker it
109
+ writes then cover a contiguous removed prefix — nothing above that marker was removed by the pass — and
110
+ the next pass retries the failed entry first. Past a failure there is no key the marker can truthfully
111
+ record, and counting failures as progress re-armed an all-failing pass at 10 ms. The guarantee is
112
+ pass-local: the marker is written after its removals, `deleteHistory` never writes it, and a marker an
113
+ older build persisted past a failed entry is not repaired; completeness is the audit floor's question.
114
+ Stopping costs liveness only for an entry that fails on every pass, which is why `removeAuditEntry` must
115
+ fail only when `auditStore.remove()` does (below). Regression: `auditLog.test.js` "ends a pass at a failed
116
+ removal".
117
+
118
+ Individual removal failures are logged and excluded from the returned count, but a purge that attempted
119
+ at least one removal and completed none rejects with the first error after both phases have drained.
120
+ Without that, `delete_transaction_logs_before` reports a successful `entries_deleted: 0` whether nothing
121
+ was eligible or the store rejected every write, and an operator pruning to bound disk growth has no signal
122
+ that pruning did nothing. Drain first, then decide: a failing store should still get every removal it can
123
+ accept, and a single success means the purge made progress and reports normally.
124
+
125
+ The optional primary-store cleanup snapshots each tombstone's key and version before yielding and passes
126
+ that version to `remove()`. LMDB enforces the condition natively. Harper's RocksDB adapter re-reads and
127
+ removes inside one native transaction, retrying a conflict once, because rocksdb-js's `remove()` accepts
128
+ an options object rather than an LMDB-style version argument. Never replace this with a separate live read
129
+ followed by an unconditional remove: a record recreated between those operations would be deleted.
130
+
131
+ `removeAuditEntry` has a second, nested version of the same hazard: for a `'delete'`-type audit record it
132
+ also invokes a per-table delete callback (`addDeleteRemovalCallback`) that removes the corresponding
133
+ primary-store tombstone. That callback's promise must be returned and joined with the audit-store
134
+ removal (currently via `Promise.all`, with the callback's own rejection — and a throwing tombstone
135
+ lookup, such as lmdb-js `getEntry(undefined)` for an undecodable recordId — caught and logged through
136
+ `warnContained`, so neither a failed tombstone cleanup nor a throwing log sink gets misreported as a failed
137
+ audit-entry removal) — otherwise the tombstone removal is fire-and-forget and the same detached-rejection
138
+ hazard reappears one level down.
139
+ A tombstone whose cleanup fails this way is not swept automatically — `scheduleAuditCleanup`'s automatic
140
+ pass never retries it, since the audit entry that would have triggered a retry is already gone. It sits
141
+ in the primary store until an operator runs `delete_transaction_logs_before` with `cleanup_deleted_records: true`.
142
+
143
+ ## Audit retention cleanup is a self-rearming, engine-independent lifecycle
144
+
145
+ One call to `scheduleAuditCleanup` establishes a retention cadence that ends when the root store closes
146
+ (or immediately in process-wide read-only mode). Storage-engine selection changes the work inside each pass, not whether the timer,
147
+ serialization barrier, error containment, and re-arm exist. LMDB removes bounded batches of audit
148
+ entries; RocksDB asks rocksdb-js to purge conservatively eligible log segments before the same time
149
+ cutoff. Disk-pressure callbacks may accelerate the next pass and shorten the effective window, but
150
+ ordinary retention progress must not depend on pressure.
151
+
152
+ The two engines do not share a cadence rule, because their units of progress differ. LMDB's adaptive
153
+ backoff reads a per-entry delete count: it speeds up while entries are being removed and doubles while
154
+ idle. Rocks reclaims whole segments whose eligibility changes only on rotation/flush, so the same
155
+ signal would only make it rescan the same files — its delay is instead a pure function of the
156
+ pressure-adjusted retention window (a tenth of it, floored at `DEFAULT_AUDIT_CLEANUP_DELAY`).
157
+
158
+ Exactly one Rocks purge loop exists per store, and that is owned by the **arming** sites, not the
159
+ re-arm: `onStorageReclamation` registers its handler only on the last worker (it takes no
160
+ `skipThreadCheck`), and the store-open arm gates on the same index. The last-worker conjunct on the
161
+ re-arm is therefore unreachable through either of those paths; it is a backstop for a direct caller
162
+ of the exported `scheduleAuditCleanup`, because a store-wide segment purge looping on every worker is
163
+ duplicated work. If a future change passes `skipThreadCheck: true` at the registration site, that
164
+ backstop — not the registration — becomes the thing keeping the loop single.
165
+
166
+ Both re-arm guards are **Rocks-only**. The LMDB arm re-arms unconditionally, so it neither yields to
167
+ an already-pending pass (a pressure-armed 100ms pass can be cancelled and replaced by the idle
168
+ backoff) nor restricts itself to one worker — pre-existing LMDB behavior, not an invariant.
169
+
170
+ Two things a purge does **not** need to coordinate, both load-bearing for the continuous cadence.
171
+ Unlinking a segment a consumer has mapped is safe **on POSIX**: the inode outlives the unlink, and the
172
+ mapping cache (`_logBuffers`) holds `WeakRef`s, with a strong ref only on the newest segment, which is
173
+ never purge-eligible — so nothing pins a purged inode and no cross-worker cache invalidation is
174
+ required. Windows does not share that property: deleting a mapped segment raises a sharing violation,
175
+ so the purge throws, is warn-logged, re-arms, and makes no progress for as long as a consumer holds the
176
+ mapping. The continuous cadence therefore turns a Windows retention stall into a steady state rather
177
+ than a one-off, and nothing covers it — the Rocks retention integration test skips win32.
178
+ What is _not_ covered is the segment a lagging consumer has not mapped yet: `TransactionLog.query()`'s
179
+ iterator returns `done` when its next segment cannot be mapped, indistinguishable from being caught up
180
+ (rocksdb-js `src/transaction-log-reader.ts`). A consumer that far behind needs a full copy rather than
181
+ log replay, so the gap is a missing escalation signal in the reader, not a reason to hold retention —
182
+ tracked as HarperFast/rocksdb-js#805. Continuous retention is what moves it from unreachable-in-steady-state
183
+ to routine: a peer offline longer than `logging.auditRetention` now resumes into a purged prefix and is
184
+ recorded as caught up, and `txnlogReplayGapBytes` observes the gap without escalating on it.
185
+
186
+ Retirement is two things, and teardown needs both. `stopAuditCleanup()` latches the loop closed and
187
+ cancels the pending timer, and it **returns a drain barrier** — a promise that settles once the pass
188
+ already running has finished. The barrier is what makes closing stores safe: lmdb-js stamps the DBI
189
+ number into its write instruction synchronously and the native writer consumes it later
190
+ (`node_modules/lmdb/write.js`), so a pass suspended inside `await removeAuditEntry()` still has a
191
+ delete pending against the primary and audit DBIs, and LMDB forbids closing a DBI an existing
192
+ transaction has modified. `dropDatabase()` and the legacy arm of `Table.dropTable()` await it.
193
+ `closeDatabase()` and branch `close()` close commit admission, then drain tracked Rocks transactions,
194
+ table maintenance, audit cleanup, and derived-index work before closing stores. Every environment
195
+ touch remaining in a resumed cleanup pass — cursor advance, cursor release, marker write, re-arm —
196
+ also re-checks `rootStore.status`. `resetDatabases()` closes LMDB roots with no retirement call at all,
197
+ so that re-check is a routine
198
+ path rather than a defensive one.
199
+
200
+ The last-removed marker is retained until it commits. A rejected write is logged and carried to the
201
+ next pass rather than dropped: a pass that deletes nothing never reaches the write again, so one
202
+ transient failure would otherwise leave the recorded boundary permanently behind the entries that
203
+ were already removed.