@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
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { readKey, writeKey } from 'ordered-binary';
2
3
  import { initSync, get as envGet } from '../utility/environment/environmentManager.ts';
3
4
  import { AUDIT_STORE_NAME } from '../utility/lmdb/terms.ts';
@@ -12,7 +13,8 @@ import { onStorageReclamation } from '../server/storageReclamation.ts';
12
13
  import { RocksDatabase } from '@harperfast/rocksdb-js';
13
14
  import { asBinary } from 'lmdb';
14
15
  import { RocksTransactionLogStore } from './RocksTransactionLogStore.ts';
15
- import { isReadOnlyMode } from './databases.ts';
16
+ import { endSubscriptionsFromEarlierHandles } from './transactionBroadcast.ts';
17
+ import { isReadOnlyMode, openRocksDatabase } from './databases.ts';
16
18
 
17
19
  /**
18
20
  * This module is responsible for the binary representation of audit records in an efficient form.
@@ -138,9 +140,10 @@ const FLOAT_BUFFER = new Uint8Array(FLOAT_TARGET.buffer);
138
140
  const AUDIT_FLOOR_KEY = Symbol.for('audit-floor');
139
141
  // The epoch `establishAuditFloor` stamped, never raised or removed. Its PRESENCE marks the floor as
140
142
  // 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`.
143
+ // before tracking began. No comparison retires the mark: a later prune certifies only what it
144
+ // removed, so `floor > bootstrap` says nothing about the older gap. Resume never relies on it, since a
145
+ // position is bound to a database generation and everything after its cursor was written after
146
+ // tracking began. The value records how far the guess reached. See `establishAuditFloor`.
144
147
  const AUDIT_FLOOR_BOOTSTRAP_KEY = Symbol.for('audit-floor-bootstrap');
145
148
  /**
146
149
  * The floor's own eight bytes, deliberately NOT the FLOAT_TARGET/FLOAT_BUFFER pair the `last-removed`
@@ -152,6 +155,25 @@ const FLOOR_TARGET = new Float64Array(1);
152
155
  const FLOOR_BUFFER = new Uint8Array(FLOOR_TARGET.buffer);
153
156
  /** No trustworthy floor: the highest possible floor, so every cursor compares as stale. */
154
157
  const AUDIT_FLOOR_UNKNOWN = Infinity;
158
+ /**
159
+ * Which copy of this database's history this is: a sixteen-byte random id, then the float64 time the
160
+ * generation began (0 for genesis). Every path that publishes a copy stamps a fresh one first
161
+ * (`stampDatabaseGeneration`), so a position naming an older id is refused (harper#2451). RocksDB only,
162
+ * like the resume floor: an LMDB database carries neither, so no position is resumable against it.
163
+ */
164
+ const DATABASE_GENERATION_KEY = Symbol.for('database-generation');
165
+ const GENERATION_ID_BYTES = 16;
166
+ const GENERATION_RECORD_BYTES = GENERATION_ID_BYTES + 8;
167
+ /**
168
+ * The highest prune cutoff within the current generation, encoded like the floor. Kept apart from the
169
+ * floor, which also steers `Table.commit`'s reconciliation: resume validity must restart at a copy and
170
+ * must not be absorbed by the floor's unknown sentinel, and doing either to the floor changes merges.
171
+ */
172
+ const AUDIT_RESUME_FLOOR_KEY = Symbol.for('audit-resume-floor');
173
+
174
+ function isRocksStore(store: any): boolean {
175
+ return store instanceof RocksTransactionLogStore || store instanceof RocksDatabase;
176
+ }
155
177
 
156
178
  /** Last resort on a detached path: a failing log sink must not itself become an unhandled rejection. */
