@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
@@ -62,6 +62,7 @@ exports.purgeBackupsOffline = purgeBackupsOffline;
62
62
  const node_crypto_1 = require("node:crypto");
63
63
  const node_fs_1 = require("node:fs");
64
64
  const promises_1 = require("node:fs/promises");
65
+ const node_crypto_2 = require("node:crypto");
65
66
  const node_path_1 = require("node:path");
66
67
  const node_stream_1 = require("node:stream");
67
68
  const promises_2 = require("node:stream/promises");
@@ -70,6 +71,7 @@ const promises_3 = require("node:timers/promises");
70
71
  const tar_stream_1 = require("tar-stream");
71
72
  const rocksdb_js_1 = require("@harperfast/rocksdb-js");
72
73
  const databases_ts_1 = require("../resources/databases.js");
74
+ const auditStore_ts_1 = require("../resources/auditStore.js");
73
75
  const blob_ts_1 = require("../resources/blob.js");
74
76
  const environmentManager_ts_1 = require("../utility/environment/environmentManager.js");
75
77
  const configUtils_ts_1 = require("../config/configUtils.js");
@@ -79,6 +81,8 @@ const hdbError_ts_1 = require("../utility/errors/hdbError.js");
79
81
  const signalling = __importStar(require("../utility/signalling.js"));
80
82
  const itc_js_1 = require("../server/threads/itc.js");
81
83
  const restoreMarker_ts_1 = require("./restoreMarker.js");
84
+ const backupRepository_ts_1 = require("./backupRepository.js");
85
+ const durableFile_ts_1 = require("../utility/durableFile.js");
82
86
  const blobBackup_ts_1 = require("./blobBackup.js");
83
87
  const backupManifest_ts_1 = require("./backupManifest.js");
84
88
  const harper_logger_ts_1 = __importDefault(require("../utility/logging/harper_logger.js"));
@@ -184,6 +188,31 @@ function requireRocksRootStore(databaseName, operation) {
184
188
  }
185
189
  return rootStore;
186
190
  }
