@harperfast/harper 5.2.0-beta.4 → 5.2.0

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 (169) hide show
  1. package/bin/backup.ts +169 -0
  2. package/bin/cliOperations.ts +92 -73
  3. package/bin/harper.ts +25 -6
  4. package/components/Scope.ts +52 -6
  5. package/components/componentLoader.ts +107 -9
  6. package/components/mcp/toolRegistry.ts +10 -0
  7. package/components/mcp/tools/application.ts +12 -5
  8. package/components/mcp/tools/operations.ts +3 -0
  9. package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
  10. package/components/mcp/tools/schemas/operations.ts +9 -0
  11. package/components/operations.js +1 -0
  12. package/components/operationsValidation.js +32 -2
  13. package/components/scopeMount.ts +150 -0
  14. package/config-root.schema.json +4 -0
  15. package/dataLayer/backupManifest.ts +102 -0
  16. package/dataLayer/blobBackup.ts +286 -0
  17. package/dataLayer/harperBridge/ResourceBridge.ts +43 -0
  18. package/dataLayer/restoreMarker.ts +276 -0
  19. package/dataLayer/rocksdbBackup.ts +1100 -0
  20. package/dist/bin/backup.d.ts +9 -0
  21. package/dist/bin/backup.js +192 -0
  22. package/dist/bin/backup.js.map +1 -0
  23. package/dist/bin/cliOperations.d.ts +13 -0
  24. package/dist/bin/cliOperations.js +89 -70
  25. package/dist/bin/cliOperations.js.map +1 -1
  26. package/dist/bin/harper.d.ts +7 -0
  27. package/dist/bin/harper.js +27 -6
  28. package/dist/bin/harper.js.map +1 -1
  29. package/dist/components/Scope.d.ts +37 -1
  30. package/dist/components/Scope.js +48 -6
  31. package/dist/components/Scope.js.map +1 -1
  32. package/dist/components/componentLoader.d.ts +2 -6
  33. package/dist/components/componentLoader.js +91 -8
  34. package/dist/components/componentLoader.js.map +1 -1
  35. package/dist/components/mcp/toolRegistry.d.ts +7 -0
  36. package/dist/components/mcp/toolRegistry.js +10 -0
  37. package/dist/components/mcp/toolRegistry.js.map +1 -1
  38. package/dist/components/mcp/tools/application.js +11 -5
  39. package/dist/components/mcp/tools/application.js.map +1 -1
  40. package/dist/components/mcp/tools/operations.js +3 -0
  41. package/dist/components/mcp/tools/operations.js.map +1 -1
  42. package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
  43. package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
  44. package/dist/components/mcp/tools/schemas/operations.js +9 -0
  45. package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
  46. package/dist/components/operations.js +2 -0
  47. package/dist/components/operations.js.map +1 -1
  48. package/dist/components/operationsValidation.js +34 -2
  49. package/dist/components/operationsValidation.js.map +1 -1
  50. package/dist/components/scopeMount.d.ts +86 -0
  51. package/dist/components/scopeMount.js +131 -0
  52. package/dist/components/scopeMount.js.map +1 -0
  53. package/dist/dataLayer/backupManifest.d.ts +26 -0
  54. package/dist/dataLayer/backupManifest.js +97 -0
  55. package/dist/dataLayer/backupManifest.js.map +1 -0
  56. package/dist/dataLayer/blobBackup.d.ts +87 -0
  57. package/dist/dataLayer/blobBackup.js +282 -0
  58. package/dist/dataLayer/blobBackup.js.map +1 -0
  59. package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
  60. package/dist/dataLayer/harperBridge/ResourceBridge.js +33 -0
  61. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  62. package/dist/dataLayer/restoreMarker.d.ts +122 -0
  63. package/dist/dataLayer/restoreMarker.js +261 -0
  64. package/dist/dataLayer/restoreMarker.js.map +1 -0
  65. package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
  66. package/dist/dataLayer/rocksdbBackup.js +1039 -0
  67. package/dist/dataLayer/rocksdbBackup.js.map +1 -0
  68. package/dist/resources/DatabaseTransaction.js +0 -6
  69. package/dist/resources/DatabaseTransaction.js.map +1 -1
  70. package/dist/resources/ResourceInterface.d.ts +0 -3
  71. package/dist/resources/ResourceInterface.js.map +1 -1
  72. package/dist/resources/Table.js +22 -22
  73. package/dist/resources/Table.js.map +1 -1
  74. package/dist/resources/blob.d.ts +8 -0
  75. package/dist/resources/blob.js +16 -7
  76. package/dist/resources/blob.js.map +1 -1
  77. package/dist/resources/databases.d.ts +42 -1
  78. package/dist/resources/databases.js +276 -40
  79. package/dist/resources/databases.js.map +1 -1
  80. package/dist/resources/transaction.js +0 -3
  81. package/dist/resources/transaction.js.map +1 -1
  82. package/dist/server/REST.js +25 -9
  83. package/dist/server/REST.js.map +1 -1
  84. package/dist/server/fastifyRoutes.js +15 -1
  85. package/dist/server/fastifyRoutes.js.map +1 -1
  86. package/dist/server/itc/serverHandlers.js +7 -1
  87. package/dist/server/itc/serverHandlers.js.map +1 -1
  88. package/dist/server/jobs/jobProcess.js +20 -1
  89. package/dist/server/jobs/jobProcess.js.map +1 -1
  90. package/dist/server/jobs/jobRunner.js +10 -0
  91. package/dist/server/jobs/jobRunner.js.map +1 -1
  92. package/dist/server/jobs/jobs.js +11 -0
  93. package/dist/server/jobs/jobs.js.map +1 -1
  94. package/dist/server/middlewareChain.d.ts +10 -1
  95. package/dist/server/middlewareChain.js +81 -21
  96. package/dist/server/middlewareChain.js.map +1 -1
  97. package/dist/server/serverHelpers/serverHandlers.js +8 -4
  98. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  99. package/dist/server/serverHelpers/serverUtilities.js +11 -0
  100. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  101. package/dist/server/static.js +11 -3
  102. package/dist/server/static.js.map +1 -1
  103. package/dist/utility/OperationFunctionCaller.js +25 -6
  104. package/dist/utility/OperationFunctionCaller.js.map +1 -1
  105. package/dist/utility/hdbTerms.d.ts +11 -1
  106. package/dist/utility/hdbTerms.js +11 -1
  107. package/dist/utility/hdbTerms.js.map +1 -1
  108. package/dist/utility/logging/harper_logger.d.ts +40 -0
  109. package/dist/utility/logging/harper_logger.js +746 -14
  110. package/dist/utility/logging/harper_logger.js.map +1 -1
  111. package/dist/utility/operation_authorization.js +13 -3
  112. package/dist/utility/operation_authorization.js.map +1 -1
  113. package/npm-shrinkwrap.json +3 -2
  114. package/package.json +4 -1
  115. package/resources/DESIGN.md +2 -0
  116. package/resources/DatabaseTransaction.ts +0 -3
  117. package/resources/ResourceInterface.ts +0 -3
  118. package/resources/Table.ts +20 -20
  119. package/resources/blob.ts +16 -6
  120. package/resources/databases.ts +272 -42
  121. package/resources/transaction.ts +0 -3
  122. package/server/DESIGN.md +16 -0
  123. package/server/REST.ts +25 -9
  124. package/server/fastifyRoutes.ts +20 -1
  125. package/server/itc/serverHandlers.js +7 -1
  126. package/server/jobs/jobProcess.ts +18 -1
  127. package/server/jobs/jobRunner.ts +10 -0
  128. package/server/jobs/jobs.ts +11 -0
  129. package/server/middlewareChain.ts +79 -20
  130. package/server/serverHelpers/serverHandlers.js +8 -4
  131. package/server/serverHelpers/serverUtilities.ts +19 -0
  132. package/server/static.ts +12 -3
  133. package/static/defaultConfig.yaml +1 -0
  134. package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-DoVWScmq.js} +2 -2
  135. package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-DoVWScmq.js.map} +1 -1
  136. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-UZ2NsUOZ.js} +4 -4
  137. package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-UZ2NsUOZ.js.map} +1 -1
  138. package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-BUI_04o7.js} +2 -2
  139. package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-BUI_04o7.js.map} +1 -1
  140. package/studio/web/assets/{applications-BDfH8urd.js → applications-D03NA7wW.js} +2 -2
  141. package/studio/web/assets/{applications-BDfH8urd.js.map → applications-D03NA7wW.js.map} +1 -1
  142. package/studio/web/assets/{index-BHo3c2Gk.js → index-Bh_CNAHr.js} +6 -6
  143. package/studio/web/assets/index-Bh_CNAHr.js.map +1 -0
  144. package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-Dx3MpyDC.js} +4 -4
  145. package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-Dx3MpyDC.js.map} +1 -1
  146. package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-0edoFTsb.js} +2 -2
  147. package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-0edoFTsb.js.map} +1 -1
  148. package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CwKhipK7.js} +2 -2
  149. package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CwKhipK7.js.map} +1 -1
  150. package/studio/web/assets/{profile-Doj5FVDE.js → profile-DUfEPQtx.js} +2 -2
  151. package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-DUfEPQtx.js.map} +1 -1
  152. package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-DMPo4UjC.js} +2 -2
  153. package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-DMPo4UjC.js.map} +1 -1
  154. package/studio/web/assets/{setup-DJwR0BHd.js → setup-B56Oz1_u.js} +2 -2
  155. package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-B56Oz1_u.js.map} +1 -1
  156. package/studio/web/assets/{status-Br_AbsJs.js → status-BAod7p3o.js} +2 -2
  157. package/studio/web/assets/{status-Br_AbsJs.js.map → status-BAod7p3o.js.map} +1 -1
  158. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-lQrBxfwM.js} +2 -2
  159. package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-lQrBxfwM.js.map} +1 -1
  160. package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-CrHCRjTK.js} +2 -2
  161. package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-CrHCRjTK.js.map} +1 -1
  162. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-DoaBMEvU.js} +2 -2
  163. package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-DoaBMEvU.js.map} +1 -1
  164. package/studio/web/index.html +1 -1
  165. package/utility/OperationFunctionCaller.ts +24 -3
  166. package/utility/hdbTerms.ts +11 -1
  167. package/utility/logging/harper_logger.ts +729 -15
  168. package/utility/operation_authorization.ts +31 -3
  169. package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