157
179
  function warnContained(message: string, error: unknown) {
@@ -191,6 +213,8 @@ export function openAuditStore(rootStore) {
191
213
  rootStore.auditStore = auditStore;
192
214
  auditStore.rootStore = rootStore;
193
215
  establishAuditFloor(auditStore);
216
+ establishDatabaseGeneration(auditStore);
217
+ endSubscriptionsFromEarlierHandles(auditStore, isRocksStore(auditStore));
194
218
  auditStore.tableStores = [];
195
219
  const deleteCallbacks = [];
196
220
  auditStore.addDeleteRemovalCallback = function (tableId, table, callback) {
@@ -508,13 +532,13 @@ function decodeAuditFloor(stored: any): number {
508
532
  }
509
533
 
510
534
  /**
511
- * Did the floor write land? A record has to be PRESENT, not merely decode to the value we wrote:
512
- * `decodeAuditFloor(undefined)` is the unknown sentinel too, so on a floorless store — where the
513
- * resolver writes exactly that sentinel — comparing decoded values alone reported a commit for a
514
- * write that never happened, and the caller pruned with nothing persisted.
535
+ * Did a metadata write land? A record has to be PRESENT with exactly the bytes written, not merely
536
+ * decode to the same value: `decodeAuditFloor(undefined)` is the unknown sentinel too, so on a
537
+ * floorless store — where the resolver writes exactly that sentinel — comparing decoded values alone
538
+ * reported a commit for a write that never happened, and the caller pruned with nothing persisted.
515
539
  */
516
- function floorWriteLanded(stored: any, floor: number): boolean {
517
- return stored !== undefined && decodeAuditFloor(stored) === floor;
540
+ function writeLanded(stored: any, written: Uint8Array): boolean {
541
+ return stored !== undefined && stored.byteLength === written.byteLength && Buffer.compare(stored, written) === 0;
518
542
  }
519
543
 
520
544
  /** Own eight bytes per write: the store must never be handed a live view of the reused module buffer. */
@@ -538,50 +562,66 @@ function updateAuditFloor(
538
562
  resolve: (current: number, recorded: boolean) => number | undefined,
539
563
  key: symbol = AUDIT_FLOOR_KEY
540
564
  ): void {
541
- // A legacy `auditPath` layout is opened as its own standalone LMDB root (databases.ts) and has no
542
- // `.rootStore`, so it owns the transaction itself.
543
- const transactionOwner = auditStore?.rootStore ?? auditStore;
565
+ commitAuditMetadata(
566
+ auditStore,
567
+ (read) => {
568
+ const stored = read(key);
569
+ const floor = resolve(decodeAuditFloor(stored), stored !== undefined);
570
+ return floor === undefined ? undefined : [[key, encodeAuditFloor(floor)]];
571
+ },
572
+ 'audit retention floor'
573
+ );
574
+ }
575
+
576
+ /**
577
+ * Write audit metadata records under one store transaction, all or none: every write is read back,
578
+ * and a mismatch throws inside the transaction so it aborts — returning false commits on LMDB.
579
+ */
580
+ function commitAuditMetadata(
581
+ store: any,
582
+ plan: (read: (key: symbol) => any) => Array<[symbol, Uint8Array]> | undefined,
583
+ what: string
584
+ ): void {
585
+ // A copy being stamped passes its RocksDB root directly. A legacy `auditPath` layout is opened as its
586
+ // own standalone LMDB root (databases.ts) and has no `.rootStore`, so it owns the transaction itself.
587
+ const onRocksDB = isRocksStore(store);
588
+ const transactionOwner = store instanceof RocksDatabase ? store : (store?.rootStore ?? store);
544
589
  if (!transactionOwner?.transactionSync)
545
- throw new Error('Cannot record the audit retention floor: this database has no audit store');
546
- // Both branches read their own write back and report `false` on mismatch, and the caller demands an
547
- // explicit `true`. Both halves are load-bearing: a RocksDB transactionSync returns undefined for a
548
- // swallowed abort rather than throwing (see RecordEncoder.saveStructures), and a write that fails
549
- // without throwing is otherwise indistinguishable from one that landed — a caller pruning against a
550
- // floor never recorded. Reads inside a write transaction see their own writes on both engines, so
551
- // the read-back observes what commit will make durable.
552
- const committed =
553
- auditStore instanceof RocksTransactionLogStore
554
- ? transactionOwner.transactionSync(
555
- (txn) => {
556
- const stored = txn.getBinarySync(key);
557
- const floor = resolve(decodeAuditFloor(stored), stored !== undefined);
558
- if (floor !== undefined) {
559
- txn.putSync(key, asBinary(encodeAuditFloor(floor)));
560
- if (!floorWriteLanded(txn.getBinarySync(key), floor)) return false;
561
- }
562
- return true;
563
- },
564
- { retryOnBusy: true }
565
- )
566
- : transactionOwner.transactionSync(() => {
567
- const stored = auditStore.getBinary(key);
568
- const floor = resolve(decodeAuditFloor(stored), stored !== undefined);
569
- // `put` rather than `putSync`, and inside the transaction: lmdb's putSync is
570
- // `put(...) === SYNC_PROMISE_SUCCESS`, so it drops whatever put returns, and a rejected put
571
- // would leak with no owner. Within a write transaction put writes synchronously and returns
572
- // an already-resolved sentinel, so the value is visible immediately either way and this
573
- // only takes ownership of the failure case.
574
- // asBinary: a legacy standalone audit root's encoder has no Uint8Array passthrough, so raw
575
- // bytes would reach createAuditEntry and throw. This bypasses both encoders.
576
- if (floor !== undefined) {
577
- auditStore
578
- .put(key, asBinary(encodeAuditFloor(floor)))
579
- ?.catch?.((error) => warnContained('Error writing the audit retention floor', error));
580
- if (!floorWriteLanded(auditStore.getBinary(key), floor)) return false;
590
+ throw new Error(`Cannot record the ${what}: this database has no audit store`);
591
+ const notCommitted = () => new Error(`The ${what} transaction did not commit`);
592
+ // The caller still demands an explicit `true`: a RocksDB transactionSync returns undefined for a
593
+ // swallowed abort rather than throwing (see RecordEncoder.saveStructures). Reads inside a write
594
+ // transaction see their own writes on both engines, so the read-back observes what commit will make
595
+ // durable.
596
+ const committed = onRocksDB
597
+ ? transactionOwner.transactionSync(
598
+ (txn) => {
599
+ const writes = plan((key) => txn.getBinarySync(key));
600
+ if (writes) {
601
+ for (const [key, bytes] of writes) txn.putSync(key, asBinary(bytes));
602
+ for (const [key, bytes] of writes) if (!writeLanded(txn.getBinarySync(key), bytes)) throw notCommitted();
581
603
  }
582
604
  return true;
583
- });
584
- if (committed !== true) throw new Error('The audit retention floor transaction did not commit');
605
+ },
606
+ { retryOnBusy: true }
607
+ )
608
+ : transactionOwner.transactionSync(() => {
609
+ const writes = plan((key) => store.getBinary(key));
610
+ // `put` rather than `putSync`, and inside the transaction: lmdb's putSync is
611
+ // `put(...) === SYNC_PROMISE_SUCCESS`, so it drops whatever put returns, and a rejected put
612
+ // would leak with no owner. Within a write transaction put writes synchronously and returns
613
+ // an already-resolved sentinel, so the value is visible immediately either way and this
614
+ // only takes ownership of the failure case.
615
+ // asBinary: a legacy standalone audit root's encoder has no Uint8Array passthrough, so raw
616
+ // bytes would reach createAuditEntry and throw. This bypasses both encoders.
617
+ if (writes) {
618
+ for (const [key, bytes] of writes)
619
+ store.put(key, asBinary(bytes))?.catch?.((error) => warnContained(`Error writing the ${what}`, error));
620
+ for (const [key, bytes] of writes) if (!writeLanded(store.getBinary(key), bytes)) throw notCommitted();
621
+ }
622
+ return true;
623
+ });
624
+ if (committed !== true) throw notCommitted();
585
625
  }
586
626
 
587
627
  /**
@@ -666,25 +706,42 @@ export function raiseAuditFloor(auditStore: any, cutoff: number): void {
666
706
  // database, a cutoff below one a wider prune already set), and taking the env write lock to
667
707
  // discover that serializes every worker's boot and reclamation on it. The in-transaction guards
668
708
  // below stay authoritative.
669
- // Skips only the case it can prove is a no-op: a record that exists and already sits at or above
670
- // the cutoff. An absent record is NOT decided here — the presence question is settled inside the
671
- // transaction below, because another worker's establishAuditFloor can land between this read and
672
- // that write.
709
+ // Skips only the case it can prove is a no-op: the floor, and on RocksDB the resume record, exist and
710
+ // already sit at or above the cutoff. An absent record is NOT decided here — the presence question is
711
+ // settled inside the transaction below, because another worker's establishAuditFloor can land between
712
+ // this read and that write.
713
+ const resumeTracked = isRocksStore(auditStore);
673
714
  if (auditStore?.getBinary) {
674
715
  const stored = auditStore.getBinary(AUDIT_FLOOR_KEY);
675
- if (stored !== undefined && !(cutoff > decodeAuditFloor(stored))) return;
716
+ const resume = resumeTracked ? auditStore.getBinary(AUDIT_RESUME_FLOOR_KEY) : undefined;
717
+ if (
718
+ stored !== undefined &&
719
+ !(cutoff > decodeAuditFloor(stored)) &&
720
+ (!resumeTracked || (resume !== undefined && !(cutoff > decodeAuditFloor(resume))))
721
+ )
722
+ return;
676
723
  }
677
- updateAuditFloor(auditStore, (current, recorded) => {
678
- // Still no record, and we are about to prune: persist the unknown sentinel. Leaving no marker
679
- // lets the next open stamp a FINITE epoch, and a prune bound above that epoch (a future
680
- // `endTime`, or a rolled-back clock) then certifies cursors whose history this prune deleted.
681
- // Unknown is the honest value, because a store with no record may have been pruned before this
682
- // run too.
683
- if (!recorded) return AUDIT_FLOOR_UNKNOWN;
684
- // A record appeared while we were getting here, so this is an ordinary monotonic raise: pruning
685
- // to `cutoff` against a floor left below it is exactly the silent gap this function prevents.
686
- return cutoff > current ? cutoff : undefined;
687
- });
724
+ commitAuditMetadata(
725
+ auditStore,
726
+ (read) => {
727
+ const writes: Array<[symbol, Uint8Array]> = [];
728
+ const stored = read(AUDIT_FLOOR_KEY);
729
+ // Still no record, and we are about to prune: persist the unknown sentinel. Leaving no marker
730
+ // lets the next open stamp a FINITE epoch, and a prune bound above that epoch (a future
731
+ // `endTime`, or a rolled-back clock) then certifies cursors whose history this prune deleted.
732
+ // Unknown is the honest value, because a store with no record may have been pruned before this
733
+ // run too.
734
+ if (stored === undefined) writes.push([AUDIT_FLOOR_KEY, encodeAuditFloor(AUDIT_FLOOR_UNKNOWN)]);
735
+ else if (cutoff > decodeAuditFloor(stored)) writes.push([AUDIT_FLOOR_KEY, encodeAuditFloor(cutoff)]);
736
+ if (resumeTracked) {
737
+ const resume = read(AUDIT_RESUME_FLOOR_KEY);
738
+ if (resume === undefined || cutoff > decodeAuditFloor(resume))
739
+ writes.push([AUDIT_RESUME_FLOOR_KEY, encodeAuditFloor(cutoff)]);
740
+ }
741
+ return writes;
742
+ },
743
+ 'audit retention floor'
744
+ );
688
745
  }
689
746
 
690
747
  /**
@@ -715,8 +772,9 @@ export function raiseAuditFloor(auditStore: any, cutoff: number): void {
715
772
  * unverified pre-tracking window for as long as it exists, however far the floor has since moved: a
716
773
  * prune raising the floor above the epoch certifies only what that prune removed, and says nothing
717
774
  * about history removed before tracking began — which may sit above the epoch, since that is exactly
718
- * the case the guess cannot see. Retiring the mark takes a database generation (harper#2451), not a
719
- * floor that has climbed past it.
775
+ * the case the guess cannot see. A floor that has climbed past it does not retire it. Resumable
776
+ * positions never depend on it: each is bound to a database generation minted after tracking began,
777
+ * so the pre-tracking window lies below every one of them (`getDatabaseGeneration`).
720
778
  *
721
779
  * Ordering. The provenance record is written **first**, so a crash between the two writes leaves a
722
780
  * record with no floor — which the next open retries, since the early return above tests the floor.
@@ -787,10 +845,10 @@ export function establishAuditFloor(auditStore: any): void {
787
845
 
788
846
  /**
789
847
  * The floor of this database's retained audit history: every audit entry at or after the returned
790
- * time is still retained, so a consumer whose last-processed audit-log cursor is `>=` it can resume
791
- * incrementally, and one below it must resync — it may have lost nothing, but the floor cannot
792
- * certify it either way. Returns `Infinity` when
793
- * the floor is unknown, which fails closed — no cursor compares as safe.
848
+ * time is still retained, and below it the log may have lost anything. Returns `Infinity` when the
849
+ * floor is unknown, which fails closed — no cursor compares as safe. A resume is decided by the
850
+ * database generation and its resume floor (`getDatabaseGeneration`) instead: this floor survives a
851
+ * restore, and an unknown one is absorbing.
794
852
  *
795
853
  * **One exception, and it is the only one: history removed before the floor existed.** Every prune
796
854
  * that runs with a floor recorded is covered — it raises the floor first, so it cannot remove an entry
@@ -798,7 +856,9 @@ export function establishAuditFloor(auditStore: any): void {
798
856
  * (`establishAuditFloor`), and a legacy `Table.deleteHistory` that removed a table's newest entries,
799
857
  * followed by a clock rollback, leaves that stamp below history that is gone; a cursor in the window
800
858
  * is then certified over the gap. So the guarantee is one-directional for tracked prunes and silent
801
- * about untracked ones. Only a database generation can close that (harper#2451).
859
+ * about untracked ones. Resumable positions do not face it: they are checked against the database
860
+ * generation and its resume floor (`getDatabaseGeneration`), not against this floor, which also steers
861
+ * `Table.commit`'s reconciliation and so cannot move in the direction a cursor check would want.
802
862
  *
803
863
  * The time domain is the audit-log key: what `subscribe`'s events carry as `localTime` and what MQTT
804
864
  * durable sessions persist as `startTime`, so those compare against the floor directly.
@@ -817,8 +877,9 @@ export function establishAuditFloor(auditStore: any): void {
817
877
  *
818
878
  * **A moment-in-time observation.** Retention can advance between this call and whatever the caller
819
879
  * does with the answer, so a check-then-resume sequence has a window where the floor moves under it.
820
- * Closing that requires validating the cursor inside the resume itself (harper#2448); until then a
821
- * lost race degrades to the truncation that happens today, never to anything worse.
880
+ * Closing that requires validating the position inside the resume itself (harper#2448), against the
881
+ * generation and the resume floor; until then a lost race degrades to the truncation that happens
882
+ * today, never to anything worse.
822
883
  *
823
884
  * **On RocksDB the floor tracks the configured horizon, not retained reality.** That branch purges at
824
885
  * whole-log-file granularity, so it cannot know before the fact which entries a purge will drop, and
@@ -828,18 +889,179 @@ export function establishAuditFloor(auditStore: any): void {
828
889
  * resync. Conservative in the one safe direction, and the reason the LMDB branch (which can see a
829
890
  * single eligible entry) instead raises off the first one it finds.
830
891
  *
831
- * **Not covered: copying a database's state without its history.** `restore_backup` replaces a
832
- * database with the backup's, floor and all, and a RocksDB checkpoint (a branch database) copies the
833
- * floor record but no transaction logs — so in both cases a cursor from after the copy point sits
834
- * above a floor that is present, and therefore trusted, for history that is not there. Nothing here
835
- * can detect that on its own, and it is not the audit log's problem alone: the same copy rolls back
836
- * record versions and per-node replication sequence state, so making this one field honest while
837
- * those stay stale would not give a consumer a coherent answer. It needs a database-level epoch —
838
- * harper#2451.
892
+ * **Copies of a database's state.** `restore_backup` copies this record with everything else, and it
893
+ * stays accurate for the log the restore carried; a branch checkpoint copies it with no log at all.
894
+ * What a copy changes is which history the database is, and that is the database generation's to
895
+ * answer: every copy path stamps a new one (`stampDatabaseGeneration`), and a copy that carried no log
896
+ * raises this floor to the generation's epoch.
839
897
  */
840
898
  export function getAuditFloor(auditStore: any): number {
841
899
  return decodeAuditFloor(auditStore.getBinary(AUDIT_FLOOR_KEY));
842
900
  }
901
+
902
+ export interface DatabaseGeneration {
903
+ /** Thirty-two lowercase hex characters. */
904
+ id: string;
905
+ /** When the generation began, in the audit-log key domain; 0 for a genesis generation. */
906
+ epoch: number;
907
+ }
908
+
909
+ /**
910
+ * The generation this handle's open established, or undefined when it could not establish one, in
911
+ * which case nothing may resume against it. Per database per node, never cluster-wide.
912
+ */
913
+ export function getDatabaseGeneration(auditStore: any): DatabaseGeneration | undefined {
914
+ return auditStore?.databaseGeneration;
915
+ }
916
+
917
+ /**
918
+ * The bound a resumed position is checked against, or `Infinity` when unknown: the resume floor, but
919
+ * never below a finite audit floor, since a binary that predates the resume floor raised only the
920
+ * audit floor when it pruned. Such a binary pruning while the audit floor was unknown records nothing
921
+ * either record can show.
922
+ */
923
+ export function getAuditResumeFloor(auditStore: any): number {
924
+ // The audit floor first: both records only rise, and an unknown audit floor stays unknown, so a prune
925
+ // committing between the two reads is still seen through the resume floor read second.
926
+ const auditFloor = getAuditFloor(auditStore);
927
+ const resumeFloor = decodeAuditFloor(auditStore.getBinary(AUDIT_RESUME_FLOOR_KEY));
928
+ return Number.isFinite(auditFloor) && auditFloor > resumeFloor ? auditFloor : resumeFloor;
929
+ }
930
+
931
+ /**
932
+ * Whether a position may resume here: it names this handle's generation and carries a finite cursor
933
+ * at or above the resume floor. The cursor must be progress-based — no lower than the log position
934
+ * observed when the position was established — or a quiet scope's snapshot key falls below a floor
935
+ * that retention keeps advancing and is refused on every resume. The answer holds as of the read: a
936
+ * prune that commits after it returns is the caller's to order against its replay. Always false on
937
+ * LMDB, which has no generation: a caller applies the check to RocksDB databases only.
938
+ */
939
+ export function isResumablePosition(auditStore: any, generationId: string | undefined, cursor: number): boolean {
940
+ const generation = getDatabaseGeneration(auditStore);
941
+ return (
942
+ generation !== undefined &&
943
+ generationId === generation.id &&
944
+ typeof cursor === 'number' &&
945
+ Number.isFinite(cursor) &&
946
+ cursor >= getAuditResumeFloor(auditStore)
947
+ );
948
+ }
949
+
950
+ function newGeneration(epoch: number): DatabaseGeneration {
951
+ return { id: randomBytes(GENERATION_ID_BYTES).toString('hex'), epoch };
952
+ }
953
+
954
+ function encodeGeneration(generation: DatabaseGeneration): Uint8Array {
955
+ const bytes = new Uint8Array(GENERATION_RECORD_BYTES);
956
+ bytes.set(Buffer.from(generation.id, 'hex'));
957
+ new DataView(bytes.buffer).setFloat64(GENERATION_ID_BYTES, generation.epoch, true);
958
+ return bytes;
959
+ }
960
+
961
+ function decodeGeneration(stored: any): DatabaseGeneration | undefined {
962
+ if (stored?.byteLength !== GENERATION_RECORD_BYTES) return undefined;
963
+ const epoch = new DataView(stored.buffer, stored.byteOffset, GENERATION_RECORD_BYTES).getFloat64(
964
+ GENERATION_ID_BYTES,
965
+ true
966
+ );
967
+ if (!Number.isFinite(epoch) || epoch < 0 || Object.is(epoch, -0)) return undefined;
968
+ return { id: Buffer.from(stored.buffer, stored.byteOffset, GENERATION_ID_BYTES).toString('hex'), epoch };
969
+ }
970
+
971
+ /**
972
+ * Establish this handle's generation at open: adopt the recorded one, or mint genesis for a RocksDB
973
+ * store that has none (an LMDB store gets none). An undecodable record is never replaced here — an
974
+ * ordinary open cannot know whether another worker already serves it; only a copy's stamp replaces a
975
+ * generation. Never fails the open.
976
+ */
977
+ export function establishDatabaseGeneration(auditStore: any): void {
978
+ auditStore.databaseGeneration = undefined;
979
+ if (!isRocksStore(auditStore)) return;
980
+ try {
981
+ let stored = auditStore.getBinary(DATABASE_GENERATION_KEY);
982
+ if (stored === undefined) {
983
+ if (isReadOnlyMode()) return;
984
+ const genesis = encodeGeneration(newGeneration(0));
985
+ commitAuditMetadata(
986
+ auditStore,
987
+ (read) => {
988
+ // compare-and-set on absence, so racing workers converge on the first one's id
989
+ if (read(DATABASE_GENERATION_KEY) !== undefined) return undefined;
990
+ const writes: Array<[symbol, Uint8Array]> = [[DATABASE_GENERATION_KEY, genesis]];
991
+ if (read(AUDIT_RESUME_FLOOR_KEY) === undefined) {
992
+ // starting at the audit floor rather than 0 keeps the next prune's lock-free skip effective
993
+ const auditFloor = decodeAuditFloor(read(AUDIT_FLOOR_KEY));
994
+ writes.push([AUDIT_RESUME_FLOOR_KEY, encodeAuditFloor(Number.isFinite(auditFloor) ? auditFloor : 0)]);
995
+ }
996
+ return writes;
997
+ },
998
+ 'database generation'
999
+ );
1000
+ stored = auditStore.getBinary(DATABASE_GENERATION_KEY);
1001
+ }
1002
+ auditStore.databaseGeneration = decodeGeneration(stored);
1003
+ if (!auditStore.databaseGeneration)
1004
+ warnContained(
1005
+ 'The database generation record is unreadable, so no subscription can resume against this database',
1006
+ new Error(`database generation record of ${stored?.byteLength} bytes`)
1007
+ );
1008
+ } catch (error) {
1009
+ warnContained('Error establishing the database generation', error);
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * Give a copy of a database a generation of its own, before anything can read it; the caller holds
1015
+ * the copy exclusively and makes the stamp durable before publishing. A copy that kept no
1016
+ * transaction log has nothing below its epoch, so a finite floor is raised to it — raising it over
1017
+ * history a copy carried would change `Table.commit`'s reconciliation, and an unknown floor stays
1018
+ * unknown. `generation` lets a caller replay a value it recorded durably first.
1019
+ */
1020
+ export function stampDatabaseGeneration(
1021
+ store: any,
1022
+ { carriesLog, generation = newGeneration(Date.now()) }: { carriesLog: boolean; generation?: DatabaseGeneration }
1023
+ ): DatabaseGeneration {
1024
+ if (
1025
+ !/^[0-9a-f]{32}$/.test(generation?.id) ||
1026
+ decodeGeneration(encodeGeneration(generation))?.epoch !== generation.epoch
1027
+ )
1028
+ throw new Error(`Invalid database generation: ${JSON.stringify(generation)}`);
1029
+ const stamp: Array<[symbol, Uint8Array]> = [
1030
+ [DATABASE_GENERATION_KEY, encodeGeneration(generation)],
1031
+ [AUDIT_RESUME_FLOOR_KEY, encodeAuditFloor(0)],
1032
+ ];
1033
+ commitAuditMetadata(
1034
+ store,
1035
+ (read) => {
1036
+ if (carriesLog) return stamp;
1037
+ const stored = read(AUDIT_FLOOR_KEY);
1038
+ const floor = decodeAuditFloor(stored);
1039
+ return stored !== undefined && Number.isFinite(floor) && floor < generation.epoch
1040
+ ? [...stamp, [AUDIT_FLOOR_KEY, encodeAuditFloor(generation.epoch)]]
1041
+ : stamp;
1042
+ },
1043
+ 'database generation'
1044
+ );
1045
+ return generation;
1046
+ }
1047
+
1048
+ /**
1049
+ * Stamp the RocksDB database at `path`, which nothing else may have open, and flush it: a root-store
1050
+ * write alone is not power-loss durable, and directory fsync is best-effort where unsupported.
1051
+ */
1052
+ export async function stampDatabaseDirectory(
1053
+ path: string,
1054
+ options: { carriesLog: boolean; generation?: DatabaseGeneration }
1055
+ ): Promise<DatabaseGeneration> {
1056
+ const database = openRocksDatabase(path, { disableWAL: false });
1057
+ try {
1058
+ const generation = stampDatabaseGeneration(database, options);
1059
+ await database.flush({ allowWriteStall: true });
1060
+ return generation;
1061
+ } finally {
1062
+ database.close();
1063
+ }
1064
+ }
843
1065
  export function setAuditRetention(retentionTime, defaultDelay = DEFAULT_AUDIT_CLEANUP_DELAY) {
844
1066
  auditRetention = retentionTime;
845
1067
  DEFAULT_AUDIT_CLEANUP_DELAY = defaultDelay;
@@ -26,6 +26,7 @@ import {
26
26
  retakeBranchIdentity,
27
27
  } from './databases.ts';
28
28
  import { replayLogs, replayTimeBudgetMs } from './replayLogs.ts';
29
+ import { stampDatabaseDirectory } from './auditStore.ts';
29
30
 
30
31
  /**
31
32
  * Private per-application forks of a database, for running several variants of an application against
@@ -345,6 +346,7 @@ async function materializeBranch(
345
346
  await base.createCheckpoint(staging);
346
347
  report.progress();
347
348
  await cloneBlobRoots(baseName, baseRoots, blobRoots, report.progress);
349
+ await stampDatabaseDirectory(staging, { carriesLog: false });
348
350
  await writeFile(join(staging, COMPLETION_MARKER), JSON.stringify({ blobRoots } satisfies BranchCompletion));
349
351
  await rename(staging, branchPath);
350
352
  return blobRoots;
@@ -1459,6 +1459,9 @@ function initStores(
1459
1459
  const fullTextIndexRetirements = persistedFullTextIndexNames(primaryAttribute.fullTextIndexRetirements);
1460
1460
  if (table && !recreateTable) {
1461
1461
  if (primaryAttribute.audit === true && table.audit !== true) table.enableAuditing();
1462
+ // Absent means unchanged, not `replicates`: NON_REPLICATING_SYSTEM_TABLES sets `replicate = false`
1463
+ // on the live class and stores nothing, so an unconditional refresh would clear that override.
1464
+ if (typeof primaryAttribute.replicate === 'boolean') table.replicate = primaryAttribute.replicate;
1462
1465
  table.fullTextIndexes = fullTextIndexes;
1463
1466
  table.fullTextFields = fullTextFields;
1464
1467
  table.fullTextIndexGenerations = fullTextIndexGenerations;
@@ -4304,8 +4307,8 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
4304
4307
  origin !== 'cluster' &&
4305
4308
  (schemaDefinedMismatch ||
4306
4309
  (typeof audit === 'boolean' && audit !== attributeDescriptor.audit) ||
4307
- (sealed !== undefined && sealed !== Table.sealed) ||
4308
- (replicate !== undefined && replicate !== Table.replicate) ||
4310
+ (sealed !== undefined && sealed !== attributeDescriptor.sealed) ||
4311
+ (replicate !== undefined && replicate !== attributeDescriptor.replicate) ||
4309
4312
  (+expiration || undefined) !== (+attributeDescriptor.expiration || undefined) ||
4310
4313
  (+eviction || undefined) !== (+attributeDescriptor.eviction || undefined) ||
4311
4314
  attribute.type !== attributeDescriptor.type)
@@ -4695,6 +4698,7 @@ function declareTable<TableResourceType>(target: TableTarget, tableDefinition: T
4695
4698
  );
4696
4699
  refreshDerivedIndexes(Table);
4697
4700
 
4701
+ if (typeof replicate === 'boolean' && origin !== 'cluster') Table.replicate = replicate;
4698
4702
  Table.origin = origin;
4699
4703
  // scope-private: replication and other global subscribers must not learn of a branch class
4700
4704
  if ((hasChanges || refreshRelationshipAttributes) && !target.branch) {