@harperfast/harper 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 (86) hide show
  1. package/bin/cliOperations.js +6 -4
  2. package/bin/copyDb.ts +208 -0
  3. package/bin/restart.js +8 -7
  4. package/bin/run.js +2 -1
  5. package/components/Application.ts +24 -9
  6. package/components/ApplicationScope.ts +2 -3
  7. package/components/componentLoader.ts +13 -2
  8. package/config/harperConfigEnvVars.ts +34 -0
  9. package/dist/bin/cliOperations.js +6 -4
  10. package/dist/bin/cliOperations.js.map +1 -1
  11. package/dist/bin/copyDb.d.ts +1 -0
  12. package/dist/bin/copyDb.js +197 -0
  13. package/dist/bin/copyDb.js.map +1 -1
  14. package/dist/bin/restart.js +8 -7
  15. package/dist/bin/restart.js.map +1 -1
  16. package/dist/bin/run.js +3 -1
  17. package/dist/bin/run.js.map +1 -1
  18. package/dist/components/Application.js +15 -5
  19. package/dist/components/Application.js.map +1 -1
  20. package/dist/components/ApplicationScope.d.ts +2 -2
  21. package/dist/components/ApplicationScope.js +2 -3
  22. package/dist/components/ApplicationScope.js.map +1 -1
  23. package/dist/components/componentLoader.js +11 -2
  24. package/dist/components/componentLoader.js.map +1 -1
  25. package/dist/config/harperConfigEnvVars.d.ts +16 -0
  26. package/dist/config/harperConfigEnvVars.js +33 -0
  27. package/dist/config/harperConfigEnvVars.js.map +1 -1
  28. package/dist/resources/DatabaseTransaction.js +17 -2
  29. package/dist/resources/DatabaseTransaction.js.map +1 -1
  30. package/dist/resources/RecordEncoder.d.ts +1 -1
  31. package/dist/resources/RecordEncoder.js +2 -2
  32. package/dist/resources/RecordEncoder.js.map +1 -1
  33. package/dist/resources/ResourceInterface.d.ts +1 -1
  34. package/dist/resources/RocksIndexStore.d.ts +14 -7
  35. package/dist/resources/RocksIndexStore.js +19 -12
  36. package/dist/resources/RocksIndexStore.js.map +1 -1
  37. package/dist/resources/Table.js +55 -29
  38. package/dist/resources/Table.js.map +1 -1
  39. package/dist/resources/analytics/write.js +7 -10
  40. package/dist/resources/analytics/write.js.map +1 -1
  41. package/dist/resources/databases.js +18 -14
  42. package/dist/resources/databases.js.map +1 -1
  43. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -1
  44. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +38 -19
  45. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  46. package/dist/security/certificateVerification/ocspVerification.js +1 -1
  47. package/dist/security/certificateVerification/ocspVerification.js.map +1 -1
  48. package/dist/security/jsLoader.js +54 -21
  49. package/dist/security/jsLoader.js.map +1 -1
  50. package/dist/security/keys.js +7 -7
  51. package/dist/security/keys.js.map +1 -1
  52. package/dist/security/user.js +9 -8
  53. package/dist/security/user.js.map +1 -1
  54. package/dist/server/itc/serverHandlers.js +0 -4
  55. package/dist/server/itc/serverHandlers.js.map +1 -1
  56. package/dist/utility/hdbTerms.d.ts +1 -0
  57. package/dist/utility/hdbTerms.js +1 -0
  58. package/dist/utility/hdbTerms.js.map +1 -1
  59. package/dist/utility/install/installer.js +11 -8
  60. package/dist/utility/install/installer.js.map +1 -1
  61. package/package.json +3 -3
  62. package/resources/DatabaseTransaction.ts +19 -2
  63. package/resources/RecordEncoder.ts +2 -2
  64. package/resources/ResourceInterface.ts +1 -1
  65. package/resources/RocksIndexStore.ts +20 -15
  66. package/resources/Table.ts +50 -25
  67. package/resources/analytics/write.ts +7 -10
  68. package/resources/databases.ts +29 -14
  69. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +67 -30
  70. package/security/certificateVerification/ocspVerification.ts +1 -1
  71. package/security/jsLoader.ts +68 -22
  72. package/security/keys.js +7 -7
  73. package/security/user.ts +10 -8
  74. package/server/itc/serverHandlers.js +0 -4
  75. package/static/defaultConfig.yaml +1 -1
  76. package/studio/web/assets/{index-C0iJWrnF.js → index-CxTavHFE.js} +5 -5
  77. package/studio/web/assets/{index-C0iJWrnF.js.map → index-CxTavHFE.js.map} +1 -1
  78. package/studio/web/assets/{index.lazy-C647wC7n.js → index.lazy-CfiR1tvq.js} +2 -2
  79. package/studio/web/assets/{index.lazy-C647wC7n.js.map → index.lazy-CfiR1tvq.js.map} +1 -1
  80. package/studio/web/assets/{profile-BTS_ZjxV.js → profile-C-uokAal.js} +2 -2
  81. package/studio/web/assets/{profile-BTS_ZjxV.js.map → profile-C-uokAal.js.map} +1 -1
  82. package/studio/web/assets/{status-Dc-S5M23.js → status-D6xeT4ss.js} +2 -2
  83. package/studio/web/assets/{status-Dc-S5M23.js.map → status-D6xeT4ss.js.map} +1 -1
  84. package/studio/web/index.html +1 -1
  85. package/utility/hdbTerms.ts +1 -0
  86. package/utility/install/installer.js +14 -10