191
+ /**
192
+ * Apply the engine gate only when there is a loaded database to gate.
193
+ *
194
+ * A repository outlives its database. A restore that failed leaves the database blocked by its
195
+ * restoring marker with the repository intact, and a backup can be imported for a name that does not
196
+ * exist yet — both are exactly the states an operator needs list/verify/delete/purge for. Refusing
197
+ * them because no root store is loaded turns a recoverable situation into a filesystem intervention,
198
+ * which on Fabric the operator cannot perform at all. A name that is neither a loaded database nor a
199
+ * repository is still a 404, so a typo does not silently answer with an empty list.
200
+ */
201
+ function requireBackupRepositoryAccess(databaseName, operation) {
202
+ const loaded = (0, databases_ts_1.getDatabases)()[databaseName];
203
+ if (loaded != null && Object.keys(loaded).length > 0) {
204
+ requireRocksRootStore(databaseName, operation);
205
+ return;
206
+ }
207
+ requireBackupRepositoryDirectory(databaseName);
208
+ }
209
+ function requireBackupRepositoryDirectory(databaseName) {
210
+ const backupDir = backupDirForDatabase(databaseName);
211
+ if (!(0, node_fs_1.existsSync)(backupDir)) {
212
+ throw new BackupNotFoundError(`Database '${databaseName}' is not loaded and has no backup repository at ${backupDir}`);
213
+ }
214
+ return backupDir;
215
+ }
187
216
  function requireBackupId(backupId) {
188
217
  if (!Number.isSafeInteger(backupId) || backupId <= 0) {
189
218
  throw new hdbError_ts_1.ClientError(`'backup_id' must be a positive integer`);
@@ -208,10 +237,24 @@ function mapLockedError(error, databaseName) {
208
237
  }
209
238
  // --- directory helpers (operate on a backup directory only; no open database, usable offline) ---
210
239
  async function listBackupsInDir(backupDir) {
211
- // the backup dir doesn't exist until the first create_backup
212
- if (!(0, node_fs_1.existsSync)(backupDir))
240
+ // the backup dir doesn't exist until the first create_backup. pathPresent, not existsSync: an
241
+ // empty listing is what reconcileHarperManagedBackupFiles reads as "keep nothing", so a
242
+ // permission or I/O fault answering "absent" would license deleting every manifest and blob
243
+ // snapshot in a repository that is merely unreadable.
244
+ if (!(0, durableFile_ts_1.pathPresent)(backupDir))
213
245
  return [];
214
- return rocksdb_js_1.backups.list(backupDir);
246
+ try {
247
+ return await rocksdb_js_1.backups.list(backupDir);
248
+ }
249
+ catch (error) {
250
+ // Taking the management lock creates the repository directory before anything has written
251
+ // engine metadata into it, so a directory with no metadata is an empty repository, not a
252
+ // failure — otherwise a purge on a database that never had a backup reports the binding's
253
+ // "meta is missing" instead of "no backups found".
254
+ if (!(0, durableFile_ts_1.pathPresent)((0, node_path_1.join)(backupDir, 'meta')))
255
+ return [];
256
+ throw error;
257
+ }
215
258
  }
216
259
  async function findBackup(backupDir, backupId, databaseName) {
217
260
  requireBackupId(backupId); // every id-taking path flows through here, including the offline CLI
@@ -280,6 +323,16 @@ async function resolveCompleteBackup(backupDir, requestedId, databaseName) {
280
323
  const latest = complete.reduce((a, b) => (b.backupId > a.backupId ? b : a));
281
324
  return { backupId: latest.backupId, manifest: { backupId: latest.backupId, blobs: latest.blobs, completedAt: 0 } };
282
325
  }
326
+ /**
327
+ * Drop the Harper-managed files — blob snapshots and completion manifests — of every backup the
328
+ * engine no longer has. Derived from what survives rather than from what a caller believes it
329
+ * removed, so a delete or purge that failed partway through still leaves no orphans behind.
330
+ */
331
+ async function reconcileHarperManagedBackupFiles(backupDir) {
332
+ const keepIds = new Set((await listBackupsInDir(backupDir)).map((backup) => backup.backupId));
333
+ await (0, blobBackup_ts_1.purgeBlobSnapshots)(backupDir, keepIds);
334
+ await (0, backupManifest_ts_1.purgeBackupManifests)(backupDir, keepIds);
335
+ }
283
336
  /**
284
337
  * Publish a backup's completion manifest after the engine backup and (when included) blob snapshot
285
338
  * are durable. On failure, best-effort roll back the just-created engine backup, its partial blob
@@ -289,6 +342,13 @@ async function finalizeBackup(backupDir, backupId, databaseName, blobs) {
289
342
  try {
290
343
  if (blobs)
291
344
  await (0, blobBackup_ts_1.snapshotBlobs)(backupDir, backupId, (0, blob_ts_1.getBlobPathsForDatabaseName)(databaseName));
345
+ // Guards writers that do not take Harper's lock — an older binary, or a direct binding call.
346
+ // Harper's own purge cannot reach here: the whole create is one critical section.
347
+ // The manifest is what publishes a backup as usable, so writing one for engine files that are
348
+ // gone is the #2031 false-green shape: a backup that lists and verifies with nothing to restore.
349
+ if (!(await listBackupsInDir(backupDir)).some((backup) => backup.backupId === backupId)) {
350
+ throw new BackupNotFoundError(`Backup ${backupId} of database '${databaseName}' was removed while it was being finalized; rerun create_backup`);
351
+ }
292
352
  await (0, backupManifest_ts_1.writeBackupManifest)(backupDir, backupId, blobs);
293
353
  }
294
354
  catch (error) {
@@ -303,53 +363,20 @@ async function listBackups(request) {
303
363
  requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.LIST_BACKUPS);
304
364
  const databaseName = getDatabaseName(request);
305
365
  harper_logger_ts_1.default.info(`Listing backups for database '${databaseName}'`);
306
- requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.LIST_BACKUPS);
307
- return (await listCompleteBackups(backupDirForDatabase(databaseName))).map((backup) => toBackupResponse(backup, backup.blobs));
366
+ requireBackupRepositoryAccess(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.LIST_BACKUPS);
367
+ return listBackupsOffline(databaseName);
308
368
  }
309
369
  async function deleteBackup(request) {
310
370
  requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.DELETE_BACKUP);
311
371
  const databaseName = getDatabaseName(request);
312
- requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.DELETE_BACKUP);
313
- const backupId = requireBackupId(request.backup_id);
314
- const backupDir = backupDirForDatabase(databaseName);
315
- await findBackup(backupDir, backupId, databaseName);
316
- try {
317
- await rocksdb_js_1.backups.delete(backupDir, backupId);
318
- }
319
- catch (error) {
320
- throw mapLockedError(error, databaseName);
321
- }
322
- // the engine's delete leaves the (Harper-managed) blob snapshot + manifest behind — remove them too
323
- await (0, blobBackup_ts_1.deleteBlobSnapshot)(backupDir, backupId);
324
- await (0, backupManifest_ts_1.deleteBackupManifest)(backupDir, backupId);
325
- return { ok: true };
372
+ requireBackupRepositoryAccess(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.DELETE_BACKUP);
373
+ return deleteBackupOffline(databaseName, requireBackupId(request.backup_id));
326
374
  }
327
375
  async function purgeBackups(request) {
328
376
  requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.PURGE_BACKUPS);
329
377
  const databaseName = getDatabaseName(request);
330
- requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.PURGE_BACKUPS);
331
- const keepCount = request.keep_count;
332
- if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
333
- throw new hdbError_ts_1.ClientError(`'keep_count' must be a non-negative integer`);
334
- }
335
- const backupDir = backupDirForDatabase(databaseName);
336
- const before = await listBackupsInDir(backupDir);
337
- if (before.length === 0) {
338
- throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
339
- }
340
- try {
341
- await rocksdb_js_1.backups.purge(backupDir, keepCount);
342
- }
343
- catch (error) {
344
- throw mapLockedError(error, databaseName);
345
- }
346
- const remainingBackups = await listBackupsInDir(backupDir);
347
- // drop blob snapshots + manifests for every id the engine purged (keep only the survivors')
348
- const keepIds = new Set(remainingBackups.map((backup) => backup.backupId));
349
- await (0, blobBackup_ts_1.purgeBlobSnapshots)(backupDir, keepIds);
350
- await (0, backupManifest_ts_1.purgeBackupManifests)(backupDir, keepIds);
351
- // clamp: a concurrent create between the two lists can otherwise make this negative
352
- return { deleted: Math.max(0, before.length - remainingBackups.length), remaining: remainingBackups.length };
378
+ requireBackupRepositoryAccess(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.PURGE_BACKUPS);
379
+ return purgeBackupsOffline(databaseName, request.keep_count);
353
380
  }
354
381
  // --- job operations: create_backup / verify_backup / restore_backup ---
355
382
  // Each has a synchronous-validation function (run by jobs.addJob before the job record is
@@ -366,15 +393,19 @@ async function createBackup(request) {
366
393
  const excludeBlobs = requireBooleanOption(request.exclude_blobs, 'exclude_blobs');
367
394
  const rootStore = requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.CREATE_BACKUP);
368
395
  const backupDir = backupDirForDatabase(databaseName);
369
- let backupId;
370
- try {
371
- backupId = await rootStore.backup(backupDir, { transactionLogs: true });
372
- }
373
- catch (error) {
374
- throw mapLockedError(error, databaseName);
375
- }
376
- // snapshot blobs (unless excluded) then publish the completion manifest; rolls back on failure
377
- await finalizeBackup(backupDir, backupId, databaseName, !excludeBlobs);
396
+ // One critical section, management lock before the engine call: a purge can neither remove the
397
+ // engine backup before the manifest publishes it nor shift which ids it would remove.
398
+ const backupId = await (0, backupRepository_ts_1.withBackupRepositoryLock)(backupDir, databaseName, async () => {
399
+ let id;
400
+ try {
401
+ id = await rootStore.backup(backupDir, { transactionLogs: true });
402
+ }
403
+ catch (error) {
404
+ throw mapLockedError(error, databaseName);
405
+ }
406
+ await finalizeBackup(backupDir, id, databaseName, !excludeBlobs);
407
+ return id;
408
+ }, true);
378
409
  await writeBackupReadme(backupDir, databaseName);
379
410
  return {
380
411
  database: databaseName,
@@ -457,7 +488,7 @@ Both remove the RocksDB backup and its blob snapshot.
457
488
  async function validateVerifyBackup(request) {
458
489
  requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.VERIFY_BACKUP);
459
490
  const databaseName = getDatabaseName(request);
460
- requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.VERIFY_BACKUP);
491
+ requireBackupRepositoryAccess(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.VERIFY_BACKUP);
461
492
  requireBooleanOption(request.verify_checksum, 'verify_checksum');
462
493
  const backupDir = backupDirForDatabase(databaseName);
463
494
  const backupId = requireBackupId(request.backup_id);
@@ -466,19 +497,8 @@ async function validateVerifyBackup(request) {
466
497
  }
467
498
  async function verifyBackup(request) {
468
499
  const databaseName = getDatabaseName(request);
469
- requireRocksRootStore(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.VERIFY_BACKUP);
470
- const backupId = requireBackupId(request.backup_id);
471
- const verifyWithChecksum = requireBooleanOption(request.verify_checksum, 'verify_checksum');
472
- const backupDir = backupDirForDatabase(databaseName);
473
- await findBackup(backupDir, backupId, databaseName);
474
- const manifest = await requireBackupComplete(backupDir, backupId, databaseName);
475
- await rocksdb_js_1.backups.verify(backupDir, backupId, { verifyWithChecksum });
476
- // verification covers the declared blob snapshot: a backup that recorded blobs must still have its
477
- // snapshot directory (a manifest without its blobs is a corrupt backup)
478
- if (manifest.blobs && !(0, node_fs_1.existsSync)((0, blobBackup_ts_1.blobSnapshotDir)(backupDir, backupId))) {
479
- throw new hdbError_ts_1.ClientError(`Backup ${backupId} of database '${databaseName}' declares captured blobs but its blob snapshot is missing (corrupt backup)`);
480
- }
481
- return { database: databaseName, backup_id: backupId, ok: true, blobs: manifest.blobs };
500
+ requireBackupRepositoryAccess(databaseName, hdbTerms_ts_1.OPERATIONS_ENUM.VERIFY_BACKUP);
501
+ return verifyBackupOffline(databaseName, requireBackupId(request.backup_id), request.verify_checksum);
482
502
  }
483
503
  async function validateRestoreBackup(request) {
484
504
  requireSuperUser(request, hdbTerms_ts_1.OPERATIONS_ENUM.RESTORE_BACKUP);
@@ -543,9 +563,15 @@ async function restoreBackup(request) {
543
563
  // Once is enough: the decision reads the manifest and the opt-in, never the destination, so no
544
564
  // concurrent writer can change the answer between here and the purge.
545
565
  (0, blobBackup_ts_1.assertEngineOnlyRestoreAllowed)(databaseName, { backupHasBlobs: manifest.blobs, allowEngineOnly });
546
- const lock = beginRestoreForDatabase(databaseDir, databaseName);
566
+ const pinId = restorePinId(databaseDir);
547
567
  const restoreToken = (0, node_crypto_1.randomUUID)();
548
568
  let destructionStarted = false;
569
+ // Re-check before replacing a previous attempt's claim; publish the new claim before the marker
570
+ // under both locks, so a crash cannot leave a marked database with an unprotected source.
571
+ const lock = await (0, backupRepository_ts_1.withBackupRepositoryLock)(backupDir, databaseName, async () => {
572
+ await findBackup(backupDir, backupId, databaseName);
573
+ return beginRestoreForDatabase(databaseDir, databaseName, () => (0, backupRepository_ts_1.pinBackup)(backupDir, pinId, backupId, `restore of database '${databaseName}'`, databaseDir));
574
+ });
549
575
  try {
550
576
  // Block new blob saves, drain in-flight saves, and close the database across all worker threads.
551
577
  // Each thread also rescans, and the restoring marker keeps it from reloading mid-restore.
@@ -568,6 +594,7 @@ async function restoreBackup(request) {
568
594
  if (manifest.blobs) {
569
595
  await (0, blobBackup_ts_1.restoreBlobSnapshot)(backupDir, backupId, databaseName, (0, blob_ts_1.getBlobPathsForDatabaseName)(databaseName));
570
596
  }
597
+ await (0, auditStore_ts_1.stampDatabaseDirectory)(databaseDir, { carriesLog: true });
571
598
  }
572
599
  catch (error) {
573
600
  // Leave the marker (so startup/rescan detection reports an incomplete restore until a rerun
@@ -577,6 +604,8 @@ async function restoreBackup(request) {
577
604
  // partial/corrupt data. Only a *fresh* marker on a *previously healthy* database that failed
578
605
  // before any destruction is safe to clear.
579
606
  if (destructionStarted || lock.preexisting) {
607
+ // The marker stays, so the database is unloadable until a rerun — and the rerun needs this
608
+ // backup. The pin stays with it, and lapses on its own once the marker is gone.
580
609
  (0, restoreMarker_ts_1.abandonRestore)(lock);
581
610
  // The restore is over even though it failed, so release the workers' blob fence. Without this
582
611
  // the fence outlives the attempt: the marker keeps the database from loading, but an operator
@@ -598,15 +627,36 @@ async function restoreBackup(request) {
598
627
  }
599
628
  // nothing destructive happened and the marker was fresh — clear it and let every thread reload
600
629
  // the intact database
601
- (0, restoreMarker_ts_1.completeRestore)(lock);
630
+ releaseRestoreClaim(backupDir, pinId, lock, databaseName);
602
631
  await signalling.signalSchemaChange(restoreSchemaEvent(databaseName, 'reload', restoreToken, false));
603
632
  throw error;
604
633
  }
605
- (0, restoreMarker_ts_1.completeRestore)(lock);
634
+ releaseRestoreClaim(backupDir, pinId, lock, databaseName);
606
635
  // signal again: with the marker gone, every thread's rescan reloads the restored database
607
636
  await signalling.signalSchemaChange(restoreSchemaEvent(databaseName, 'reload', restoreToken));
608
637
  return { database: databaseName, backup_id: backupId, ...(allowEngineOnly ? { allow_engine_only: true } : {}) };
609
638
  }
639
+ /**
640
+ * Retire a finished restore: marker, then claim, then the destination lock. While the database is
641
+ * still marked a rerun is required and the rerun needs this backup, so the claim has to outlive the
642
+ * marker — a crash the other way round leaves an unloadable database whose source a purge may
643
+ * delete. Both happen under the restore lock, so no later attempt can have written a claim at this
644
+ * same id yet; one stranded after the marker is gone simply lapses and is swept.
645
+ */
646
+ function releaseRestoreClaim(backupDir, pinId, lock, databaseName) {
647
+ try {
648
+ (0, restoreMarker_ts_1.clearRestoringMarker)(lock);
649
+ try {
650
+ (0, backupRepository_ts_1.unpinBackup)(backupDir, pinId);
651
+ }
652
+ catch (error) {
653
+ harper_logger_ts_1.default.error(`Could not release the backup claim after restoring '${databaseName}'; it lapses with the marker`, error);
654
+ }
655
+ }
656
+ finally {
657
+ (0, restoreMarker_ts_1.releaseRestoreLock)(lock);
658
+ }
659
+ }
610
660
  /**
611
661
  * `restoreToken` identifies the restore that owns the blob fence. Two restores of the same database
612
662
  * can overlap, so a worker releases the fence only for the token that established it -- see
@@ -654,9 +704,16 @@ async function verifyDatabaseClosed(databaseDir, databaseName) {
654
704
  * beginRestore's own error message carries the filesystem path (useful in CLI/server logs);
655
705
  * client-facing operations report by database name instead.
656
706
  */
657
- function beginRestoreForDatabase(databaseDir, databaseName) {
707
+ /**
708
+ * One pin per target database, not per attempt: two attempts can never collide on it, and a rerun
709
+ * after a failed restore reuses the claim the failed attempt left protecting its source.
710
+ */
711
+ function restorePinId(databaseDir) {
712
+ return `restore-${(0, node_crypto_2.createHash)('sha256').update((0, node_path_1.resolve)(databaseDir)).digest('hex').slice(0, 32)}`;
713
+ }
714
+ function beginRestoreForDatabase(databaseDir, databaseName, beforePublishMarker) {
658
715
  try {
659
- return (0, restoreMarker_ts_1.beginRestore)(databaseDir);
716
+ return (0, restoreMarker_ts_1.beginRestore)(databaseDir, beforePublishMarker);
660
717
  }
661
718
  catch (error) {
662
719
  if (error.statusCode === 409) {
@@ -943,14 +1000,17 @@ async function createBackupOffline(databaseName, excludeBlobs = false) {
943
1000
  const database = rocksdb_js_1.RocksDatabase.open(databaseDir);
944
1001
  try {
945
1002
  const backupDir = backupDirForDatabase(databaseName);
946
- let backupId;
947
- try {
948
- backupId = await database.backup(backupDir, { transactionLogs: true });
949
- }
950
- catch (error) {
951
- throw mapLockedError(error, databaseName);
952
- }
953
- await finalizeBackup(backupDir, backupId, databaseName, !excludeBlobs);
1003
+ const backupId = await (0, backupRepository_ts_1.withBackupRepositoryLock)(backupDir, databaseName, async () => {
1004
+ let id;
1005
+ try {
1006
+ id = await database.backup(backupDir, { transactionLogs: true });
1007
+ }
1008
+ catch (error) {
1009
+ throw mapLockedError(error, databaseName);
1010
+ }
1011
+ await finalizeBackup(backupDir, id, databaseName, !excludeBlobs);
1012
+ return id;
1013
+ }, true);
954
1014
  await writeBackupReadme(backupDir, databaseName);
955
1015
  return {
956
1016
  database: databaseName,
@@ -992,10 +1052,13 @@ async function restoreBackupOffline(databaseName, backupId, targetDatabase, allo
992
1052
  const blobRoots = (0, blob_ts_1.getBlobPathsForDatabaseName)(targetDatabase ?? databaseName);
993
1053
  await (0, blobBackup_ts_1.assertBlobSnapshotRestorable)(backupDir, backupId, blobRoots);
994
1054
  (0, blobBackup_ts_1.assertEngineOnlyRestoreAllowed)(targetDatabase ?? databaseName, { backupHasBlobs: manifest.blobs, allowEngineOnly });
995
- // Take the restore lock + marker BEFORE probing so a server that starts after this point sees the
996
- // marker and refuses to load the database (closing the window between the probe and the purge).
997
- const lock = beginRestoreForDatabase(databaseDir, targetDatabase ?? databaseName);
1055
+ const pinId = restorePinId(databaseDir);
998
1056
  let destructionStarted = false;
1057
+ // As online, claim before marking under both locks, and mark before probing the destination.
1058
+ const lock = await (0, backupRepository_ts_1.withBackupRepositoryLock)(backupDir, databaseName, async () => {
1059
+ await findBackup(backupDir, backupId, databaseName);
1060
+ return beginRestoreForDatabase(databaseDir, targetDatabase ?? databaseName, () => (0, backupRepository_ts_1.pinBackup)(backupDir, pinId, backupId, `restore of database '${targetDatabase ?? databaseName}'`, databaseDir));
1061
+ });
999
1062
  try {
1000
1063
  // The offline path is entered only when the CLI sees no running server (getHdbPid), but that is
1001
1064
  // a heuristic: the PID file is briefly absent mid-`harper restart`, and backups.restore's
@@ -1023,15 +1086,19 @@ async function restoreBackupOffline(databaseName, backupId, targetDatabase, allo
1023
1086
  if (manifest.blobs) {
1024
1087
  await (0, blobBackup_ts_1.restoreBlobSnapshot)(backupDir, backupId, databaseName, (0, blob_ts_1.getBlobPathsForDatabaseName)(targetDatabase ?? databaseName));
1025
1088
  }
1089
+ await (0, auditStore_ts_1.stampDatabaseDirectory)(databaseDir, { carriesLog: true });
1026
1090
  }
1027
1091
  catch (error) {
1028
1092
  // Preserve the marker on a destructive failure or a recovery over a pre-existing marker (see
1029
1093
  // the online restoreBackup for the rationale); otherwise clear the fresh marker so an intact,
1030
1094
  // merely-locked database is not left flagged as an incomplete restore.
1095
+ // The pin stays exactly as long as the marker does: a retained marker means a rerun is required,
1096
+ // and the rerun needs this backup to still be there.
1031
1097
  if (destructionStarted || lock.preexisting)
1032
1098
  (0, restoreMarker_ts_1.abandonRestore)(lock);
1033
- else
1034
- (0, restoreMarker_ts_1.completeRestore)(lock);
1099
+ else {
1100
+ releaseRestoreClaim(backupDir, pinId, lock, databaseName);
1101
+ }
1035
1102
  // preserve typed client errors (e.g. the 409 lock probe) unwrapped; only wrap an opaque restore
1036
1103
  // failure after destruction has begun
1037
1104
  if (destructionStarted && !(error instanceof hdbError_ts_1.ClientError)) {
@@ -1039,7 +1106,7 @@ async function restoreBackupOffline(databaseName, backupId, targetDatabase, allo
1039
1106
  }
1040
1107
  throw error;
1041
1108
  }
1042
- (0, restoreMarker_ts_1.completeRestore)(lock);
1109
+ releaseRestoreClaim(backupDir, pinId, lock, databaseName);
1043
1110
  return {
1044
1111
  database: databaseName,
1045
1112
  backup_id: backupId,
@@ -1057,7 +1124,10 @@ function isMissingOrEmptyDir(path) {
1057
1124
  throw error;
1058
1125
  }
1059
1126
  }
1060
- // --- offline management wrappers (no engine validation: they operate on the directory only) ---
1127
+ // --- directory-only management implementations ---
1128
+ // What the CLI runs when the local server is stopped, and what the online operations delegate to
1129
+ // once their engine gate has passed. One implementation, so the two paths cannot drift, and so a
1130
+ // repository whose database is absent or blocked by a restore stays manageable.
1061
1131
  async function listBackupsOffline(databaseName) {
1062
1132
  validateDatabaseName(databaseName);
1063
1133
  // map to the same snake_case response shape as the online list_backups operation
@@ -1077,38 +1147,77 @@ async function verifyBackupOffline(databaseName, backupId, verifyChecksum) {
1077
1147
  }
1078
1148
  async function deleteBackupOffline(databaseName, backupId) {
1079
1149
  validateDatabaseName(databaseName);
1080
- const backupDir = backupDirForDatabase(databaseName);
1081
- await findBackup(backupDir, backupId, databaseName);
1082
- try {
1083
- await rocksdb_js_1.backups.delete(backupDir, backupId);
1084
- }
1085
- catch (error) {
1086
- throw mapLockedError(error, databaseName);
1087
- }
1088
- await (0, blobBackup_ts_1.deleteBlobSnapshot)(backupDir, backupId);
1089
- await (0, backupManifest_ts_1.deleteBackupManifest)(backupDir, backupId);
1090
- return { ok: true };
1150
+ requireBackupId(backupId);
1151
+ const backupDir = requireBackupRepositoryDirectory(databaseName);
1152
+ return (0, backupRepository_ts_1.withBackupRepositoryLock)(backupDir, databaseName, async () => {
1153
+ await findBackup(backupDir, backupId, databaseName);
1154
+ (0, backupRepository_ts_1.assertBackupsUnpinned)(backupDir, [backupId], databaseName);
1155
+ let engineSucceeded = false;
1156
+ try {
1157
+ await rocksdb_js_1.backups.delete(backupDir, backupId);
1158
+ engineSucceeded = true;
1159
+ }
1160
+ catch (error) {
1161
+ throw mapLockedError(error, databaseName);
1162
+ }
1163
+ finally {
1164
+ // The engine's delete leaves the Harper-managed blob snapshot + manifest behind, and it can
1165
+ // fail after removing engine files — so reconcile against what survives rather than assuming
1166
+ // this id was the only thing that changed.
1167
+ // Preserve a primary engine error, but never report success when cleanup failed.
1168
+ await reconcileHarperManagedBackupFiles(backupDir).catch((error) => {
1169
+ if (engineSucceeded)
1170
+ throw error;
1171
+ harper_logger_ts_1.default.warn(`Could not reconcile Harper-managed backup files in ${backupDir}`, error);
1172
+ });
1173
+ }
1174
+ return { ok: true };
1175
+ });
1091
1176
  }
1092
1177
  async function purgeBackupsOffline(databaseName, keepCount) {
1093
1178
  validateDatabaseName(databaseName);
1094
1179
  if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
1095
1180
  throw new hdbError_ts_1.ClientError(`'keep_count' must be a non-negative integer`);
1096
1181
  }
1097
- const backupDir = backupDirForDatabase(databaseName);
1098
- const before = await listBackupsInDir(backupDir);
1099
- if (before.length === 0) {
1100
- throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
1101
- }
1102
- try {
1103
- await rocksdb_js_1.backups.purge(backupDir, keepCount);
1104
- }
1105
- catch (error) {
1106
- throw mapLockedError(error, databaseName);
1107
- }
1108
- const remainingBackups = await listBackupsInDir(backupDir);
1109
- const keepIds = new Set(remainingBackups.map((backup) => backup.backupId));
1110
- await (0, blobBackup_ts_1.purgeBlobSnapshots)(backupDir, keepIds);
1111
- await (0, backupManifest_ts_1.purgeBackupManifests)(backupDir, keepIds);
1112
- return { deleted: Math.max(0, before.length - remainingBackups.length), remaining: remainingBackups.length };
1182
+ const backupDir = requireBackupRepositoryDirectory(databaseName);
1183
+ return (0, backupRepository_ts_1.withBackupRepositoryLock)(backupDir, databaseName, async () => {
1184
+ const before = await listBackupsInDir(backupDir);
1185
+ if (before.length === 0) {
1186
+ throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
1187
+ }
1188
+ // Every Harper writer is excluded by this lock, so `before` is exactly what the binding will
1189
+ // see, and `purge` keeps the newest `keepCount` by id — so the ids it will remove can be named
1190
+ // here and checked against the claims before it runs. One bulk call then does the removal.
1191
+ // Sorted rather than sliced off the listing as returned: this set gates claim protection, so it
1192
+ // does not rest on the documented list order holding, matching resolveCompleteBackup below.
1193
+ const removing = [...before]
1194
+ .sort((first, second) => first.backupId - second.backupId)
1195
+ .slice(0, Math.max(0, before.length - keepCount))
1196
+ .map((backup) => backup.backupId);
1197
+ (0, backupRepository_ts_1.assertBackupsUnpinned)(backupDir, removing, databaseName);
1198
+ let engineSucceeded = false;
1199
+ try {
1200
+ await rocksdb_js_1.backups.purge(backupDir, keepCount);
1201
+ engineSucceeded = true;
1202
+ }
1203
+ catch (error) {
1204
+ throw mapLockedError(error, databaseName);
1205
+ }
1206
+ finally {
1207
+ // Reconciled from what actually survives, in a finally: a purge that failed partway through
1208
+ // still removed engine backups, and their blob snapshots would otherwise be orphaned on disk
1209
+ // — invisible to list_backups and still charged to the tenant's quota.
1210
+ // Preserve a primary engine error, but never report success when cleanup failed.
1211
+ await reconcileHarperManagedBackupFiles(backupDir).catch((error) => {
1212
+ if (engineSucceeded)
1213
+ throw error;
1214
+ harper_logger_ts_1.default.warn(`Could not reconcile Harper-managed backup files in ${backupDir}`, error);
1215
+ });
1216
+ }
1217
+ // Counted from what actually survives rather than from `removing`: no create can land inside
1218
+ // this lock, so the difference is exactly what the purge removed.
1219
+ const remaining = (await listBackupsInDir(backupDir)).length;
1220
+ return { deleted: before.length - remaining, remaining };
1221
+ });
1113
1222
  }
1114
1223
  //# sourceMappingURL=rocksdbBackup.js.map