@@ -0,0 +1,1100 @@
1
+ 'use strict';
2
+
3
+ import { createReadStream, existsSync, readdirSync } from 'node:fs';
4
+ import { open, readdir, writeFile } from 'node:fs/promises';
5
+ import { join, relative, resolve, sep } from 'node:path';
6
+ import { PassThrough, Writable } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { createGzip } from 'node:zlib';
9
+ import { setTimeout as delay } from 'node:timers/promises';
10
+ import { pack as tarPack, type Pack } from 'tar-stream';
11
+ import { RocksDatabase, backups, registryStatus, type BackupInfo } from '@harperfast/rocksdb-js';
12
+ import { getDatabases, resolveDatabasePath } from '../resources/databases.ts';
13
+ import { getBlobPathsForDatabaseName } from '../resources/blob.ts';
14
+ import { getHdbBasePath } from '../utility/environment/environmentManager.ts';
15
+ import { getConfigPath } from '../config/configUtils.ts';
16
+ import { getBackupDirPath } from '../config/configHelpers.ts';
17
+ import { CONFIG_PARAMS, OPERATIONS_ENUM } from '../utility/hdbTerms.ts';
18
+ import { ClientError } from '../utility/errors/hdbError.ts';
19
+ import * as signalling from '../utility/signalling.ts';
20
+ import { SchemaEventMsg } from '../server/threads/itc.js';
21
+ import { beginRestore, completeRestore, abandonRestore, checkRestoreState, type RestoreLock } from './restoreMarker.ts';
22
+ import {
23
+ assertBlobSnapshotRestorable,
24
+ blobSnapshotDir,
25
+ blobsReadmeContent,
26
+ deleteBlobSnapshot,
27
+ purgeBlobSnapshots,
28
+ restoreBlobSnapshot,
29
+ snapshotBlobs,
30
+ } from './blobBackup.ts';
31
+ import {
32
+ deleteBackupManifest,
33
+ purgeBackupManifests,
34
+ readAllManifests,
35
+ readBackupManifest,
36
+ writeBackupManifest,
37
+ type BackupManifest,
38
+ } from './backupManifest.ts';
39
+ import logger from '../utility/logging/harper_logger.ts';
40
+
41
+ /**
42
+ * Shared core for the RocksDB managed-backup operations (`create_backup`, `list_backups`,
43
+ * `verify_backup`, `delete_backup`, `purge_backups`, `restore_backup`) and the RocksDB path of
44
+ * `get_backup`. Used by both the operation API (running server) and the CLI (stopped server) so
45
+ * the two behave identically.
46
+ *
47
+ * Directory backups are confined to `<backupsRoot>/<database>/` where the backups root comes
48
+ * from the `storage.backupPath` config (default `<hdb_root>/backups`); operations never accept
49
+ * arbitrary filesystem paths.
50
+ */
51
+
52
+ export class BackupNotFoundError extends ClientError {
53
+ constructor(message: string) {
54
+ super(message, 404);
55
+ this.name = 'BackupNotFoundError';
56
+ }
57
+ }
58
+
59
+ export class BackupInProgressError extends ClientError {
60
+ constructor(message: string) {
61
+ super(message, 409);
62
+ this.name = 'BackupInProgressError';
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Enforce super_user for the managed-backup operations. These are whole-database administrative
68
+ * operations (not table-scoped), so they must never be delegable to a non-super_user role. The
69
+ * registered permission alone can't guarantee that: operation_authorization gate-2 authorizes any
70
+ * `requires_su` op placed in a role's `operations` allowlist without evaluating the declared table
71
+ * CRUD perms, so a non-SU role could otherwise reach these. Enforcing here — mirroring
72
+ * get_deployment_payload's requireSuperUser — closes that path regardless of the allowlist. For the
73
+ * job operations (create/verify/restore) this runs in the request-context validator, before any job
74
+ * record is created.
75
+ */
76
+ function requireSuperUser(request: any, operationName: string): void {
77
+ if (!request?.hdb_user?.role?.permission?.super_user) {
78
+ throw new ClientError(`Operation '${operationName}' is restricted to super_user roles`, 403);
79
+ }
80
+ }
81
+
82
+ export function getBackupsRoot(): string {
83
+ const configured = getConfigPath(CONFIG_PARAMS.STORAGE_BACKUPPATH);
84
+ if (configured && typeof configured === 'string') return configured;
85
+ // same <hdb_root>/backup directory as config-file backups; databases get subdirectories
86
+ return getBackupDirPath(getHdbBasePath());
87
+ }
88
+
89
+ function getDatabaseName(request: any): string {
90
+ const databaseName = request.database || request.schema || 'data';
91
+ validateDatabaseName(databaseName);
92
+ return databaseName;
93
+ }
94
+
95
+ /**
96
+ * The database name becomes a path segment under the backups root and the databases root —
97
+ * reject anything that could traverse outside them.
98
+ */
99
+ export function validateDatabaseName(databaseName: any): void {
100
+ if (typeof databaseName !== 'string' || databaseName.length === 0) {
101
+ throw new ClientError(`'database' must be a non-empty string`);
102
+ }
103
+ if (
104
+ databaseName.includes('/') ||
105
+ databaseName.includes('\\') ||
106
+ databaseName.includes('\0') ||
107
+ databaseName === '.' ||
108
+ databaseName === '..'
109
+ ) {
110
+ throw new ClientError(`Invalid database name '${databaseName}'`);
111
+ }
112
+ }
113
+
114
+ export function backupDirForDatabase(databaseName: string): string {
115
+ validateDatabaseName(databaseName);
116
+ return join(getBackupsRoot(), databaseName);
117
+ }
118
+
119
+ /**
120
+ * Resolve the single root store for a database. A database can span multiple root stores when a
121
+ * table has a per-table `path` config; backing up such a database is not supported and errors
122
+ * descriptively. Engine gating (RocksDB vs LMDB) is done inline at each call site.
123
+ */
124
+ export function resolveSingleRootStore(databaseName: string): any {
125
+ const database = getDatabases()[databaseName];
126
+ if (!database) {
127
+ throw new BackupNotFoundError(`Database '${databaseName}' does not exist`);
128
+ }
129
+ const rootStores = new Set<any>();
130
+ for (const tableName in database) {
131
+ const rootStore = database[tableName]?.primaryStore?.rootStore;
132
+ if (rootStore) rootStores.add(rootStore);
133
+ }
134
+ if (rootStores.size > 1) {
135
+ throw new ClientError(
136
+ `Database '${databaseName}' spans multiple root stores (tables with a per-table 'path' config); backup operations only support single-root databases`
137
+ );
138
+ }
139
+ if (rootStores.size === 0) {
140
+ throw new ClientError(`Database '${databaseName}' has no tables to back up`);
141
+ }
142
+ return rootStores.values().next().value;
143
+ }
144
+
145
+ function requireRocksRootStore(databaseName: string, operation: string): RocksDatabase {
146
+ const rootStore = resolveSingleRootStore(databaseName);
147
+ if (!(rootStore instanceof RocksDatabase)) {
148
+ throw new ClientError(
149
+ `Operation '${operation}' requires a RocksDB database; '${databaseName}' uses the LMDB storage engine (use 'get_backup' to download an LMDB backup)`
150
+ );
151
+ }
152
+ return rootStore as RocksDatabase;
153
+ }
154
+
155
+ function requireBackupId(backupId: any): number {
156
+ if (!Number.isSafeInteger(backupId) || backupId <= 0) {
157
+ throw new ClientError(`'backup_id' must be a positive integer`);
158
+ }
159
+ return backupId;
160
+ }
161
+
162
+ function requireBooleanOption(value: any, name: string): boolean {
163
+ if (value !== undefined && typeof value !== 'boolean') {
164
+ throw new ClientError(`'${name}' must be a boolean`);
165
+ }
166
+ return value === true;
167
+ }
168
+
169
+ /**
170
+ * The binding serializes backup-directory writers with an on-disk `.backup.lock`; a concurrent
171
+ * writer rejects with a "locked" error. Map it to a descriptive 409 — fail fast, no queueing.
172
+ */
173
+ function mapLockedError(error: any, databaseName: string): any {
174
+ if (typeof error?.message === 'string' && error.message.includes('is locked')) {
175
+ return new BackupInProgressError(`Backup operation already in progress for database '${databaseName}'`);
176
+ }
177
+ return error;
178
+ }
179
+
180
+ // --- directory helpers (operate on a backup directory only; no open database, usable offline) ---
181
+
182
+ export async function listBackupsInDir(backupDir: string): Promise<BackupInfo[]> {
183
+ // the backup dir doesn't exist until the first create_backup
184
+ if (!existsSync(backupDir)) return [];
185
+ return backups.list(backupDir);
186
+ }
187
+
188
+ async function findBackup(backupDir: string, backupId: number, databaseName: string): Promise<BackupInfo> {
189
+ requireBackupId(backupId); // every id-taking path flows through here, including the offline CLI
190
+ const list = await listBackupsInDir(backupDir);
191
+ const info = list.find((backup) => backup.backupId === backupId);
192
+ if (!info) {
193
+ throw new BackupNotFoundError(`Backup ${backupId} not found for database '${databaseName}'`);
194
+ }
195
+ return info;
196
+ }
197
+
198
+ /**
199
+ * Shape a rocksdb-js BackupInfo into the snake_case response the Operations API exposes — the
200
+ * binding's fields are camelCase, and `appMetadata` is internal, so it is not passed through.
201
+ * `blobs` reflects the completion manifest's recorded blob-inclusion policy.
202
+ */
203
+ function toBackupResponse(
204
+ info: BackupInfo,
205
+ blobs: boolean
206
+ ): {
207
+ backup_id: number;
208
+ timestamp: number;
209
+ size: number;
210
+ file_count: number;
211
+ blobs: boolean;
212
+ } {
213
+ return {
214
+ backup_id: info.backupId,
215
+ timestamp: info.timestamp,
216
+ size: info.size,
217
+ file_count: info.numberFiles,
218
+ blobs,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * The engine backups in a directory that have a completion manifest — i.e. whose creation finished
224
+ * successfully. An engine backup without a manifest is incomplete (still being written, or a failed
225
+ * create) and is never listed or restored, so a blob snapshot that is mid-copy or absent-after-
226
+ * failure can't be mistaken for a healthy or intentionally-engine-only backup.
227
+ */
228
+ async function listCompleteBackups(backupDir: string): Promise<Array<BackupInfo & { blobs: boolean }>> {
229
+ const [engineBackups, manifests] = await Promise.all([listBackupsInDir(backupDir), readAllManifests(backupDir)]);
230
+ const complete: Array<BackupInfo & { blobs: boolean }> = [];
231
+ for (const info of engineBackups) {
232
+ const manifest = manifests.get(info.backupId);
233
+ if (manifest) complete.push({ ...info, blobs: manifest.blobs });
234
+ }
235
+ return complete;
236
+ }
237
+
238
+ /**
239
+ * Load a specific backup's completion manifest, rejecting it as incomplete (409) when the engine
240
+ * backup exists but has no manifest — its creation did not finish, or is still in progress.
241
+ */
242
+ async function requireBackupComplete(
243
+ backupDir: string,
244
+ backupId: number,
245
+ databaseName: string
246
+ ): Promise<BackupManifest> {
247
+ const manifest = await readBackupManifest(backupDir, backupId);
248
+ if (!manifest) {
249
+ throw new BackupInProgressError(
250
+ `Backup ${backupId} of database '${databaseName}' is incomplete (its creation did not finish or is still in progress); it cannot be restored or verified`
251
+ );
252
+ }
253
+ return manifest;
254
+ }
255
+
256
+ /**
257
+ * Resolve which backup id a restore/verify should act on and load its completion manifest. A
258
+ * specific id that exists in the engine but has no manifest is rejected as incomplete; without a
259
+ * requested id, the latest *complete* backup is chosen.
260
+ */
261
+ async function resolveCompleteBackup(
262
+ backupDir: string,
263
+ requestedId: number | undefined,
264
+ databaseName: string
265
+ ): Promise<{ backupId: number; manifest: BackupManifest }> {
266
+ if (requestedId !== undefined) {
267
+ await findBackup(backupDir, requestedId, databaseName); // validates id + engine presence
268
+ return { backupId: requestedId, manifest: await requireBackupComplete(backupDir, requestedId, databaseName) };
269
+ }
270
+ const complete = await listCompleteBackups(backupDir);
271
+ if (complete.length === 0) {
272
+ throw new BackupNotFoundError(`No complete backups found for database '${databaseName}'`);
273
+ }
274
+ const latest = complete.reduce((a, b) => (b.backupId > a.backupId ? b : a));
275
+ return { backupId: latest.backupId, manifest: { backupId: latest.backupId, blobs: latest.blobs, completedAt: 0 } };
276
+ }
277
+
278
+ /**
279
+ * Publish a backup's completion manifest after the engine backup and (when included) blob snapshot
280
+ * are durable. On failure, best-effort roll back the just-created engine backup, its partial blob
281
+ * snapshot, and any manifest so an incomplete backup never lingers as usable.
282
+ */
283
+ async function finalizeBackup(
284
+ backupDir: string,
285
+ backupId: number,
286
+ databaseName: string,
287
+ blobs: boolean
288
+ ): Promise<void> {
289
+ try {
290
+ if (blobs) await snapshotBlobs(backupDir, backupId, getBlobPathsForDatabaseName(databaseName));
291
+ await writeBackupManifest(backupDir, backupId, blobs);
292
+ } catch (error) {
293
+ await deleteBackupManifest(backupDir, backupId).catch(() => {});
294
+ await deleteBlobSnapshot(backupDir, backupId).catch(() => {});
295
+ await backups.delete(backupDir, backupId).catch(() => {});
296
+ throw error;
297
+ }
298
+ }
299
+
300
+ // --- synchronous operations ---
301
+
302
+ export async function listBackups(request: any) {
303
+ requireSuperUser(request, OPERATIONS_ENUM.LIST_BACKUPS);
304
+ const databaseName = getDatabaseName(request);
305
+ logger.info(`Listing backups for database '${databaseName}'`);
306
+ requireRocksRootStore(databaseName, OPERATIONS_ENUM.LIST_BACKUPS);
307
+ return (await listCompleteBackups(backupDirForDatabase(databaseName))).map((backup) =>
308
+ toBackupResponse(backup, backup.blobs)
309
+ );
310
+ }
311
+
312
+ export async function deleteBackup(request: any) {
313
+ requireSuperUser(request, OPERATIONS_ENUM.DELETE_BACKUP);
314
+ const databaseName = getDatabaseName(request);
315
+ requireRocksRootStore(databaseName, OPERATIONS_ENUM.DELETE_BACKUP);
316
+ const backupId = requireBackupId(request.backup_id);
317
+ const backupDir = backupDirForDatabase(databaseName);
318
+ await findBackup(backupDir, backupId, databaseName);
319
+ try {
320
+ await backups.delete(backupDir, backupId);
321
+ } catch (error) {
322
+ throw mapLockedError(error, databaseName);
323
+ }
324
+ // the engine's delete leaves the (Harper-managed) blob snapshot + manifest behind — remove them too
325
+ await deleteBlobSnapshot(backupDir, backupId);
326
+ await deleteBackupManifest(backupDir, backupId);
327
+ return { ok: true };
328
+ }
329
+
330
+ export async function purgeBackups(request: any) {
331
+ requireSuperUser(request, OPERATIONS_ENUM.PURGE_BACKUPS);
332
+ const databaseName = getDatabaseName(request);
333
+ requireRocksRootStore(databaseName, OPERATIONS_ENUM.PURGE_BACKUPS);
334
+ const keepCount = request.keep_count;
335
+ if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
336
+ throw new ClientError(`'keep_count' must be a non-negative integer`);
337
+ }
338
+ const backupDir = backupDirForDatabase(databaseName);
339
+ const before = await listBackupsInDir(backupDir);
340
+ if (before.length === 0) {
341
+ throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
342
+ }
343
+ try {
344
+ await backups.purge(backupDir, keepCount);
345
+ } catch (error) {
346
+ throw mapLockedError(error, databaseName);
347
+ }
348
+ const remainingBackups = await listBackupsInDir(backupDir);
349
+ // drop blob snapshots + manifests for every id the engine purged (keep only the survivors')
350
+ const keepIds = new Set(remainingBackups.map((backup) => backup.backupId));
351
+ await purgeBlobSnapshots(backupDir, keepIds);
352
+ await purgeBackupManifests(backupDir, keepIds);
353
+ // clamp: a concurrent create between the two lists can otherwise make this negative
354
+ return { deleted: Math.max(0, before.length - remainingBackups.length), remaining: remainingBackups.length };
355
+ }
356
+
357
+ // --- job operations: create_backup / verify_backup / restore_backup ---
358
+ // Each has a synchronous-validation function (run by jobs.addJob before the job record is
359
+ // created) and the job function itself (run in the job worker thread).
360
+
361
+ export async function validateCreateBackup(request: any) {
362
+ requireSuperUser(request, OPERATIONS_ENUM.CREATE_BACKUP);
363
+ const databaseName = getDatabaseName(request);
364
+ requireBooleanOption(request.exclude_blobs, 'exclude_blobs');
365
+ requireRocksRootStore(databaseName, OPERATIONS_ENUM.CREATE_BACKUP);
366
+ }
367
+
368
+ export async function createBackup(request: any) {
369
+ const databaseName = getDatabaseName(request);
370
+ // blobs are captured by default; exclude_blobs=true produces an engine-only backup
371
+ const excludeBlobs = requireBooleanOption(request.exclude_blobs, 'exclude_blobs');
372
+ const rootStore = requireRocksRootStore(databaseName, OPERATIONS_ENUM.CREATE_BACKUP);
373
+ const backupDir = backupDirForDatabase(databaseName);
374
+ let backupId;
375
+ try {
376
+ backupId = await rootStore.backup(backupDir, { transactionLogs: true });
377
+ } catch (error) {
378
+ throw mapLockedError(error, databaseName);
379
+ }
380
+ // snapshot blobs (unless excluded) then publish the completion manifest; rolls back on failure
381
+ await finalizeBackup(backupDir, backupId, databaseName, !excludeBlobs);
382
+ await writeBackupReadme(backupDir, databaseName);
383
+ return {
384
+ database: databaseName,
385
+ backup_id: backupId,
386
+ blobs: !excludeBlobs,
387
+ ...(await describeBackup(backupDir, backupId)),
388
+ };
389
+ }
390
+
391
+ /**
392
+ * db.backup() returns only the id; size/timestamp come from a list() match. A missing match
393
+ * (e.g. a concurrent delete/purge between the two calls) is logged rather than silently
394
+ * reported as undefined fields.
395
+ */
396
+ async function describeBackup(backupDir: string, backupId: number): Promise<{ size?: number; timestamp?: any }> {
397
+ const info = (await listBackupsInDir(backupDir)).find((backup) => backup.backupId === backupId);
398
+ if (!info) {
399
+ logger.warn(`Backup ${backupId} was created but is no longer listed in ${backupDir} (deleted concurrently?)`);
400
+ return {};
401
+ }
402
+ return { size: info.size, timestamp: info.timestamp };
403
+ }
404
+
405
+ /**
406
+ * Write a `README.md` into a database's backup directory with the commands to list, verify, and
407
+ * restore the backup, so the repository is recoverable without reading the source. Best-effort:
408
+ * a failure to write the doc must not fail an otherwise-successful backup. Overwritten on each
409
+ * create so it stays current.
410
+ */
411
+ async function writeBackupReadme(backupDir: string, databaseName: string): Promise<void> {
412
+ const content = `# Harper backup — database "${databaseName}"
413
+
414
+ This directory is a Harper-managed backup repository for the "${databaseName}" database: one or more
415
+ RocksDB backups (engine data + transaction logs) and, unless created with \`exclude_blobs\`, a
416
+ \`blobs/\` snapshot of the database's file-backed blobs (see blobs/README.md). It lives under
417
+ \`storage.backupPath\` (default \`<rootPath>/backup\`), one directory per database. Do not edit these
418
+ files by hand.
419
+
420
+ ## List / verify
421
+
422
+ harper list_backups database=${databaseName}
423
+ harper verify_backup database=${databaseName} backup_id=<id>
424
+
425
+ ## Restore
426
+
427
+ Restore is destructive: it purges and rewrites the database directory — and every blob root — from
428
+ the backup (blobs are restored automatically). Restore the latest backup in place:
429
+
430
+ harper restore_backup database=${databaseName}
431
+
432
+ ...or a specific id:
433
+
434
+ harper restore_backup database=${databaseName} backup_id=<id>
435
+
436
+ A database held open by a loaded component — and always the \`system\` database — cannot be restored
437
+ while Harper is running; stop the server and run the same command offline. Offline you can also
438
+ restore into a *copy*, leaving the original untouched:
439
+
440
+ harper restore_backup database=${databaseName} target_database=${databaseName}-restore
441
+
442
+ ## Prune
443
+
444
+ harper delete_backup database=${databaseName} backup_id=<id>
445
+ harper purge_backups database=${databaseName} keep_count=<n>
446
+
447
+ Both remove the RocksDB backup and its blob snapshot.
448
+ `;
449
+ try {
450
+ await writeFile(join(backupDir, 'README.md'), content);
451
+ } catch (error) {
452
+ logger.warn(`Failed to write backup README in ${backupDir}: ${(error as Error).message}`);
453
+ }
454
+ }
455
+
456
+ export async function validateVerifyBackup(request: any) {
457
+ requireSuperUser(request, OPERATIONS_ENUM.VERIFY_BACKUP);
458
+ const databaseName = getDatabaseName(request);
459
+ requireRocksRootStore(databaseName, OPERATIONS_ENUM.VERIFY_BACKUP);
460
+ requireBooleanOption(request.verify_checksum, 'verify_checksum');
461
+ const backupDir = backupDirForDatabase(databaseName);
462
+ const backupId = requireBackupId(request.backup_id);
463
+ await findBackup(backupDir, backupId, databaseName);
464
+ await requireBackupComplete(backupDir, backupId, databaseName);
465
+ }
466
+
467
+ export async function verifyBackup(request: any) {
468
+ const databaseName = getDatabaseName(request);
469
+ requireRocksRootStore(databaseName, 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 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 && !existsSync(blobSnapshotDir(backupDir, backupId))) {
479
+ throw new ClientError(
480
+ `Backup ${backupId} of database '${databaseName}' declares captured blobs but its blob snapshot is missing (corrupt backup)`
481
+ );
482
+ }
483
+ return { database: databaseName, backup_id: backupId, ok: true, blobs: manifest.blobs };
484
+ }
485
+
486
+ export async function validateRestoreBackup(request: any) {
487
+ requireSuperUser(request, OPERATIONS_ENUM.RESTORE_BACKUP);
488
+ const databaseName = getDatabaseName(request);
489
+ if (databaseName === 'system') {
490
+ throw new ClientError(
491
+ `The 'system' database cannot be restored while Harper is running; stop the server and run: harper restore_backup database=system`
492
+ );
493
+ }
494
+ if (request.target_database !== undefined) {
495
+ // silently ignoring this would destructively restore over the source database instead of
496
+ // the copy the caller asked for
497
+ throw new ClientError(
498
+ `'target_database' is not supported while Harper is running (restore_backup always restores in place); stop the server and run: harper restore_backup database=${databaseName} target_database=<name>`
499
+ );
500
+ }
501
+ const backupDir = backupDirForDatabase(databaseName);
502
+ if (request.backup_id !== undefined) {
503
+ const backupId = requireBackupId(request.backup_id);
504
+ await findBackup(backupDir, backupId, databaseName);
505
+ await requireBackupComplete(backupDir, backupId, databaseName);
506
+ } else if ((await listCompleteBackups(backupDir)).length === 0) {
507
+ throw new BackupNotFoundError(`No complete backups found for database '${databaseName}'`);
508
+ }
509
+ // Only a loaded database that actually has tables can be validated as a single-root RocksDB
510
+ // store here (an empty/tableless database has no table to resolve a root store from, and an
511
+ // unloaded one recovering an interrupted restore isn't open yet); those cases are validated when
512
+ // the restore job runs. `Object.keys` skips the DEFINED_TABLES symbol, so an empty database is 0.
513
+ const loaded = getDatabases()[databaseName];
514
+ if (loaded != null && Object.keys(loaded).length > 0) {
515
+ requireRocksRootStore(databaseName, OPERATIONS_ENUM.RESTORE_BACKUP);
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Online restore of a user database (see the design's restore lock + marker protocol):
521
+ * take the per-database restore lock, write the restoring marker, close the database across all
522
+ * worker threads, restore, delete the marker, release the lock, and reload everywhere.
523
+ */
524
+ export async function restoreBackup(request: any) {
525
+ const databaseName = getDatabaseName(request);
526
+ if (databaseName === 'system') {
527
+ throw new ClientError(
528
+ `The 'system' database cannot be restored while Harper is running; stop the server and run: harper restore_backup database=system`
529
+ );
530
+ }
531
+ if (request.target_database !== undefined) {
532
+ throw new ClientError(
533
+ `'target_database' is not supported while Harper is running (restore_backup always restores in place); stop the server and run: harper restore_backup database=${databaseName} target_database=<name>`
534
+ );
535
+ }
536
+ const backupDir = backupDirForDatabase(databaseName);
537
+ // choose the latest complete backup (or the requested id, rejected if incomplete); the manifest
538
+ // tells us whether blobs were captured so we restore them only when they were
539
+ const { backupId, manifest } = await resolveCompleteBackup(
540
+ backupDir,
541
+ request.backup_id === undefined ? undefined : requireBackupId(request.backup_id),
542
+ databaseName
543
+ );
544
+ // a loaded database *with tables* knows its real directory via its root store (which can differ
545
+ // from the computed default, e.g. legacy layouts); fall back to the computed path when the
546
+ // database is unloaded (recovering an interrupted restore) or empty (no table to resolve a root
547
+ // store from — Object.keys skips the DEFINED_TABLES symbol)
548
+ const loaded = getDatabases()[databaseName];
549
+ const databaseDir =
550
+ loaded != null && Object.keys(loaded).length > 0
551
+ ? requireRocksRootStore(databaseName, OPERATIONS_ENUM.RESTORE_BACKUP).path
552
+ : resolveDatabasePath(databaseName);
553
+ // reject a backup with more blob roots than the current config *before* anything destructive —
554
+ // restoring it would mis-address blobs (records persist their root index)
555
+ await assertBlobSnapshotRestorable(backupDir, backupId, getBlobPathsForDatabaseName(databaseName));
556
+ const lock = beginRestoreForDatabase(databaseDir, databaseName);
557
+ let destructionStarted = false;
558
+ try {
559
+ // close the database across all worker threads (each thread also rescans, and the
560
+ // restoring marker keeps the scan from reloading it mid-restore)
561
+ await signalling.signalSchemaChange(new SchemaEventMsg(process.pid, OPERATIONS_ENUM.RESTORE_BACKUP, databaseName));
562
+ // A live component (or the system database) can hold its own handle on the database that
563
+ // Harper does not track and cannot close, so verify actual process-wide closure before
564
+ // purging — restoring under an open instance would corrupt it. If handles remain, fail
565
+ // with a clear pointer to the offline CLI path rather than purging.
566
+ await verifyDatabaseClosed(databaseDir, databaseName);
567
+ destructionStarted = true;
568
+ await backups.restore(backupDir, databaseDir, { backupId, mode: 'purgeAllFiles' });
569
+ // restore blobs only for a backup that captured them (an engine-only backup leaves the live
570
+ // blob roots untouched); the manifest, not the mere presence of a snapshot dir, is the source
571
+ // of truth so a mid-copy or absent snapshot can't be misread
572
+ if (manifest.blobs) {
573
+ await restoreBlobSnapshot(backupDir, backupId, databaseName, getBlobPathsForDatabaseName(databaseName));
574
+ }
575
+ } catch (error: any) {
576
+ // Leave the marker (so startup/rescan detection reports an incomplete restore until a rerun
577
+ // succeeds) when either the destructive purge has begun, OR this attempt was itself a recovery
578
+ // over a pre-existing marker: in that case the directory may already be half-purged from an
579
+ // earlier failed restore, so clearing the marker and reloading it as healthy would surface
580
+ // partial/corrupt data. Only a *fresh* marker on a *previously healthy* database that failed
581
+ // before any destruction is safe to clear.
582
+ if (destructionStarted || lock.preexisting) {
583
+ abandonRestore(lock);
584
+ // wrap rather than mutate error.message: a frozen/library error can have a non-writable
585
+ // message (assigning it throws TypeError under 'use strict')
586
+ throw new Error(
587
+ `Restore of database '${databaseName}' from backup ${backupId} failed (rerun restore_backup to recover): ${error.message}`,
588
+ { cause: error }
589
+ );
590
+ }
591
+ // nothing destructive happened and the marker was fresh — clear it and let every thread reload
592
+ // the intact database
593
+ completeRestore(lock);
594
+ await signalling.signalSchemaChange(new SchemaEventMsg(process.pid, OPERATIONS_ENUM.RESTORE_BACKUP, databaseName));
595
+ throw error;
596
+ }
597
+ completeRestore(lock);
598
+ // signal again: with the marker gone, every thread's rescan reloads the restored database
599
+ await signalling.signalSchemaChange(new SchemaEventMsg(process.pid, OPERATIONS_ENUM.RESTORE_BACKUP, databaseName));
600
+ return { database: databaseName, backup_id: backupId };
601
+ }
602
+
603
+ // After the close broadcast is acknowledged, every worker thread has released its Harper-managed
604
+ // handles; a short grace period covers a just-finished job worker still draining its own close.
605
+ // Anything still open past that is a handle Harper neither tracks nor controls (a loaded component
606
+ // holding its own instance, or the system database), which will never close on its own — so fail
607
+ // fast rather than waiting out a long timeout.
608
+ const DATABASE_CLOSE_WAIT_MS = 3000;
609
+ const DATABASE_CLOSE_POLL_INTERVAL_MS = 250;
610
+
611
+ /**
612
+ * Verify no thread in this process still has the database open (rocksdb-js's registry is
613
+ * process-global across worker threads), polling briefly to let a just-finished job worker's own
614
+ * close drain. Throws 409 with an actionable message if handles remain — which means a loaded
615
+ * component is holding the database open (Harper can neither detect which component nor force its
616
+ * handle closed), so an online in-place restore is not possible and the offline CLI is the path.
617
+ */
618
+ async function verifyDatabaseClosed(databaseDir: string, databaseName: string): Promise<void> {
619
+ const targetPath = resolve(databaseDir);
620
+ const deadline = Date.now() + DATABASE_CLOSE_WAIT_MS;
621
+ for (;;) {
622
+ const stillOpen = registryStatus().some(
623
+ (instance) => resolve(instance.path) === targetPath && instance.refCount > 0
624
+ );
625
+ if (!stillOpen) return;
626
+ if (Date.now() >= deadline) {
627
+ throw new BackupInProgressError(
628
+ `Cannot restore database '${databaseName}' while Harper is running: it is held open by a loaded component (or is the system database). ` +
629
+ `Restore it offline instead — stop the server and run: harper restore_backup database=${databaseName}` +
630
+ (databaseName === 'system' ? '' : ` backup_id=<id>`)
631
+ );
632
+ }
633
+ await delay(DATABASE_CLOSE_POLL_INTERVAL_MS);
634
+ }
635
+ }
636
+
637
+ /**
638
+ * beginRestore's own error message carries the filesystem path (useful in CLI/server logs);
639
+ * client-facing operations report by database name instead.
640
+ */
641
+ function beginRestoreForDatabase(databaseDir: string, databaseName: string): RestoreLock {
642
+ try {
643
+ return beginRestore(databaseDir);
644
+ } catch (error) {
645
+ if (error.statusCode === 409) {
646
+ throw new BackupInProgressError(`Restore already in progress for database '${databaseName}'`);
647
+ }
648
+ throw error;
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Recognize RocksDB's own on-disk `LOCK`-file contention error. The pinned rocksdb-js 2.5.0 binding
654
+ * surfaces it as a plain `Error` with no `code` and a message like
655
+ * `IO error: While lock file: <db>/LOCK: Resource temporarily unavailable`, so string-matching is
656
+ * the only signal available (there is no typed error to key on — a native primitive is a rocksdb-js
657
+ * follow-on). We match conservatively and fail *closed* on a hit so the offline restore never purges
658
+ * a database another process still has open.
659
+ */
660
+ function isRocksDbLockError(error: any): boolean {
661
+ const message = typeof error?.message === 'string' ? error.message : '';
662
+ return (
663
+ /lock file:/i.test(message) ||
664
+ message.includes('LOCK:') ||
665
+ message.includes('Resource temporarily unavailable') ||
666
+ message.includes('is locked')
667
+ );
668
+ }
669
+
670
+ // --- get_backup (RocksDB path): stream a fresh full-snapshot tar in the HTTP response ---
671
+
672
+ /**
673
+ * Returns a Readable (with `.headers`) streaming a full-snapshot tar (optionally gzipped) of the
674
+ * database's current state. No scratch disk; a consumer error aborts the native backup cleanly.
675
+ * `noCompression` opts out of serverHandlers' accept-encoding auto-gzip — this response must never
676
+ * be compressed by the server.
677
+ *
678
+ * With blobs included (the default; `excludeBlobs` opts out), the database's file-backed blob roots
679
+ * are appended to the same archive under `blobs/<rootIndex>/<relpath>` so a downloaded backup is a
680
+ * complete Harper database. Blob capture is best-effort point-in-time (the archive contains whatever
681
+ * files exist while it streams); a blob deleted mid-stream is skipped.
682
+ */
683
+ export function createBackupStream(
684
+ rootStore: RocksDatabase,
685
+ databaseName: string,
686
+ gzip: boolean,
687
+ excludeBlobs = false
688
+ ): PassThrough {
689
+ const stream: any = new PassThrough();
690
+ // database names may legally contain `"` and `\` (schemaRegex) — sanitize so the quoted
691
+ // content-disposition filename stays parseable
692
+ const filename = `${databaseName.replace(/["\\]/g, '_')}.tar${gzip ? '.gz' : ''}`;
693
+ stream.headers = new Map([
694
+ ['content-type', gzip ? 'application/gzip' : 'application/x-tar'],
695
+ ['content-disposition', `attachment; filename="${filename}"`],
696
+ ]);
697
+ stream.noCompression = true;
698
+ if (excludeBlobs) {
699
+ // engine-only: the binding produces (and gzips) the whole archive directly
700
+ rootStore
701
+ .backup(Writable.toWeb(stream) as any, { gzip, transactionLogs: true })
702
+ .catch((error) => stream.destroy(error));
703
+ return stream;
704
+ }
705
+ streamBackupWithBlobs(rootStore, databaseName, gzip, stream).catch((error) => {
706
+ // the consumer aborting (destroying the response) is the common case, not an error to re-raise
707
+ if (!stream.destroyed) stream.destroy(error);
708
+ });
709
+ return stream;
710
+ }
711
+
712
+ // The native streaming backup finalizes its tar with exactly two zero-filled 512-byte blocks (the
713
+ // USTAR end-of-archive marker). To append blob entries into the same archive we drop that trailer
714
+ // from the native tar and let tar-stream write the single real end-of-archive marker after the blob
715
+ // entries.
716
+ const TAR_TRAILER_BYTES = 1024;
717
+
718
+ /**
719
+ * Stream a full-snapshot tar of the database followed by its blob roots as one archive. The native
720
+ * (plain) tar is streamed with its end-of-archive trailer stripped, blob files are appended as
721
+ * `blobs/<rootIndex>/<relpath>` entries via tar-stream, and the combined plain tar is gzipped here
722
+ * when requested (the binding is asked for a plain tar so we can append before compressing).
723
+ */
724
+ async function streamBackupWithBlobs(
725
+ rootStore: RocksDatabase,
726
+ databaseName: string,
727
+ gzip: boolean,
728
+ out: PassThrough
729
+ ): Promise<void> {
730
+ const plain = new PassThrough(); // the combined, uncompressed tar
731
+ const nativeTar = new PassThrough(); // native (plain) tar, before its trailer is stripped
732
+ // consumer side: gzip the combined archive (or pass it through) into the response stream
733
+ const consumed = gzip ? pipeline(plain, createGzip(), out) : pipeline(plain, out);
734
+ // producer side: native plain tar → nativeTar, copied into `plain` minus its trailer
735
+ const nativeDone = rootStore.backup(Writable.toWeb(nativeTar) as any, { gzip: false, transactionLogs: true });
736
+ // A consumer that aborts (destroys `out`) rejects `consumed` before we reach the `await` below, so
737
+ // attach silent observers now to close the unhandled-rejection window; the awaits/allSettled still
738
+ // see the rejection and drive the real teardown.
739
+ consumed.catch(() => {});
740
+ nativeDone.catch(() => {});
741
+ try {
742
+ await copyDroppingTarTrailer(nativeTar, plain);
743
+ await nativeDone; // surface any native backup error before we append blobs
744
+
745
+ const pack = tarPack();
746
+ const packed = pipeline(pack, plain); // ends `plain` once the blob entries + trailer are written
747
+ const blobRoots = getBlobPathsForDatabaseName(databaseName);
748
+ await appendBlobEntries(pack, blobRoots);
749
+ // generate the same self-documenting READMEs a managed backup writes to disk, on the fly
750
+ await addTextEntry(pack, 'README.md', streamedBackupReadme(databaseName));
751
+ await addTextEntry(pack, 'blobs/README.md', blobsReadmeContent(blobRoots, { archive: true }));
752
+ pack.finalize();
753
+ await packed;
754
+ await consumed;
755
+ } catch (error) {
756
+ // Tear down both pipelines and observe every side promise so a consumer abort (or any mid-
757
+ // stream failure) can never leave an unhandled rejection from `consumed`/`nativeDone`.
758
+ if (!nativeTar.destroyed) nativeTar.destroy();
759
+ if (!plain.destroyed) plain.destroy(error as Error);
760
+ await Promise.allSettled([consumed, nativeDone]);
761
+ throw error;
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Copy `src` into `dest` (without ending `dest`) while withholding the final {@link TAR_TRAILER_BYTES}
767
+ * bytes — the native tar's end-of-archive marker — so more entries can be appended. Verifies the
768
+ * withheld bytes are the expected all-zero trailer so a format change in the binding fails loudly
769
+ * rather than producing a silently-corrupt archive.
770
+ */
771
+ async function copyDroppingTarTrailer(src: PassThrough, dest: PassThrough): Promise<void> {
772
+ let tail: Buffer = Buffer.alloc(0);
773
+ for await (const chunk of src) {
774
+ tail = tail.length === 0 ? (chunk as Buffer) : Buffer.concat([tail, chunk as Buffer]);
775
+ if (tail.length > TAR_TRAILER_BYTES) {
776
+ const emit = tail.subarray(0, tail.length - TAR_TRAILER_BYTES);
777
+ tail = Buffer.from(tail.subarray(tail.length - TAR_TRAILER_BYTES));
778
+ await writeWithBackpressure(dest, emit as Buffer);
779
+ }
780
+ }
781
+ if (tail.length !== TAR_TRAILER_BYTES || tail.some((byte) => byte !== 0)) {
782
+ throw new Error(
783
+ `Unexpected trailer from native backup stream (expected ${TAR_TRAILER_BYTES} zero bytes, got ${tail.length}); cannot append blobs`
784
+ );
785
+ }
786
+ }
787
+
788
+ /** Write to a stream, awaiting `drain` on backpressure and rejecting (rather than hanging) on error. */
789
+ function writeWithBackpressure(dest: PassThrough, chunk: Buffer): Promise<void> {
790
+ return new Promise((resolvePromise, reject) => {
791
+ if (dest.write(chunk)) return resolvePromise();
792
+ const cleanup = () => {
793
+ dest.off('drain', onDrain);
794
+ dest.off('error', onError);
795
+ };
796
+ const onDrain = () => {
797
+ cleanup();
798
+ resolvePromise();
799
+ };
800
+ const onError = (error: Error) => {
801
+ cleanup();
802
+ reject(error);
803
+ };
804
+ dest.once('drain', onDrain);
805
+ dest.once('error', onError);
806
+ });
807
+ }
808
+
809
+ /**
810
+ * Append every blob file under the given blob roots to `pack` as `blobs/<rootIndex>/<relpath>`
811
+ * entries. Size is captured at open time and exactly that many bytes are streamed; a file that
812
+ * vanished before it could be opened (a concurrent blob delete) is skipped.
813
+ */
814
+ async function appendBlobEntries(pack: Pack, blobRoots: string[]): Promise<void> {
815
+ for (let index = 0; index < blobRoots.length; index++) {
816
+ const root = blobRoots[index];
817
+ if (!existsSync(root)) continue;
818
+ const stack: string[] = [root];
819
+ while (stack.length > 0) {
820
+ const dir = stack.pop() as string;
821
+ let entries;
822
+ try {
823
+ entries = await readdir(dir, { withFileTypes: true });
824
+ } catch (error: any) {
825
+ if (error.code === 'ENOENT') continue;
826
+ throw error;
827
+ }
828
+ for (const entry of entries) {
829
+ const filePath = join(dir, entry.name);
830
+ if (entry.isDirectory()) {
831
+ stack.push(filePath);
832
+ } else if (entry.isFile()) {
833
+ // tar entry names are always POSIX-separated; relative() yields `\` on Windows, which
834
+ // would otherwise become literal filename characters when extracted on POSIX
835
+ const relativePath = relative(root, filePath).split(sep).join('/');
836
+ await appendBlobFile(pack, filePath, `blobs/${index}/${relativePath}`);
837
+ }
838
+ }
839
+ }
840
+ }
841
+ }
842
+
843
+ /** Add a single file to the pack, streaming exactly the byte count captured at open time. */
844
+ async function appendBlobFile(pack: Pack, filePath: string, name: string): Promise<void> {
845
+ let handle;
846
+ try {
847
+ handle = await open(filePath, 'r');
848
+ } catch (error: any) {
849
+ if (error.code === 'ENOENT') return; // deleted mid-walk
850
+ throw error;
851
+ }
852
+ try {
853
+ const { size } = await handle.stat();
854
+ await new Promise<void>((resolvePromise, reject) => {
855
+ const entry = pack.entry({ name, size }, (error) => (error ? reject(error) : resolvePromise()));
856
+ if (size === 0) {
857
+ entry.end();
858
+ return;
859
+ }
860
+ // stream exactly the bytes present at open time (end is inclusive); pin the fd open
861
+ // (autoClose:false) since the finally below closes the handle
862
+ const source = createReadStream('', { fd: handle!.fd, autoClose: false, start: 0, end: size - 1 });
863
+ source.on('error', (error) => entry.destroy(error));
864
+ source.pipe(entry);
865
+ });
866
+ } finally {
867
+ await handle.close();
868
+ }
869
+ }
870
+
871
+ /** Add an in-memory string to the pack as a single tar entry (used for the generated READMEs). */
872
+ async function addTextEntry(pack: Pack, name: string, content: string): Promise<void> {
873
+ const buffer = Buffer.from(content, 'utf8');
874
+ await new Promise<void>((resolvePromise, reject) => {
875
+ const entry = pack.entry({ name, size: buffer.length }, (error) => (error ? reject(error) : resolvePromise()));
876
+ entry.end(buffer);
877
+ });
878
+ }
879
+
880
+ /**
881
+ * The top-level `README.md` embedded in a downloaded `get_backup` archive. Unlike a managed backup
882
+ * repository (which is restored in place via `restore_backup`), this is a raw snapshot tar restored
883
+ * by extracting its files back into the database directory and blob roots.
884
+ */
885
+ function streamedBackupReadme(databaseName: string): string {
886
+ return `# Harper backup archive — database "${databaseName}"
887
+
888
+ A full point-in-time snapshot of the "${databaseName}" database, produced by \`get_backup\`:
889
+ - the RocksDB data and manifest at the archive root (CURRENT, MANIFEST-*, *.sst, OPTIONS-*)
890
+ - transaction_logs/ — the transaction log snapshot
891
+ - blobs/ — the database's file-backed blobs, unless this archive was created with exclude_blobs
892
+ (see blobs/README.md for the layout and the root-index mapping)
893
+
894
+ ## Restoring
895
+
896
+ This is a raw snapshot archive, not a managed backup repository. To restore it, stop Harper and lay
897
+ the files back down in two places:
898
+ 1. The RocksDB files — everything except blobs/ — go into the database's directory
899
+ (typically <rootPath>/database/${databaseName}).
900
+ 2. Each blobs/<rootIndex>/ tree goes into the matching blob root — the index maps to
901
+ storage.blobPaths[n], or <rootPath>/blobs/${databaseName} when blobPaths is not configured
902
+ (see blobs/README.md). Then start Harper.
903
+
904
+ For a server-managed, in-place restore instead, use the managed backup workflow (create_backup /
905
+ restore_backup): https://docs.harperdb.io/reference/v5/operations-api/operations
906
+ `;
907
+ }
908
+
909
+ // --- offline CLI paths (server stopped) ---
910
+
911
+ /**
912
+ * Offline create: open the RocksDatabase directly, run an ordinary incremental directory backup
913
+ * into the configured backup root, and close. RocksDB is single-writer, so this collides on the
914
+ * database lock if the server is running — callers guard on the server being stopped.
915
+ */
916
+ export async function createBackupOffline(databaseName: string, excludeBlobs = false) {
917
+ validateDatabaseName(databaseName);
918
+ const databaseDir = resolveDatabasePath(databaseName);
919
+ if (!existsSync(join(databaseDir, 'CURRENT'))) {
920
+ throw new BackupNotFoundError(`No RocksDB database found at ${databaseDir}`);
921
+ }
922
+ const restoreState = checkRestoreState(databaseDir);
923
+ if (restoreState !== 'clear') {
924
+ throw new BackupInProgressError(
925
+ `Database '${databaseName}' has an ${restoreState === 'in-progress' ? 'active' : 'incomplete'} restore; rerun restore_backup before backing up`
926
+ );
927
+ }
928
+ const database = RocksDatabase.open(databaseDir);
929
+ try {
930
+ const backupDir = backupDirForDatabase(databaseName);
931
+ let backupId;
932
+ try {
933
+ backupId = await database.backup(backupDir, { transactionLogs: true });
934
+ } catch (error) {
935
+ throw mapLockedError(error, databaseName);
936
+ }
937
+ await finalizeBackup(backupDir, backupId, databaseName, !excludeBlobs);
938
+ await writeBackupReadme(backupDir, databaseName);
939
+ return {
940
+ database: databaseName,
941
+ backup_id: backupId,
942
+ blobs: !excludeBlobs,
943
+ ...(await describeBackup(backupDir, backupId)),
944
+ };
945
+ } finally {
946
+ database.close();
947
+ }
948
+ }
949
+
950
+ /**
951
+ * Offline restore (required for the `system` database; works for any database). Runs the same
952
+ * lock + marker protocol as the online operation so a crashed CLI restore is detected at next
953
+ * server start. `targetDatabase` restores into a different database directory (non-destructive
954
+ * for the source database); the server picks it up on next start via normal engine detection.
955
+ */
956
+ export async function restoreBackupOffline(databaseName: string, backupId?: number, targetDatabase?: string) {
957
+ validateDatabaseName(databaseName);
958
+ const backupDir = backupDirForDatabase(databaseName);
959
+ // resolve to the latest complete backup (or the requested id, rejected if incomplete)
960
+ const resolved = await resolveCompleteBackup(backupDir, backupId, databaseName);
961
+ backupId = resolved.backupId;
962
+ const manifest = resolved.manifest;
963
+ if (targetDatabase !== undefined) validateDatabaseName(targetDatabase);
964
+ const databaseDir = resolveDatabasePath(targetDatabase ?? databaseName);
965
+ if (targetDatabase !== undefined && targetDatabase !== databaseName && !isMissingOrEmptyDir(databaseDir)) {
966
+ // target_database is documented as non-destructive: never purge an existing database of
967
+ // that name out from under the operator
968
+ throw new ClientError(
969
+ `target_database '${targetDatabase}' already exists at ${databaseDir}; restoring into it would destroy it — choose a new name, or restore in place by omitting target_database`
970
+ );
971
+ }
972
+ // reject a backup with more blob roots than the target's current config before anything
973
+ // destructive (records persist their root index, so collapsing would mis-address blobs)
974
+ await assertBlobSnapshotRestorable(backupDir, backupId, getBlobPathsForDatabaseName(targetDatabase ?? databaseName));
975
+ // Take the restore lock + marker BEFORE probing so a server that starts after this point sees the
976
+ // marker and refuses to load the database (closing the window between the probe and the purge).
977
+ const lock = beginRestoreForDatabase(databaseDir, targetDatabase ?? databaseName);
978
+ let destructionStarted = false;
979
+ try {
980
+ // The offline path is entered only when the CLI sees no running server (getHdbPid), but that is
981
+ // a heuristic: the PID file is briefly absent mid-`harper restart`, and backups.restore's
982
+ // purgeAllFiles never takes RocksDB's own lock. Probe that lock by opening the database — a live
983
+ // holder makes open throw its LOCK-file error (isRocksDbLockError) — so we fail closed rather
984
+ // than purge a database another process still has open. A directory that fails to open for any
985
+ // *other* reason (corrupt or half-restored) is exactly what restore recovers, so only a lock
986
+ // conflict aborts.
987
+ if (existsSync(join(databaseDir, 'CURRENT'))) {
988
+ let handle: RocksDatabase | undefined;
989
+ try {
990
+ handle = RocksDatabase.open(databaseDir);
991
+ } catch (error: any) {
992
+ if (isRocksDbLockError(error)) {
993
+ throw new BackupInProgressError(
994
+ `Cannot restore database '${databaseName}': it is open by a running Harper process — stop Harper before restoring offline`
995
+ );
996
+ }
997
+ // otherwise corrupt/half-restored — fall through and let restore recover it
998
+ }
999
+ handle?.close();
1000
+ }
1001
+ destructionStarted = true;
1002
+ await backups.restore(backupDir, databaseDir, { backupId, mode: 'purgeAllFiles' });
1003
+ // restore blobs only for a backup that captured them (per the manifest, not snapshot presence)
1004
+ if (manifest.blobs) {
1005
+ await restoreBlobSnapshot(
1006
+ backupDir,
1007
+ backupId,
1008
+ databaseName,
1009
+ getBlobPathsForDatabaseName(targetDatabase ?? databaseName)
1010
+ );
1011
+ }
1012
+ } catch (error: any) {
1013
+ // Preserve the marker on a destructive failure or a recovery over a pre-existing marker (see
1014
+ // the online restoreBackup for the rationale); otherwise clear the fresh marker so an intact,
1015
+ // merely-locked database is not left flagged as an incomplete restore.
1016
+ if (destructionStarted || lock.preexisting) abandonRestore(lock);
1017
+ else completeRestore(lock);
1018
+ // preserve typed client errors (e.g. the 409 lock probe) unwrapped; only wrap an opaque restore
1019
+ // failure after destruction has begun
1020
+ if (destructionStarted && !(error instanceof ClientError)) {
1021
+ throw new Error(
1022
+ `Restore of database '${databaseName}' from backup ${backupId} failed (rerun restore_backup to recover): ${error.message}`,
1023
+ { cause: error }
1024
+ );
1025
+ }
1026
+ throw error;
1027
+ }
1028
+ completeRestore(lock);
1029
+ return { database: databaseName, backup_id: backupId, restored_to: databaseDir };
1030
+ }
1031
+
1032
+ function isMissingOrEmptyDir(path: string): boolean {
1033
+ try {
1034
+ return readdirSync(path).length === 0;
1035
+ } catch (error) {
1036
+ if (error.code === 'ENOENT') return true;
1037
+ throw error;
1038
+ }
1039
+ }
1040
+
1041
+ // --- offline management wrappers (no engine validation: they operate on the directory only) ---
1042
+
1043
+ export async function listBackupsOffline(databaseName: string) {
1044
+ validateDatabaseName(databaseName);
1045
+ // map to the same snake_case response shape as the online list_backups operation
1046
+ return (await listCompleteBackups(backupDirForDatabase(databaseName))).map((backup) =>
1047
+ toBackupResponse(backup, backup.blobs)
1048
+ );
1049
+ }
1050
+
1051
+ export async function verifyBackupOffline(databaseName: string, backupId: number, verifyChecksum?: boolean) {
1052
+ validateDatabaseName(databaseName);
1053
+ const verifyWithChecksum = requireBooleanOption(verifyChecksum, 'verify_checksum');
1054
+ const backupDir = backupDirForDatabase(databaseName);
1055
+ await findBackup(backupDir, backupId, databaseName);
1056
+ const manifest = await requireBackupComplete(backupDir, backupId, databaseName);
1057
+ await backups.verify(backupDir, backupId, { verifyWithChecksum });
1058
+ if (manifest.blobs && !existsSync(blobSnapshotDir(backupDir, backupId))) {
1059
+ throw new ClientError(
1060
+ `Backup ${backupId} of database '${databaseName}' declares captured blobs but its blob snapshot is missing (corrupt backup)`
1061
+ );
1062
+ }
1063
+ return { database: databaseName, backup_id: backupId, ok: true, blobs: manifest.blobs };
1064
+ }
1065
+
1066
+ export async function deleteBackupOffline(databaseName: string, backupId: number) {
1067
+ validateDatabaseName(databaseName);
1068
+ const backupDir = backupDirForDatabase(databaseName);
1069
+ await findBackup(backupDir, backupId, databaseName);
1070
+ try {
1071
+ await backups.delete(backupDir, backupId);
1072
+ } catch (error) {
1073
+ throw mapLockedError(error, databaseName);
1074
+ }
1075
+ await deleteBlobSnapshot(backupDir, backupId);
1076
+ await deleteBackupManifest(backupDir, backupId);
1077
+ return { ok: true };
1078
+ }
1079
+
1080
+ export async function purgeBackupsOffline(databaseName: string, keepCount: number) {
1081
+ validateDatabaseName(databaseName);
1082
+ if (!Number.isSafeInteger(keepCount) || keepCount < 0) {
1083
+ throw new ClientError(`'keep_count' must be a non-negative integer`);
1084
+ }
1085
+ const backupDir = backupDirForDatabase(databaseName);
1086
+ const before = await listBackupsInDir(backupDir);
1087
+ if (before.length === 0) {
1088
+ throw new BackupNotFoundError(`No backups found for database '${databaseName}'`);
1089
+ }
1090
+ try {
1091
+ await backups.purge(backupDir, keepCount);
1092
+ } catch (error) {
1093
+ throw mapLockedError(error, databaseName);
1094
+ }
1095
+ const remainingBackups = await listBackupsInDir(backupDir);
1096
+ const keepIds = new Set(remainingBackups.map((backup) => backup.backupId));
1097
+ await purgeBlobSnapshots(backupDir, keepIds);
1098
+ await purgeBackupManifests(backupDir, keepIds);
1099
+ return { deleted: Math.max(0, before.length - remainingBackups.length), remaining: remainingBackups.length };
1100
+ }