@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.
- package/bin/backup.ts +169 -0
- package/bin/cliOperations.ts +92 -73
- package/bin/harper.ts +25 -6
- package/components/Scope.ts +52 -6
- package/components/componentLoader.ts +107 -9
- package/components/mcp/toolRegistry.ts +10 -0
- package/components/mcp/tools/application.ts +12 -5
- package/components/mcp/tools/operations.ts +3 -0
- package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
- package/components/mcp/tools/schemas/operations.ts +9 -0
- package/components/operations.js +1 -0
- package/components/operationsValidation.js +32 -2
- package/components/scopeMount.ts +150 -0
- package/config-root.schema.json +4 -0
- package/dataLayer/backupManifest.ts +102 -0
- package/dataLayer/blobBackup.ts +286 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +43 -0
- package/dataLayer/restoreMarker.ts +276 -0
- package/dataLayer/rocksdbBackup.ts +1100 -0
- package/dist/bin/backup.d.ts +9 -0
- package/dist/bin/backup.js +192 -0
- package/dist/bin/backup.js.map +1 -0
- package/dist/bin/cliOperations.d.ts +13 -0
- package/dist/bin/cliOperations.js +89 -70
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/harper.d.ts +7 -0
- package/dist/bin/harper.js +27 -6
- package/dist/bin/harper.js.map +1 -1
- package/dist/components/Scope.d.ts +37 -1
- package/dist/components/Scope.js +48 -6
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.d.ts +2 -6
- package/dist/components/componentLoader.js +91 -8
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/mcp/toolRegistry.d.ts +7 -0
- package/dist/components/mcp/toolRegistry.js +10 -0
- package/dist/components/mcp/toolRegistry.js.map +1 -1
- package/dist/components/mcp/tools/application.js +11 -5
- package/dist/components/mcp/tools/application.js.map +1 -1
- package/dist/components/mcp/tools/operations.js +3 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
- package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
- package/dist/components/mcp/tools/schemas/operations.js +9 -0
- package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
- package/dist/components/operations.js +2 -0
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +34 -2
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/scopeMount.d.ts +86 -0
- package/dist/components/scopeMount.js +131 -0
- package/dist/components/scopeMount.js.map +1 -0
- package/dist/dataLayer/backupManifest.d.ts +26 -0
- package/dist/dataLayer/backupManifest.js +97 -0
- package/dist/dataLayer/backupManifest.js.map +1 -0
- package/dist/dataLayer/blobBackup.d.ts +87 -0
- package/dist/dataLayer/blobBackup.js +282 -0
- package/dist/dataLayer/blobBackup.js.map +1 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js +33 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +122 -0
- package/dist/dataLayer/restoreMarker.js +261 -0
- package/dist/dataLayer/restoreMarker.js.map +1 -0
- package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
- package/dist/dataLayer/rocksdbBackup.js +1039 -0
- package/dist/dataLayer/rocksdbBackup.js.map +1 -0
- package/dist/resources/DatabaseTransaction.js +0 -6
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +0 -3
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/Table.js +22 -22
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/blob.d.ts +8 -0
- package/dist/resources/blob.js +16 -7
- package/dist/resources/blob.js.map +1 -1
- package/dist/resources/databases.d.ts +42 -1
- package/dist/resources/databases.js +276 -40
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/transaction.js +0 -3
- package/dist/resources/transaction.js.map +1 -1
- package/dist/server/REST.js +25 -9
- package/dist/server/REST.js.map +1 -1
- package/dist/server/fastifyRoutes.js +15 -1
- package/dist/server/fastifyRoutes.js.map +1 -1
- package/dist/server/itc/serverHandlers.js +7 -1
- package/dist/server/itc/serverHandlers.js.map +1 -1
- package/dist/server/jobs/jobProcess.js +20 -1
- package/dist/server/jobs/jobProcess.js.map +1 -1
- package/dist/server/jobs/jobRunner.js +10 -0
- package/dist/server/jobs/jobRunner.js.map +1 -1
- package/dist/server/jobs/jobs.js +11 -0
- package/dist/server/jobs/jobs.js.map +1 -1
- package/dist/server/middlewareChain.d.ts +10 -1
- package/dist/server/middlewareChain.js +81 -21
- package/dist/server/middlewareChain.js.map +1 -1
- package/dist/server/serverHelpers/serverHandlers.js +8 -4
- package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +11 -0
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/static.js +11 -3
- package/dist/server/static.js.map +1 -1
- package/dist/utility/OperationFunctionCaller.js +25 -6
- package/dist/utility/OperationFunctionCaller.js.map +1 -1
- package/dist/utility/hdbTerms.d.ts +11 -1
- package/dist/utility/hdbTerms.js +11 -1
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/logging/harper_logger.d.ts +40 -0
- package/dist/utility/logging/harper_logger.js +746 -14
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/dist/utility/operation_authorization.js +13 -3
- package/dist/utility/operation_authorization.js.map +1 -1
- package/npm-shrinkwrap.json +3 -2
- package/package.json +4 -1
- package/resources/DESIGN.md +2 -0
- package/resources/DatabaseTransaction.ts +0 -3
- package/resources/ResourceInterface.ts +0 -3
- package/resources/Table.ts +20 -20
- package/resources/blob.ts +16 -6
- package/resources/databases.ts +272 -42
- package/resources/transaction.ts +0 -3
- package/server/DESIGN.md +16 -0
- package/server/REST.ts +25 -9
- package/server/fastifyRoutes.ts +20 -1
- package/server/itc/serverHandlers.js +7 -1
- package/server/jobs/jobProcess.ts +18 -1
- package/server/jobs/jobRunner.ts +10 -0
- package/server/jobs/jobs.ts +11 -0
- package/server/middlewareChain.ts +79 -20
- package/server/serverHelpers/serverHandlers.js +8 -4
- package/server/serverHelpers/serverUtilities.ts +19 -0
- package/server/static.ts +12 -3
- package/static/defaultConfig.yaml +1 -0
- package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-DoVWScmq.js} +2 -2
- package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-DoVWScmq.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-UZ2NsUOZ.js} +4 -4
- package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-UZ2NsUOZ.js.map} +1 -1
- package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-BUI_04o7.js} +2 -2
- package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-BUI_04o7.js.map} +1 -1
- package/studio/web/assets/{applications-BDfH8urd.js → applications-D03NA7wW.js} +2 -2
- package/studio/web/assets/{applications-BDfH8urd.js.map → applications-D03NA7wW.js.map} +1 -1
- package/studio/web/assets/{index-BHo3c2Gk.js → index-Bh_CNAHr.js} +6 -6
- package/studio/web/assets/index-Bh_CNAHr.js.map +1 -0
- package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-Dx3MpyDC.js} +4 -4
- package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-Dx3MpyDC.js.map} +1 -1
- package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-0edoFTsb.js} +2 -2
- package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-0edoFTsb.js.map} +1 -1
- package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CwKhipK7.js} +2 -2
- package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CwKhipK7.js.map} +1 -1
- package/studio/web/assets/{profile-Doj5FVDE.js → profile-DUfEPQtx.js} +2 -2
- package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-DUfEPQtx.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-DMPo4UjC.js} +2 -2
- package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-DMPo4UjC.js.map} +1 -1
- package/studio/web/assets/{setup-DJwR0BHd.js → setup-B56Oz1_u.js} +2 -2
- package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-B56Oz1_u.js.map} +1 -1
- package/studio/web/assets/{status-Br_AbsJs.js → status-BAod7p3o.js} +2 -2
- package/studio/web/assets/{status-Br_AbsJs.js.map → status-BAod7p3o.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-lQrBxfwM.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-lQrBxfwM.js.map} +1 -1
- package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-CrHCRjTK.js} +2 -2
- package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-CrHCRjTK.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-DoaBMEvU.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-DoaBMEvU.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/OperationFunctionCaller.ts +24 -3
- package/utility/hdbTerms.ts +11 -1
- package/utility/logging/harper_logger.ts +729 -15
- package/utility/operation_authorization.ts +31 -3
- package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
package/resources/databases.ts
CHANGED
|
@@ -41,6 +41,15 @@ import { RocksIndexStore } from './RocksIndexStore.ts';
|
|
|
41
41
|
import { when } from '../utility/when.ts';
|
|
42
42
|
import { resolveRocksMemoryConfig } from '../utility/rocksMemoryConfig.ts';
|
|
43
43
|
import { isProcessRunning } from '../utility/processManagement/processManagement.js';
|
|
44
|
+
import {
|
|
45
|
+
acquireRestoreLock,
|
|
46
|
+
checkRestoreState,
|
|
47
|
+
releaseRestoreLock,
|
|
48
|
+
restoreMarkerPresent,
|
|
49
|
+
scanBlockedRestores,
|
|
50
|
+
RESTORE_META_DIR,
|
|
51
|
+
type RestoreLock,
|
|
52
|
+
} from '../dataLayer/restoreMarker.ts';
|
|
44
53
|
|
|
45
54
|
/**
|
|
46
55
|
* Check if Harper is running in read-only mode.
|
|
@@ -167,8 +176,23 @@ export const databaseEventsEmitter = new EventEmitter<DatabaseWatcherEventMap>()
|
|
|
167
176
|
export const tables: Tables = Object.create(null);
|
|
168
177
|
export const databases: Databases = Object.create(null);
|
|
169
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Map a persisted (LMDB-era) compression value to what rocksdb-js accepts. Table metadata
|
|
181
|
+
* carries values where a defined falsy value (false, '') means compression was explicitly
|
|
182
|
+
* disabled and true/{ threshold, ... } mean enabled with defaults. rocksdb-js >= 2.6
|
|
183
|
+
* validates `compression` strictly and treats UNSET as "use the build default (lz4)", so
|
|
184
|
+
* disabled must be mapped to 'none' explicitly, and true to unset.
|
|
185
|
+
*/
|
|
186
|
+
export function toRocksCompression(compression: unknown): unknown {
|
|
187
|
+
if (compression === true) return undefined;
|
|
188
|
+
if (compression !== undefined && !compression) return 'none';
|
|
189
|
+
return compression;
|
|
190
|
+
}
|
|
191
|
+
|
|
170
192
|
function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSort?: boolean }) {
|
|
171
193
|
options.disableWAL ??= true;
|
|
194
|
+
const legacyOptions = options as { compression?: unknown };
|
|
195
|
+
legacyOptions.compression = toRocksCompression(legacyOptions.compression);
|
|
172
196
|
// Apply read-only mode if enabled
|
|
173
197
|
if (isReadOnlyMode()) {
|
|
174
198
|
options.readOnly = true;
|
|
@@ -315,11 +339,18 @@ export function getDatabases(): Databases {
|
|
|
315
339
|
if (databasePath && existsSync(databasePath)) {
|
|
316
340
|
// First load all the databases from our main database folder
|
|
317
341
|
// TODO: Load any databases defined with explicit storage paths from the config
|
|
318
|
-
|
|
342
|
+
const entries = readdirSync(databasePath, { withFileTypes: true });
|
|
343
|
+
const blockedByRestore = databasesBlockedByRestore(databasePath);
|
|
344
|
+
for (const databaseEntry of entries) {
|
|
319
345
|
// in-progress migration staging dirs are not databases until atomically renamed into place
|
|
320
346
|
if (databaseEntry.name.endsWith(MIGRATING_DIR_SUFFIX)) continue;
|
|
347
|
+
// the restore-metadata directory is reserved: never load it as a database, even if a
|
|
348
|
+
// (out-of-band) RocksDB directory happens to occupy that reserved name — the API can't
|
|
349
|
+
// create it (schemaRegex forbids the backtick), but the scan opens any CURRENT+MANIFEST dir
|
|
350
|
+
if (databaseEntry.name === RESTORE_META_DIR) continue;
|
|
321
351
|
const dbName = basename(databaseEntry.name, '.mdb');
|
|
322
352
|
const dbPath = join(databasePath, databaseEntry.name);
|
|
353
|
+
if (blockedByRestore.has(dbName)) continue;
|
|
323
354
|
|
|
324
355
|
if (
|
|
325
356
|
databaseEntry.isFile() &&
|
|
@@ -376,8 +407,12 @@ export function getDatabases(): Databases {
|
|
|
376
407
|
const schemaConfig = schemaConfigs[dbName];
|
|
377
408
|
const databasePath = schemaConfig.path;
|
|
378
409
|
if (existsSync(databasePath)) {
|
|
379
|
-
|
|
380
|
-
|
|
410
|
+
const entries = readdirSync(databasePath, { withFileTypes: true });
|
|
411
|
+
const blockedByRestore = databasesBlockedByRestore(databasePath);
|
|
412
|
+
for (const databaseEntry of entries) {
|
|
413
|
+
if (databaseEntry.name.endsWith(MIGRATING_DIR_SUFFIX)) continue; // migration staging dir
|
|
414
|
+
if (databaseEntry.name === RESTORE_META_DIR) continue; // reserved restore-metadata dir
|
|
415
|
+
if (blockedByRestore.has(basename(databaseEntry.name, '.mdb'))) continue;
|
|
381
416
|
if (databaseEntry.isFile() && extname(databaseEntry.name).toLowerCase() === '.mdb') {
|
|
382
417
|
readMetaDb(join(databasePath, databaseEntry.name), basename(databaseEntry.name, '.mdb'), dbName);
|
|
383
418
|
} else {
|
|
@@ -452,6 +487,29 @@ export function getDatabases(): Databases {
|
|
|
452
487
|
return databases;
|
|
453
488
|
}
|
|
454
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Scan a databases directory's entries for restore lock/marker files and return the names of
|
|
492
|
+
* databases that must not be loaded: a held restore lock means a restore is in progress in some
|
|
493
|
+
* process; an unheld lock with a surviving `.restoring` marker means a restore was interrupted
|
|
494
|
+
* mid-purge (the directory may be partial garbage) and must be rerun. The files live *next to*
|
|
495
|
+
* the database directory, so this also covers a database whose directory is missing or empty.
|
|
496
|
+
*/
|
|
497
|
+
function databasesBlockedByRestore(databasePath: string): Set<string> {
|
|
498
|
+
const blocked = new Set<string>();
|
|
499
|
+
for (const [dbName, state] of scanBlockedRestores(databasePath)) {
|
|
500
|
+
if (state === 'in-progress') {
|
|
501
|
+
logger.warn(`A restore of database '${dbName}' is in progress; not loading it`);
|
|
502
|
+
blocked.add(dbName);
|
|
503
|
+
} else if (state === 'incomplete') {
|
|
504
|
+
logger.error(
|
|
505
|
+
`Incomplete restore of database '${dbName}' detected (a restore started but did not finish); not loading it — rerun the restore to recover`
|
|
506
|
+
);
|
|
507
|
+
blocked.add(dbName);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return blocked;
|
|
511
|
+
}
|
|
512
|
+
|
|
455
513
|
/**
|
|
456
514
|
* This is responsible for reading the internal dbi of a single database file to get a list of all the tables and
|
|
457
515
|
* their indexed or registered attributes
|
|
@@ -916,18 +974,12 @@ function setTable(tables, tableName, Table) {
|
|
|
916
974
|
return Table;
|
|
917
975
|
}
|
|
918
976
|
/**
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
977
|
+
* Resolve the directory that holds (or would hold) a database's storage, from the databases
|
|
978
|
+
* config, storage path config/env, or the hdb root — without opening anything. This is the
|
|
979
|
+
* parent directory selection used by `database()`; a RocksDB database lives at
|
|
980
|
+
* `join(resolveDatabaseStorageRoot(...), databaseName)`.
|
|
922
981
|
*/
|
|
923
|
-
export function
|
|
924
|
-
if (!databaseName) databaseName = DEFAULT_DATABASE_NAME;
|
|
925
|
-
getDatabases();
|
|
926
|
-
ensureDB(databaseName);
|
|
927
|
-
const definedDatabase = definedDatabases.get(databaseName);
|
|
928
|
-
if ((definedDatabase as any)?.rootStore) {
|
|
929
|
-
return (definedDatabase as any).rootStore;
|
|
930
|
-
}
|
|
982
|
+
export function resolveDatabaseStorageRoot(databaseName: string, tableName?: string): string {
|
|
931
983
|
const databaseConfig = envGet(CONFIG_PARAMS.DATABASES) || {};
|
|
932
984
|
if (process.env.SCHEMAS_DATA_PATH) {
|
|
933
985
|
databaseConfig.data = { path: process.env.SCHEMAS_DATA_PATH };
|
|
@@ -952,6 +1004,35 @@ export function database({ database: databaseName, table: tableName }) {
|
|
|
952
1004
|
`Unable to determine database storage path. Ensure STORAGE_PATH, HDB_ROOT, or a valid config path is set.`
|
|
953
1005
|
);
|
|
954
1006
|
}
|
|
1007
|
+
return databasePath;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Resolve the directory path of a RocksDB database (whether or not it exists or is loaded).
|
|
1012
|
+
*/
|
|
1013
|
+
export function resolveDatabasePath(databaseName: string): string {
|
|
1014
|
+
return join(resolveDatabaseStorageRoot(databaseName), databaseName);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Get root store for a database
|
|
1019
|
+
* @param options
|
|
1020
|
+
* @returns
|
|
1021
|
+
*/
|
|
1022
|
+
export function database({ database: databaseName, table: tableName }) {
|
|
1023
|
+
if (!databaseName) databaseName = DEFAULT_DATABASE_NAME;
|
|
1024
|
+
getDatabases();
|
|
1025
|
+
ensureDB(databaseName);
|
|
1026
|
+
const definedDatabase = definedDatabases.get(databaseName);
|
|
1027
|
+
if ((definedDatabase as any)?.rootStore) {
|
|
1028
|
+
return (definedDatabase as any).rootStore;
|
|
1029
|
+
}
|
|
1030
|
+
const databaseConfig = envGet(CONFIG_PARAMS.DATABASES) || {};
|
|
1031
|
+
if (process.env.SCHEMAS_DATA_PATH) {
|
|
1032
|
+
databaseConfig.data = { path: process.env.SCHEMAS_DATA_PATH };
|
|
1033
|
+
}
|
|
1034
|
+
const tablePath = tableName && databaseConfig[databaseName]?.tables?.[tableName]?.path;
|
|
1035
|
+
const databasePath = resolveDatabaseStorageRoot(databaseName, tableName);
|
|
955
1036
|
|
|
956
1037
|
let rootStore: RootDatabaseKind;
|
|
957
1038
|
const useRocksdb = (process.env.HARPER_STORAGE_ENGINE || envGet(CONFIG_PARAMS.STORAGE_ENGINE)) !== 'lmdb';
|
|
@@ -959,6 +1040,10 @@ export function database({ database: databaseName, table: tableName }) {
|
|
|
959
1040
|
const path = join(databasePath, tablePath ? tableName : databaseName);
|
|
960
1041
|
rootStore = rocksdbDatabaseEnvs.get(path);
|
|
961
1042
|
if (!rootStore || rootStore.status === 'closed') {
|
|
1043
|
+
// this on-demand open (create_table/create_database and friends) must not resurrect a
|
|
1044
|
+
// database that a restore is rewriting (or left half-purged) — the scan-time restore
|
|
1045
|
+
// checks don't cover this path
|
|
1046
|
+
throwIfBlockedByRestore(path, databaseName);
|
|
962
1047
|
rootStore = openRocksDatabase(path, {
|
|
963
1048
|
disableWAL: false,
|
|
964
1049
|
enableStats: true,
|
|
@@ -981,6 +1066,51 @@ export function database({ database: databaseName, table: tableName }) {
|
|
|
981
1066
|
if (definedDatabase) (definedDatabase as any).rootStore = rootStore;
|
|
982
1067
|
return rootStore;
|
|
983
1068
|
}
|
|
1069
|
+
function throwIfBlockedByRestore(dbPath: string, databaseName: string): void {
|
|
1070
|
+
const restoreState = checkRestoreState(dbPath);
|
|
1071
|
+
if (restoreState !== 'clear') {
|
|
1072
|
+
const error: any = new Error(
|
|
1073
|
+
restoreState === 'in-progress'
|
|
1074
|
+
? `Database '${databaseName}' is being restored; retry when the restore completes`
|
|
1075
|
+
: `Database '${databaseName}' has an incomplete restore; rerun restore_backup to recover it`
|
|
1076
|
+
);
|
|
1077
|
+
error.statusCode = 409;
|
|
1078
|
+
throw error;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Take the per-database restore lock for a drop, refusing (409) if a restore holds it (in-progress)
|
|
1084
|
+
* or a crashed restore left a marker (incomplete). Pushes the acquired lock onto `held` so the
|
|
1085
|
+
* caller releases it after the drop. On refusal, releases anything already held and throws.
|
|
1086
|
+
*
|
|
1087
|
+
* The lock is not reentrant within a process, so a path already in `held` must be skipped — every
|
|
1088
|
+
* table in a RocksDB database shares one root store (and one lock path), and re-acquiring it in the
|
|
1089
|
+
* same drop would spuriously 409 on the second table.
|
|
1090
|
+
*/
|
|
1091
|
+
function lockDatabaseForDrop(dbPath: string, databaseName: string, held: RestoreLock[]): void {
|
|
1092
|
+
if (held.some((h) => h.dbPath === dbPath)) return;
|
|
1093
|
+
let lock: RestoreLock;
|
|
1094
|
+
try {
|
|
1095
|
+
lock = acquireRestoreLock(dbPath);
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
for (const h of held) releaseRestoreLock(h);
|
|
1098
|
+
throw error; // 409: a restore is in progress and holds the lock
|
|
1099
|
+
}
|
|
1100
|
+
// We now hold the lock, so no restore is active. A surviving marker is therefore debris from a
|
|
1101
|
+
// crashed restore (incomplete) — refuse rather than delete a directory that still needs recovery.
|
|
1102
|
+
if (restoreMarkerPresent(dbPath)) {
|
|
1103
|
+
releaseRestoreLock(lock);
|
|
1104
|
+
for (const h of held) releaseRestoreLock(h);
|
|
1105
|
+
const error: any = new Error(
|
|
1106
|
+
`Database '${databaseName}' has an incomplete restore; rerun restore_backup to recover it`
|
|
1107
|
+
);
|
|
1108
|
+
error.statusCode = 409;
|
|
1109
|
+
throw error;
|
|
1110
|
+
}
|
|
1111
|
+
held.push(lock);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
984
1114
|
/**
|
|
985
1115
|
* Delete the database
|
|
986
1116
|
* @param databaseName
|
|
@@ -990,17 +1120,105 @@ export async function dropDatabase(databaseName) {
|
|
|
990
1120
|
const dbTables = databases[databaseName];
|
|
991
1121
|
let rootStore;
|
|
992
1122
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1123
|
+
// Hold the per-database restore lock across the entire drop so its file deletion can never
|
|
1124
|
+
// interleave with a restore's purge-and-copy on the same directory — a destroy landing after a
|
|
1125
|
+
// restore's copy would gut a "successful" restore, and vice versa. Restore takes the same lock
|
|
1126
|
+
// (before writing its marker), so both operations serialize on this one primitive rather than on
|
|
1127
|
+
// a check-then-act marker probe. Released in the finally below.
|
|
1128
|
+
const restoreLocks: RestoreLock[] = [];
|
|
1129
|
+
try {
|
|
1130
|
+
for (const tableName in dbTables) {
|
|
1131
|
+
const table = dbTables[tableName];
|
|
1132
|
+
rootStore = table.primaryStore.rootStore;
|
|
1133
|
+
if (rootStore instanceof RocksDatabase) lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
|
|
1134
|
+
lmdbDatabaseEnvs.delete(rootStore.path);
|
|
1135
|
+
rocksdbDatabaseEnvs.delete(rootStore.path);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
for (const tableName in dbTables) {
|
|
1139
|
+
databaseEventsEmitter.emit('dropTable', tableName, databaseName);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (databaseName === 'data') {
|
|
1143
|
+
for (const tableName in tables) {
|
|
1144
|
+
delete tables[tableName];
|
|
1145
|
+
}
|
|
1146
|
+
delete tables[DEFINED_TABLES];
|
|
1147
|
+
}
|
|
1148
|
+
delete databases[databaseName];
|
|
1149
|
+
|
|
1150
|
+
databaseEventsEmitter.emit('dropDatabase', databaseName);
|
|
1151
|
+
|
|
1152
|
+
if (rootStore) {
|
|
1153
|
+
if (rootStore.status === 'open') {
|
|
1154
|
+
if (rootStore instanceof RocksDatabase) {
|
|
1155
|
+
rootStore.close();
|
|
1156
|
+
rootStore.destroy();
|
|
1157
|
+
} else {
|
|
1158
|
+
await rootStore.close();
|
|
1159
|
+
await unlink(rootStore.path);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
} else {
|
|
1163
|
+
rootStore = database({ database: databaseName, table: null });
|
|
1164
|
+
// a tableless database resolves its root store here rather than in the loop above, so take
|
|
1165
|
+
// the drop lock now (still before any destructive step)
|
|
1166
|
+
if (rootStore instanceof RocksDatabase) lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
|
|
1167
|
+
if (rootStore instanceof RocksDatabase) {
|
|
1168
|
+
rootStore.close();
|
|
1169
|
+
rootStore.destroy();
|
|
1170
|
+
} else if (rootStore.status === 'open') {
|
|
1171
|
+
await rootStore.close();
|
|
1172
|
+
await unlink(rootStore.path);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
await deleteRootBlobPathsForDB(rootStore);
|
|
1177
|
+
} finally {
|
|
1178
|
+
for (const lock of restoreLocks) releaseRestoreLock(lock);
|
|
998
1179
|
}
|
|
1180
|
+
}
|
|
999
1181
|
|
|
1182
|
+
/**
|
|
1183
|
+
* Close a RocksDB database's store handles on the current thread and unregister it, without
|
|
1184
|
+
* touching its files. Used by the restore_backup flow: every thread must release its handles so
|
|
1185
|
+
* `backups.restore()` can purge and rewrite the (fully closed) database directory. A subsequent
|
|
1186
|
+
* `resetDatabases()`/`getDatabases()` rescan reloads it (or skips it while a restore is in
|
|
1187
|
+
* progress, per the restore marker checks in the scan).
|
|
1188
|
+
*/
|
|
1189
|
+
export function closeDatabase(databaseName: string): boolean {
|
|
1190
|
+
const dbTables = databases[databaseName];
|
|
1191
|
+
if (!dbTables) return false;
|
|
1192
|
+
const rootStores = new Set<any>();
|
|
1193
|
+
const closeStore = (store: any, description: string) => {
|
|
1194
|
+
try {
|
|
1195
|
+
store?.close?.();
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
logger.warn(`Error closing ${description} while closing database ${databaseName}:`, error);
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1000
1200
|
for (const tableName in dbTables) {
|
|
1001
|
-
|
|
1201
|
+
const table: any = dbTables[tableName];
|
|
1202
|
+
if (!table?.primaryStore) continue;
|
|
1203
|
+
if (table.primaryStore.rootStore) rootStores.add(table.primaryStore.rootStore);
|
|
1204
|
+
for (const indexName in table.indices || {}) {
|
|
1205
|
+
closeStore(table.indices[indexName], `index ${tableName}.${indexName}`);
|
|
1206
|
+
}
|
|
1207
|
+
closeStore(table.primaryStore, `table ${tableName}`);
|
|
1002
1208
|
}
|
|
1003
|
-
|
|
1209
|
+
// a database with no tables (an empty schema, or one whose tables were all dropped) still holds
|
|
1210
|
+
// an open root store, tracked only on the defined-database entry rather than any table — include
|
|
1211
|
+
// it so its handles are released too (the Set dedupes it against the per-table root stores above)
|
|
1212
|
+
const definedRoot = (definedDatabases?.get(databaseName) as any)?.rootStore;
|
|
1213
|
+
if (definedRoot) rootStores.add(definedRoot);
|
|
1214
|
+
for (const rootStore of rootStores) {
|
|
1215
|
+
closeStore(rootStore.dbisDb, 'attributes store');
|
|
1216
|
+
closeStore(rootStore, 'root store');
|
|
1217
|
+
lmdbDatabaseEnvs.delete(rootStore.path);
|
|
1218
|
+
rocksdbDatabaseEnvs.delete(rootStore.path);
|
|
1219
|
+
}
|
|
1220
|
+
const definedDatabase = definedDatabases?.get(databaseName);
|
|
1221
|
+
if (definedDatabase) (definedDatabase as any).rootStore = undefined;
|
|
1004
1222
|
if (databaseName === 'data') {
|
|
1005
1223
|
for (const tableName in tables) {
|
|
1006
1224
|
delete tables[tableName];
|
|
@@ -1008,31 +1226,41 @@ export async function dropDatabase(databaseName) {
|
|
|
1008
1226
|
delete tables[DEFINED_TABLES];
|
|
1009
1227
|
}
|
|
1010
1228
|
delete databases[databaseName];
|
|
1229
|
+
return true;
|
|
1230
|
+
}
|
|
1011
1231
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1232
|
+
/**
|
|
1233
|
+
* Close every RocksDB (user) database this thread has open, releasing its native handles.
|
|
1234
|
+
*
|
|
1235
|
+
* rocksdb-js's registry is process-global across worker threads, and a thread that exits WITHOUT
|
|
1236
|
+
* closing leaks its handles (the process-global refCount never drops), while the only alternative,
|
|
1237
|
+
* `shutdown()`, tears down rocksdb for the entire process. So a worker thread that opens databases
|
|
1238
|
+
* and then exits — notably a job worker (jobProcess), which opens the whole database graph via
|
|
1239
|
+
* `getDatabases()` and exits when the job finishes — must close its handles explicitly, or those
|
|
1240
|
+
* handles linger process-wide (and, e.g., block an online `restore_backup` from confirming the
|
|
1241
|
+
* database is closed). The `system` database is intentionally left open: it is non-enumerable here
|
|
1242
|
+
* (skipped by the loop), is never restored online, and the exiting worker may still touch the job
|
|
1243
|
+
* table during teardown. Best-effort: closing failures are swallowed inside `closeDatabase`.
|
|
1244
|
+
*/
|
|
1245
|
+
export function closeLoadedDatabases(): void {
|
|
1246
|
+
// snapshot the names first: closeDatabase() deletes from `databases` as it goes
|
|
1247
|
+
for (const databaseName of Object.keys(databases)) {
|
|
1248
|
+
const dbTables = databases[databaseName];
|
|
1249
|
+
if (!dbTables) continue;
|
|
1250
|
+
let isRocks = false;
|
|
1251
|
+
for (const tableName in dbTables) {
|
|
1252
|
+
if (dbTables[tableName]?.primaryStore?.rootStore instanceof RocksDatabase) {
|
|
1253
|
+
isRocks = true;
|
|
1254
|
+
break;
|
|
1022
1255
|
}
|
|
1023
1256
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
if (rootStore instanceof RocksDatabase) {
|
|
1027
|
-
|
|
1028
|
-
rootStore.destroy();
|
|
1029
|
-
} else if (rootStore.status === 'open') {
|
|
1030
|
-
await rootStore.close();
|
|
1031
|
-
await unlink(rootStore.path);
|
|
1257
|
+
// a tableless database exposes no table root store, so also check the defined-database
|
|
1258
|
+
// entry — otherwise its open root store would leak on worker exit
|
|
1259
|
+
if (!isRocks && (definedDatabases?.get(databaseName) as any)?.rootStore instanceof RocksDatabase) {
|
|
1260
|
+
isRocks = true;
|
|
1032
1261
|
}
|
|
1262
|
+
if (isRocks) closeDatabase(databaseName);
|
|
1033
1263
|
}
|
|
1034
|
-
|
|
1035
|
-
await deleteRootBlobPathsForDB(rootStore);
|
|
1036
1264
|
}
|
|
1037
1265
|
// HNSW_NO_AUTOVERSION kill-switch: when set, a NEW index initializes as legacy rather than
|
|
1038
1266
|
// versioned. process.env values are strings, so a bare truthiness check would treat "0"/"false"
|
|
@@ -2052,7 +2280,9 @@ export function getDefaultCompression() {
|
|
|
2052
2280
|
if (STORAGE_COMPRESSION_DICTIONARY)
|
|
2053
2281
|
LMDB_COMPRESSION_OPTS['dictionary'] = readFileSync(STORAGE_COMPRESSION_DICTIONARY);
|
|
2054
2282
|
if (STORAGE_COMPRESSION_THRESHOLD) LMDB_COMPRESSION_OPTS['threshold'] = STORAGE_COMPRESSION_THRESHOLD;
|
|
2055
|
-
|
|
2283
|
+
// normalize disabled to false so a falsy config value ('' or null) is never persisted
|
|
2284
|
+
// into table metadata as-is (openRocksDatabase maps defined-falsy to 'none')
|
|
2285
|
+
return LMDB_COMPRESSION ? LMDB_COMPRESSION_OPTS : false;
|
|
2056
2286
|
}
|
|
2057
2287
|
|
|
2058
2288
|
/**
|
package/resources/transaction.ts
CHANGED
|
@@ -42,9 +42,6 @@ export function transaction<T>(
|
|
|
42
42
|
if (context.replicatedConfirmation) transaction.replicatedConfirmation = context.replicatedConfirmation;
|
|
43
43
|
if (context.sourceApply) transaction.sourceApply = true;
|
|
44
44
|
transaction.setContext(context);
|
|
45
|
-
|
|
46
|
-
// create a resource cache so that multiple requests to the same resource return the same resource
|
|
47
|
-
if (!context.resourceCache) context.resourceCache = new Map();
|
|
48
45
|
let result;
|
|
49
46
|
try {
|
|
50
47
|
result =
|
package/server/DESIGN.md
CHANGED
|
@@ -119,6 +119,22 @@ Components register listeners with optional `before: 'name'` / `after: 'name'` o
|
|
|
119
119
|
|
|
120
120
|
The default WebSocket upgrade handler is registered automatically inside `onWebSocket()` the first time it runs for a given port.
|
|
121
121
|
|
|
122
|
+
### Application mounts (`host` / `urlPath` in the root config)
|
|
123
|
+
|
|
124
|
+
An operator mounts an application by putting `host`/`urlPath` on its entry in the **root** config; `components/scopeMount.ts` models it and the loader threads it into every `Scope` for that application (both load paths — the root-config `package` recursion and the components-root directory scan).
|
|
125
|
+
|
|
126
|
+
**The mount is applied at exactly one place: `Scope.routeFor()`, used by the `scope.server` proxy.** Do not push it anywhere else. In particular, do not compose it into the plugin config the entry pipeline reads: `entry.urlPath` is what `graphqlSchema` and `jsResource` derive **resource** paths from, and the router strips the mount _before_ REST resolves them. Composing it there registers a table at `/v1/Thing` while REST looks up `Thing`, and every mounted REST route 404s. A single-app `static` test will not catch it — static de-prefixes its own map keys, so it stays self-consistent either way.
|
|
127
|
+
|
|
128
|
+
Consequences worth knowing:
|
|
129
|
+
|
|
130
|
+
- Everything inside an application addresses itself **mount-relative**. Only two things need the absolute path: code that emits a URL back to the client (use `Scope.externalBasePath()` — static's redirect `Location`), and code that bypasses the routed chain (legacy fastify registers on the bare server, so its route prefix must be the full external path). `static.ts`'s mount-root redirect gates on the _external_ base path, not the plugin-local one — a root-level static plugin (`baseURLPath === '/'`) still needs the redirect when the application itself carries a mount, since the client-visible mount root is then `externalBaseURLPath`, not `/`.
|
|
131
|
+
- A plugin registering per-mount state must key it on `Scope.routeFor()`'s resolved route, not on the parts it composes from — distinct `(mount, pluginUrlPath)` pairs can flatten to the same string (`/a`+`bc` and `/ab`+`c`). `REST.ts`'s `startedMounts` does this; it replaced a process-global `started` flag that silently 404'd the second mounted application's REST API. `handleApplication` also closes over `resources`/`httpOptions` per call rather than a module-level var, and skips deploy pre-flight validation scopes (`scope.isTransientValidation`) entirely — registering handlers from a throwaway validation scope would splice a validation run into the live request path and permanently mark that mount started, silently skipping the real scope's later registration.
|
|
132
|
+
- A mount is routing, **not** isolation: exported resources stay instance-wide, and a `host` mount cannot constrain legacy fastify routes — `fastifyRoutes.ts` refuses to load (throws) rather than warn when a `host` mount is configured, since the fallback really is reachable on every host.
|
|
133
|
+
- An invalid mount (unparseable `host`/`urlPath`) fails the application **closed**: `componentLoader.tryRootConfigMount` skips loading it entirely rather than falling back to unmounted access — loading unconstrained would silently drop the isolation the operator asked for, which is worse than not loading at all.
|
|
134
|
+
- Two applications mounted at different routes can register same-named middleware (e.g. both enable `rest`) without colliding: `middlewareChain.resolveRoutedChains` resolves `before`/`after` name references against a registry scoped to that route's own group, falling back to a _global_ registry that only holds genuinely unmounted entries (e.g. `authentication`) — never another mounted route's entries.
|
|
135
|
+
- `host` matching reads `request.host` (Harper's `Request.host` getter), not the raw `Host` header — HTTP/2 clients send `:authority`, never `Host`, so reading the header directly silently 404s every host-mounted app under h2 while h1 keeps working. `hostnameFromHeader` also strips a trailing dot (`api.example.com.`, the absolute-FQDN form some resolvers emit) since it names the same origin.
|
|
136
|
+
- `scopeMount.normalizeMountHost` validates against the same grammar as the `deploy_component` operation's `host` field (bare DNS hostname or IPv6 literal) and throws otherwise, so a hand-typed root-config `host` with a port/scheme/path fails the application closed too, instead of loading it unreachably. `nestScopeMount` logs a warning when a child's `host` is discarded by the parent-authority rule, so that isn't silent.
|
|
137
|
+
|
|
122
138
|
---
|
|
123
139
|
|
|
124
140
|
## Resource ↔ HTTP boundary
|
package/server/REST.ts
CHANGED
|
@@ -23,7 +23,6 @@ import { entryMap } from '../resources/RecordEncoder.ts';
|
|
|
23
23
|
const { errorToString, errorForLog } = harperLogger;
|
|
24
24
|
const etagBytes = new Uint8Array(8);
|
|
25
25
|
const etagFloat = new Float64Array(etagBytes.buffer, 0, 1);
|
|
26
|
-
let httpOptions = {};
|
|
27
26
|
|
|
28
27
|
const OPENAPI_DOMAIN = 'openapi';
|
|
29
28
|
|
|
@@ -118,7 +117,7 @@ async function findInactiveComponent(url: string): Promise<string | undefined> {
|
|
|
118
117
|
}
|
|
119
118
|
}
|
|
120
119
|
|
|
121
|
-
async function http(request: Request, nextHandler) {
|
|
120
|
+
async function http(request: Request, nextHandler, resources: Resources, httpOptions: any) {
|
|
122
121
|
const headersObject = request.headers.asObject;
|
|
123
122
|
const isSse = headersObject.accept === 'text/event-stream';
|
|
124
123
|
const method = isSse ? 'CONNECT' : request.method;
|
|
@@ -395,24 +394,41 @@ async function http(request: Request, nextHandler) {
|
|
|
395
394
|
}
|
|
396
395
|
}
|
|
397
396
|
|
|
398
|
-
|
|
399
|
-
|
|
397
|
+
// One registration per distinct route mount, not one per process. Two applications that both
|
|
398
|
+
// enable `rest` under different root-config mounts each need their own chain — a single global
|
|
399
|
+
// `started` flag registered only the first, silently 404ing the other application's REST API.
|
|
400
|
+
const startedMounts = new Set<string>();
|
|
400
401
|
let addedMetrics;
|
|
401
402
|
let connectionCount = 0;
|
|
402
403
|
|
|
403
404
|
export function handleApplication(scope: import('../components/Scope.ts').Scope) {
|
|
404
|
-
|
|
405
|
+
// A deploy pre-flight validation scope exists only to validate config, and may share a live
|
|
406
|
+
// component's identity — registering real HTTP/WS handlers from it would splice a validation
|
|
407
|
+
// run into the live request path, and permanently marking its mount as started below would
|
|
408
|
+
// leave the REAL scope's later registration silently skipped (review finding).
|
|
409
|
+
if (scope.isTransientValidation) return;
|
|
410
|
+
|
|
411
|
+
const httpOptions = scope.options.getAll();
|
|
405
412
|
if ((httpOptions as any).includeExpensiveRecordCountEstimates) {
|
|
406
413
|
// If they really want to enable expensive record count estimates
|
|
407
414
|
(Request.prototype as any).includeExpensiveRecordCountEstimates = true;
|
|
408
415
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
416
|
+
// Captured per-mount in this closure — not a shared module-level variable — so each mount's
|
|
417
|
+
// registered handler always reads ITS OWN resources/options, not whichever mount happened to
|
|
418
|
+
// register last (review finding: two mounts sharing mutable module globals).
|
|
419
|
+
const resources = scope.resources;
|
|
420
|
+
// Key on the route this registration will actually answer on, not on the parts it is composed
|
|
421
|
+
// from: two configurations that resolve to the same route are the same chain and must dedupe,
|
|
422
|
+
// while two that resolve to different routes must not collide. Concatenating the mount and the
|
|
423
|
+
// plugin's raw urlPath did collide (mount '/a' + 'bc' and mount '/ab' + 'c' both gave '/abc').
|
|
424
|
+
const route = scope.routeFor(httpOptions as any);
|
|
425
|
+
const mountKey = `${route.host ?? ''}|${route.urlPath ?? ''}`;
|
|
426
|
+
if (startedMounts.has(mountKey)) return;
|
|
427
|
+
startedMounts.add(mountKey);
|
|
412
428
|
scope.server.http(
|
|
413
429
|
async (request: any, nextHandler) => {
|
|
414
430
|
if (request.isWebSocket) return;
|
|
415
|
-
return http(request, nextHandler);
|
|
431
|
+
return http(request, nextHandler, resources, httpOptions);
|
|
416
432
|
},
|
|
417
433
|
{ after: 'authentication', ...(httpOptions as any) }
|
|
418
434
|
);
|
package/server/fastifyRoutes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, basename } from 'path';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import { deriveRoutePrefix } from './fastifyRoutes/helpers/deriveRoutePrefix.ts';
|
|
4
|
+
import { resolveBaseURLPath } from '../components/resolveBaseURLPath.ts';
|
|
4
5
|
import fastify from 'fastify';
|
|
5
6
|
import fastifyCors from '@fastify/cors';
|
|
6
7
|
import requestTimePlugin from './serverHelpers/requestTimePlugin.js';
|
|
@@ -38,6 +39,19 @@ const routeFolders = new Set();
|
|
|
38
39
|
export function handleApplication(scope: import('../components/Scope.ts').Scope) {
|
|
39
40
|
// if we have a secure port, need to use the secure HTTP server for fastify (it can be used for HTTP as well)
|
|
40
41
|
const isHttps = (scope.options.getAll() as { securePort?: number }).securePort > 0;
|
|
42
|
+
// Fastify is registered as a global fallback below (`server.http(built.server)` on the bare
|
|
43
|
+
// server, not the scoped one), so it never joins a routed middleware chain. A `urlPath` mount
|
|
44
|
+
// still works — it becomes the fastify route prefix, which is matched against the full external
|
|
45
|
+
// path — but a `host` mount cannot constrain it: these routes would stay reachable under every
|
|
46
|
+
// Host header, silently dropping the isolation the operator declared. Refuse to load rather than
|
|
47
|
+
// imply isolation the fallback doesn't provide (review finding) — contained to this component by
|
|
48
|
+
// the loader's per-component try/catch, so it does not take down the rest of the application.
|
|
49
|
+
const mountedHost = scope.mount?.host ?? (scope.options.getAll() as { host?: string })?.host;
|
|
50
|
+
if (mountedHost) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Application '${basename(scope.appName)}' is mounted on host '${mountedHost}', but its fastifyRoutes cannot be constrained by host: legacy fastify routes are registered as a global fallback and would remain reachable on every host. Port them to server.http() to get host routing, or drop the host mount.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
41
55
|
scope.handleEntry(async (entry) => {
|
|
42
56
|
if (entry.eventType !== 'add') {
|
|
43
57
|
scope.requestRestart();
|
|
@@ -62,7 +76,12 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope)
|
|
|
62
76
|
// ignores the deprecated `path` alias, so it can't express the app-name namespace.
|
|
63
77
|
// `appName` may be an absolute path (loaded via RUN_HDB_APP), hence basename().
|
|
64
78
|
const config = (scope.options.getAll() as { urlPath?: string; path?: string }) ?? {};
|
|
65
|
-
|
|
79
|
+
// Fastify is registered on the bare server, so nothing strips the application's mount before
|
|
80
|
+
// it matches — unlike a routed chain, its prefix must be the full external path.
|
|
81
|
+
const prefix = deriveRoutePrefix(
|
|
82
|
+
basename(scope.appName),
|
|
83
|
+
scope.externalBasePath(resolveBaseURLPath(basename(scope.appName), config.urlPath ?? config.path))
|
|
84
|
+
);
|
|
66
85
|
if (!routeFolders.has(routeFolder)) {
|
|
67
86
|
routeFolders.add(routeFolder);
|
|
68
87
|
try {
|
|
@@ -12,7 +12,7 @@ const harperBridge =
|
|
|
12
12
|
require('../../dataLayer/harperBridge/harperBridge.ts');
|
|
13
13
|
const process = require('process');
|
|
14
14
|
const { isMainThread, workerData } = require('worker_threads');
|
|
15
|
-
const { resetDatabases } = require('../../resources/databases.ts');
|
|
15
|
+
const { resetDatabases, closeDatabase } = require('../../resources/databases.ts');
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* This object/functions are passed to the ITC client instance and dynamically added as event handlers.
|
|
@@ -46,6 +46,12 @@ async function schemaHandler(event) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
hdbLogger.trace(`ITC schemaHandler received schema event:`, event);
|
|
49
|
+
// restore_backup: this thread must release its store handles so the restore can purge and
|
|
50
|
+
// rewrite the database directory. The rescan below (resetDatabases) skips reloading it while
|
|
51
|
+
// the restoring marker is present, and reloads it on the completion signal (marker gone).
|
|
52
|
+
if (event.message?.operation === hdbTerms.OPERATIONS_ENUM.RESTORE_BACKUP && event.message.schema) {
|
|
53
|
+
closeDatabase(event.message.schema);
|
|
54
|
+
}
|
|
49
55
|
await cleanLmdbMap(event.message);
|
|
50
56
|
await syncSchemaMetadata(event.message);
|
|
51
57
|
for (let listener of schemaListeners) {
|
|
@@ -77,7 +77,24 @@ const JOB_ID = JOB_NAME.substring(4);
|
|
|
77
77
|
jobObj.message = err.message ? err.message : err;
|
|
78
78
|
jobObj.end_datetime = moment().valueOf();
|
|
79
79
|
} finally {
|
|
80
|
-
|
|
80
|
+
// A rejected updateJob must not skip handle cleanup and exit scheduling below (that would
|
|
81
|
+
// leak this worker's process-global RocksDB handles and leave the worker hanging).
|
|
82
|
+
try {
|
|
83
|
+
await jobs.updateJob(jobObj);
|
|
84
|
+
} catch (updateErr) {
|
|
85
|
+
harperLogger.error('Error updating job record on job worker exit:', updateErr);
|
|
86
|
+
}
|
|
87
|
+
// Release this worker's RocksDB handles before it exits. A job worker opens the whole
|
|
88
|
+
// database graph via getDatabases(); rocksdb-js's registry is process-global and a thread
|
|
89
|
+
// that exits without closing leaks its handles process-wide, which (among other costs)
|
|
90
|
+
// blocks an online restore_backup from confirming the target database is closed. Best
|
|
91
|
+
// effort — never let cleanup mask the job result.
|
|
92
|
+
try {
|
|
93
|
+
const { closeLoadedDatabases } = await import('../../resources/databases.ts');
|
|
94
|
+
closeLoadedDatabases();
|
|
95
|
+
} catch (closeErr) {
|
|
96
|
+
harperLogger.warn('Error releasing database handles on job worker exit:', closeErr);
|
|
97
|
+
}
|
|
81
98
|
// On Bun 1.3.13, calling process.exit() in a worker thread with lmdb-js loaded
|
|
82
99
|
// while sibling workers are running causes a NAPI fatal error crash. Unref
|
|
83
100
|
// parentPort (which broadcastWithAcknowledgement may have ref'd during schema
|
package/server/jobs/jobRunner.ts
CHANGED
|
@@ -10,6 +10,7 @@ import log from '../../utility/logging/harper_logger.ts';
|
|
|
10
10
|
import * as jobs from './jobs.ts';
|
|
11
11
|
import * as hdbExport from '../../dataLayer/export.ts';
|
|
12
12
|
import * as hdbDelete from '../../dataLayer/delete.ts';
|
|
13
|
+
import * as rocksdbBackup from '../../dataLayer/rocksdbBackup.ts';
|
|
13
14
|
import * as threadsStart from '../threads/manageThreads.js';
|
|
14
15
|
import * as transactionLog from '../../utility/logging/transactionLog.ts';
|
|
15
16
|
import * as restart from '../../bin/restart.ts';
|
|
@@ -79,6 +80,15 @@ async function parseMessage(runnerMessage: any) {
|
|
|
79
80
|
case hdbTerms.JOB_TYPE_ENUM.delete_transaction_logs_before:
|
|
80
81
|
await runJob(runnerMessage, transactionLog.deleteTransactionLogsBefore);
|
|
81
82
|
break;
|
|
83
|
+
case hdbTerms.JOB_TYPE_ENUM.create_backup:
|
|
84
|
+
await runJob(runnerMessage, rocksdbBackup.createBackup);
|
|
85
|
+
break;
|
|
86
|
+
case hdbTerms.JOB_TYPE_ENUM.verify_backup:
|
|
87
|
+
await runJob(runnerMessage, rocksdbBackup.verifyBackup);
|
|
88
|
+
break;
|
|
89
|
+
case hdbTerms.JOB_TYPE_ENUM.restore_backup:
|
|
90
|
+
await runJob(runnerMessage, rocksdbBackup.restoreBackup);
|
|
91
|
+
break;
|
|
82
92
|
case hdbTerms.JOB_TYPE_ENUM.restart_service:
|
|
83
93
|
await runJob(runnerMessage, restart.restartService);
|
|
84
94
|
return `Restarting ${runnerMessage.json.service}`;
|
package/server/jobs/jobs.ts
CHANGED
|
@@ -20,6 +20,7 @@ import * as hdbUtil from '../../utility/common_utils.ts';
|
|
|
20
20
|
import { promisify } from 'util';
|
|
21
21
|
import moment from 'moment';
|
|
22
22
|
import * as fileLoadValidator from '../../validation/fileLoadValidator.ts';
|
|
23
|
+
import * as rocksdbBackup from '../../dataLayer/rocksdbBackup.ts';
|
|
23
24
|
import bulkDeleteValidator from '../../validation/bulkDeleteValidator.ts';
|
|
24
25
|
import { deleteTransactionLogsBeforeValidator } from '../../validation/transactionLogValidator.ts';
|
|
25
26
|
import { handleHDBError, ClientError } from '../../utility/errors/hdbError.ts';
|
|
@@ -122,6 +123,16 @@ export async function addJob(jsonBody: any) {
|
|
|
122
123
|
throw handleHDBError(new Error(), 'Invalid service', HTTP_STATUS_CODES.BAD_REQUEST, undefined, undefined, true);
|
|
123
124
|
}
|
|
124
125
|
break;
|
|
126
|
+
// backup job validators throw ClientError (with statusCode) directly on failure
|
|
127
|
+
case hdbTerms.OPERATIONS_ENUM.CREATE_BACKUP:
|
|
128
|
+
await rocksdbBackup.validateCreateBackup(jsonBody);
|
|
129
|
+
break;
|
|
130
|
+
case hdbTerms.OPERATIONS_ENUM.VERIFY_BACKUP:
|
|
131
|
+
await rocksdbBackup.validateVerifyBackup(jsonBody);
|
|
132
|
+
break;
|
|
133
|
+
case hdbTerms.OPERATIONS_ENUM.RESTORE_BACKUP:
|
|
134
|
+
await rocksdbBackup.validateRestoreBackup(jsonBody);
|
|
135
|
+
break;
|
|
125
136
|
default:
|
|
126
137
|
break;
|
|
127
138
|
}
|