@@ -1424,23 +1424,35 @@ export function makeTable(options) {
1424
1424
  */
1425
1425
  static evict(id, existingRecord, existingVersion) {
1426
1426
  let entry;
1427
- if (hasSourceGet || audit) {
1428
- if (!existingRecord) return;
1429
- entry = primaryStore.getEntry(id);
1430
- if (!entry || !existingRecord) return;
1431
- if (entry.version !== existingVersion) return;
1432
- }
1433
- if (hasSourceGet) {
1434
- // if there is a resolution in-progress, abandon the eviction
1435
- if (primaryStore.hasLock(id, entry.version)) return;
1427
+ let transaction = txnForContext({ transaction: new DatabaseTransaction() }).getReadTxn();
1428
+ let options = { transaction };
1429
+ try {
1430
+ if (hasSourceGet || audit) {
1431
+ if (!existingRecord) return;
1432
+ entry = primaryStore.getEntry(id, options);
1433
+ if (!entry || !existingRecord) return;
1434
+ if (entry.version !== existingVersion) return;
1435
+ }
1436
+ if (hasSourceGet) {
1437
+ // if there is a resolution in-progress, abandon the eviction
1438
+ if (primaryStore.hasLock(id, entry.version)) return;
1439
+ }
1440
+ // evictions never go in the audit log, so we can not record a deletion entry for the eviction
1441
+ // as there is no corresponding audit entry and it would never get cleaned up. So we must simply
1442
+ // removed the entry entirely, but first cleanup indices
1443
+ if (primaryStore.ifVersion) {
1444
+ // lmdb
1445
+ primaryStore.ifVersion?.(id, existingVersion, () => {
1446
+ updateIndices(id, existingRecord, null);
1447
+ });
1448
+ return removeEntry(primaryStore, entry ?? primaryStore.getEntry(id), existingVersion);
1449
+ } else {
1450
+ updateIndices(id, existingRecord, null, options);
1451
+ return removeEntry(primaryStore, entry ?? primaryStore.getEntry(id), options);
1452
+ }
1453
+ } finally {
1454
+ return transaction.commit();
1436
1455
  }
1437
- primaryStore.ifVersion?.(id, existingVersion, () => {
1438
- updateIndices(id, existingRecord, null);
1439
- });
1440
- // evictions never go in the audit log, so we can not record a deletion entry for the eviction
1441
- // as there is no corresponding audit entry and it would never get cleaned up. So we must simply
1442
- // removed the entry entirely
1443
- return removeEntry(primaryStore, entry ?? primaryStore.getEntry(id), existingVersion);
1444
1456
  }
1445
1457
  /**
1446
1458
  * This is intended to acquire a lock on a record from the whole cluster.
@@ -1951,9 +1963,10 @@ export function makeTable(options) {
1951
1963
  context.lastModified = existingEntry.version;
1952
1964
  TableResource._updateResource(this, existingEntry);
1953
1965
  }
1954
- if (precedesExistingVersion(txnTime, existingEntry, options?.nodeId) <= 0) return; // a newer record exists locally
1955
- updateIndices(id, existingRecord);
1956
- logger.trace?.(`Deleting record with id: ${id}, txn timestamp: ${new Date(txnTime).toISOString()}`);
1966
+ if (precedesExistingVersion(txnTime, existingEntry, options?.nodeId) < 0) {
1967
+ return;
1968
+ } // a newer record exists locally
1969
+ updateIndices(id, existingRecord, null, transaction && { transaction });
1957
1970
  if (audit || trackDeletes) {
1958
1971
  updateRecord(
1959
1972
  id,
@@ -2262,7 +2275,9 @@ export function makeTable(options) {
2262
2275
  return (entryA, entryB) => {
2263
2276
  const a = getAttributeValue(entryA, order.attribute, context);
2264
2277
  const b = getAttributeValue(entryB, order.attribute, context);
2265
- const diff = descending ? compareKeys(b, a) : compareKeys(a, b);
2278
+ const diff = descending
2279
+ ? compareKeys(convertToComparableKeys(b), convertToComparableKeys(a))
2280
+ : compareKeys(convertToComparableKeys(a), convertToComparableKeys(b));
2266
2281
  if (diff === 0) return nextComparator?.(entryA, entryB) || 0;
2267
2282
  return diff;
2268
2283
  };
@@ -3509,6 +3524,7 @@ export function makeTable(options) {
3509
3524
  // determine what index values need to be removed and added
3510
3525
  let valuesToAdd = getIndexedValues(value, indexNulls) as any[];
3511
3526
  let valuesToRemove = getIndexedValues(existingValue, indexNulls) as any[];
3527
+ let isLMDB = !!index.prefetch;
3512
3528
  if (valuesToRemove?.length > 0) {
3513
3529
  // put this in a conditional so we can do a faster version for new records
3514
3530
  // determine the changes/diff from new values and old values
@@ -3525,18 +3541,18 @@ export function makeTable(options) {
3525
3541
  })
3526
3542
  : [];
3527
3543
  valuesToRemove = Array.from(setToRemove);
3528
- if ((valuesToRemove.length > 0 || valuesToAdd.length > 0) && LMDB_PREFETCH_WRITES) {
3544
+ if (isLMDB && (valuesToRemove.length > 0 || valuesToAdd.length > 0) && LMDB_PREFETCH_WRITES) {
3529
3545
  // prefetch any values that have been removed or added
3530
3546
  const valuesToPrefetch = valuesToRemove.concat(valuesToAdd).map((v) => ({ key: v, value: id }));
3531
- index.prefetch?.(valuesToPrefetch, noop);
3547
+ index.prefetch(valuesToPrefetch, noop);
3532
3548
  }
3533
3549
  //if the update cleared out the attribute value we need to delete it from the index
3534
3550
  for (let i = 0, l = valuesToRemove.length; i < l; i++) {
3535
3551
  index.remove(valuesToRemove[i], id, options);
3536
3552
  }
3537
- } else if (valuesToAdd?.length > 0 && LMDB_PREFETCH_WRITES) {
3553
+ } else if (isLMDB && valuesToAdd?.length > 0 && LMDB_PREFETCH_WRITES) {
3538
3554
  // no old values, just new
3539
- index.prefetch?.(
3555
+ index.prefetch(
3540
3556
  valuesToAdd.map((v) => ({ key: v, value: id })),
3541
3557
  noop
3542
3558
  );
@@ -4122,7 +4138,7 @@ export function makeTable(options) {
4122
4138
  // don't do anything if the version has changed
4123
4139
  return;
4124
4140
  }
4125
- updateIndices(id, existingRecord, updatedRecord);
4141
+ updateIndices(id, existingRecord, updatedRecord, transaction && { transaction });
4126
4142
  if (updatedRecord) {
4127
4143
  if (existingEntry) {
4128
4144
  context.previousResidency = TableResource.getResidencyRecord(existingEntry.residencyId);
@@ -4572,3 +4588,12 @@ function hasOtherProcesses(store) {
4572
4588
  return +line.match(/\d+/)?.[0] != pid;
4573
4589
  });
4574
4590
  }
4591
+ function convertToComparableKeys(a) {
4592
+ if (a instanceof Date) {
4593
+ return a.getTime();
4594
+ }
4595
+ if (Array.isArray(a)) {
4596
+ return a.map(convertToComparableKeys);
4597
+ }
4598
+ return a;
4599
+ }
@@ -219,15 +219,12 @@ export async function recordHostname() {
219
219
  const nodeId = stableNodeId(hostname);
220
220
  log.trace?.('recordHostname nodeId:', nodeId);
221
221
  const hostnamesTable = getAnalyticsHostnameTable();
222
- const record = await hostnamesTable.get(nodeId);
223
- if (!record) {
224
- const hostnameRecord = {
225
- id: nodeId,
226
- hostname,
227
- };
228
- log.trace?.(`recordHostname storing hostname: ${JSON.stringify(hostnameRecord)}`);
229
- await hostnamesTable.put(hostnameRecord.id, hostnameRecord);
230
- }
222
+ const hostnameRecord = {
223
+ id: nodeId,
224
+ hostname,
225
+ };
226
+ log.trace?.(`recordHostname storing hostname: ${JSON.stringify(hostnameRecord)}`);
227
+ await hostnamesTable.put(hostnameRecord.id, hostnameRecord);
231
228
  }
232
229
 
233
230
  export interface Metric {
@@ -514,7 +511,7 @@ async function aggregation(fromPeriod, toPeriod = 60000) {
514
511
  await rest();
515
512
  }
516
513
  for (const entry of threadsToAverage) {
517
- // eslint-disable-next-line @typescript-eslint/no-unused-vars,prefer-const
514
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
518
515
  let { path, method, type, metric, count, total, distribution, threads, ...measures } = entry;
519
516
  threads = threads.filter((thread) => thread);
520
517
  for (const measureName in measures) {
@@ -120,9 +120,13 @@ function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSo
120
120
  }
121
121
  let db: RocksRootDatabase;
122
122
  if (options.dupSort) {
123
- db = RocksDatabase.open(new RocksIndexStore(path, options)) as RocksDatabaseEx;
123
+ db = new RocksIndexStore(path, options).open() as RocksDatabaseEx;
124
124
  } else {
125
125
  db = RocksDatabase.open(path, options) as RocksDatabaseEx;
126
+ // the RocksDB put and remove return promises, which masks thrown errors in non-awaiting calls to put/remove,
127
+ // making them unsafe to replace LMDB methods, which will synchronously throw errors if there is a problem
128
+ db.put = db.putSync;
129
+ db.remove = db.removeSync;
126
130
  db.encoder.name = options.name;
127
131
  }
128
132
  db.env = {};
@@ -386,18 +390,18 @@ function initStores(
386
390
  ) {
387
391
  const envInit = new OpenEnvironmentObject(path, false);
388
392
  const internalDbiInit = createOpenDBIObject(false);
389
- let dbisStore = rootStore.dbisDb;
390
- if (!dbisStore) {
393
+ let attributesDbi = rootStore.dbisDb;
394
+ if (!attributesDbi) {
391
395
  if (rootStore instanceof RocksDatabase) {
392
- dbisStore = openRocksDatabase(rootStore.path, {
396
+ attributesDbi = openRocksDatabase(rootStore.path, {
393
397
  ...internalDbiInit,
394
398
  disableWAL: false,
395
399
  name: INTERNAL_DBIS_NAME,
396
400
  }) as RocksDatabaseEx;
397
401
  } else {
398
- dbisStore = rootStore.openDB(INTERNAL_DBIS_NAME, internalDbiInit);
402
+ attributesDbi = rootStore.openDB(INTERNAL_DBIS_NAME, internalDbiInit);
399
403
  }
400
- rootStore.dbisDb = dbisStore;
404
+ rootStore.dbisDb = attributesDbi;
401
405
  }
402
406
 
403
407
  let auditStore = rootStore.auditStore;
@@ -428,7 +432,7 @@ function initStores(
428
432
  definedTables.rootStore = rootStore;
429
433
  const tablesToLoad = new Map<string, any>();
430
434
 
431
- for (const result of dbisStore.getRange({ start: false })) {
435
+ for (const result of attributesDbi.getRange({ start: false })) {
432
436
  const { key, value } = result as { key: string; value: any };
433
437
  let [tableName, attribute_name] = key.toString().split('/');
434
438
  if (attribute_name === '') {
@@ -489,16 +493,16 @@ function initStores(
489
493
  } else {
490
494
  tableId = primaryAttribute.tableId;
491
495
  if (tableId) {
492
- if (tableId >= (dbisStore.getSync(NEXT_TABLE_ID) || 0)) {
493
- dbisStore.putSync(NEXT_TABLE_ID, tableId + 1);
496
+ if (tableId >= (attributesDbi.getSync(NEXT_TABLE_ID) || 0)) {
497
+ attributesDbi.putSync(NEXT_TABLE_ID, tableId + 1);
494
498
  logger.info(`Updating next table id (it was out of sync) to ${tableId + 1} for ${tableName}`);
495
499
  }
496
500
  } else {
497
- primaryAttribute.tableId = tableId = dbisStore.getSync(NEXT_TABLE_ID);
501
+ primaryAttribute.tableId = tableId = attributesDbi.getSync(NEXT_TABLE_ID);
498
502
  if (!tableId) tableId = 1;
499
503
  logger.debug(`Table {tableName} missing an id, assigning {tableId}`);
500
- dbisStore.putSync(NEXT_TABLE_ID, tableId + 1);
501
- dbisStore.putSync(primaryAttribute.key, primaryAttribute);
504
+ attributesDbi.putSync(NEXT_TABLE_ID, tableId + 1);
505
+ attributesDbi.putSync(primaryAttribute.key, primaryAttribute);
502
506
  }
503
507
  const dbiInit = createOpenDBIObject(!primaryAttribute.isPrimaryKey, primaryAttribute.isPrimaryKey);
504
508
  dbiInit.compression = primaryAttribute.compression;
@@ -544,7 +548,18 @@ function initStores(
544
548
  const attribute = attributes.find((attribute) => attribute.name === existingAttribute.name);
545
549
  if (!attribute) {
546
550
  if (existingAttribute.isPrimaryKey) {
547
- logger.error('Unable to remove existing primary key attribute', existingAttribute);
551
+ logger.error(
552
+ new Error('Unable to remove existing primary key attribute'),
553
+ existingAttribute,
554
+ 'from attributes',
555
+ existingAttributes,
556
+ 'in',
557
+ tableName,
558
+ 'requesting new attribute list',
559
+ attributes,
560
+ 'full metadata list',
561
+ Array.from(attributesDbi.getRange({ start: false }))
562
+ );
548
563
  continue;
549
564
  }
550
565
  if (existingAttribute.indexed) {
@@ -581,7 +596,7 @@ function initStores(
581
596
  indices,
582
597
  attributes,
583
598
  schemaDefined: primaryAttribute.schemaDefined,
584
- dbisDB: dbisStore,
599
+ dbisDB: attributesDbi,
585
600
  })
586
601
  );
587
602
  table.schemaVersion = 1;
@@ -130,8 +130,8 @@ export class HierarchicalNavigableSmallWorld {
130
130
  // Generate random level for this new element
131
131
  const level = oldNode.level ?? Math.min(Math.floor(-Math.log(Math.random()) * this.mL), MAX_LEVEL);
132
132
  let currentLevel = entryPoint.level;
133
- if (level >= currentLevel) {
134
- // if we are at this level or higher, make this the new entry point
133
+ if (level > currentLevel) {
134
+ // if we are at a higher level, make this the new entry point
135
135
  if (typeof nodeId !== 'number') {
136
136
  throw new Error('Invalid nodeId: ' + nodeId);
137
137
  }
@@ -142,7 +142,14 @@ export class HierarchicalNavigableSmallWorld {
142
142
  // For each level from top to bottom
143
143
  while (currentLevel > level) {
144
144
  // Search for closest neighbors at current level
145
- const neighbors = this.searchLayer(vector, entryPointId, entryPoint, this.efConstruction, currentLevel);
145
+ const neighbors = this.searchLayer(
146
+ vector,
147
+ entryPointId,
148
+ entryPoint,
149
+ this.efConstruction,
150
+ currentLevel,
151
+ options
152
+ );
146
153
 
147
154
  if (neighbors.length > 0) {
148
155
  entryPointId = neighbors[0].id; // closest neighbor becomes new entry point
@@ -157,7 +164,7 @@ export class HierarchicalNavigableSmallWorld {
157
164
 
158
165
  // Connect the new element to neighbors at its level and below
159
166
  for (let l = Math.min(level, currentLevel); l >= 0; l--) {
160
- let neighbors = this.searchLayer(vector, entryPointId, entryPoint, this.efConstruction, l);
167
+ let neighbors = this.searchLayer(vector, entryPointId, entryPoint, this.efConstruction, l, options);
161
168
  neighbors = neighbors.slice(0, this.M << 1) as SearchResults;
162
169
 
163
170
  if (neighbors.length === 0 && l === 0) {
@@ -232,6 +239,19 @@ export class HierarchicalNavigableSmallWorld {
232
239
  oldNode[l] = oldConnections;
233
240
  }
234
241
  oldConnections.splice(oldPosition, 1);
242
+ // update the distance in the reverse connection if the vector changed
243
+ if (oldConnection.distance !== distance) {
244
+ const neighborNode = updateNode(id, node);
245
+ if (neighborNode[l]) {
246
+ if (Object.isFrozen(neighborNode[l])) {
247
+ neighborNode[l] = neighborNode[l].slice();
248
+ }
249
+ const reverseIdx = neighborNode[l].findIndex(({ id: nid }) => nid === nodeId);
250
+ if (reverseIdx >= 0) {
251
+ neighborNode[l][reverseIdx] = { id: nodeId, distance };
252
+ }
253
+ }
254
+ }
235
255
  } else {
236
256
  // add new connection since this is truly a new connection now
237
257
  this.addConnection(id, updateNode(id, node), nodeId, l, distance, updateNode, options);
@@ -323,16 +343,16 @@ export class HierarchicalNavigableSmallWorld {
323
343
  this.indexStore.put(id, updatedNode, options);
324
344
  }
325
345
  for (const [key, vector] of needsReindexing) {
326
- this.index(key, vector, vector);
346
+ this.index(key, vector, vector, options);
327
347
  }
328
348
  this.checkSymmetry(nodeId, this.indexStore.getSync(nodeId, options), options);
329
349
  }
330
350
 
331
- private getEntryPoint() {
351
+ private getEntryPoint(options: { transaction?: any } = {}) {
332
352
  // Get entry point
333
- const entryPointId = this.indexStore.getSync(ENTRY_POINT);
353
+ const entryPointId = this.indexStore.getSync(ENTRY_POINT, options);
334
354
  if (entryPointId === undefined) return;
335
- const node = this.indexStore.getSync(entryPointId);
355
+ const node = this.indexStore.getSync(entryPointId, options);
336
356
  return { id: entryPointId, ...node };
337
357
  }
338
358
 
@@ -346,6 +366,7 @@ export class HierarchicalNavigableSmallWorld {
346
366
  * @param ef
347
367
  * @param level
348
368
  * @param distanceFunction
369
+ * @param options
349
370
  * @private
350
371
  */
351
372
  private searchLayer(
@@ -354,13 +375,14 @@ export class HierarchicalNavigableSmallWorld {
354
375
  entryPoint: any,
355
376
  ef: number,
356
377
  level: number,
378
+ options: { transaction?: any } = {},
357
379
  distanceFunction = this.distance
358
380
  ): SearchResults {
359
381
  const visited = new Set([entryPointId]);
360
382
  const candidates = [
361
383
  {
362
384
  id: entryPointId,
363
- distance: this.distance(queryVector, entryPoint.vector),
385
+ distance: distanceFunction(queryVector, entryPoint.vector),
364
386
  node: entryPoint,
365
387
  },
366
388
  ];
@@ -383,7 +405,7 @@ export class HierarchicalNavigableSmallWorld {
383
405
  if (visited.has(neighborId) || neighborId === undefined) continue;
384
406
  visited.add(neighborId);
385
407
 
386
- const neighbor = this.indexStore.getSync(neighborId);
408
+ const neighbor = this.indexStore.getSync(neighborId, options);
387
409
  if (!neighbor) continue;
388
410
  this.nodesVisitedCount++;
389
411
  const distance = distanceFunction(queryVector, neighbor.vector);
@@ -416,19 +438,22 @@ export class HierarchicalNavigableSmallWorld {
416
438
  * @param comparator
417
439
  * @param context
418
440
  */
419
- search({
420
- target,
421
- value,
422
- descending,
423
- distance,
424
- comparator,
425
- }: {
426
- target: number[];
427
- value: number;
428
- descending: boolean;
429
- distance: string;
430
- comparator: string;
431
- }) {
441
+ search(
442
+ {
443
+ target,
444
+ value,
445
+ descending,
446
+ distance,
447
+ comparator,
448
+ }: {
449
+ target: number[];
450
+ value: number;
451
+ descending: boolean;
452
+ distance: string;
453
+ comparator: string;
454
+ },
455
+ context: any
456
+ ) {
432
457
  let limit = 0; // zero is ignored, only used if set below
433
458
  switch (comparator) {
434
459
  case 'lt':
@@ -449,14 +474,23 @@ export class HierarchicalNavigableSmallWorld {
449
474
  if (!target) throw new ClientError('A target vector must be provided for an HNSW query');
450
475
  if (!Array.isArray(target)) throw new ClientError('The target vector must be an array');
451
476
 
452
- let entryPoint = this.getEntryPoint();
477
+ const options = context.transaction; // should have a nested RocksDB transaction
478
+ let entryPoint = this.getEntryPoint(options);
453
479
  if (!entryPoint) return [];
454
480
  let entryPointId = entryPoint.id;
455
481
  let results: Candidate[] = [];
456
482
  // For each level from top to bottom
457
483
  for (let l = entryPoint.level; l >= 0; l--) {
458
484
  // Search for closest neighbors at current level
459
- results = this.searchLayer(target, entryPointId, entryPoint, this.efConstructionSearch, l, distanceFunction);
485
+ results = this.searchLayer(
486
+ target,
487
+ entryPointId,
488
+ entryPoint,
489
+ this.efConstructionSearch,
490
+ l,
491
+ options,
492
+ distanceFunction
493
+ );
460
494
 
461
495
  if (results.length > 0) {
462
496
  const neighbor = results[0]; // closest neighbor becomes new entry point
@@ -487,7 +521,7 @@ export class HierarchicalNavigableSmallWorld {
487
521
  // verify that the connection is symmetrical
488
522
  const symmetrical = neighborNode[l]?.find(({ id: nid }) => nid == id);
489
523
  if (!symmetrical) {
490
- logger.info?.('asymmetry detected', neighborNode[l]);
524
+ logger.info?.('asymmetry detected', neighborNode[l], 'does not have', id);
491
525
  }
492
526
  }
493
527
  l++;
@@ -531,10 +565,13 @@ export class HierarchicalNavigableSmallWorld {
531
565
  if (removedNode) {
532
566
  // Remove the reverse connection if it exists
533
567
  if (removedNode[level]) {
534
- removedNode = updateNode(removed.id, removedNode);
535
- removedNode[level] = removedNode[level].filter(({ id }) => id !== fromId);
536
- if (level === 0 && removedNode[level].length === 0) {
537
- logger.info?.('should not remove last connection', fromId, toId);
568
+ const filtered = removedNode[level].filter(({ id }) => id !== fromId);
569
+ if (level === 0 && filtered.length === 0) {
570
+ // don't remove the last connection at level 0 — it would orphan this node
571
+ logger.info?.('skipping removal of last connection', fromId, toId);
572
+ } else {
573
+ removedNode = updateNode(removed.id, removedNode);
574
+ removedNode[level] = filtered;
538
575
  }
539
576
  }
540
577
  }
@@ -95,7 +95,7 @@ export async function verifyOCSP(
95
95
  method: cached.method || 'ocsp',
96
96
  };
97
97
  } catch (error) {
98
- logger.error?.(`OCSP verification error: ${error}`);
98
+ logger.error?.(`OCSP verification error:`, error);
99
99
 
100
100
  // Check failure mode
101
101
  if (config.failureMode === 'fail-closed') {
@@ -14,7 +14,16 @@ import * as child_process from 'node:child_process';
14
14
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
15
15
  import { contentTypes } from '../server/serverHelpers/contentTypes.ts';
16
16
  import type { CompartmentOptions } from 'ses';
17
- import { mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, statSync } from 'node:fs';
17
+ import {
18
+ mkdirSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ unlinkSync,
22
+ openSync,
23
+ closeSync,
24
+ statSync,
25
+ realpathSync,
26
+ } from 'node:fs';
18
27
  import { join } from 'node:path';
19
28
  import { EventEmitter } from 'node:events';
20
29
  import { whenComponentsLoaded } from '../server/threads/threadServer.js';
@@ -495,13 +504,13 @@ async function loadModuleWithVM(moduleUrl: string, scope: ApplicationScope, useC
495
504
  }
496
505
 
497
506
  if (url.startsWith('file://') && usePrivateGlobal) {
498
- checkAllowedModulePath(url, scope.verifyPath);
507
+ checkAllowedModulePath(url, scope.allowedPath);
499
508
  const source = readFileSync(new URL(url), { encoding: 'utf-8' });
500
509
  return createModuleFromSource(url, source, usePrivateGlobal);
501
510
  }
502
511
 
503
512
  // For Node.js built-in modules (node:) and npm packages without application loader for dependency
504
- const replacedModule = checkAllowedModulePath(url, scope.verifyPath);
513
+ const replacedModule = checkAllowedModulePath(url, scope.allowedPath);
505
514
  if (replacedModule) {
506
515
  return createSyntheticModule(url, normalizeImportedModule(replacedModule));
507
516
  }
@@ -570,7 +579,7 @@ async function getCompartment(scope: ApplicationScope, globals) {
570
579
  }
571
580
  return new StaticModuleRecord(moduleText, moduleSpecifier);
572
581
  } else {
573
- checkAllowedModulePath(moduleSpecifier, scope.verifyPath);
582
+ checkAllowedModulePath(moduleSpecifier, scope.allowedPath);
574
583
  const moduleExports = await import(moduleSpecifier);
575
584
  return {
576
585
  imports: [],
@@ -761,23 +770,54 @@ function isProcessRunning(pid: number): boolean {
761
770
  * Acquires an exclusive lock using the PID file itself (synchronously with busy-wait)
762
771
  * Returns 0 if lock was acquired (need to spawn new process), or the existing PID if process is running
763
772
  */
764
- function acquirePidFileLock(pidFilePath: string, maxRetries = 100, retryDelay = 5): number {
773
+ function parsePidFile(content: string): { pid: number; version: number } {
774
+ const lines = content.trim().split('\n');
775
+ const pid = Number.parseInt(lines[0], 10);
776
+ const version = lines.length > 1 ? parseInt(lines[1], 10) : 0;
777
+ return { pid, version };
778
+ }
779
+
780
+ function acquirePidFileLock(
781
+ pidFilePath: string,
782
+ requestedVersion?: number,
783
+ maxRetries = 100,
784
+ retryDelay = 5
785
+ ): { pid: number; version: number } {
765
786
  for (let attempt = 0; attempt < maxRetries; attempt++) {
766
787
  try {
767
788
  // Try to open exclusively - 'wx' fails if file exists
768
789
  const fd = openSync(pidFilePath, 'wx');
769
790
  closeSync(fd);
770
- return 0; // Successfully acquired lock (file created), caller should spawn process
791
+ return { pid: 0, version: 0 }; // Successfully acquired lock (file created), caller should spawn process
771
792
  } catch (err) {
772
793
  if (err.code === 'EEXIST') {
773
794
  // File exists - check if it contains a valid running process
774
795
  try {
775
796
  const pidContent = readFileSync(pidFilePath, 'utf-8');
776
- const existingPid = parseInt(pidContent.trim(), 10);
797
+ const { pid: existingPid, version: existingVersion } = parsePidFile(pidContent);
777
798
 
778
799
  if (!isNaN(existingPid) && isProcessRunning(existingPid)) {
779
- // Valid process is running, return its PID immediately
780
- return existingPid;
800
+ // If the version isn't the one we want, kill the existing process and re-acquire
801
+ if (requestedVersion != null && requestedVersion !== existingVersion) {
802
+ try {
803
+ process.kill(existingPid);
804
+ } catch {
805
+ // Process may have already exited
806
+ }
807
+ try {
808
+ unlinkSync(pidFilePath);
809
+ } catch {
810
+ // Another thread may have removed it
811
+ }
812
+ // Retry to acquire the lock for the new version
813
+ const start = Date.now();
814
+ while (Date.now() - start < retryDelay) {
815
+ // Busy wait for process cleanup
816
+ }
817
+ continue;
818
+ }
819
+ // Valid process is running at same or higher version, return its PID
820
+ return { pid: existingPid, version: existingVersion };
781
821
  }
782
822
 
783
823
  // Invalid/empty PID - check file age to determine if it's stale or being written
@@ -824,6 +864,7 @@ function createSpawn(spawnFunction: (...args: any) => child_process.ChildProcess
824
864
  throw new Error(
825
865
  `Calling ${spawnFunction.name} in Harper must have a process "name" in the options to ensure that a single process is started and reused`
826
866
  );
867
+ const requestedVersion = options?.version;
827
868
 
828
869
  // Ensure PID directory exists
829
870
  const pidDir = join(basePath, 'pids');
@@ -831,20 +872,22 @@ function createSpawn(spawnFunction: (...args: any) => child_process.ChildProcess
831
872
 
832
873
  const pidFilePath = join(pidDir, `${processName}.pid`);
833
874
 
834
- // Try to acquire lock - returns 0 if acquired, or existing PID
835
- const existingPid = acquirePidFileLock(pidFilePath);
875
+ // Try to acquire lock - returns pid: 0 if acquired, or existing PID/version
876
+ const existing = acquirePidFileLock(pidFilePath, requestedVersion);
836
877
 
837
- if (existingPid !== 0) {
878
+ if (existing.pid !== 0) {
838
879
  // Existing process is running, return wrapper
839
- return new ExistingProcessWrapper(existingPid);
880
+ return new ExistingProcessWrapper(existing.pid);
840
881
  }
841
882
 
842
883
  // We acquired the lock (file was created), spawn new process
843
884
  const childProcess = spawnFunction(command, args, options, callback);
844
885
 
845
- // Write PID to the file we just created
886
+ // Write PID (and version if provided) to the file we just created
887
+ const pidFileContent =
888
+ requestedVersion != null ? `${childProcess.pid}\n${requestedVersion}` : childProcess.pid.toString();
846
889
  try {
847
- writeFileSync(pidFilePath, childProcess.pid.toString(), 'utf-8');
890
+ writeFileSync(pidFilePath, pidFileContent, 'utf-8');
848
891
  } catch (err) {
849
892
  // Failed to write PID, clean up
850
893
  try {
@@ -869,21 +912,24 @@ function createSpawn(spawnFunction: (...args: any) => child_process.ChildProcess
869
912
 
870
913
  /**
871
914
  * Validates whether a module can be loaded based on security restrictions and returns the module path or replacement.
872
- * For file URLs, ensures the module is within the containing folder.
915
+ * For file URLs, ensures the module is within the allowed path.
873
916
  * For node built-in modules, checks against an allowlist and returns any replacements.
874
917
  *
875
918
  * @param {string} moduleUrl - The URL or identifier of the module to be loaded, which may be a file: URL, node: URL, or bare module specifier.
876
- * @param {string} containingFolder - The absolute path of the folder that contains the application, used to validate file: URLs are within bounds.
919
+ * @param {string} allowedPath - The absolute path that the module is allowed to load from.
877
920
  * @return {any} Returns undefined for allowed file paths, or a replacement module identifier for allowed node built-in modules.
878
- * @throws {Error} Throws an error if the module is outside the application folder or if the module is not in the allowed list.
921
+ * @throws {Error} Throws an error if the module is outside the allowed path or if the module is not in the allowed list.
879
922
  */
880
- function checkAllowedModulePath(moduleUrl: string, containingFolder?: string): boolean {
923
+ function checkAllowedModulePath(moduleUrl: string, allowedPath?: string): boolean {
881
924
  if (moduleUrl.startsWith('file:')) {
882
- const path = moduleUrl.slice(7);
883
- if (!containingFolder || path.startsWith(containingFolder)) {
925
+ let path = moduleUrl.slice(7);
926
+ try {
927
+ path = realpathSync(path);
928
+ } catch {}
929
+ if (!allowedPath || path.startsWith(allowedPath)) {
884
930
  return;
885
931
  }
886
- throw new Error(`Can not load module outside of application folder ${containingFolder}`);
932
+ throw new Error(`Can not load module outside of allowed path`);
887
933
  }
888
934
  let simpleName = moduleUrl.startsWith('node:') ? moduleUrl.slice(5) : moduleUrl;
889
935
  simpleName = simpleName.split('/')[0];