@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,286 @@
1
+ 'use strict';
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { copyFile, link, mkdir, readdir, rename, rm, unlink, writeFile } from 'node:fs/promises';
5
+ import { dirname, join, relative } from 'node:path';
6
+ import { ClientError } from '../utility/errors/hdbError.ts';
7
+ import logger from '../utility/logging/harper_logger.ts';
8
+
9
+ /**
10
+ * Managed-backup snapshotting of a database's file-backed blobs.
11
+ *
12
+ * A database's blobs live in one or more root directories *outside* the RocksDB directory (see
13
+ * `resources/blob.ts` `getBlobPathsForDatabaseName`), so the engine's own backup does not capture
14
+ * them. These helpers snapshot the blob roots alongside a RocksDB managed backup and restore them
15
+ * with it, so a restored database's records still resolve their file-backed blobs.
16
+ *
17
+ * Layout mirrors the binding's transaction-log snapshots (`<backupDir>/transaction_logs/<id>/`): a
18
+ * full, non-incremental copy per backup at `<backupDir>/blobs/<backupId>/<rootIndex>/<relpath>`,
19
+ * where `rootIndex` is the position of the source root in the database's blob-root list (preserved
20
+ * so a multi-root database restores each root back to its original slot). Files are hard-linked when
21
+ * possible (cheap, no extra space on the same filesystem) and copied otherwise (never symlinked, so
22
+ * a snapshot is a standalone set of files that survives independent of the live blob).
23
+ *
24
+ * Consistency is best-effort and point-in-time-ish, matching how the engine treats the transaction
25
+ * log: the walk captures whatever files exist at snapshot time. A blob deleted mid-walk is skipped;
26
+ * a blob being written mid-walk is captured as-is (a hard link shares the inode, so it reflects the
27
+ * writer's final bytes; a cross-filesystem copy captures the bytes present at copy time). Harper
28
+ * does not freeze blob writes for the duration of a backup.
29
+ *
30
+ * Hard-linking is safe against later mutation because Harper blobs are content-addressed and
31
+ * write-once: each write allocates a fresh monotonic file id (a new path), and an update or delete
32
+ * unlinks the old path rather than rewriting it in place — so a snapshot's hard link keeps the exact
33
+ * bytes alive even after the live blob is deleted, and no in-place overwrite can retroactively alter
34
+ * a snapshot.
35
+ */
36
+
37
+ /** Directory holding all blob snapshots for a backup repository. */
38
+ export function blobsRootDir(backupDir: string): string {
39
+ return join(backupDir, 'blobs');
40
+ }
41
+
42
+ /** Directory holding a single backup id's blob snapshot. */
43
+ export function blobSnapshotDir(backupDir: string, backupId: number): string {
44
+ return join(blobsRootDir(backupDir), String(backupId));
45
+ }
46
+
47
+ /**
48
+ * Hard-link `src` to `dest`, falling back to a copy when the two are on different filesystems (or
49
+ * the filesystem does not support additional hard links). Never creates a symlink. A source that
50
+ * vanished mid-walk (a concurrent blob delete) is skipped rather than failing the whole snapshot.
51
+ */
52
+ async function linkOrCopy(src: string, dest: string): Promise<void> {
53
+ await mkdir(dirname(dest), { recursive: true });
54
+ try {
55
+ await link(src, dest);
56
+ } catch (error: any) {
57
+ if (error.code === 'ENOENT') {
58
+ // src disappeared (concurrent delete) — nothing to snapshot
59
+ if (!existsSync(src)) return;
60
+ throw error;
61
+ }
62
+ if (
63
+ error.code === 'EXDEV' || // cross-device link
64
+ error.code === 'EMLINK' || // link count exhausted
65
+ error.code === 'EPERM' || // filesystem forbids hard links
66
+ error.code === 'ENOTSUP' ||
67
+ error.code === 'EOPNOTSUPP'
68
+ ) {
69
+ await copyFile(src, dest);
70
+ return;
71
+ }
72
+ if (error.code === 'EEXIST') {
73
+ await unlink(dest);
74
+ await linkOrCopy(src, dest);
75
+ return;
76
+ }
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Recursively copy every file under `srcRoot` into `destRoot` (hard-link-else-copy), preserving the
83
+ * relative directory structure. Missing `srcRoot` is a no-op (a database with no blobs yet).
84
+ */
85
+ async function copyTree(srcRoot: string, destRoot: string): Promise<void> {
86
+ if (!existsSync(srcRoot)) return;
87
+ const stack: string[] = [srcRoot];
88
+ while (stack.length > 0) {
89
+ const dir = stack.pop() as string;
90
+ let entries;
91
+ try {
92
+ entries = await readdir(dir, { withFileTypes: true });
93
+ } catch (error: any) {
94
+ if (error.code === 'ENOENT') continue; // directory removed mid-walk
95
+ throw error;
96
+ }
97
+ for (const entry of entries) {
98
+ const srcPath = join(dir, entry.name);
99
+ if (entry.isDirectory()) {
100
+ stack.push(srcPath);
101
+ } else if (entry.isFile()) {
102
+ await linkOrCopy(srcPath, join(destRoot, relative(srcRoot, srcPath)));
103
+ }
104
+ // symlinks/other node types in a blob root are not expected and are intentionally skipped
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Snapshot a database's blob roots into a backup's blob directory. Writes to a temporary sibling
111
+ * and atomically renames into place so a create_backup that fails mid-copy never leaves a partial
112
+ * `blobs/<backupId>/` that a later restore would trust. Overwrites any pre-existing snapshot for the
113
+ * same id (create_backup always produces a fresh id, so this only matters on a retried offline run).
114
+ */
115
+ export async function snapshotBlobs(backupDir: string, backupId: number, blobRoots: string[]): Promise<void> {
116
+ const finalDir = blobSnapshotDir(backupDir, backupId);
117
+ const tempDir = join(blobsRootDir(backupDir), `.tmp-${backupId}`);
118
+ await rm(tempDir, { recursive: true, force: true });
119
+ await mkdir(tempDir, { recursive: true });
120
+ try {
121
+ for (let index = 0; index < blobRoots.length; index++) {
122
+ await copyTree(blobRoots[index], join(tempDir, String(index)));
123
+ }
124
+ await rm(finalDir, { recursive: true, force: true });
125
+ await rename(tempDir, finalDir);
126
+ } catch (error) {
127
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
128
+ throw error;
129
+ }
130
+ await writeBlobsReadme(backupDir, blobRoots);
131
+ }
132
+
133
+ /**
134
+ * Build the `blobs/README.md` documenting the blob snapshot layout, so an operator inspecting or
135
+ * hand-recovering a backup can decode the numeric directories. Two variants:
136
+ * - managed (default): a create_backup repository, where snapshots are keyed by backup id
137
+ * (`<backupId>/<rootIndex>/…`) and restore is automatic via `restore_backup`.
138
+ * - archive (`archive: true`): a downloaded `get_backup` tar, which holds a single snapshot with no
139
+ * backup-id level (`<rootIndex>/…`) and is restored by extracting the files back into the roots.
140
+ */
141
+ export function blobsReadmeContent(blobRoots: string[], { archive = false }: { archive?: boolean } = {}): string {
142
+ const rootMapping =
143
+ blobRoots.length > 0 ? blobRoots.map((root, index) => ` ${index} -> ${root}`).join('\n') : ' (none)';
144
+ const layout = archive
145
+ ? '<rootIndex>/<shard1>/<shard2>/<fileId>'
146
+ : '<backupId>/<rootIndex>/<shard1>/<shard2>/<fileId>';
147
+ const intro = archive
148
+ ? `This directory holds this database's file-backed blobs within a downloaded \`get_backup\` archive.
149
+ To restore them, extract each \`<rootIndex>/\` tree back into the matching blob root (see the mapping
150
+ below and ../README.md).`
151
+ : `This directory holds point-in-time snapshots of this database's file-backed blobs, captured
152
+ alongside each RocksDB managed backup. You do not restore these by hand — \`restore_backup\` puts
153
+ them back automatically (see ../README.md); this file just documents the layout.`;
154
+ const backupIdBullet = archive
155
+ ? ''
156
+ : `- **<backupId>** matches the RocksDB backup id (\`harper list_backups\`). Each id is a full,
157
+ independent snapshot (not incremental).
158
+ `;
159
+ return `# Harper blob snapshots
160
+
161
+ ${intro}
162
+
163
+ ## Directory layout
164
+
165
+ ${layout}
166
+
167
+ ${backupIdBullet}- **<rootIndex>** is which of the database's blob roots the file came from — the index into
168
+ \`storage.blobPaths[n]\`. When \`storage.blobPaths\` is not configured there is a single default root
169
+ (\`<rootPath>/blobs/<db>\`) at index 0. Current mapping for this backup:
170
+
171
+ ${rootMapping}
172
+
173
+ - **<shard1>/<shard2>/<fileId>** is Harper's on-disk blob layout, copied verbatim from the live
174
+ root: the hex blob file id split into two directory levels plus the file itself (keeping roughly
175
+ 4096 entries per directory). E.g. a blob with id \`0x12345678\` lives at \`12/345/678\`; a short id
176
+ like \`0xc1a\` lives at \`0/0/c1a\`.
177
+
178
+ Files are hard links to the live blobs when the backup is on the same filesystem, and copies
179
+ otherwise.
180
+ `;
181
+ }
182
+
183
+ /**
184
+ * Write the `blobs/README.md` into a managed backup's `blobs/` directory. Best-effort: a failure to
185
+ * write the doc must not fail an otherwise-successful backup.
186
+ */
187
+ export async function writeBlobsReadme(backupDir: string, blobRoots: string[]): Promise<void> {
188
+ try {
189
+ await writeFile(join(blobsRootDir(backupDir), 'README.md'), blobsReadmeContent(blobRoots));
190
+ } catch (error) {
191
+ logger.warn(`Failed to write blob snapshot README in ${backupDir}: ${(error as Error).message}`);
192
+ }
193
+ }
194
+
195
+ /** The blob-root indices present in a snapshot (sorted); [] when the backup has no blob snapshot. */
196
+ async function snapshotRootIndices(snapshotDir: string): Promise<number[]> {
197
+ if (!existsSync(snapshotDir)) return [];
198
+ return (await readdir(snapshotDir, { withFileTypes: true }))
199
+ .filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name))
200
+ .map((entry) => Number(entry.name))
201
+ .sort((a, b) => a - b);
202
+ }
203
+
204
+ /**
205
+ * Reject (before any destructive step) a restore whose snapshot has more blob roots than the
206
+ * database is currently configured with. File-backed blob references persist their `storageIndex`,
207
+ * so a record written under root index 1 keeps resolving through `blobRoots[1]`; collapsing the
208
+ * out-of-range index onto another root would preserve the bytes at the wrong address and the restore
209
+ * would "succeed" while reads of those blobs fail. The operator must reconcile `storage.blobPaths`
210
+ * to at least as many roots as the backup before restoring. Callers invoke this ahead of the engine
211
+ * restore so a mismatch never purges the database.
212
+ */
213
+ export async function assertBlobSnapshotRestorable(
214
+ backupDir: string,
215
+ backupId: number,
216
+ blobRoots: string[]
217
+ ): Promise<void> {
218
+ const indices = await snapshotRootIndices(blobSnapshotDir(backupDir, backupId));
219
+ const maxIndex = indices.length > 0 ? indices[indices.length - 1] : -1;
220
+ if (maxIndex >= blobRoots.length) {
221
+ throw new ClientError(
222
+ `Cannot restore backup ${backupId}: it captured ${maxIndex + 1} blob root(s) but the database is now configured with ${blobRoots.length}. ` +
223
+ `Blob references persist their root index, so restoring would mis-address blobs — set 'storage.blobPaths' to at least ${maxIndex + 1} root(s) before restoring.`
224
+ );
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Restore a backup's blob snapshot back into the database's blob roots. Each root is purged and
230
+ * rewritten from `blobs/<backupId>/<rootIndex>/` so the restored blob set matches the backup exactly
231
+ * (a newer blob written after the backup is removed, mirroring the engine's `purgeAllFiles` restore).
232
+ *
233
+ * A backup created with blobs excluded (or an older backup that predates blob snapshots) has no
234
+ * snapshot directory: in that case the live blob roots are left untouched and a warning is logged,
235
+ * since purging them would strip blobs the restored records may still reference. Roots are restored
236
+ * by index into the *same* configured root; an incompatible root count is rejected up front (see
237
+ * `assertBlobSnapshotRestorable`) rather than collapsed, so blobs are never mis-addressed.
238
+ */
239
+ export async function restoreBlobSnapshot(
240
+ backupDir: string,
241
+ backupId: number,
242
+ databaseName: string,
243
+ blobRoots: string[]
244
+ ): Promise<void> {
245
+ const snapshotDir = blobSnapshotDir(backupDir, backupId);
246
+ if (!existsSync(snapshotDir)) {
247
+ logger.warn(
248
+ `Backup ${backupId} of database '${databaseName}' has no blob snapshot; leaving existing blob files in place (this backup did not include blobs)`
249
+ );
250
+ return;
251
+ }
252
+ // defense in depth: the restore flow pre-checks before the engine restore, but re-validate here
253
+ // so this function never mis-addresses blobs regardless of caller
254
+ await assertBlobSnapshotRestorable(backupDir, backupId, blobRoots);
255
+ const indexDirs = await snapshotRootIndices(snapshotDir);
256
+ // purge every current blob root first so nothing newer than the backup survives the restore
257
+ for (const root of blobRoots) {
258
+ await rm(root, { recursive: true, force: true });
259
+ }
260
+ for (const index of indexDirs) {
261
+ await copyTree(join(snapshotDir, String(index)), blobRoots[index]);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Remove the blob snapshot for a single backup id (paired with `backups.delete`). Best-effort:
267
+ * a missing snapshot directory is fine.
268
+ */
269
+ export async function deleteBlobSnapshot(backupDir: string, backupId: number): Promise<void> {
270
+ await rm(blobSnapshotDir(backupDir, backupId), { recursive: true, force: true });
271
+ }
272
+
273
+ /**
274
+ * Remove blob snapshots for every backup id not in `keepIds` (paired with `backups.purge`, which
275
+ * reference-counts and removes the engine files; blob snapshots are full per-id copies, so they are
276
+ * simply deleted). Given the ids that survive the purge, this deletes the rest.
277
+ */
278
+ export async function purgeBlobSnapshots(backupDir: string, keepIds: Set<number>): Promise<void> {
279
+ const root = blobsRootDir(backupDir);
280
+ if (!existsSync(root)) return;
281
+ for (const entry of await readdir(root, { withFileTypes: true })) {
282
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
283
+ if (keepIds.has(Number(entry.name))) continue;
284
+ await rm(join(root, entry.name), { recursive: true, force: true });
285
+ }
286
+ }
@@ -27,6 +27,7 @@ import { errorToString } from '../../utility/logging/harper_logger.ts';
27
27
  import { RocksDatabase } from '@harperfast/rocksdb-js';
28
28
  import { BridgeMethods } from './BridgeMethods.ts';
29
29
  import lmdbGetBackup from './lmdbBridge/lmdbMethods/lmdbGetBackup.js';
30
+ import { createBackupStream, resolveSingleRootStore } from '../rocksdbBackup.ts';
30
31
  import { DeleteTransactionLogsBeforeResults } from './DeleteTransactionLogsBeforeResults.ts';
31
32
  import type { Readable } from 'node:stream';
32
33
 
@@ -563,7 +564,49 @@ export class ResourceBridge extends BridgeMethods {
563
564
  schema?: string;
564
565
  table?: string;
565
566
  tables?: string[];
567
+ include_audit?: boolean;
568
+ gzip?: boolean;
569
+ exclude_blobs?: boolean;
566
570
  }): Promise<Readable> {
571
+ const databaseName = getBackupObj.database || getBackupObj.schema || 'data';
572
+ const database = getDatabases()[databaseName];
573
+ if (!database) {
574
+ throw new ClientError(`Database '${databaseName}' does not exist`, 404);
575
+ }
576
+ const firstTable = database[Object.keys(database)[0]];
577
+ if (!firstTable) {
578
+ throw new ClientError(`Database '${databaseName}' has no tables to back up`);
579
+ }
580
+ if (firstTable.primaryStore.rootStore instanceof RocksDatabase) {
581
+ // RocksDB: stream a fresh full-snapshot tar of the database's current state — no
582
+ // scratch disk, nothing to clean up. Per-engine params are validated descriptively.
583
+ if (getBackupObj.tables || getBackupObj.table) {
584
+ throw new ClientError(`'tables'/'table' are LMDB-only options; RocksDB backups are always whole-database`);
585
+ }
586
+ if (getBackupObj.include_audit !== undefined) {
587
+ throw new ClientError(
588
+ `'include_audit' is an LMDB-only option; RocksDB backups always include the transaction log`
589
+ );
590
+ }
591
+ if (getBackupObj.gzip !== undefined && typeof getBackupObj.gzip !== 'boolean') {
592
+ throw new ClientError(`'gzip' must be a boolean`);
593
+ }
594
+ if (getBackupObj.exclude_blobs !== undefined && typeof getBackupObj.exclude_blobs !== 'boolean') {
595
+ throw new ClientError(`'exclude_blobs' must be a boolean`);
596
+ }
597
+ const rootStore = resolveSingleRootStore(databaseName);
598
+ // gzip defaults on (it compresses the snapshot substantially); gzip=false opts out.
599
+ // blobs are included by default; exclude_blobs=true streams an engine-only tar.
600
+ return createBackupStream(
601
+ rootStore,
602
+ databaseName,
603
+ getBackupObj.gzip !== false,
604
+ getBackupObj.exclude_blobs === true
605
+ );
606
+ }
607
+ if (getBackupObj.gzip !== undefined) {
608
+ throw new ClientError(`'gzip' is a RocksDB-only option; LMDB backups are gzipped per the accept-encoding header`);
609
+ }
567
610
  return lmdbGetBackup(getBackupObj);
568
611
  }
569
612
  }
@@ -0,0 +1,276 @@
1
+ 'use strict';
2
+
3
+ import {
4
+ closeSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ unlinkSync,
12
+ writeSync,
13
+ } from 'node:fs';
14
+ import { basename, dirname, join } from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import { tryFileLock, fileLockRelease } from '@harperfast/rocksdb-js';
17
+
18
+ /**
19
+ * Restore lock + marker protocol for RocksDB database restores (online operation and offline CLI),
20
+ * and the shared per-database exclusion used by `dropDatabase` so a drop and a restore can never
21
+ * mutate the same directory concurrently.
22
+ *
23
+ * Restore metadata lives in an isolated `` `restore` `` directory *beside* the database directory
24
+ * (never inside it, since a restore purges the destination). Each database's two files are keyed by
25
+ * a hash of the database directory name rather than being suffixed onto the name itself. That keeps
26
+ * them out of the database-name namespace — a legal database literally named `orders.restoring`
27
+ * would otherwise be mistaken for the restore marker of `orders`, and a 250-character name plus a
28
+ * `.restore.lock` suffix would exceed the 255-byte `NAME_MAX` on most filesystems. The directory
29
+ * name deliberately contains a backtick: `schemaRegex` (the database-name validator) forbids only
30
+ * `/` and `` ` `` among filesystem-legal characters, so no legal database can ever occupy this path
31
+ * — including a database literally named `.restore` (which *is* a legal name, so a plain `.restore`
32
+ * directory would collide with it and land the markers inside the live database). The directory is
33
+ * not itself a RocksDB/LMDB database (no `CURRENT`/`MANIFEST-`/`.mdb`), so the startup scan ignores
34
+ * it, and no user can create a database that resolves to it.
35
+ *
36
+ * - `<meta-dir>/<key>.lock` — an OS-level exclusive file lock (via rocksdb-js `tryFileLock`),
37
+ * effective across processes, containers, and worker threads, auto-released on process exit.
38
+ * Only *held-ness* is meaningful; the file itself persists after release (harmless). Held for the
39
+ * duration of a restore, and briefly by `dropDatabase` so the two serialize on the same primitive.
40
+ * Known limitation: the lock is owned by the process, so if the restore job's worker *thread*
41
+ * dies without the process exiting, the lock stays held (restores 409) until Harper restarts.
42
+ * - `<meta-dir>/<key>.restoring` — the completion marker. Written (and fsynced) after the lock is
43
+ * acquired and before the destructive restore begins; deleted only after the restore completes
44
+ * successfully, while still holding the lock. Its *existence* means "a restore started and has
45
+ * not finished successfully". Its first line records the database directory name so the startup
46
+ * scan can map a marker back to the database it blocks without decoding the hashed key.
47
+ */
48
+
49
+ // The backtick makes this an illegal database name (schemaRegex rejects `/` and backtick only), so
50
+ // it can never collide with a real database directory — see the module header.
51
+ export const RESTORE_META_DIR = '`restore`';
52
+ export const RESTORE_LOCK_SUFFIX = '.lock';
53
+ export const RESTORING_MARKER_SUFFIX = '.restoring';
54
+
55
+ /**
56
+ * Directory holding the restore metadata for a database — the reserved `` `restore` `` sibling of
57
+ * the database directory (see the module header for why the name contains a backtick). Shared by
58
+ * every database under the same parent, so a single readdir surfaces all pending restores during
59
+ * the startup scan.
60
+ */
61
+ export function restoreMetaDir(dbPath: string): string {
62
+ return join(dirname(dbPath), RESTORE_META_DIR);
63
+ }
64
+
65
+ /**
66
+ * Filesystem-safe, length-bounded key for a database's restore metadata files. Hashing the
67
+ * database directory name (not the full path, so it is stable regardless of where the databases
68
+ * root lives) keeps the metadata filenames short and collision-free while staying independent of
69
+ * the database-name namespace. Database directory names are unique within a databases root, so
70
+ * their hashes are too.
71
+ */
72
+ function restoreMetaKey(dbPath: string): string {
73
+ return createHash('sha256').update(basename(dbPath)).digest('hex').slice(0, 32);
74
+ }
75
+
76
+ export function restoreLockPath(dbPath: string): string {
77
+ return join(restoreMetaDir(dbPath), restoreMetaKey(dbPath) + RESTORE_LOCK_SUFFIX);
78
+ }
79
+
80
+ export function restoringMarkerPath(dbPath: string): string {
81
+ return join(restoreMetaDir(dbPath), restoreMetaKey(dbPath) + RESTORING_MARKER_SUFFIX);
82
+ }
83
+
84
+ export type RestoreState = 'in-progress' | 'incomplete' | 'clear';
85
+
86
+ /**
87
+ * Whether a `.restoring` marker exists for a database. Cheaper than `checkRestoreState` and, unlike
88
+ * it, safe to call while *this* thread holds the restore lock: `checkRestoreState` would re-probe
89
+ * the lock (which reads as held from the same thread) and report 'in-progress' rather than telling
90
+ * a caller that a *leftover* marker is present. `dropDatabase` uses this after acquiring the lock to
91
+ * distinguish debris from a crashed restore.
92
+ */
93
+ export function restoreMarkerPresent(dbPath: string): boolean {
94
+ return existsSync(restoringMarkerPath(dbPath));
95
+ }
96
+
97
+ /** The lock and (optional) marker held by a begin/acquire call, threaded back to complete/abandon. */
98
+ export type RestoreLock = {
99
+ /** rocksdb-js file-lock token; non-zero. */
100
+ token: number;
101
+ /** The database directory this lock guards. */
102
+ dbPath: string;
103
+ /**
104
+ * True when a `.restoring` marker already existed at `beginRestore` time — i.e. this restore is a
105
+ * recovery attempt over a possibly half-purged directory. A pre-existing marker must never be
106
+ * cleared by a *failed* recovery attempt, or the directory could be reloaded as healthy while
107
+ * still partial. Only set on `beginRestore`; always false for a bare `acquireRestoreLock`.
108
+ */
109
+ preexisting: boolean;
110
+ };
111
+
112
+ /**
113
+ * Determine the restore state of a database directory. Used by startup database detection and
114
+ * the open-database guards:
115
+ * - 'in-progress': marker present and the restore lock is held (a restore is running in some
116
+ * process) — do not load.
117
+ * - 'incomplete': marker present but the lock is free (crashed mid-restore; the directory may
118
+ * be partial garbage) — do not load; rerun the restore.
119
+ * - 'clear': no marker — load normally (a stale, unheld lock file alone is fine).
120
+ *
121
+ * The marker is checked FIRST and the lock is only probed when the marker exists. Probing takes
122
+ * and releases the flock, and probes are mutually exclusive across threads — if every rescan on
123
+ * every thread probed the (persistent) lock file of a long-ago-restored database, concurrent
124
+ * rescans would collide and misclassify healthy databases as 'in-progress'. Marker-first is
125
+ * safe: `beginRestore` writes (and fsyncs) the marker immediately after taking the lock and
126
+ * before any destructive step, so a database without a marker has nothing to protect yet.
127
+ */
128
+ export function checkRestoreState(dbPath: string): RestoreState {
129
+ if (!existsSync(restoringMarkerPath(dbPath))) return 'clear';
130
+ const lockPath = restoreLockPath(dbPath);
131
+ if (existsSync(lockPath)) {
132
+ const token = tryFileLock(lockPath);
133
+ if (token === 0) return 'in-progress';
134
+ fileLockRelease(token);
135
+ }
136
+ return 'incomplete';
137
+ }
138
+
139
+ /**
140
+ * fsync a directory so a create/unlink of an entry within it is durable. Best-effort: Windows (and
141
+ * some filesystems) reject opening a directory for fsync with EPERM/EISDIR/ENOTSUP — the durability
142
+ * flush is a POSIX nicety, so treat those as a no-op rather than failing the restore.
143
+ */
144
+ function fsyncDir(dir: string): void {
145
+ let dirFd: number;
146
+ try {
147
+ dirFd = openSync(dir, 'r');
148
+ } catch (error: any) {
149
+ if (error.code === 'EPERM' || error.code === 'EISDIR' || error.code === 'ENOTSUP') return;
150
+ throw error;
151
+ }
152
+ try {
153
+ fsyncSync(dirFd);
154
+ } finally {
155
+ closeSync(dirFd);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Take the per-database restore lock without writing a marker. Used by `dropDatabase` so a drop and
161
+ * a restore serialize on the same primitive: whichever takes the lock first runs to completion; the
162
+ * other gets a 409. Throws (statusCode 409) if the lock is already held.
163
+ */
164
+ export function acquireRestoreLock(dbPath: string): RestoreLock {
165
+ mkdirSync(restoreMetaDir(dbPath), { recursive: true });
166
+ const token = tryFileLock(restoreLockPath(dbPath));
167
+ if (token === 0) {
168
+ const error: any = new Error(`Restore already in progress for database at ${dbPath}`);
169
+ error.statusCode = 409;
170
+ throw error;
171
+ }
172
+ return { token, dbPath, preexisting: false };
173
+ }
174
+
175
+ /**
176
+ * Release a lock taken by `acquireRestoreLock` (no marker to remove).
177
+ */
178
+ export function releaseRestoreLock(lock: RestoreLock): void {
179
+ fileLockRelease(lock.token);
180
+ }
181
+
182
+ /**
183
+ * Acquire the per-database restore lock and write the restoring marker. Call before any
184
+ * destructive step. Returns the lock (with `preexisting` set when a marker was already present, so
185
+ * a failed recovery attempt knows not to clear it). Throws (statusCode 409) if another restore
186
+ * already holds the lock.
187
+ */
188
+ export function beginRestore(dbPath: string): RestoreLock {
189
+ const markerPath = restoringMarkerPath(dbPath);
190
+ const preexisting = existsSync(markerPath);
191
+ const lock = acquireRestoreLock(dbPath);
192
+ try {
193
+ const fd = openSync(markerPath, 'w');
194
+ try {
195
+ // first line is the database directory name so the startup scan can map this marker back to
196
+ // the database it blocks without reversing the hashed key
197
+ writeSync(fd, `${basename(dbPath)}\nrestore started ${new Date().toISOString()}\n`);
198
+ fsyncSync(fd);
199
+ } finally {
200
+ closeSync(fd);
201
+ }
202
+ // fsync the metadata directory so the marker's directory entry is durable — without this a
203
+ // power loss can lose the entry, and a half-purged database would load as healthy
204
+ fsyncDir(restoreMetaDir(dbPath));
205
+ } catch (error) {
206
+ fileLockRelease(lock.token);
207
+ throw error;
208
+ }
209
+ return { ...lock, preexisting };
210
+ }
211
+
212
+ /**
213
+ * Mark the restore successful: delete the marker (while still holding the lock), then release
214
+ * the lock.
215
+ */
216
+ export function completeRestore(lock: RestoreLock): void {
217
+ try {
218
+ unlinkSync(restoringMarkerPath(lock.dbPath));
219
+ // fsync the metadata directory so the marker's *removal* is durable — symmetric with the
220
+ // creation fsync in beginRestore. Without it, a power loss could resurrect the marker's
221
+ // directory entry and misclassify a fully-restored database as incomplete.
222
+ fsyncDir(restoreMetaDir(lock.dbPath));
223
+ } finally {
224
+ fileLockRelease(lock.token);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Release the lock after a failed restore, leaving the marker in place so the database is
230
+ * detected as an incomplete restore (and not loaded) until a rerun succeeds.
231
+ */
232
+ export function abandonRestore(lock: RestoreLock): void {
233
+ fileLockRelease(lock.token);
234
+ }
235
+
236
+ /**
237
+ * Remove a database's restore marker if one is present, then release the lock. Used by
238
+ * `dropDatabase`: a dropped database that carried an incomplete-restore marker should not leave the
239
+ * marker behind to block a future database of the same name. No-op on the marker if none exists.
240
+ */
241
+ export function clearRestoreMarker(lock: RestoreLock): void {
242
+ try {
243
+ const markerPath = restoringMarkerPath(lock.dbPath);
244
+ if (existsSync(markerPath)) {
245
+ unlinkSync(markerPath);
246
+ fsyncDir(restoreMetaDir(lock.dbPath));
247
+ }
248
+ } finally {
249
+ fileLockRelease(lock.token);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Scan a databases root's reserved `` `restore` `` metadata directory and report every database currently blocked from
255
+ * loading, mapping each surviving marker back to its database name via the marker's first line.
256
+ * Returns `[dbName, state]` pairs for markers whose state is `in-progress` or `incomplete`
257
+ * (a `clear` result means the marker was removed concurrently and the database is loadable).
258
+ */
259
+ export function scanBlockedRestores(databasesRoot: string): Array<[string, RestoreState]> {
260
+ const metaDir = join(databasesRoot, RESTORE_META_DIR);
261
+ if (!existsSync(metaDir)) return [];
262
+ const blocked: Array<[string, RestoreState]> = [];
263
+ for (const entry of readdirSync(metaDir, { withFileTypes: true })) {
264
+ if (!entry.isFile() || !entry.name.endsWith(RESTORING_MARKER_SUFFIX)) continue;
265
+ let dbName: string;
266
+ try {
267
+ dbName = readFileSync(join(metaDir, entry.name), 'utf8').split('\n', 1)[0];
268
+ } catch {
269
+ continue; // marker removed concurrently
270
+ }
271
+ if (!dbName) continue;
272
+ const state = checkRestoreState(join(databasesRoot, dbName));
273
+ if (state !== 'clear') blocked.push([dbName, state]);
274
+ }
275
+ return blocked;
276
+ }