From b373e29624805e2ec00cfa59e2a2ad2732df5670 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 18 Mar 2026 12:26:01 -0500 Subject: [PATCH 01/16] Don't trigger source refresh for TTL-expired entries when doing search --- resources/Table.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/resources/Table.ts b/resources/Table.ts index 628289133..4c1172267 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -2436,17 +2436,15 @@ export function makeTable(options) { if (entry?.then) return entry.then(transform.bind(this)); record = entry?.value; } - if ( - (checkLoaded && entry?.metadataFlags & (INVALIDATED | EVICTED)) || // invalidated or evicted should go to load from source - (entry?.expiresAt != undefined && entry?.expiresAt < Date.now()) - ) { - // should expiration really apply? - if (context.onlyIfCached) { - return { - [primaryKey]: entry.key, - message: 'This entry has expired', - }; - } + if (entry?.expiresAt != undefined && entry?.expiresAt < Date.now() && context.onlyIfCached) { + return { + [primaryKey]: entry.key, + message: 'This entry has expired', + }; + } + if (checkLoaded && entry?.metadataFlags & (INVALIDATED | EVICTED)) { + // invalidated or evicted should go to load from source; TTL-expired entries are + // returned as stale data during search — freshness is enforced by get(), not search() const loadingFromSource = ensureLoadedFromSource(source, entry.key ?? entry, entry, context); if (loadingFromSource?.then) { return loadingFromSource.then(transform); From 3a2b2868e5991c61383760395813c579a4b24e38 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 18 Mar 2026 14:21:20 -0500 Subject: [PATCH 02/16] Update systeminformation to resolve high-severity vulnerabilities --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9071925e6..1ce0196c2 100644 --- a/package.json +++ b/package.json @@ -222,7 +222,7 @@ "ses": "^1.14.0", "stream-chain": "2.2.5", "stream-json": "1.9.1", - "systeminformation": "5.27.11", + "systeminformation": "^5.31.4", "tar-fs": "3.0.9", "ulidx": "0.5.0", "uuid": "11.1.0", From dc4eed06606b3d648fa5504d0b1f0481c4d526a1 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 18 Mar 2026 10:24:50 -0600 Subject: [PATCH 03/16] Make TLS (re)loading to be explicitly async --- security/keys.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/security/keys.js b/security/keys.js index a75042cdd..3f462f34b 100644 --- a/security/keys.js +++ b/security/keys.js @@ -459,7 +459,7 @@ async function getCertAuthority() { let match; for (let cert of allCerts) { if (!cert.is_authority) continue; - const matchingPrivateKey = await getPrivateKeyByName(cert.private_key_name); + const matchingPrivateKey = getPrivateKeyByName(cert.private_key_name); if (cert.private_key_name && matchingPrivateKey) { const keyCheck = new X509Certificate(cert.certificate).checkPrivateKey(createPrivateKey(matchingPrivateKey)); if (keyCheck) { @@ -724,7 +724,7 @@ function createTLSSelector(type, mtlsOptions) { server.secureContextsListeners = []; } return (SNICallback.ready = new Promise((resolve, reject) => { - async function updateTLS() { + function updateTLS() { try { secureContexts.clear(); caCerts.clear(); @@ -733,7 +733,7 @@ function createTLSSelector(type, mtlsOptions) { resolve(); return; } - for await (const cert of databases.system.hdb_certificate.search([])) { + for (const cert of databases.system.hdb_certificate.search([])) { const certificate = cert.certificate; const certParsed = new X509Certificate(certificate); if (cert.is_authority) { @@ -742,7 +742,7 @@ function createTLSSelector(type, mtlsOptions) { } } - for await (const cert of databases.system.hdb_certificate.search([])) { + for (const cert of databases.system.hdb_certificate.search([])) { try { if (cert.is_authority) { continue; @@ -751,7 +751,7 @@ function createTLSSelector(type, mtlsOptions) { // prefer operations certificates for operations API if (cert.uses?.includes(type)) quality += 1; - const private_key = await getPrivateKeyByName(cert.private_key_name); + const private_key = getPrivateKeyByName(cert.private_key_name); let certificate = cert.certificate; const certParsed = new X509Certificate(certificate); @@ -875,10 +875,10 @@ function createTLSSelector(type, mtlsOptions) { } } -async function getPrivateKeyByName(private_key_name) { +function getPrivateKeyByName(private_key_name) { const private_key = privateKeys.get(private_key_name); if (!private_key && private_key_name) { - return await fs.readFile( + return fs.readFileSync( path.join(envManager.get(CONFIG_PARAMS.ROOTPATH), hdbTerms.LICENSE_KEY_DIR_NAME, private_key_name), 'utf8' ); From 2cb965dc7f12be96f37b8a155c6e5eece2a6b2d9 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Wed, 18 Mar 2026 14:28:40 -0600 Subject: [PATCH 04/16] Improve integration test harperLifecycle: rename setupHarper, add docs for killHarper/logDir/env vars (#235) * Update integration test code * fixup! Update integration test code --- integrationTests/README.md | 12 +- .../deploy/deploy-from-github.test.ts | 4 +- .../deploy/deploy-from-source.test.ts | 4 +- integrationTests/server/crash-replay.test.ts | 6 +- .../server/operation-user-rbac.test.ts | 4 +- .../server/operations-server.test.ts | 6 +- .../server/storage-reclamation.test.ts | 4 +- .../server/thread-management.test.ts | 4 +- integrationTests/utils/README.md | 116 +++++++++++++----- integrationTests/utils/harperLifecycle.ts | 28 ++--- 10 files changed, 117 insertions(+), 71 deletions(-) diff --git a/integrationTests/README.md b/integrationTests/README.md index 0970e6ba8..5b1c255eb 100644 --- a/integrationTests/README.md +++ b/integrationTests/README.md @@ -184,7 +184,7 @@ Integration test utilities are located in the [`integrationTests/utils/`](./util The most commonly used utilities are: -- **`setupHarper(context)`** - Sets up a complete Harper instance for testing. Use in `before()` hooks. +- **`startHarper(context)`** - Sets up a complete Harper instance for testing. Use in `before()` hooks. - **`teardownHarper(context)`** - Tears down a Harper instance and cleans up resources. Use in `after()` hooks. - **`ContextWithHarper`** - TypeScript interface for test context with Harper instance details. - **`targz(dirPath)`** - Compresses a directory into a base64-encoded tar.gz string for application deployment. @@ -193,11 +193,11 @@ The most commonly used utilities are: ```ts import { suite, test, before, after } from 'node:test'; -import { setupHarper, teardownHarper, type ContextWithHarper } from './utils/harperLifecycle.mts'; +import { startHarper, teardownHarper, type ContextWithHarper } from './utils/harperLifecycle.ts'; suite('test suite', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx); + await startHarper(ctx); }); after(async () => { @@ -227,12 +227,12 @@ Copy and paste the following content to get started: */ import { suite, test, before, after } from 'node:test'; import { strictEqual } from 'node:assert/strict'; -// Note: adjust the relative path accordingly (e.g., '../utils/harperLifecycle.mts') -import { setupHarper, teardownHarper, type ContextWithHarper } from './utils/harperLifecycle.mts'; +// Note: adjust the relative path accordingly (e.g., '../utils/harperLifecycle.ts') +import { startHarper, teardownHarper, type ContextWithHarper } from './utils/harperLifecycle.ts'; suite('short description of tests', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx); + await startHarper(ctx); }); after(async () => { diff --git a/integrationTests/deploy/deploy-from-github.test.ts b/integrationTests/deploy/deploy-from-github.test.ts index 3c95d58d7..51b4097dc 100644 --- a/integrationTests/deploy/deploy-from-github.test.ts +++ b/integrationTests/deploy/deploy-from-github.test.ts @@ -5,7 +5,7 @@ */ import { suite, test, before, after } from 'node:test'; import { deepStrictEqual, ok, strictEqual } from 'node:assert/strict'; -import { setupHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; +import { startHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -13,7 +13,7 @@ import { parse } from 'yaml'; suite('GitHub application deployment', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx); + await startHarper(ctx); }); after(async () => { diff --git a/integrationTests/deploy/deploy-from-source.test.ts b/integrationTests/deploy/deploy-from-source.test.ts index 79c5e62cc..90743571d 100644 --- a/integrationTests/deploy/deploy-from-source.test.ts +++ b/integrationTests/deploy/deploy-from-source.test.ts @@ -12,12 +12,12 @@ import { join } from 'node:path'; import { existsSync } from 'node:fs'; import { setTimeout as sleep } from 'node:timers/promises'; -import { setupHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; +import { startHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; import { targz } from '../utils/targz.ts'; suite('Local application deployment', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx); + await startHarper(ctx); }); after(async () => { diff --git a/integrationTests/server/crash-replay.test.ts b/integrationTests/server/crash-replay.test.ts index d1b55871a..9e8d7a407 100644 --- a/integrationTests/server/crash-replay.test.ts +++ b/integrationTests/server/crash-replay.test.ts @@ -3,12 +3,12 @@ * database, so replay needs to work for harper to startup. */ import { suite, test, before, after } from 'node:test'; -import { setupHarper, teardownHarper, type ContextWithHarper, startHarper } from '../utils/harperLifecycle.ts'; +import { startHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; import { equal } from 'node:assert'; suite('Transaction log replay on crash', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx, { + await startHarper(ctx, { config: {}, env: { HARPER_NO_FLUSH_ON_EXIT: true, // specifically don't flush, we are testing restart/replay and simulating a crash @@ -28,7 +28,7 @@ suite('Transaction log replay on crash', (ctx: ContextWithHarper) => { await startHarper(ctx); let response = await sendOperation(ctx.harper, { operation: 'list_roles', - authorization: ctx.admin, + authorization: ctx.harper.admin, }); equal(response.length, 1); equal(response[0].role, 'super_user'); diff --git a/integrationTests/server/operation-user-rbac.test.ts b/integrationTests/server/operation-user-rbac.test.ts index fc1bfa638..bc0f40d5b 100644 --- a/integrationTests/server/operation-user-rbac.test.ts +++ b/integrationTests/server/operation-user-rbac.test.ts @@ -11,7 +11,7 @@ import { suite, test, before, after } from 'node:test'; import { strictEqual, ok } from 'node:assert/strict'; -import { setupHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; +import { startHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; const DATABASE = 'test_db'; const TABLE = 'dogs'; @@ -35,7 +35,7 @@ const STANDARD_USER_PASS = 'Test1234!'; suite('operations RBAC', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx, { config: {}, env: {} }); + await startHarper(ctx, { config: {}, env: {} }); const adminAuth = `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`; diff --git a/integrationTests/server/operations-server.test.ts b/integrationTests/server/operations-server.test.ts index 7928d11cc..8b9289dd2 100644 --- a/integrationTests/server/operations-server.test.ts +++ b/integrationTests/server/operations-server.test.ts @@ -12,11 +12,11 @@ import { ok, strictEqual } from 'node:assert/strict'; import { pack, unpack } from 'msgpackr'; import { encode, decode } from 'cbor-x'; -import { setupHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; +import { startHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; suite('Operations Server', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx, { config: {}, env: {} }); + await startHarper(ctx, { config: {}, env: {} }); }); after(async () => { @@ -95,6 +95,7 @@ suite('Operations Server', (ctx: ContextWithHarper) => { 'Accept': 'application/json', 'Authorization': `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`, }, + // @ts-expect-error - Need to update fetch types to support `Buffer` body: pack({ operation: 'describe_all' }), }); strictEqual(response.status, 200); @@ -140,6 +141,7 @@ suite('Operations Server', (ctx: ContextWithHarper) => { 'Accept': 'application/json', 'Authorization': `Basic ${Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64')}`, }, + // @ts-expect-error - Need to update fetch types to support `Buffer` body: encode({ operation: 'describe_all' }), }); strictEqual(response.status, 200); diff --git a/integrationTests/server/storage-reclamation.test.ts b/integrationTests/server/storage-reclamation.test.ts index 708d4e3a0..6cd2545ae 100644 --- a/integrationTests/server/storage-reclamation.test.ts +++ b/integrationTests/server/storage-reclamation.test.ts @@ -20,7 +20,7 @@ import { suite, test, before, after } from 'node:test'; import { ok, strictEqual } from 'node:assert/strict'; import { setTimeout as sleep } from 'node:timers/promises'; -import { setupHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; +import { startHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; const TEST_DATABASE = 'test'; const TEST_TABLE = 'reclaim'; @@ -29,7 +29,7 @@ suite('Storage reclamation', (ctx: ContextWithHarper) => { before(async () => { // Set a very high reclamation threshold (99%) so reclamation triggers immediately // and a short interval (1 second) for faster test execution - await setupHarper(ctx, { + await startHarper(ctx, { config: { STORAGE_RECLAMATION_THRESHOLD: 0.99, STORAGE_RECLAMATION_INTERVAL: '1s', diff --git a/integrationTests/server/thread-management.test.ts b/integrationTests/server/thread-management.test.ts index a22693fb8..f1aaf41aa 100644 --- a/integrationTests/server/thread-management.test.ts +++ b/integrationTests/server/thread-management.test.ts @@ -8,11 +8,11 @@ import { suite, test, before, after } from 'node:test'; import { strictEqual } from 'node:assert/strict'; -import { setupHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; +import { startHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; suite('Thread Management', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx, { config: {}, env: {} }); + await startHarper(ctx, { config: {}, env: {} }); }); after(async () => { diff --git a/integrationTests/utils/README.md b/integrationTests/utils/README.md index d6c68eed2..8bff0309b 100644 --- a/integrationTests/utils/README.md +++ b/integrationTests/utils/README.md @@ -4,26 +4,41 @@ This directory contains utility functions and modules for Harper integration tes ## Table of Contents -- [Harper Lifecycle Management](#harper-lifecycle-management) -- [Loopback Address Pool](#loopback-address-pool) -- [Compression Utilities](#compression-utilities) +- [Integration Test Utilities](#integration-test-utilities) + - [Table of Contents](#table-of-contents) + - [Harper Lifecycle Management](#harper-lifecycle-management) + - [`startHarper(context, options?): Promise`](#startharpercontext-options-promisecontextwithharper) + - [`StartHarperOptions`](#startharperoptions) + - [`killHarper(context): Promise`](#killharpercontext-promisevoid) + - [`teardownHarper(context): Promise`](#teardownharpercontext-promisevoid) + - [`ContextWithHarper`](#contextwithharper) + - [Loopback Address Pool](#loopback-address-pool) + - [`validateLoopbackAddressPool(): Promise`](#validateloopbackaddresspool-promisevalidationresult) + - [`getNextAvailableLoopbackAddress(): Promise`](#getnextavailableloopbackaddress-promisestring) + - [`releaseLoopbackAddress(address: string): Promise`](#releaseloopbackaddressaddress-string-promisevoid) + - [`releaseAllLoopbackAddressesForCurrentProcess(): Promise`](#releaseallloopbackaddressesforcurrentprocess-promisevoid) + - [Compression Utilities](#compression-utilities) + - [`targz(dirPath: string): Promise`](#targzdirpath-string-promisestring) + - [Scripts](#scripts) + - [`scripts/setup-loopback.sh`](#scriptssetup-loopbacksh) + - [`scripts/run.mts`](#scriptsrunmts) --- ## Harper Lifecycle Management -**Module:** [`harperLifecycle.mts`](./harperLifecycle.mts) +**Module:** [`harperLifecycle.ts`](./harperLifecycle.ts) Provides functions for managing Harper instances during integration tests, including installation, startup, and teardown. -### `setupHarper(context, options?): Promise` +### `startHarper(context, options?): Promise` Sets up a complete Harper instance for testing. **Parameters:** - `context` - [`ContextWithHarper`](#contextwithharper) - The test context object -- `options` - [`SetupHarperOptions`](#setupharperoptions) (optional) - Configuration options for the setup process +- `options` - [`StartHarperOptions`](#startharperoptions) (optional) - Configuration options for the setup process **Returns:** `Promise` - The context with the `harper` property populated @@ -31,8 +46,8 @@ Sets up a complete Harper instance for testing. This method should be used in the `before()` lifecycle hook for a test suite. It performs the following steps: -1. Creates a Harper instance in a temporary directory -2. Assigns a unique loopback address from the loopback address pool +1. Creates a Harper instance in a temporary directory (reuses `ctx.harper.installDir` if already set) +2. Assigns a unique loopback address from the loopback address pool (reuses `ctx.harper.hostname` if already set) 3. Starts Harper with test configuration (which self-installs) 4. Waits for Harper to be fully started, waiting for the startup message to appear in stdout 5. Populates the `context.harper` object with connection details @@ -43,12 +58,12 @@ This method should be used in the `before()` lifecycle hook for a test suite. It ```ts import { suite, test, before, after } from 'node:test'; -import { setupHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.mts'; +import { startHarper, teardownHarper, type ContextWithHarper } from '../utils/harperLifecycle.ts'; // Default setup suite('My test suite', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx); + await startHarper(ctx); }); after(async () => { @@ -64,12 +79,12 @@ suite('My test suite', (ctx: ContextWithHarper) => { --- -### `SetupHarperOptions` +### `StartHarperOptions` -Configuration options for `setupHarper()`. +Configuration options for `startHarper()`. ```typescript -export interface SetupHarperOptions { +export interface StartHarperOptions { startupTimeoutMs?: number; config: any; env: any; @@ -78,13 +93,44 @@ export interface SetupHarperOptions { **Properties:** -- **`config`** - `object` (optional) - Additional configuration options to pass to the Harper CLI. -- **`env`** - `object` (optional) - Additional environment variables to set when starting Harper. +- **`config`** - `object` - Additional configuration options to pass to the Harper CLI. +- **`env`** - `object` - Additional environment variables to set when starting Harper. - **`startupTimeoutMs`** - `number` (optional) - Timeout in milliseconds to wait for Harper to start. Defaults to 30000, or the value of the `HARPER_INTEGRATION_TEST_STARTUP_TIMEOUT_MS` environment variable if set. **Environment Variables:** -- `HARPER_INTEGRATION_TEST_STARTUP_TIMEOUT_MS` - Sets the default startup delay for all tests when `startupTimeoutMs` is not explicitly provided +- `HARPER_INTEGRATION_TEST_STARTUP_TIMEOUT_MS` - Sets the default startup timeout for all tests when `startupTimeoutMs` is not explicitly provided +- `HARPER_INTEGRATION_TEST_INSTALL_PARENT_DIR` - Override the parent directory for Harper installation directories (defaults to the OS temp directory) +- `HARPER_INTEGRATION_TEST_INSTALL_SCRIPT` - Override the path to the Harper CLI script (defaults to `dist/bin/harper.js` relative to the repo root) +- `HARPER_INTEGRATION_TEST_LOG_DIR` - When set, stdout/stderr logs and Harper's `hdb.log` are written to per-suite subdirectories here; logs are deleted automatically on successful exit and retained on failure + +--- + +### `killHarper(context): Promise` + +Kills the running Harper process. Does **not** release the loopback address or remove the installation directory. + +**Parameters:** + +- `context` - [`ContextWithHarper`](#contextwithharper) - The test context with a running Harper instance + +**Returns:** `Promise` + +**Description:** + +Sends `SIGTERM` to the Harper process and waits for it to exit. If the process does not exit within 200ms, `SIGKILL` is sent. + +This is useful for testing Harper restart/crash scenarios. After calling `killHarper()`, call `startHarper()` to restart the instance in the same directory with the same loopback address. + +**Example:** + +```ts +test('recovers after restart', async () => { + await killHarper(ctx); + await startHarper(ctx); + // Harper is running again on the same address +}); +``` --- @@ -100,7 +146,7 @@ Tears down a Harper instance and cleans up all resources. **Description:** -This method should be used in the `after()` lifecycle hook in conjunction with `setupHarper()` and `before()`. It performs the following cleanup steps: +This method should be used in the `after()` lifecycle hook in conjunction with `startHarper()` and `before()`. It performs the following cleanup steps: 1. Stops the Harper instance 2. Releases the loopback address back to the pool @@ -111,7 +157,7 @@ This method should be used in the `after()` lifecycle hook in conjunction with ` ```ts suite('My test suite', (ctx: ContextWithHarper) => { before(async () => { - await setupHarper(ctx); + await startHarper(ctx); }); after(async () => { @@ -131,23 +177,27 @@ TypeScript interface that extends `SuiteContext` and `TestContext` from Node.js **Interface Definition:** ```typescript -interface ContextWithHarper extends SuiteContext, TestContext { - harper: { - installDir: string; - admin: { - username: string; - password: string; - }; - httpURL: string; - operationsAPIURL: string; - loopbackAddress: string; +export interface HarperContext { + installDir: string; + admin: { + username: string; + password: string; }; + httpURL: string; + operationsAPIURL: string; + hostname: string; + process: ChildProcess; + logDir?: string; +} + +export interface ContextWithHarper extends SuiteContext, TestContext { + harper: HarperContext; } ``` **Properties:** -- **`harper`** - `object` - The Harper instance details +- **`harper`** - `HarperContext` - The Harper instance details - **`installDir`** - `string` - The absolute path to the Harper installation directory - **`admin`** - `object` - Admin credentials - **`username`** - `string` - The Harper Admin Username (default: `'admin'`) @@ -155,6 +205,8 @@ interface ContextWithHarper extends SuiteContext, TestContext { - **`httpURL`** - `string` - The HTTP URL for the Harper instance (e.g., `'http://127.0.0.2:9926'`) - **`operationsAPIURL`** - `string` - The Operations API URL (e.g., `'http://127.0.0.2:9925'`) - **`hostname`** - `string` - The assigned loopback IP address (e.g., `'127.0.0.2'`) + - **`process`** - `ChildProcess` - The Node.js child process handle for the running Harper instance + - **`logDir`** - `string | undefined` - Absolute path to the per-suite log directory; only set when `HARPER_INTEGRATION_TEST_LOG_DIR` is configured **Example Usage:** @@ -175,7 +227,7 @@ test('authenticate with admin credentials', async () => { ## Loopback Address Pool -**Module:** [`loopbackAddressPool.mts`](./loopbackAddressPool.mts) +**Module:** [`loopbackAddressPool.ts`](./loopbackAddressPool.ts) Manages a pool of loopback addresses for concurrent test execution. This allows multiple Harper instances to run simultaneously on different loopback addresses without port conflicts. @@ -227,7 +279,7 @@ If no addresses are available, the function waits and retries until one becomes **Pool file location:** `${tmpdir()}/harper-integration-test-loopback-pool.json` **Lock file location:** `${tmpdir()}/harper-integration-test-loopback-pool.lock` -**Note:** This is automatically called by `setupHarper()`. You typically don't need to call this directly unless you're implementing custom test infrastructure. +**Note:** This is automatically called by `startHarper()`. You typically don't need to call this directly unless you're implementing custom test infrastructure. --- @@ -285,7 +337,7 @@ try { ## Compression Utilities -**Module:** [`targz.mts`](./targz.mts) +**Module:** [`targz.ts`](./targz.ts) Provides utilities for compressing directories into tar.gz archives. diff --git a/integrationTests/utils/harperLifecycle.ts b/integrationTests/utils/harperLifecycle.ts index 7a423ea53..6bb20c136 100644 --- a/integrationTests/utils/harperLifecycle.ts +++ b/integrationTests/utils/harperLifecycle.ts @@ -18,7 +18,7 @@ const LOG_DIR = process.env.HARPER_INTEGRATION_TEST_LOG_DIR; /** * Options for setting up a Harper instance. */ -export interface SetupHarperOptions { +export interface StartHarperOptions { /** * Timeout in milliseconds to wait for Harper to start. * @default 30000 @@ -59,8 +59,8 @@ export interface HarperContext { /** * Test context interface with Harper instance details. * - * This interface is populated by `setupHarper()` and contains all necessary - * information to interact with the test Harper instance. + * This interface is populated by `startHarper()` and contains + * all necessary information to interact with the test Harper instance. */ export interface ContextWithHarper extends SuiteContext, TestContext { harper: HarperContext; @@ -184,6 +184,10 @@ function runHarperCommand({ args, env, completionMessage, logDir }: RunHarperCom * This function performs installation, startup, and waits for Harper to be ready. * Always call `teardownHarper()` in the `after()` hook to clean up resources. * + * If `ctx.harper.installDir` or `ctx.harper.hostname` are already set they are + * reused rather than creating new ones — making this safe to call after + * `killHarper()` to restart an existing instance. + * * @param ctx - The test context to populate with Harper instance details * @param options - Optional configuration for the setup process * @returns The context with the `harper` property populated @@ -192,7 +196,7 @@ function runHarperCommand({ args, env, completionMessage, logDir }: RunHarperCom * ```ts * suite('My tests', (ctx: ContextWithHarper) => { * before(async () => { - * await setupHarper(ctx); + * await startHarper(ctx); * }); * * after(async () => { @@ -206,19 +210,7 @@ function runHarperCommand({ args, env, completionMessage, logDir }: RunHarperCom * }); * ``` */ -export async function setupHarper(ctx: ContextWithHarper, options?: SetupHarperOptions): Promise { - return startHarper(ctx, options); -} - -/** - * Starts a Harper instance that has been installed. - * - * This is a lower-level function called by `setupHarper()`. - * Most tests should use `setupHarper()` instead. - * - * @param ctx - The test context with Harper installation details - */ -export async function startHarper(ctx: ContextWithHarper, options?: SetupHarperOptions): Promise { +export async function startHarper(ctx: ContextWithHarper, options?: StartHarperOptions): Promise { // Create a directory for this Harper installation // Use the system temp directory by default, or a custom parent directory if specified const installDirPrefix = join( @@ -322,7 +314,7 @@ export async function killHarper(ctx: ContextWithHarper): Promise { * ```ts * suite('My tests', (ctx: ContextWithHarper) => { * before(async () => { - * await setupHarper(ctx); + * await startHarper(ctx); * }); * * after(async () => { From e06fdbc2e914b134f2d489c9fbdc96e966cd24bf Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 18 Mar 2026 14:51:33 -0500 Subject: [PATCH 05/16] Update fastify to resolve high/low-severity vulnerabilities --- package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 1ce0196c2..7e26c66af 100644 --- a/package.json +++ b/package.json @@ -152,10 +152,10 @@ "@aws-sdk/lib-storage": "3.964.0", "@datadog/pprof": "^5.11.1", "@endo/static-module-record": "^1.1.2", - "@fastify/autoload": "5.10.0", - "@fastify/compress": "~6.5.0", - "@fastify/cors": "~9.0.1", - "@fastify/static": "~7.0.4", + "@fastify/autoload": "^6.3.1", + "@fastify/compress": "^8.3.1", + "@fastify/cors": "^11.2.0", + "@fastify/static": "^9.0.0", "@harperfast/extended-iterable": "^1.0.1", "@harperfast/rocksdb-js": "^0.1.12", "@turf/area": "6.5.0", @@ -179,8 +179,8 @@ "dotenv": "^16.4.7", "easy-ocsp": "1.2.2", "fast-glob": "3.3.3", - "fastify": "~4.29.0", - "fastify-plugin": "~4.5.1", + "fastify": "^5.8.2", + "fastify-plugin": "^5.1.0", "fs-extra": "11.3.3", "graphql": "^16.10.0", "graphql-http": "^1.22.4", From c09c0f9d0ce02d4a632e7b4516f7f9af868a0da0 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 18 Mar 2026 13:54:32 -0600 Subject: [PATCH 06/16] Enqueue message events if there are any in the queue, to better emulate normal message delivery --- server/threads/manageThreads.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/threads/manageThreads.js b/server/threads/manageThreads.js index 041f08088..552ebb4f9 100644 --- a/server/threads/manageThreads.js +++ b/server/threads/manageThreads.js @@ -339,7 +339,9 @@ function onMessageByType(type, listener) { listeners.push(listener); if (messagesQueuedByType.has(type)) { for (let message of messagesQueuedByType.get(type)) { - listener(message); + // enqueue in next event turn; messages always come as events, and trying to do this synchronously can be + // problematic for getting mixed up with module loading + setImmediate(() => listener(message)); } messagesQueuedByType.delete(type); } From efb7b80bebeb16d902ab4112ff63da412e3b9e37 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 18 Mar 2026 14:15:02 -0500 Subject: [PATCH 07/16] Update undici to resolve high-severity vulnerabilities --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7e26c66af..1aefc5488 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "tsx": "^4.20.6", "typescript": "^5.8.2", "typescript-eslint": "^8.45.0", - "undici": "^7.16.0", + "undici": "^7.24.4", "why-is-node-still-running": "^1.0.0" }, "dependencies": { From 44e8965b847a2f3d795a19b9216eaa517d9c7af0 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Thu, 19 Mar 2026 01:30:20 -0500 Subject: [PATCH 08/16] Fix conflicts --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1aefc5488..0dcf06da5 100644 --- a/package.json +++ b/package.json @@ -223,7 +223,7 @@ "stream-chain": "2.2.5", "stream-json": "1.9.1", "systeminformation": "^5.31.4", - "tar-fs": "3.0.9", + "tar-fs": "^3.1.2", "ulidx": "0.5.0", "uuid": "11.1.0", "validate.js": "0.13.1", From 71f797d868787aff28a7999701764183e971b08b Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 18 Mar 2026 14:24:33 -0500 Subject: [PATCH 09/16] Update lodash to resolve moderate-severity vulnerabilities --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0dcf06da5..04cbf9cf7 100644 --- a/package.json +++ b/package.json @@ -194,7 +194,7 @@ "jsonata": "1.8.7", "jsonwebtoken": "9.0.3", "lmdb": "3.5.2", - "lodash": "4.17.21", + "lodash": "^4.17.23", "mathjs": "11.12.0", "micromatch": "^4.0.8", "minimist": "1.2.8", From 949d52a87009397c7403ab2608cd4a9b2c1e2526 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 18 Mar 2026 14:29:18 -0500 Subject: [PATCH 10/16] Update @aws-sdk/client-s3 to resolve critical-severity vulnerabilities --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 04cbf9cf7..f47ecbf34 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,7 @@ "why-is-node-still-running": "^1.0.0" }, "dependencies": { - "@aws-sdk/client-s3": "3.964.0", + "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/lib-storage": "3.964.0", "@datadog/pprof": "^5.11.1", "@endo/static-module-record": "^1.1.2", From 232ccea02abde80b70a0e49c9c1c9e9b8387bbb7 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Wed, 18 Mar 2026 14:36:07 -0500 Subject: [PATCH 11/16] Update mocha to resolve high-severity vulnerabilities --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f47ecbf34..8bfc8c611 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "globals": "^16.5.0", "intercept-stdout": "0.1.2", "mkcert": "^3.2.0", - "mocha": "^11.7.4", + "mocha": "^11.7.5", "mqtt": "~4.3.8", "oxlint": "^1.31.0", "prettier": "~3.7.3", From f68187370f0639e5250292a1b175a8d9689cbac8 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 18 Mar 2026 15:03:46 -0600 Subject: [PATCH 12/16] Update version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8bfc8c611..d8bdf05e7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "harper", "description": "Harper is an open-source Node.js performance platform that unifies database, cache, application, and messaging layers into one in-memory process.", - "version": "5.0.0-beta.1", + "version": "5.0.0-beta.3", "license": "Apache-2.0", "homepage": "https://harper.fast", "bugs": { From 443ddb1f2a3cb4781361813df4c14f95605d42d7 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 18 Mar 2026 17:38:48 -0400 Subject: [PATCH 13/16] fix: Always latest for now --- .github/workflows/publish-npm.yaml | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish-npm.yaml b/.github/workflows/publish-npm.yaml index f90de81e8..9fda142cf 100644 --- a/.github/workflows/publish-npm.yaml +++ b/.github/workflows/publish-npm.yaml @@ -30,12 +30,16 @@ jobs: version: ${{ github.event.release.tag_name }} - name: Set package tag id: package-tag +# Once we have our first full, non-beta release, turn this back on: +# run: | +# if [[ "${{ steps.version-components.outputs.prerelease }}" == '' ]]; then +# echo "packageTag=latest" >> "$GITHUB_OUTPUT" +# else +# echo "packageTag=next" >> "$GITHUB_OUTPUT" +# fi +# and get rid of this hard coded latest tagging: run: | - if [[ "${{ steps.version-components.outputs.prerelease }}" == '' ]]; then - echo "packageTag=latest" >> "$GITHUB_OUTPUT" - else - echo "packageTag=next" >> "$GITHUB_OUTPUT" - fi + echo "packageTag=latest" >> "$GITHUB_OUTPUT" - name: Publish package to NPM registry env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -70,12 +74,16 @@ jobs: tar -czvf harperfast-harper-${{ steps.version-components.outputs.version }}.tgz package - name: Set package tag id: package-tag +# Once we have our first full, non-beta release, turn this back on: +# run: | +# if [[ "${{ steps.version-components.outputs.prerelease }}" == '' ]]; then +# echo "packageTag=latest" >> "$GITHUB_OUTPUT" +# else +# echo "packageTag=next" >> "$GITHUB_OUTPUT" +# fi +# and get rid of this hard coded latest tagging: run: | - if [[ "${{ steps.version-components.outputs.prerelease }}" == '' ]]; then - echo "packageTag=latest" >> "$GITHUB_OUTPUT" - else - echo "packageTag=next" >> "$GITHUB_OUTPUT" - fi + echo "packageTag=latest" >> "$GITHUB_OUTPUT" - name: Publish package to NPM registry env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 3d3e0187fa6913b191b42fe9cb3708f6645f6c16 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 18 Mar 2026 17:42:19 -0400 Subject: [PATCH 14/16] chore: Run formatter --- .github/workflows/publish-npm.yaml | 32 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/publish-npm.yaml b/.github/workflows/publish-npm.yaml index 9fda142cf..c71150885 100644 --- a/.github/workflows/publish-npm.yaml +++ b/.github/workflows/publish-npm.yaml @@ -30,14 +30,14 @@ jobs: version: ${{ github.event.release.tag_name }} - name: Set package tag id: package-tag -# Once we have our first full, non-beta release, turn this back on: -# run: | -# if [[ "${{ steps.version-components.outputs.prerelease }}" == '' ]]; then -# echo "packageTag=latest" >> "$GITHUB_OUTPUT" -# else -# echo "packageTag=next" >> "$GITHUB_OUTPUT" -# fi -# and get rid of this hard coded latest tagging: + # Once we have our first full, non-beta release, turn this back on: + # run: | + # if [[ "${{ steps.version-components.outputs.prerelease }}" == '' ]]; then + # echo "packageTag=latest" >> "$GITHUB_OUTPUT" + # else + # echo "packageTag=next" >> "$GITHUB_OUTPUT" + # fi + # and get rid of this hard coded latest tagging: run: | echo "packageTag=latest" >> "$GITHUB_OUTPUT" - name: Publish package to NPM registry @@ -74,14 +74,14 @@ jobs: tar -czvf harperfast-harper-${{ steps.version-components.outputs.version }}.tgz package - name: Set package tag id: package-tag -# Once we have our first full, non-beta release, turn this back on: -# run: | -# if [[ "${{ steps.version-components.outputs.prerelease }}" == '' ]]; then -# echo "packageTag=latest" >> "$GITHUB_OUTPUT" -# else -# echo "packageTag=next" >> "$GITHUB_OUTPUT" -# fi -# and get rid of this hard coded latest tagging: + # Once we have our first full, non-beta release, turn this back on: + # run: | + # if [[ "${{ steps.version-components.outputs.prerelease }}" == '' ]]; then + # echo "packageTag=latest" >> "$GITHUB_OUTPUT" + # else + # echo "packageTag=next" >> "$GITHUB_OUTPUT" + # fi + # and get rid of this hard coded latest tagging: run: | echo "packageTag=latest" >> "$GITHUB_OUTPUT" - name: Publish package to NPM registry From 3d5acbe73ebfe79243502eacb1304a87ed38c335 Mon Sep 17 00:00:00 2001 From: laviniat1996 Date: Wed, 18 Mar 2026 23:46:59 +0200 Subject: [PATCH 15/16] fix: handle JSON file imports in sandboxed module loader --- security/jsLoader.ts | 95 ++++++++++++++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/security/jsLoader.ts b/security/jsLoader.ts index 2194d757f..abdfcaba2 100644 --- a/security/jsLoader.ts +++ b/security/jsLoader.ts @@ -122,6 +122,18 @@ async function stripTypeScriptTypes(source: string): Promise { return amaro.transformSync(source, { mode: 'strip-only' }).code; } +/** + * Parse a JSON string and return the resulting object. Wraps JSON.parse errors + * with the module URL for easier debugging. + */ +function parseJsonModule(source: string, url: string): any { + try { + return JSON.parse(source); + } catch (err) { + throw new Error(`Failed to parse JSON module ${url}: ${err.message}`); + } +} + /** * Load a module using Node's vm.Module API with (not really secure) sandboxing */ @@ -162,7 +174,7 @@ async function loadModuleWithVM(moduleUrl: string, scope: ApplicationScope) { function loadCJS(url: string, source: string): { exports: any } { const cjsModule = { exports: {} }; if (url.endsWith('.json')) { - cjsModule.exports = JSON.parse(source); + cjsModule.exports = parseJsonModule(source, url); return cjsModule; } const require = createRequire(url); @@ -289,38 +301,50 @@ async function loadModuleWithVM(moduleUrl: string, scope: ApplicationScope) { ); } else if (url.startsWith('file://')) { checkAllowedModulePath(url, scope.verifyPath); - // Load source text from file let source = await readFile(new URL(url), { encoding: 'utf-8' }); - // Strip TypeScript types if this is a .ts file - if (url.endsWith('.ts') || url.endsWith('.tsx')) { - source = await stripTypeScriptTypes(source); - } - - // Try to parse as ESM first - try { - module = new SourceTextModule(source, { - identifier: url, - context, - initializeImportMeta(meta) { - meta.url = url; - }, - async importModuleDynamically(specifier: string) { - const resolvedUrl = resolveModule(specifier, url); - const dynamicModule = await loadModuleWithCache(resolvedUrl, true); - return dynamicModule; + // Handle JSON modules as a SyntheticModule with a default export. + // JSON imports only support default exports per the ESM spec. + if (url.endsWith('.json')) { + const jsonData = parseJsonModule(source, url); + module = new SyntheticModule( + ['default'], + function () { + this.setExport('default', jsonData); }, - }); - } catch (err) { - // If ESM parsing fails, try to load as CommonJS - if ( - err.message?.includes('require is not defined') || - source.includes('module.exports') || - source.includes('exports.') - ) { - module = loadCJSModule(url, source, usePrivateGlobal); - } else { - throw err; + { identifier: url, context } + ); + } else { + // Strip TypeScript types if this is a .ts file + if (url.endsWith('.ts') || url.endsWith('.tsx')) { + source = await stripTypeScriptTypes(source); + } + + // Try to parse as ESM first + try { + module = new SourceTextModule(source, { + identifier: url, + context, + initializeImportMeta(meta) { + meta.url = url; + }, + async importModuleDynamically(specifier: string) { + const resolvedUrl = resolveModule(specifier, url); + const dynamicModule = await loadModuleWithCache(resolvedUrl, true); + return dynamicModule; + }, + }); + } catch (err) { + // If ESM parsing fails, try to load as CommonJS + if ( + err.message?.includes('require is not defined') || + source.includes('module.exports') || + source.includes('exports.') + ) { + module = loadCJSModule(url, source, usePrivateGlobal); + } else { + throw err; + } } } } else { @@ -400,6 +424,17 @@ async function getCompartment(scope: ApplicationScope, globals) { }; } else if (moduleSpecifier.startsWith('file:') && !moduleSpecifier.includes('node_modules')) { const moduleText = await readFile(new URL(moduleSpecifier), { encoding: 'utf-8' }); + // Handle JSON files in comparttment mode the same way as in VM mode + if (moduleSpecifier.endsWith('.json')) { + const jsonData = parseJsonModule(moduleText, moduleSpecifier); + return { + imports: [], + exports: ['default'], + execute(exports) { + exports.default = jsonData; + }, + }; + } return new StaticModuleRecord(moduleText, moduleSpecifier); } else { checkAllowedModulePath(moduleSpecifier, scope.verifyPath); From 5fc775f35be3fb4d9ab26d14ac6b185620dffad4 Mon Sep 17 00:00:00 2001 From: Chris Barber Date: Thu, 19 Mar 2026 01:27:21 -0500 Subject: [PATCH 16/16] Separate data availability and freshness --- resources/Table.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/Table.ts b/resources/Table.ts index 4c1172267..7cea816bf 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -2436,13 +2436,15 @@ export function makeTable(options) { if (entry?.then) return entry.then(transform.bind(this)); record = entry?.value; } - if (entry?.expiresAt != undefined && entry?.expiresAt < Date.now() && context.onlyIfCached) { + const isExpired = entry?.expiresAt != undefined && entry?.expiresAt < Date.now(); + const needsRefresh = checkLoaded && Boolean(entry?.metadataFlags & (INVALIDATED | EVICTED)); + if ((isExpired || needsRefresh) && context.onlyIfCached) { return { [primaryKey]: entry.key, - message: 'This entry has expired', + message: 'This entry has expired/invalidated', }; } - if (checkLoaded && entry?.metadataFlags & (INVALIDATED | EVICTED)) { + if (needsRefresh) { // invalidated or evicted should go to load from source; TTL-expired entries are // returned as stale data during search — freshness is enforced by get(), not search() const loadingFromSource = ensureLoadedFromSource(source, entry.key ?? entry, entry, context);