@harperfast/harper-pro 5.0.1 → 5.0.3

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 (91) hide show
  1. package/analytics/profile.ts +47 -0
  2. package/core/bin/cliOperations.js +6 -4
  3. package/core/bin/copyDb.ts +208 -0
  4. package/core/bin/restart.js +8 -7
  5. package/core/bin/run.js +2 -1
  6. package/core/components/Application.ts +24 -9
  7. package/core/components/ApplicationScope.ts +2 -3
  8. package/core/components/componentLoader.ts +13 -2
  9. package/core/config/harperConfigEnvVars.ts +34 -0
  10. package/core/resources/DatabaseTransaction.ts +19 -2
  11. package/core/resources/RecordEncoder.ts +2 -2
  12. package/core/resources/ResourceInterface.ts +1 -1
  13. package/core/resources/RocksIndexStore.ts +20 -15
  14. package/core/resources/Table.ts +50 -25
  15. package/core/resources/analytics/write.ts +7 -10
  16. package/core/resources/databases.ts +29 -14
  17. package/core/resources/indexes/HierarchicalNavigableSmallWorld.ts +67 -30
  18. package/core/security/certificateVerification/ocspVerification.ts +1 -1
  19. package/core/security/jsLoader.ts +68 -22
  20. package/core/security/keys.js +7 -7
  21. package/core/security/user.ts +10 -8
  22. package/core/server/itc/serverHandlers.js +0 -4
  23. package/core/static/defaultConfig.yaml +1 -1
  24. package/core/utility/hdbTerms.ts +1 -0
  25. package/core/utility/install/installer.js +14 -10
  26. package/dist/analytics/profile.js +47 -0
  27. package/dist/analytics/profile.js.map +1 -1
  28. package/dist/cloneNode/cloneNode.js +5 -5
  29. package/dist/cloneNode/cloneNode.js.map +1 -1
  30. package/dist/core/bin/cliOperations.js +6 -4
  31. package/dist/core/bin/cliOperations.js.map +1 -1
  32. package/dist/core/bin/copyDb.js +197 -0
  33. package/dist/core/bin/copyDb.js.map +1 -1
  34. package/dist/core/bin/restart.js +8 -7
  35. package/dist/core/bin/restart.js.map +1 -1
  36. package/dist/core/bin/run.js +3 -1
  37. package/dist/core/bin/run.js.map +1 -1
  38. package/dist/core/components/Application.js +15 -5
  39. package/dist/core/components/Application.js.map +1 -1
  40. package/dist/core/components/ApplicationScope.js +2 -3
  41. package/dist/core/components/ApplicationScope.js.map +1 -1
  42. package/dist/core/components/componentLoader.js +11 -2
  43. package/dist/core/components/componentLoader.js.map +1 -1
  44. package/dist/core/config/harperConfigEnvVars.js +33 -0
  45. package/dist/core/config/harperConfigEnvVars.js.map +1 -1
  46. package/dist/core/resources/DatabaseTransaction.js +17 -2
  47. package/dist/core/resources/DatabaseTransaction.js.map +1 -1
  48. package/dist/core/resources/RecordEncoder.js +2 -2
  49. package/dist/core/resources/RecordEncoder.js.map +1 -1
  50. package/dist/core/resources/RocksIndexStore.js +19 -12
  51. package/dist/core/resources/RocksIndexStore.js.map +1 -1
  52. package/dist/core/resources/Table.js +55 -29
  53. package/dist/core/resources/Table.js.map +1 -1
  54. package/dist/core/resources/analytics/write.js +7 -10
  55. package/dist/core/resources/analytics/write.js.map +1 -1
  56. package/dist/core/resources/databases.js +18 -14
  57. package/dist/core/resources/databases.js.map +1 -1
  58. package/dist/core/resources/indexes/HierarchicalNavigableSmallWorld.js +38 -19
  59. package/dist/core/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  60. package/dist/core/security/certificateVerification/ocspVerification.js +1 -1
  61. package/dist/core/security/certificateVerification/ocspVerification.js.map +1 -1
  62. package/dist/core/security/jsLoader.js +54 -21
  63. package/dist/core/security/jsLoader.js.map +1 -1
  64. package/dist/core/security/keys.js +7 -7
  65. package/dist/core/security/keys.js.map +1 -1
  66. package/dist/core/security/user.js +9 -8
  67. package/dist/core/security/user.js.map +1 -1
  68. package/dist/core/server/itc/serverHandlers.js +0 -4
  69. package/dist/core/server/itc/serverHandlers.js.map +1 -1
  70. package/dist/core/utility/hdbTerms.js +1 -0
  71. package/dist/core/utility/hdbTerms.js.map +1 -1
  72. package/dist/core/utility/install/installer.js +11 -8
  73. package/dist/core/utility/install/installer.js.map +1 -1
  74. package/dist/replication/setNode.js +5 -2
  75. package/dist/replication/setNode.js.map +1 -1
  76. package/dist/security/certificate.js +28 -6
  77. package/dist/security/certificate.js.map +1 -1
  78. package/npm-shrinkwrap.json +2 -2
  79. package/package.json +1 -1
  80. package/replication/setNode.ts +5 -2
  81. package/security/certificate.ts +28 -6
  82. package/static/defaultConfig.yaml +1 -1
  83. package/studio/web/assets/{index-C0iJWrnF.js → index-CxTavHFE.js} +5 -5
  84. package/studio/web/assets/{index-C0iJWrnF.js.map → index-CxTavHFE.js.map} +1 -1
  85. package/studio/web/assets/{index.lazy-C647wC7n.js → index.lazy-CfiR1tvq.js} +2 -2
  86. package/studio/web/assets/{index.lazy-C647wC7n.js.map → index.lazy-CfiR1tvq.js.map} +1 -1
  87. package/studio/web/assets/{profile-BTS_ZjxV.js → profile-C-uokAal.js} +2 -2
  88. package/studio/web/assets/{profile-BTS_ZjxV.js.map → profile-C-uokAal.js.map} +1 -1
  89. package/studio/web/assets/{status-Dc-S5M23.js → status-D6xeT4ss.js} +2 -2
  90. package/studio/web/assets/{status-Dc-S5M23.js.map → status-D6xeT4ss.js.map} +1 -1
  91. package/studio/web/index.html +1 -1
