@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
@@ -3,6 +3,7 @@
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { createReadStream, existsSync, readdirSync } from 'node:fs';
5
5
  import { open, writeFile } from 'node:fs/promises';
6
+ import { createHash } from 'node:crypto';
6
7
  import { join, relative, resolve, sep } from 'node:path';
7
8
  import { PassThrough, Writable } from 'node:stream';
8
9
  import { pipeline } from 'node:stream/promises';
@@ -11,6 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
11
12
  import { pack as tarPack, type Pack } from 'tar-stream';
12
13
  import { RocksDatabase, backups, registryStatus, type BackupInfo } from '@harperfast/rocksdb-js';
13
14
  import { getDatabases, resolveDatabasePath } from '../resources/databases.ts';
15
+ import { stampDatabaseDirectory } from '../resources/auditStore.ts';
14
16
  import {
15
17
  type BlobCaptureDisposition,
16
18
  classifyBlobFileForCapture,
@@ -25,7 +27,16 @@ import { CONFIG_PARAMS, OPERATIONS_ENUM } from '../utility/hdbTerms.ts';
25
27
  import { ClientError } from '../utility/errors/hdbError.ts';
26
28
  import * as signalling from '../utility/signalling.ts';
27
29
  import { SchemaEventMsg } from '../server/threads/itc.js';
28
- import { beginRestore, completeRestore, abandonRestore, checkRestoreState, type RestoreLock } from './restoreMarker.ts';
30
+ import {
31
+ beginRestore,
32
+ clearRestoringMarker,
33
+ abandonRestore,
34
+ checkRestoreState,
35
+ releaseRestoreLock,
36
+ type RestoreLock,
37
+ } from './restoreMarker.ts';
38
+ import { assertBackupsUnpinned, pinBackup, unpinBackup, withBackupRepositoryLock } from './backupRepository.ts';
39
+ import { pathPresent } from '../utility/durableFile.ts';
29
40
  import {
30
41
  assertBlobSnapshotRestorable,
31
42
  assertEngineOnlyRestoreAllowed,
@@ -161,6 +172,35 @@ function requireRocksRootStore(databaseName: string, operation: string): RocksDa
161
172
  return rootStore as RocksDatabase;
162
173
  }
163
174
 
175
+ /**
176
+ * Apply the engine gate only when there is a loaded database to gate.
177
+ *
178
+ * A repository outlives its database. A restore that failed leaves the database blocked by its
179
+ * restoring marker with the repository intact, and a backup can be imported for a name that does not
180
+ * exist yet — both are exactly the states an operator needs list/verify/delete/purge for. Refusing
181
+ * them because no root store is loaded turns a recoverable situation into a filesystem intervention,
182
+ * which on Fabric the operator cannot perform at all. A name that is neither a loaded database nor a
183
+ * repository is still a 404, so a typo does not silently answer with an empty list.
184
+ */
185
+ function requireBackupRepositoryAccess(databaseName: string, operation: string): void {
186
+ const loaded = getDatabases()[databaseName];
187
+ if (loaded != null && Object.keys(loaded).length > 0) {
188
+ requireRocksRootStore(databaseName, operation);
189
+ return;
190
+ }
191
+ requireBackupRepositoryDirectory(databaseName);
192
+ }
193
+
194
+ function requireBackupRepositoryDirectory(databaseName: string): string {
195
+ const backupDir = backupDirForDatabase(databaseName);
196
+ if (!existsSync(backupDir)) {
197
+ throw new BackupNotFoundError(
198
+ `Database '${databaseName}' is not loaded and has no backup repository at ${backupDir}`
199
+ );
200
+ }
201
+ return backupDir;
202
+ }
203
+
164
204
  function requireBackupId(backupId: any): number {
165
205
  if (!Number.isSafeInteger(backupId) || backupId <= 0) {
166
206
  throw new ClientError(`'backup_id' must be a positive integer`);
@@ -189,9 +229,21 @@ function mapLockedError(error: any, databaseName: string): any {
189
229
  // --- directory helpers (operate on a backup directory only; no open database, usable offline) ---
190
230
 
191
231
  export async function listBackupsInDir(backupDir: string): Promise<BackupInfo[]> {
192
- // the backup dir doesn't exist until the first create_backup
193
- if (!existsSync(backupDir)) return [];
194
- return backups.list(backupDir);
232
+ // the backup dir doesn't exist until the first create_backup. pathPresent, not existsSync: an
233
+ // empty listing is what reconcileHarperManagedBackupFiles reads as "keep nothing", so a
234
+ // permission or I/O fault answering "absent" would license deleting every manifest and blob
235
+ // snapshot in a repository that is merely unreadable.
236
+ if (!pathPresent(backupDir)) return [];
237
+ try {
238
+ return await backups.list(backupDir);
239
+ } catch (error) {
240
+ // Taking the management lock creates the repository directory before anything has written
241
+ // engine metadata into it, so a directory with no metadata is an empty repository, not a
242
+ // failure — otherwise a purge on a database that never had a backup reports the binding's
243
+ // "meta is missing" instead of "no backups found".
244
+ if (!pathPresent(join(backupDir, 'meta'))) return [];
245
+ throw error;
246
+ }
195
247
  }
196
248
 
197
249
  async function findBackup(backupDir: string, backupId: number, databaseName: string): Promise<BackupInfo> {
@@ -284,6 +336,17 @@ async function resolveCompleteBackup(
284
336
  return { backupId: latest.backupId, manifest: { backupId: latest.backupId, blobs: latest.blobs, completedAt: 0 } };
285
337
  }
286
338
 
339
+ /**
340
+ * Drop the Harper-managed files — blob snapshots and completion manifests — of every backup the
341
+ * engine no longer has. Derived from what survives rather than from what a caller believes it
342
+ * removed, so a delete or purge that failed partway through still leaves no orphans behind.
343
+ */
344
+ async function reconcileHarperManagedBackupFiles(backupDir: string): Promise<void> {
345
+ const keepIds = new Set((await listBackupsInDir(backupDir)).map((backup) => backup.backupId));
346
+ await purgeBlobSnapshots(backupDir, keepIds);
347
+ await purgeBackupManifests(backupDir, keepIds);
348
+ }
349
+
287
350
  /**
288
351
  * Publish a backup's completion manifest after the engine backup and (when included) blob snapshot
289
352
  * are durable. On failure, best-effort roll back the just-created engine backup, its partial blob
@@ -297,6 +360,15 @@ async function finalizeBackup(
297
360
  ): Promise<void> {
298
361
  try {
299
362
  if (blobs) await snapshotBlobs(backupDir, backupId, getBlobPathsForDatabaseName(databaseName));
363
+ // Guards writers that do not take Harper's lock — an older binary, or a direct binding call.
364
+ // Harper's own purge cannot reach here: the whole create is one critical section.
365
+ // The manifest is what publishes a backup as usable, so writing one for engine files that are
366
+ // gone is the #2031 false-green shape: a backup that lists and verifies with nothing to restore.
367
+ if (!(await listBackupsInDir(backupDir)).some((backup) => backup.backupId === backupId)) {
368
+ throw new BackupNotFoundError(
369
+ `Backup ${backupId} of database '${databaseName}' was removed while it was being finalized; rerun create_backup`
370
+ );
371
+ }
300
372
  await writeBackupManifest(backupDir, backupId, blobs);
301
373
  } catch (error) {
302
374
  await deleteBackupManifest(backupDir, backupId).catch(() => {});
@@ -312,55 +384,22 @@ export async function listBackups(request: any) {
312
384
  requireSuperUser(request, OPERATIONS_ENUM.LIST_BACKUPS);
313
385
  const databaseName = getDatabaseName(request);
314
386
  logger.info(`Listing backups for database '${databaseName}'`);
315
- requireRocksRootStore(databaseName, OPERATIONS_ENUM.LIST_BACKUPS);
316
- return (await listCompleteBackups(backupDirForDatabase(databaseName))).map((backup) =>
317
- toBackupResponse(backup, backup.blobs)
318
- );
387
+ requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.LIST_BACKUPS);
388
+ return listBackupsOffline(databaseName);
319
389
  }
320
390
 
321
391
  export async function deleteBackup(request: any) {
322
392
  requireSuperUser(request, OPERATIONS_ENUM.DELETE_BACKUP);
323
393
  const databaseName = getDatabaseName(request);
324
- requireRocksRootStore(databaseName, OPERATIONS_ENUM.DELETE_BACKUP);
325
- const backupId = requireBackupId(request.backup_id);
326
- const backupDir = backupDirForDatabase(databaseName);
327
- await findBackup(backupDir, backupId, databaseName);
328
- try {
329
- await backups.delete(backupDir, backupId);
330
- } catch (error) {
331
- throw mapLockedError(error, databaseName);
332
- }
333
- // the engine's delete leaves the (Harper-managed) blob snapshot + manifest behind — remove them too
334
- await deleteBlobSnapshot(backupDir, backupId);
335
- await deleteBackupManifest(backupDir, backupId);
336
- return { ok: true };
394
+ requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.DELETE_BACKUP);
395
+ return deleteBackupOffline(databaseName, requireBackupId(request.backup_id));
337
396
  }
338
397
 
339
398
  export async function purgeBackups(request: any) {
340
399
  requireSuperUser(request, OPERATIONS_ENUM.PURGE_BACKUPS);
341
400
  const databaseName = getDatabaseName(request);
342
- requireRocksRootStore(databaseName, OPERATIONS_ENUM.PURGE_BACKUPS);
343
- const keepCount = request.keep_count;
344
- if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
345
- throw new ClientError(`'keep_count' must be a non-negative integer`);
346
- }
347
- const backupDir = backupDirForDatabase(databaseName);
348
- const before = await listBackupsInDir(backupDir);
349
- if (before.length === 0) {
350
- throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
351
- }
352
- try {
353
- await backups.purge(backupDir, keepCount);
354
- } catch (error) {
355
- throw mapLockedError(error, databaseName);
356
- }
357
- const remainingBackups = await listBackupsInDir(backupDir);
358
- // drop blob snapshots + manifests for every id the engine purged (keep only the survivors')
359
- const keepIds = new Set(remainingBackups.map((backup) => backup.backupId));
360
- await purgeBlobSnapshots(backupDir, keepIds);
361
- await purgeBackupManifests(backupDir, keepIds);
362
- // clamp: a concurrent create between the two lists can otherwise make this negative
363
- return { deleted: Math.max(0, before.length - remainingBackups.length), remaining: remainingBackups.length };
401
+ requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.PURGE_BACKUPS);
402
+ return purgeBackupsOffline(databaseName, request.keep_count);
364
403
  }
365
404
 
366
405
  // --- job operations: create_backup / verify_backup / restore_backup ---
@@ -380,14 +419,23 @@ export async function createBackup(request: any) {
380
419
  const excludeBlobs = requireBooleanOption(request.exclude_blobs, 'exclude_blobs');
381
420
  const rootStore = requireRocksRootStore(databaseName, OPERATIONS_ENUM.CREATE_BACKUP);
382
421
  const backupDir = backupDirForDatabase(databaseName);
383
- let backupId;
384
- try {
385
- backupId = await rootStore.backup(backupDir, { transactionLogs: true });
386
- } catch (error) {
387
- throw mapLockedError(error, databaseName);
388
- }
389
- // snapshot blobs (unless excluded) then publish the completion manifest; rolls back on failure
390
- await finalizeBackup(backupDir, backupId, databaseName, !excludeBlobs);
422
+ // One critical section, management lock before the engine call: a purge can neither remove the
423
+ // engine backup before the manifest publishes it nor shift which ids it would remove.
424
+ const backupId = await withBackupRepositoryLock(
425
+ backupDir,
426
+ databaseName,
427
+ async () => {
428
+ let id;
429
+ try {
430
+ id = await rootStore.backup(backupDir, { transactionLogs: true });
431
+ } catch (error) {
432
+ throw mapLockedError(error, databaseName);
433
+ }
434
+ await finalizeBackup(backupDir, id, databaseName, !excludeBlobs);
435
+ return id;
436
+ },
437
+ true
438
+ );
391
439
  await writeBackupReadme(backupDir, databaseName);
392
440
  return {
393
441
  database: databaseName,
@@ -472,7 +520,7 @@ Both remove the RocksDB backup and its blob snapshot.
472
520
  export async function validateVerifyBackup(request: any) {
473
521
  requireSuperUser(request, OPERATIONS_ENUM.VERIFY_BACKUP);
474
522
  const databaseName = getDatabaseName(request);
475
- requireRocksRootStore(databaseName, OPERATIONS_ENUM.VERIFY_BACKUP);
523
+ requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.VERIFY_BACKUP);
476
524
  requireBooleanOption(request.verify_checksum, 'verify_checksum');
477
525
  const backupDir = backupDirForDatabase(databaseName);
478
526
  const backupId = requireBackupId(request.backup_id);
@@ -482,21 +530,8 @@ export async function validateVerifyBackup(request: any) {
482
530
 
483
531
  export async function verifyBackup(request: any) {
484
532
  const databaseName = getDatabaseName(request);
485
- requireRocksRootStore(databaseName, OPERATIONS_ENUM.VERIFY_BACKUP);
486
- const backupId = requireBackupId(request.backup_id);
487
- const verifyWithChecksum = requireBooleanOption(request.verify_checksum, 'verify_checksum');
488
- const backupDir = backupDirForDatabase(databaseName);
489
- await findBackup(backupDir, backupId, databaseName);
490
- const manifest = await requireBackupComplete(backupDir, backupId, databaseName);
491
- await backups.verify(backupDir, backupId, { verifyWithChecksum });
492
- // verification covers the declared blob snapshot: a backup that recorded blobs must still have its
493
- // snapshot directory (a manifest without its blobs is a corrupt backup)
494
- if (manifest.blobs && !existsSync(blobSnapshotDir(backupDir, backupId))) {
495
- throw new ClientError(
496
- `Backup ${backupId} of database '${databaseName}' declares captured blobs but its blob snapshot is missing (corrupt backup)`
497
- );
498
- }
499
- return { database: databaseName, backup_id: backupId, ok: true, blobs: manifest.blobs };
533
+ requireBackupRepositoryAccess(databaseName, OPERATIONS_ENUM.VERIFY_BACKUP);
534
+ return verifyBackupOffline(databaseName, requireBackupId(request.backup_id), request.verify_checksum);
500
535
  }
501
536
 
502
537
  export async function validateRestoreBackup(request: any) {
@@ -575,9 +610,17 @@ export async function restoreBackup(request: any) {
575
610
  // Once is enough: the decision reads the manifest and the opt-in, never the destination, so no
576
611
  // concurrent writer can change the answer between here and the purge.
577
612
  assertEngineOnlyRestoreAllowed(databaseName, { backupHasBlobs: manifest.blobs, allowEngineOnly });
578
- const lock = beginRestoreForDatabase(databaseDir, databaseName);
613
+ const pinId = restorePinId(databaseDir);
579
614
  const restoreToken = randomUUID();
580
615
  let destructionStarted = false;
616
+ // Re-check before replacing a previous attempt's claim; publish the new claim before the marker
617
+ // under both locks, so a crash cannot leave a marked database with an unprotected source.
618
+ const lock = await withBackupRepositoryLock(backupDir, databaseName, async () => {
619
+ await findBackup(backupDir, backupId, databaseName);
620
+ return beginRestoreForDatabase(databaseDir, databaseName, () =>
621
+ pinBackup(backupDir, pinId, backupId, `restore of database '${databaseName}'`, databaseDir)
622
+ );
623
+ });
581
624
  try {
582
625
  // Block new blob saves, drain in-flight saves, and close the database across all worker threads.
583
626
  // Each thread also rescans, and the restoring marker keeps it from reloading mid-restore.
@@ -601,6 +644,7 @@ export async function restoreBackup(request: any) {
601
644
  if (manifest.blobs) {
602
645
  await restoreBlobSnapshot(backupDir, backupId, databaseName, getBlobPathsForDatabaseName(databaseName));
603
646
  }
647
+ await stampDatabaseDirectory(databaseDir, { carriesLog: true });
604
648
  } catch (error: any) {
605
649
  // Leave the marker (so startup/rescan detection reports an incomplete restore until a rerun
606
650
  // succeeds) when either the destructive purge has begun, OR this attempt was itself a recovery
@@ -609,6 +653,8 @@ export async function restoreBackup(request: any) {
609
653
  // partial/corrupt data. Only a *fresh* marker on a *previously healthy* database that failed
610
654
  // before any destruction is safe to clear.
611
655
  if (destructionStarted || lock.preexisting) {
656
+ // The marker stays, so the database is unloadable until a rerun — and the rerun needs this
657
+ // backup. The pin stays with it, and lapses on its own once the marker is gone.
612
658
  abandonRestore(lock);
613
659
  // The restore is over even though it failed, so release the workers' blob fence. Without this
614
660
  // the fence outlives the attempt: the marker keeps the database from loading, but an operator
@@ -632,16 +678,39 @@ export async function restoreBackup(request: any) {
632
678
  }
633
679
  // nothing destructive happened and the marker was fresh — clear it and let every thread reload
634
680
  // the intact database
635
- completeRestore(lock);
681
+ releaseRestoreClaim(backupDir, pinId, lock, databaseName);
636
682
  await signalling.signalSchemaChange(restoreSchemaEvent(databaseName, 'reload', restoreToken, false));
637
683
  throw error;
638
684
  }
639
- completeRestore(lock);
685
+ releaseRestoreClaim(backupDir, pinId, lock, databaseName);
640
686
  // signal again: with the marker gone, every thread's rescan reloads the restored database
641
687
  await signalling.signalSchemaChange(restoreSchemaEvent(databaseName, 'reload', restoreToken));
642
688
  return { database: databaseName, backup_id: backupId, ...(allowEngineOnly ? { allow_engine_only: true } : {}) };
643
689
  }
644
690
 
691
+ /**
692
+ * Retire a finished restore: marker, then claim, then the destination lock. While the database is
693
+ * still marked a rerun is required and the rerun needs this backup, so the claim has to outlive the
694
+ * marker — a crash the other way round leaves an unloadable database whose source a purge may
695
+ * delete. Both happen under the restore lock, so no later attempt can have written a claim at this
696
+ * same id yet; one stranded after the marker is gone simply lapses and is swept.
697
+ */
698
+ function releaseRestoreClaim(backupDir: string, pinId: string, lock: RestoreLock, databaseName: string): void {
699
+ try {
700
+ clearRestoringMarker(lock);
701
+ try {
702
+ unpinBackup(backupDir, pinId);
703
+ } catch (error) {
704
+ logger.error(
705
+ `Could not release the backup claim after restoring '${databaseName}'; it lapses with the marker`,
706
+ error
707
+ );
708
+ }
709
+ } finally {
710
+ releaseRestoreLock(lock);
711
+ }
712
+ }
713
+
645
714
  /**
646
715
  * `restoreToken` identifies the restore that owns the blob fence. Two restores of the same database
647
716
  * can overlap, so a worker releases the fence only for the token that established it -- see
@@ -700,9 +769,21 @@ async function verifyDatabaseClosed(databaseDir: string, databaseName: string):
700
769
  * beginRestore's own error message carries the filesystem path (useful in CLI/server logs);
701
770
  * client-facing operations report by database name instead.
702
771
  */
703
- function beginRestoreForDatabase(databaseDir: string, databaseName: string): RestoreLock {
772
+ /**
773
+ * One pin per target database, not per attempt: two attempts can never collide on it, and a rerun
774
+ * after a failed restore reuses the claim the failed attempt left protecting its source.
775
+ */
776
+ function restorePinId(databaseDir: string): string {
777
+ return `restore-${createHash('sha256').update(resolve(databaseDir)).digest('hex').slice(0, 32)}`;
778
+ }
779
+
780
+ function beginRestoreForDatabase(
781
+ databaseDir: string,
782
+ databaseName: string,
783
+ beforePublishMarker: () => void
784
+ ): RestoreLock {
704
785
  try {
705
- return beginRestore(databaseDir);
786
+ return beginRestore(databaseDir, beforePublishMarker);
706
787
  } catch (error) {
707
788
  if (error.statusCode === 409) {
708
789
  throw new BackupInProgressError(`Restore already in progress for database '${databaseName}'`);
@@ -1010,13 +1091,21 @@ export async function createBackupOffline(databaseName: string, excludeBlobs = f
1010
1091
  const database = RocksDatabase.open(databaseDir);
1011
1092
  try {
1012
1093
  const backupDir = backupDirForDatabase(databaseName);
1013
- let backupId;
1014
- try {
1015
- backupId = await database.backup(backupDir, { transactionLogs: true });
1016
- } catch (error) {
1017
- throw mapLockedError(error, databaseName);
1018
- }
1019
- await finalizeBackup(backupDir, backupId, databaseName, !excludeBlobs);
1094
+ const backupId = await withBackupRepositoryLock(
1095
+ backupDir,
1096
+ databaseName,
1097
+ async () => {
1098
+ let id;
1099
+ try {
1100
+ id = await database.backup(backupDir, { transactionLogs: true });
1101
+ } catch (error) {
1102
+ throw mapLockedError(error, databaseName);
1103
+ }
1104
+ await finalizeBackup(backupDir, id, databaseName, !excludeBlobs);
1105
+ return id;
1106
+ },
1107
+ true
1108
+ );
1020
1109
  await writeBackupReadme(backupDir, databaseName);
1021
1110
  return {
1022
1111
  database: databaseName,
@@ -1064,10 +1153,21 @@ export async function restoreBackupOffline(
1064
1153
  const blobRoots = getBlobPathsForDatabaseName(targetDatabase ?? databaseName);
1065
1154
  await assertBlobSnapshotRestorable(backupDir, backupId, blobRoots);
1066
1155
  assertEngineOnlyRestoreAllowed(targetDatabase ?? databaseName, { backupHasBlobs: manifest.blobs, allowEngineOnly });
1067
- // Take the restore lock + marker BEFORE probing so a server that starts after this point sees the
1068
- // marker and refuses to load the database (closing the window between the probe and the purge).
1069
- const lock = beginRestoreForDatabase(databaseDir, targetDatabase ?? databaseName);
1156
+ const pinId = restorePinId(databaseDir);
1070
1157
  let destructionStarted = false;
1158
+ // As online, claim before marking under both locks, and mark before probing the destination.
1159
+ const lock = await withBackupRepositoryLock(backupDir, databaseName, async () => {
1160
+ await findBackup(backupDir, backupId as number, databaseName);
1161
+ return beginRestoreForDatabase(databaseDir, targetDatabase ?? databaseName, () =>
1162
+ pinBackup(
1163
+ backupDir,
1164
+ pinId,
1165
+ backupId as number,
1166
+ `restore of database '${targetDatabase ?? databaseName}'`,
1167
+ databaseDir
1168
+ )
1169
+ );
1170
+ });
1071
1171
  try {
1072
1172
  // The offline path is entered only when the CLI sees no running server (getHdbPid), but that is
1073
1173
  // a heuristic: the PID file is briefly absent mid-`harper restart`, and backups.restore's
@@ -1101,12 +1201,17 @@ export async function restoreBackupOffline(
1101
1201
  getBlobPathsForDatabaseName(targetDatabase ?? databaseName)
1102
1202
  );
1103
1203
  }
1204
+ await stampDatabaseDirectory(databaseDir, { carriesLog: true });
1104
1205
  } catch (error: any) {
1105
1206
  // Preserve the marker on a destructive failure or a recovery over a pre-existing marker (see
1106
1207
  // the online restoreBackup for the rationale); otherwise clear the fresh marker so an intact,
1107
1208
  // merely-locked database is not left flagged as an incomplete restore.
1209
+ // The pin stays exactly as long as the marker does: a retained marker means a rerun is required,
1210
+ // and the rerun needs this backup to still be there.
1108
1211
  if (destructionStarted || lock.preexisting) abandonRestore(lock);
1109
- else completeRestore(lock);
1212
+ else {
1213
+ releaseRestoreClaim(backupDir, pinId, lock, databaseName);
1214
+ }
1110
1215
  // preserve typed client errors (e.g. the 409 lock probe) unwrapped; only wrap an opaque restore
1111
1216
  // failure after destruction has begun
1112
1217
  if (destructionStarted && !(error instanceof ClientError)) {
@@ -1117,7 +1222,7 @@ export async function restoreBackupOffline(
1117
1222
  }
1118
1223
  throw error;
1119
1224
  }
1120
- completeRestore(lock);
1225
+ releaseRestoreClaim(backupDir, pinId, lock, databaseName);
1121
1226
  return {
1122
1227
  database: databaseName,
1123
1228
  backup_id: backupId,
@@ -1135,7 +1240,10 @@ function isMissingOrEmptyDir(path: string): boolean {
1135
1240
  }
1136
1241
  }
1137
1242
 
1138
- // --- offline management wrappers (no engine validation: they operate on the directory only) ---
1243
+ // --- directory-only management implementations ---
1244
+ // What the CLI runs when the local server is stopped, and what the online operations delegate to
1245
+ // once their engine gate has passed. One implementation, so the two paths cannot drift, and so a
1246
+ // repository whose database is absent or blocked by a restore stays manageable.
1139
1247
 
1140
1248
  export async function listBackupsOffline(databaseName: string) {
1141
1249
  validateDatabaseName(databaseName);
@@ -1162,16 +1270,29 @@ export async function verifyBackupOffline(databaseName: string, backupId: number
1162
1270
 
1163
1271
  export async function deleteBackupOffline(databaseName: string, backupId: number) {
1164
1272
  validateDatabaseName(databaseName);
1165
- const backupDir = backupDirForDatabase(databaseName);
1166
- await findBackup(backupDir, backupId, databaseName);
1167
- try {
1168
- await backups.delete(backupDir, backupId);
1169
- } catch (error) {
1170
- throw mapLockedError(error, databaseName);
1171
- }
1172
- await deleteBlobSnapshot(backupDir, backupId);
1173
- await deleteBackupManifest(backupDir, backupId);
1174
- return { ok: true };
1273
+ requireBackupId(backupId);
1274
+ const backupDir = requireBackupRepositoryDirectory(databaseName);
1275
+ return withBackupRepositoryLock(backupDir, databaseName, async () => {
1276
+ await findBackup(backupDir, backupId, databaseName);
1277
+ assertBackupsUnpinned(backupDir, [backupId], databaseName);
1278
+ let engineSucceeded = false;
1279
+ try {
1280
+ await backups.delete(backupDir, backupId);
1281
+ engineSucceeded = true;
1282
+ } catch (error) {
1283
+ throw mapLockedError(error, databaseName);
1284
+ } finally {
1285
+ // The engine's delete leaves the Harper-managed blob snapshot + manifest behind, and it can
1286
+ // fail after removing engine files — so reconcile against what survives rather than assuming
1287
+ // this id was the only thing that changed.
1288
+ // Preserve a primary engine error, but never report success when cleanup failed.
1289
+ await reconcileHarperManagedBackupFiles(backupDir).catch((error) => {
1290
+ if (engineSucceeded) throw error;
1291
+ logger.warn(`Could not reconcile Harper-managed backup files in ${backupDir}`, error);
1292
+ });
1293
+ }
1294
+ return { ok: true };
1295
+ });
1175
1296
  }
1176
1297
 
1177
1298
  export async function purgeBackupsOffline(databaseName: string, keepCount: number) {
@@ -1179,19 +1300,41 @@ export async function purgeBackupsOffline(databaseName: string, keepCount: numbe
1179
1300
  if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
1180
1301
  throw new ClientError(`'keep_count' must be a non-negative integer`);
1181
1302
  }
1182
- const backupDir = backupDirForDatabase(databaseName);
1183
- const before = await listBackupsInDir(backupDir);
1184
- if (before.length === 0) {
1185
- throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
1186
- }
1187
- try {
1188
- await backups.purge(backupDir, keepCount);
1189
- } catch (error) {
1190
- throw mapLockedError(error, databaseName);
1191
- }
1192
- const remainingBackups = await listBackupsInDir(backupDir);
1193
- const keepIds = new Set(remainingBackups.map((backup) => backup.backupId));
1194
- await purgeBlobSnapshots(backupDir, keepIds);
1195
- await purgeBackupManifests(backupDir, keepIds);
1196
- return { deleted: Math.max(0, before.length - remainingBackups.length), remaining: remainingBackups.length };
1303
+ const backupDir = requireBackupRepositoryDirectory(databaseName);
1304
+ return withBackupRepositoryLock(backupDir, databaseName, async () => {
1305
+ const before = await listBackupsInDir(backupDir);
1306
+ if (before.length === 0) {
1307
+ throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
1308
+ }
1309
+ // Every Harper writer is excluded by this lock, so `before` is exactly what the binding will
1310
+ // see, and `purge` keeps the newest `keepCount` by id — so the ids it will remove can be named
1311
+ // here and checked against the claims before it runs. One bulk call then does the removal.
1312
+ // Sorted rather than sliced off the listing as returned: this set gates claim protection, so it
1313
+ // does not rest on the documented list order holding, matching resolveCompleteBackup below.
1314
+ const removing = [...before]
1315
+ .sort((first, second) => first.backupId - second.backupId)
1316
+ .slice(0, Math.max(0, before.length - keepCount))
1317
+ .map((backup) => backup.backupId);
1318
+ assertBackupsUnpinned(backupDir, removing, databaseName);
1319
+ let engineSucceeded = false;
1320
+ try {
1321
+ await backups.purge(backupDir, keepCount);
1322
+ engineSucceeded = true;
1323
+ } catch (error) {
1324
+ throw mapLockedError(error, databaseName);
1325
+ } finally {
1326
+ // Reconciled from what actually survives, in a finally: a purge that failed partway through
1327
+ // still removed engine backups, and their blob snapshots would otherwise be orphaned on disk
1328
+ // — invisible to list_backups and still charged to the tenant's quota.
1329
+ // Preserve a primary engine error, but never report success when cleanup failed.
1330
+ await reconcileHarperManagedBackupFiles(backupDir).catch((error) => {
1331
+ if (engineSucceeded) throw error;
1332
+ logger.warn(`Could not reconcile Harper-managed backup files in ${backupDir}`, error);
1333
+ });
1334
+ }
1335
+ // Counted from what actually survives rather than from `removing`: no create can land inside
1336
+ // this lock, so the difference is exactly what the purge removed.
1337
+ const remaining = (await listBackupsInDir(backupDir)).length;
1338
+ return { deleted: before.length - remaining, remaining };
1339
+ });
1197
1340
  }
@@ -938,6 +938,9 @@ async function copyDbToRocks(sourceRootStore, sourceDatabase, targetPath) {
938
938
  if (idMappingBytes) {
939
939
  targetRootStore.putSync(REMOTE_NODE_IDS_KEY, (0, lmdb_1.asBinary)(idMappingBytes));
940
940
  }
941
+ // flushed so the stamp is durable before the caller publishes the staging directory
942
+ (0, auditStore_ts_1.stampDatabaseGeneration)(targetRootStore, { carriesLog: false });
943
+ await targetRootStore.flush({ allowWriteStall: true });
941
944
  console.log('migrated database ' + sourceDatabase + ' to RocksDB');
942
945
  }
943
946
  finally {