@@ -6,6 +6,10 @@ import { recordAction } from '../core/resources/analytics/write.ts';
6
6
  import { getHdbBasePath } from '../core/utility/environment/environmentManager.js';
7
7
  import { PACKAGE_ROOT } from '../core/utility/packageUtils.js';
8
8
  import { realpathSync, readFileSync, readdirSync } from 'node:fs';
9
+ import { execFile } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+
12
+ const execFileAsync = promisify(execFile);
9
13
  import { time as timeProfiler } from '@datadog/pprof';
10
14
  import { getWorkerIndex } from '../core/server/threads/manageThreads.js';
11
15
  import * as log from '../core/utility/logging/harper_logger.js';
@@ -42,6 +46,7 @@ export function handleApplication({ options }: Scope) {
42
46
  }, 1000); // wait for everything to load before we start the profiler
43
47
  }
44
48
  let lastChildCpuTime = 0;
49
+ let gpuAvailable = true;
45
50
 
46
51
  export async function captureProfile(delayToNextCapture = (capturePeriod ?? 60) * 1000): Promise<void> {
47
52
  clearTimeout(profilerTimer);
@@ -58,6 +63,8 @@ export async function captureProfile(delayToNextCapture = (capturePeriod ?? 60)
58
63
  const samplesByLocationId = new Map<number, number>();
59
64
  let totalUserCount = 0;
60
65
  let totalHarperCount = 0;
66
+ // Start GPU measurement early so it runs in parallel with CPU profiling work
67
+ const gpuPromise = getWorkerIndex() === 0 && gpuAvailable ? getGpuUtilization() : null;
61
68
  try {
62
69
  const profile = timeProfiler.stop(true);
63
70
  const strings = profile.stringTable.strings;
@@ -89,6 +96,11 @@ export async function captureProfile(delayToNextCapture = (capturePeriod ?? 60)
89
96
  recordAction(childCpuTimeInInterval, 'cpu-usage', 'user', 'child-processes');
90
97
  lastChildCpuTime = childCpuTime;
91
98
  }
99
+ // Record GPU utilization for this process and child processes
100
+ const gpuSeconds = await gpuPromise;
101
+ if (gpuSeconds !== null) {
102
+ recordAction(gpuSeconds, 'gpu-usage', 'user');
103
+ }
92
104
  }
93
105
  } catch (error) {
94
106
  log.error?.('analytics profiler error:', error);
@@ -177,6 +189,41 @@ function getChildProcessCpuTime(): number | null {
177
189
  }
178
190
  }
179
191
 
192
+ /**
193
+ * Get the total SM (shader/streaming multiprocessor) utilization percentage across all GPUs
194
+ * for this process and all descendant processes.
195
+ * Uses nvidia-smi pmon for per-process GPU utilization.
196
+ * Only works on Linux with NVIDIA GPUs. Returns null if unavailable.
197
+ */
198
+ async function getGpuUtilization(): Promise<number | null> {
199
+ try {
200
+ const currentPid = process.pid;
201
+ const descendants = findAllDescendants(currentPid);
202
+ const pidsToMonitor = new Set([currentPid, ...descendants]);
203
+
204
+ const { stdout } = await execFileAsync('nvidia-smi', ['pmon', '-c', '1', '-s', 'u']);
205
+
206
+ let totalSmPercent = 0;
207
+ for (const line of stdout.split('\n')) {
208
+ if (line.startsWith('#') || !line.trim()) continue;
209
+ const parts = line.trim().split(/\s+/);
210
+ // pmon -s u format: gpu pid type fb sm enc dec jpg ofa command
211
+ if (parts.length < 5) continue;
212
+ const pid = parseInt(parts[1], 10);
213
+ if (isNaN(pid) || !pidsToMonitor.has(pid)) continue;
214
+ const sm = parseInt(parts[4], 10); // SM utilization %
215
+ if (!isNaN(sm)) totalSmPercent += sm;
216
+ }
217
+
218
+ // Convert SM utilization % to GPU-seconds over the capture period
219
+ // e.g. 50% utilization over 60s = 30 GPU-seconds
220
+ return (totalSmPercent / 100) * (capturePeriod / 1000);
221
+ } catch {
222
+ gpuAvailable = false;
223
+ return null;
224
+ }
225
+ }
226
+
180
227
  /**
181
228
  * Recursively find all descendant PIDs of the given parent PID.
182
229
  */
@@ -63,7 +63,7 @@ function buildRequest() {
63
63
  */
64
64
  async function cliOperations(req) {
65
65
  if (!req.target) {
66
- req.target = process.env.CLI_TARGET;
66
+ req.target = process.env.HARPER_CLI_TARGET || process.env.CLI_TARGET;
67
67
  }
68
68
  let target;
69
69
  if (req.target) {
@@ -73,20 +73,22 @@ async function cliOperations(req) {
73
73
  try {
74
74
  target = new URL(`https://${req.target}:9925`);
75
75
  } catch {
76
- throw error; // throw the original error
76
+ throw error;
77
77
  }
78
78
  }
79
79
  target = {
80
80
  protocol: target.protocol,
81
81
  hostname: target.hostname,
82
82
  port: target.port,
83
- username: req.username || target.username || process.env.CLI_TARGET_USERNAME,
84
- password: req.password || target.password || process.env.CLI_TARGET_PASSWORD,
83
+ username: req.username || target.username || process.env.HARPER_CLI_USERNAME || process.env.CLI_TARGET_USERNAME,
84
+ password: req.password || target.password || process.env.HARPER_CLI_PASSWORD || process.env.CLI_TARGET_PASSWORD,
85
85
  rejectUnauthorized: req.rejectUnauthorized,
86
86
  };
87
+ console.error(`Connecting to ${target.protocol}//${target.hostname}:${target.port}`);
87
88
  } else {
88
89
  // if we aren't doing a targeted operation (like deploy), we initialize the config and verify that local harper
89
90
  // is running and that we can communicate with it.
91
+ console.error('Connecting to local Harper Pro instance');
90
92
  initConfig();
91
93
  if (!getHdbPid()) {
92
94
  console.error('Harper Pro must be running to perform this operation');
@@ -2,6 +2,7 @@ import { getDatabases, getDefaultCompression, resetDatabases } from '../resource
2
2
  import { open } from 'lmdb';
3
3
  import { join } from 'path';
4
4
  import { move, remove } from 'fs-extra';
5
+ import { existsSync, mkdirSync } from 'node:fs';
5
6
  import { get } from '../utility/environment/environmentManager.js';
6
7
  import OpenEnvironmentObject from '../utility/lmdb/OpenEnvironmentObject.js';
7
8
  import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.js';
@@ -11,6 +12,8 @@ import { AUDIT_STORE_OPTIONS } from '../resources/auditStore.ts';
11
12
  import { describeSchema } from '../dataLayer/schemaDescribe.js';
12
13
  import { updateConfigValue } from '../config/configUtils.js';
13
14
  import * as hdbLogger from '../utility/logging/harper_logger.js';
15
+ import { RocksDatabase, type RocksDatabaseOptions } from '@harperfast/rocksdb-js';
16
+ import { RocksIndexStore } from '../resources/RocksIndexStore.ts';
14
17
 
15
18
  export async function compactOnStart() {
16
19
  hdbLogger.notify('Running compact on start');
@@ -278,3 +281,208 @@ export async function copyDb(sourceDatabase: string, targetDatabasePath: string)
278
281
  targetEnv.close();
279
282
  }
280
283
  }
284
+
285
+ function openRocksDb(path: string, options: RocksDatabaseOptions & { dupSort?: boolean } = {}) {
286
+ options.disableWAL ??= false;
287
+ if (!existsSync(path)) {
288
+ mkdirSync(path, { recursive: true });
289
+ }
290
+ let db;
291
+ if (options.dupSort) {
292
+ db = RocksDatabase.open(new RocksIndexStore(path, options));
293
+ } else {
294
+ db = RocksDatabase.open(path, options);
295
+ db.encoder.name = options.name;
296
+ }
297
+ return db;
298
+ }
299
+
300
+ export async function migrateOnStart() {
301
+ hdbLogger.notify('Running migrate on start (LMDB to RocksDB)');
302
+ console.log('Running migrate on start (LMDB to RocksDB)');
303
+
304
+ const rootPath = get(CONFIG_PARAMS.ROOTPATH);
305
+ const databases = getDatabases();
306
+
307
+ updateConfigValue(CONFIG_PARAMS.STORAGE_MIGRATEONSTART, false);
308
+
309
+ try {
310
+ for (const databaseName in databases) {
311
+ if (databaseName === 'system') continue;
312
+ if (databaseName.endsWith('-copy')) continue;
313
+ let rootStore;
314
+ for (const tableName in databases[databaseName]) {
315
+ const table = databases[databaseName][tableName];
316
+ table.primaryStore.put = noop;
317
+ table.primaryStore.remove = noop;
318
+ for (const attributeName in table.indices) {
319
+ const index = table.indices[attributeName];
320
+ index.put = noop;
321
+ index.remove = noop;
322
+ }
323
+ if (table.auditStore) {
324
+ table.auditStore.put = noop;
325
+ table.auditStore.remove = noop;
326
+ }
327
+ rootStore = table.primaryStore.rootStore;
328
+ }
329
+ if (!rootStore) {
330
+ console.log("Couldn't find any tables in database", databaseName);
331
+ continue;
332
+ }
333
+ if (rootStore instanceof RocksDatabase) {
334
+ console.log('Database', databaseName, 'is already RocksDB, skipping');
335
+ continue;
336
+ }
337
+
338
+ const targetPath = join(rootPath, DATABASES_DIR_NAME, databaseName);
339
+ const lmdbPath = rootStore.path;
340
+ const backupDest = join(rootPath, 'backup', databaseName + '.mdb');
341
+
342
+ console.log('Migrating', databaseName, 'from LMDB to RocksDB at', targetPath);
343
+
344
+ await copyDbToRocks(rootStore, databaseName, targetPath);
345
+
346
+ // Back up the original LMDB file
347
+ console.log('Backing up LMDB', databaseName, 'to', backupDest);
348
+ try {
349
+ await move(lmdbPath, backupDest, { overwrite: true });
350
+ } catch (error) {
351
+ console.log('Error moving database', lmdbPath, 'to', backupDest, error);
352
+ }
353
+ // Remove the lock file
354
+ try {
355
+ await remove(lmdbPath + '-lock');
356
+ } catch {
357
+ // lock file may not exist
358
+ }
359
+ }
360
+
361
+ try {
362
+ resetDatabases();
363
+ } catch (err) {
364
+ hdbLogger.error('Error resetting databases after migration', err);
365
+ console.error('Error resetting databases after migration', err);
366
+ }
367
+ } catch (err) {
368
+ hdbLogger.error('Error migrating database', err);
369
+ console.error('Error migrating database', err);
370
+ throw err;
371
+ }
372
+ }
373
+
374
+ async function copyDbToRocks(sourceRootStore, sourceDatabase: string, targetPath: string) {
375
+ console.log(`Migrating database ${sourceDatabase} to RocksDB at ${targetPath}`);
376
+ const sourceDbisDb = sourceRootStore.dbisDb;
377
+
378
+ const targetRootStore = openRocksDb(targetPath, { disableWAL: false });
379
+ const targetDbisDb = openRocksDb(targetPath, {
380
+ disableWAL: false,
381
+ name: INTERNAL_DBIS_NAME,
382
+ });
383
+
384
+ let written;
385
+ let outstandingWrites = 0;
386
+ const transaction = sourceDbisDb.useReadTransaction();
387
+ try {
388
+ for (const { key, value: attribute } of sourceDbisDb.getRange({ transaction })) {
389
+ const isPrimary = attribute.isPrimaryKey;
390
+ targetDbisDb.put(key, attribute);
391
+ if (!(isPrimary || attribute.indexed)) continue;
392
+
393
+ // Open source LMDB dbi with default encoding so values are decoded
394
+ const dbiInit = new OpenDBIObject(!isPrimary, isPrimary);
395
+ const sourceDbi = sourceRootStore.openDB(key, dbiInit);
396
+
397
+ let targetDbi;
398
+ if (!isPrimary) {
399
+ targetDbi = openRocksDb(targetPath, { dupSort: true, name: key });
400
+ } else {
401
+ targetDbi = openRocksDb(targetPath, { name: key });
402
+ }
403
+
404
+ console.log('migrating', key, 'from', sourceDatabase, 'to RocksDB');
405
+ await copyDbiToRocks(sourceDbi, targetDbi, isPrimary, transaction);
406
+ }
407
+
408
+ // Note: audit store is not migrated because LMDB and RocksDB use fundamentally different
409
+ // audit store formats (LMDB uses a custom binary encoding in a regular DB, RocksDB uses TransactionLog).
410
+ // A new audit store will be created automatically when the RocksDB database is opened.
411
+
412
+ await written;
413
+ console.log('migrated database ' + sourceDatabase + ' to RocksDB');
414
+ } finally {
415
+ transaction.done();
416
+ targetRootStore.close();
417
+ }
418
+
419
+ async function copyDbiToRocks(sourceDbi, targetDbi, isPrimary, transaction) {
420
+ let recordsCopied = 0;
421
+ let skippedRecord = 0;
422
+ let retries = 1000000;
423
+ let start = null;
424
+ while (retries-- > 0) {
425
+ try {
426
+ if (isPrimary) {
427
+ for (const { key, value, version } of sourceDbi.getRange({ start, transaction, versions: true })) {
428
+ try {
429
+ start = key;
430
+ if (value == null) {
431
+ skippedRecord++;
432
+ continue;
433
+ }
434
+ written = targetDbi.put(key, value, version);
435
+ recordsCopied++;
436
+ if (transaction.openTimer) transaction.openTimer = 0;
437
+ if (outstandingWrites++ > 5000) {
438
+ await written;
439
+ console.log('migrated', recordsCopied, 'entries, skipped', skippedRecord, 'delete records');
440
+ outstandingWrites = 0;
441
+ }
442
+ } catch (error) {
443
+ console.error(
444
+ 'Error migrating record',
445
+ typeof key === 'symbol' ? 'symbol' : key,
446
+ 'from',
447
+ sourceDatabase,
448
+ error
449
+ );
450
+ }
451
+ }
452
+ } else {
453
+ for (const { key, value } of sourceDbi.getRange({ start, transaction })) {
454
+ try {
455
+ start = key;
456
+ written = targetDbi.put(key, value);
457
+ recordsCopied++;
458
+ if (transaction.openTimer) transaction.openTimer = 0;
459
+ if (outstandingWrites++ > 5000) {
460
+ await written;
461
+ console.log('migrated', recordsCopied, 'index entries');
462
+ outstandingWrites = 0;
463
+ }
464
+ } catch (error) {
465
+ console.error(
466
+ 'Error migrating index record',
467
+ typeof key === 'symbol' ? 'symbol' : key,
468
+ 'from',
469
+ sourceDatabase,
470
+ error
471
+ );
472
+ }
473
+ }
474
+ }
475
+ console.log('finish migrating, copied', recordsCopied, 'entries, skipped', skippedRecord, 'delete records');
476
+ return;
477
+ } catch {
478
+ if (typeof start === 'string') {
479
+ if (start === 'z') {
480
+ return console.error('Reached end of dbi', start, 'for', sourceDatabase);
481
+ }
482
+ start = start.slice(0, -2) + 'z';
483
+ } else if (typeof start === 'number') start++;
484
+ else return console.error('Unknown key type', start, 'for', sourceDatabase);
485
+ }
486
+ }
487
+ }
488
+ }
@@ -61,12 +61,6 @@ async function restart(req) {
61
61
 
62
62
  if (envMgr.get(hdbTerms.CONFIG_PARAMS.STORAGE_COMPACTONSTART)) await compactOnStart();
63
63
 
64
- if (process.env.HARPER_EXIT_ON_RESTART) {
65
- // use this to exit the process so that it will be restarted by the
66
- // PM/container/orchestrator.
67
- hdbLogger.warn('Exiting Harper Pro process to trigger a container restart');
68
- process.exit(0);
69
- }
70
64
  setTimeout(async () => {
71
65
  // It seems like you should just be able to start the other process and kill this process and everything should
72
66
  // be cleaned up, however that doesn't work for some reason; the socket listening fds somehow get transferred to the
@@ -79,9 +73,16 @@ async function restart(req) {
79
73
  // remove pid file so it doesn't trip up the launch
80
74
  await unlinkSync(path.join(envMgr.get(hdbTerms.CONFIG_PARAMS.ROOTPATH), hdbTerms.HDB_PID_FILE), `${process.pid}`);
81
75
  hdbLogger.debug('Starting new process...');
76
+ if (process.env.HARPER_EXIT_ON_RESTART) {
77
+ // use this to exit the process so that it will be restarted by the
78
+ // PM/container/orchestrator.
79
+ hdbLogger.warn('Exiting Harper Pro process to trigger a container restart');
80
+ process.exit(0);
81
+ }
82
82
  // now launch the new process and exit this process
83
83
  require('./run.js').launch(true);
84
- }, 50); // can't await this because it is going to do an exit()
84
+ }, 50); // can't await this because it is going to do an exit(), but wait for 50ms so we give the HTTP thread a
85
+ // chance to return a response
85
86
  } else {
86
87
  // Post msg to main parent thread requesting it restart (so the main thread can process.exit())
87
88
  parentPort.postMessage({
package/core/bin/run.js CHANGED
@@ -19,7 +19,7 @@ const installation = require('../utility/installation.ts');
19
19
  const configUtils = require('../config/configUtils.js');
20
20
  const assignCMDENVVariables = require('../utility/assignCmdEnvVariables.js');
21
21
  const upgrade = require('./upgrade.js');
22
- const { compactOnStart } = require('./copyDb.ts');
22
+ const { compactOnStart, migrateOnStart } = require('./copyDb.ts');
23
23
  const minimist = require('minimist');
24
24
  const keys = require('../security/keys.js');
25
25
  const { startHTTPThreads } = require('../server/threads/socketRouter.ts');
@@ -192,6 +192,7 @@ async function main(calledByInstall = false) {
192
192
  await initialize(calledByInstall, true);
193
193
 
194
194
  if (env.get(terms.CONFIG_PARAMS.STORAGE_COMPACTONSTART)) await compactOnStart();
195
+ if (env.get(terms.CONFIG_PARAMS.STORAGE_MIGRATEONSTART)) await migrateOnStart();
195
196
 
196
197
  const isScripted = process.env.IS_SCRIPTED_SERVICE && !cmdArgs.service;
197
198
 
@@ -172,15 +172,30 @@ export async function extractApplication(application: Application) {
172
172
  }
173
173
  }
174
174
  } else {
175
- // Given a package, resolve using `npm pack` (downloads the package as a tarball and writes the path to stdout)
176
- const {
177
- stdout: tarballFilePath,
178
- code,
179
- stderr,
180
- } = await nonInteractiveSpawn(application.name, 'npm', ['pack', application.packageIdentifier], parentDirPath);
181
- if (code !== 0) throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
182
- tarballPath = join(parentDirPath, tarballFilePath.trim());
183
- // Create a Readable from the tarball
175
+ // `npm pack --json` writes a JSON array describing the packed tarball(s).
176
+ const { stdout, code, stderr } = await nonInteractiveSpawn(
177
+ application.name,
178
+ 'npm',
179
+ ['pack', '--json', application.packageIdentifier],
180
+ parentDirPath
181
+ );
182
+ if (code !== 0) {
183
+ throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
184
+ }
185
+
186
+ let packResult: Array<{ filename: string }>;
187
+ try {
188
+ packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
189
+ } catch (err) {
190
+ throw new Error(
191
+ `Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
192
+ );
193
+ }
194
+ if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
195
+ throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
196
+ }
197
+
198
+ tarballPath = join(parentDirPath, packResult[0].filename);
184
199
  tarball = createReadStream(tarballPath);
185
200
  }
186
201
  }
@@ -21,10 +21,10 @@ export class ApplicationScope {
21
21
  server: Server;
22
22
  mode?: 'native' | 'vm' | 'vm-current-context' | 'compartment'; // option to set this from the scope
23
23
  dependencyLoader?: 'native' | 'app' | 'auto'; // option to set this from the scope
24
- verifyPath?: string;
24
+ allowedPath?: string;
25
25
  config: any;
26
26
  moduleCache: any; // used by the loader to retain a cache of modules, type is an internal detail of the loader
27
- constructor(name: string, resources: Resources, server: Server, isInternal = false, verifyPath?: string) {
27
+ constructor(name: string, resources: Resources, server: Server, isInternal = false) {
28
28
  this.logger = forComponent(name, !isInternal);
29
29
 
30
30
  this.resources = resources;
@@ -32,7 +32,6 @@ export class ApplicationScope {
32
32
 
33
33
  this.mode = env.get(CONFIG_PARAMS.APPLICATIONS_MODULELOADER) ?? 'vm';
34
34
  this.dependencyLoader = env.get(CONFIG_PARAMS.APPLICATIONS_DEPENDENCYLOADER);
35
- this.verifyPath = verifyPath;
36
35
  }
37
36
 
38
37
  /**
@@ -274,7 +274,7 @@ export async function loadComponent(
274
274
  autoReload,
275
275
  appName,
276
276
  } = options;
277
- applicationScope.verifyPath ??= componentDirectory;
277
+ applicationScope.allowedPath ??= realpathSync(componentDirectory);
278
278
  if (providedLoadedComponents) loadedComponents = providedLoadedComponents;
279
279
  try {
280
280
  let config;
@@ -326,8 +326,13 @@ export async function loadComponent(
326
326
 
327
327
  let extensionModule: any;
328
328
  const pkg = componentConfig.package;
329
+ const loadComponentOption = componentConfig.loadComponent ?? 'always';
329
330
  try {
330
331
  if (pkg) {
332
+ if (loadComponentOption === 'dev-only' && !process.env.DEV_MODE) {
333
+ componentLifecycle.loaded(componentStatusName, `Component '${componentStatusName}' skipped (dev-only)`);
334
+ continue;
335
+ }
331
336
  let componentPath: string | null = null;
332
337
  if (isRoot) {
333
338
  componentPath = join(componentDirectory, 'components', componentName);
@@ -344,7 +349,7 @@ export async function loadComponent(
344
349
  }
345
350
  }
346
351
  if (componentPath) {
347
- subApplicationScope.verifyPath ??= componentPath;
352
+ subApplicationScope.allowedPath ??= realpathSync(componentPath);
348
353
  if (!process.env.HARPER_SAFE_MODE) {
349
354
  extensionModule = await loadComponent(componentPath, resources, origin, {
350
355
  isRoot: false,
@@ -354,6 +359,12 @@ export async function loadComponent(
354
359
  });
355
360
  componentFunctionality[componentName] = true;
356
361
  }
362
+ } else if (loadComponentOption === 'if-installed') {
363
+ componentLifecycle.loaded(
364
+ componentStatusName,
365
+ `Component '${componentStatusName}' skipped (not installed)`
366
+ );
367
+ continue;
357
368
  } else {
358
369
  throw new Error(`Unable to find package ${componentName}:${pkg}`);
359
370
  }
@@ -15,6 +15,7 @@ import type { Logger } from '../utility/logging/logger.ts';
15
15
  import * as fs from 'fs-extra';
16
16
  import * as path from 'node:path';
17
17
  import * as crypto from 'node:crypto';
18
+ import { cloneDeep } from 'lodash';
18
19
  import { getBackupDirPath } from './configHelpers.ts';
19
20
 
20
21
  const STATE_FILE_NAME = '.harper-config-state.json';
@@ -590,6 +591,39 @@ function cleanupRemovedEnvVar(
590
591
  logger.debug?.(`${envVarName} removed, cleaned up values`);
591
592
  }
592
593
 
594
+ /**
595
+ * Compose a merged config from HARPER_DEFAULT_CONFIG and HARPER_SET_CONFIG
596
+ * layered with an optional base. Later layers win:
597
+ * HARPER_DEFAULT_CONFIG < base < HARPER_SET_CONFIG
598
+ *
599
+ * HARPER_DEFAULT_CONFIG provides scaffolding defaults, the base (e.g., the
600
+ * user's existing config file) is layered on top, and HARPER_SET_CONFIG
601
+ * force-overrides everything. This matches the precedence applied by the
602
+ * runtime pipeline in applyRuntimeEnvConfig.
603
+ *
604
+ * Unlike applyRuntimeEnvConfig, this does NOT read or write the config state
605
+ * file and does NOT track sources — it returns a fresh object. Use when you
606
+ * need the effective value of a config key before the state/file wiring is in
607
+ * place (e.g., during clone / pre-install).
608
+ */
609
+ export function composeConfigFromEnv(base: ConfigObject = {}): ConfigObject {
610
+ const result: ConfigObject = {};
611
+ const layers: (ConfigObject | null)[] = [
612
+ parseConfigEnvVar(process.env.HARPER_DEFAULT_CONFIG, 'HARPER_DEFAULT_CONFIG'),
613
+ cloneDeep(base),
614
+ parseConfigEnvVar(process.env.HARPER_SET_CONFIG, 'HARPER_SET_CONFIG'),
615
+ ];
616
+
617
+ for (const layer of layers) {
618
+ if (!layer) continue;
619
+ for (const [p, value] of Object.entries(flattenObject(layer))) {
620
+ setNestedValue(result, p, value);
621
+ }
622
+ }
623
+
624
+ return result;
625
+ }
626
+
593
627
  /**
594
628
  * Apply HARPER_DEFAULT_CONFIG and HARPER_SET_CONFIG
595
629
  * Can be used for both install-time and runtime
@@ -7,6 +7,7 @@ import * as envMngr from '../utility/environment/environmentManager.js';
7
7
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
8
8
  import { convertToMS } from '../utility/common_utils.js';
9
9
  import { when } from '../utility/when.ts';
10
+ import { setTimeout as delay } from 'node:timers/promises';
10
11
  import { Transaction as RocksTransaction, type Store as RocksStore } from '@harperfast/rocksdb-js';
11
12
  import type { RootDatabaseKind } from './databases.ts';
12
13
  import type { Entry } from './RecordEncoder.ts';
@@ -19,6 +20,7 @@ export const TRANSACTION_STATE = {
19
20
  OPEN: 1, // the transaction is open and can be used for reads and writes
20
21
  LINGERING: 2, // the transaction has completed a read, but can be used for immediate writes
21
22
  };
23
+ const MAX_RETRIES = 40;
22
24
  let outstandingCommit, outstandingCommitStart;
23
25
  let confirmReplication;
24
26
  export function replicationConfirmation(callback) {
@@ -93,6 +95,7 @@ export class DatabaseTransaction implements Transaction {
93
95
  if (this.open !== TRANSACTION_STATE.OPEN) return; // can not start a new read transaction as there is no future commit that will take place, just have to allow the read to latest database state
94
96
 
95
97
  this.transaction = new RocksTransaction(this.db.store);
98
+
96
99
  if (this.timestamp) {
97
100
  this.transaction.setTimestamp(this.timestamp);
98
101
  }
@@ -158,7 +161,10 @@ export class DatabaseTransaction implements Transaction {
158
161
  transaction ??= this.transaction;
159
162
  let immediateCommit = false;
160
163
  if (!transaction) {
161
- transaction = new RocksTransaction(this.db.store as RocksStore);
164
+ transaction = new RocksTransaction(operation.store.store as RocksStore);
165
+ if (operation.store.rootStore !== this.db.rootStore) {
166
+ harperLogger.warn?.('Created new transaction in save, but the store does match existing store', transaction.id);
167
+ }
162
168
  if (this.open === TRANSACTION_STATE.OPEN) {
163
169
  this.transaction = transaction;
164
170
  } else {
@@ -168,9 +174,10 @@ export class DatabaseTransaction implements Transaction {
168
174
  if (txnTime) {
169
175
  transaction.setTimestamp(txnTime);
170
176
  }
177
+ } else {
171
178
  }
172
179
  if (this.retries > 0) {
173
- // this is marks the rocks transaction as a retry so we don't write the transaction log again
180
+ // This marks the Rocks transaction as a retry so we don't write the transaction log again
174
181
  transaction.isRetry = true;
175
182
  }
176
183
  if (!txnTime) txnTime = this.timestamp = transaction.getTimestamp();
@@ -295,6 +302,16 @@ export class DatabaseTransaction implements Transaction {
295
302
  // if the transaction failed due to concurrent changes, we need to retry. First record this as an increased risk of contention/retry
296
303
  // for future transactions
297
304
  this.retries++;
305
+ harperLogger.debug?.('retrying', transaction.id, this.retries);
306
+ if (this.retries > 2) {
307
+ if (this.retries > MAX_RETRIES) {
308
+ throw new ServerError(
309
+ `After ${MAX_RETRIES} retries, unable to commit transaction, transaction is in conflict with ongoing writes`
310
+ );
311
+ }
312
+ // start delaying, back off to try to space out transactions and avoid excessive conflicts
313
+ return delay(this.retries * this.retries).then(() => this.commit({ transaction }));
314
+ }
298
315
  return this.commit({ transaction }); // try again
299
316
  } else throw error;
300
317
  }
@@ -688,13 +688,13 @@ export function recordUpdater(store, tableId, auditStore) {
688
688
  export function setAdditionalAuditRefs(refs: Array<{ version: number; nodeId: number }> | undefined) {
689
689
  additionalAuditRefsNextEncoding = refs;
690
690
  }
691
- export function removeEntry(store: any, entry: any, existingVersion?: number) {
691
+ export function removeEntry(store: any, entry: any, options?: any) {
692
692
  if (!entry) return;
693
693
  if (entry.value && entry.metadataFlags & HAS_BLOBS) {
694
694
  // if it used to have blobs, we need to delete the old blobs
695
695
  deleteBlobsInObject(entry.value);
696
696
  }
697
- return store.remove(entry.key, existingVersion);
697
+ return store.remove(entry.key, options);
698
698
  }
699
699
  export interface RecordObject {
700
700
  getUpdatedTime(): number;
@@ -69,7 +69,7 @@ export interface Context {
69
69
  /** The user making the request */
70
70
  user?: User;
71
71
  /** Check the username and password against the core user table to verify user identity */
72
- login: (username: string, password: string) => Promise<string>;
72
+ login?: (username: string, password: string) => Promise<string>;
73
73
  /** Describes the current cookie-based session if it is present and grants the capacity to delete it. authentication.enableSessions must be turned on in the harperdb-config.yaml */
74
74
  session?: Session;
75
75
  /** The database transaction object */