diff --git a/core b/core index 0c1b3b612..a6ffabfac 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 0c1b3b612dfd9fa9dc51ebe0e7a9338bb135c95c +Subproject commit a6ffabfac8faa7d24e2e091f2465253985c6ca3d diff --git a/integrationTests/analytics/fixture/resources.js b/integrationTests/analytics/fixture/resources.js index a2ba3369a..531b9014e 100644 --- a/integrationTests/analytics/fixture/resources.js +++ b/integrationTests/analytics/fixture/resources.js @@ -12,8 +12,11 @@ export class CpuWork extends Resource { } if (data?.spawnChildren) { const children = []; + const runId = Date.now(); for (let i = 0; i < 3; i++) { - const child = spawn('node', ['-e', 'const s = Date.now(); while (Date.now() - s < 200) {}']); + const child = spawn('node', ['-e', 'const s = Date.now(); while (Date.now() - s < 200) {}'], { + name: `cpu-work-child-${runId}-${i}`, + }); children.push(child); } await Promise.all(children.map((child) => new Promise((resolve) => child.on('exit', resolve)))); diff --git a/integrationTests/cloneNode/cloneNode.test.mjs b/integrationTests/cloneNode/cloneNode.test.mjs index 55e1ee25f..3b046cc7a 100644 --- a/integrationTests/cloneNode/cloneNode.test.mjs +++ b/integrationTests/cloneNode/cloneNode.test.mjs @@ -265,21 +265,37 @@ suite('Clone Node', (ctx) => { await waitForAvailableStatus(ctx.nodes[newNodeIndex]); } - for (let j = 0; j < ctx.nodes.length; j++) { - const node = ctx.nodes[j]; - const clusterStatus = await sendOperation(node, { - operation: 'cluster_status', - }); + // wait for the full mesh to establish — reaching Available status does not guarantee + // that all cross-node replication sockets have connected yet + let retries = 0; + let statuses; + while (true) { + statuses = await Promise.all(ctx.nodes.map((node) => sendOperation(node, { operation: 'cluster_status' }))); + const fullyConnected = statuses.every( + (status) => + status.connections.length === ctx.nodes.length - 1 && + status.connections.every( + (connection) => + connection.database_sockets.length === 2 && + connection.database_sockets.every((socket) => socket.connected) + ) + ); + if (fullyConnected) break; + if (retries++ > 20) { + throw new Error(`Cluster did not fully connect: ${JSON.stringify(statuses)}`); + } + await sleep(500 * retries); + } - equal(clusterStatus.connections.length, 4, JSON.stringify(clusterStatus)); - for (let connection of clusterStatus.connections) { + for (const clusterStatus of statuses) { + equal(clusterStatus.connections.length, ctx.nodes.length - 1, JSON.stringify(clusterStatus)); + for (const connection of clusterStatus.connections) { equal(connection.database_sockets.length, 2, JSON.stringify(connection)); - for (let socket of connection.database_sockets) { + for (const socket of connection.database_sockets) { ok(socket.connected, 'connected'); } } } - console.log('done'); }); }); diff --git a/integrationTests/cloneNode/fixture/test-app/schema.graphql b/integrationTests/cloneNode/fixture/test-app/schema.graphql index 3c4b7ae25..9c096383b 100644 --- a/integrationTests/cloneNode/fixture/test-app/schema.graphql +++ b/integrationTests/cloneNode/fixture/test-app/schema.graphql @@ -1,5 +1,5 @@ type CloneTestRecord @table @export { - id: ID @primaryKey - name: String @indexed - value: String + id: ID @primaryKey + name: String @indexed + value: String } diff --git a/integrationTests/cluster/ISSUE-135-FINDINGS.md b/integrationTests/cluster/ISSUE-135-FINDINGS.md new file mode 100644 index 000000000..a558ca350 --- /dev/null +++ b/integrationTests/cluster/ISSUE-135-FINDINGS.md @@ -0,0 +1,43 @@ +# Issue #135 — Integration Test Findings + +## What we know + +The bug (Resource SDK `tables.X.search` returns subset after Harper restart, ops API returns full set) does NOT reproduce in any of these scenarios: + +**Unit test layer (`core/unitTests/resources/indexSearchAfterRestart.test.js`):** + +- In-process `resetDatabases()` + search by indexed attribute. **Passes.** +- Real subprocess boundary (writer forks, exits, reader opens same DB). **Passes.** +- Adding `@indexed` to an existing attribute (schema migration), with and without awaiting `indexingOperation`. **Passes.** +- Mid-flight `resetDatabases()` interrupting a schema reindex. **Passes.** + +**Integration test layer:** + +- Scenario A (`issue135-resource-search-after-restart.test.mjs`): deploy fixture → insert 100 rows on single node → graceful `{operation:'restart'}` → compare `search_by_value` (ops API) vs `/SearchCount?snapshotId=…` (Resource SDK via `tables.ScoreEvidence.search`). **Both return 100. Passes.** +- Scenario B (`issue135-replicated-search-after-restart.test.mjs`): 2-node cluster → insert 50 rows on node 0 → wait for replication to drain to node 1 → restart node 1 → query node 1 via Resource SDK. **Receives full 50 rows. Passes.** + +## What the bug must require + +Since both single-node restart AND replication-receiving-node restart reproduce cleanly, the issue #135 fingerprint is narrower than expected. Plausible triggers: + +- Fabric-specific replication topology (multi-region, mesh of more than 2 nodes). +- Rolling `deploy_component` cycles with active replication backlog. +- The combination of an `@indexed` schema migration (`PR #133` on serent-canopy) + rolling restart, where the new index's backfill is interrupted mid-flight across nodes. +- Production-scale load / concurrent writes that the integration tests don't exercise. + +## Companion fix landed + +The cluster tests in this PR depended on `add_node_back` being registered on worker threads, which was broken after the operations API moved to main-thread-only (40600bfc4 in core). The companion fix in core (`components/componentLoader.ts`) eagerly requires `serverHelpers/serverUtilities` in worker threads so `server.registerOperation?.({...})` calls from replication and other plugins land in the operation function map. Without that fix, `fullyConnectedReplication.test.mjs`, `replicationLoad.test.mjs`, and Scenario B here all fail with `Operation 'add_node_back' not found and connection was required to sign certificate`. + +## Test files + +- `integrationTests/cluster/issue135-resource-search-after-restart.test.mjs` — Scenario A (single-node) +- `integrationTests/cluster/issue135-replicated-search-after-restart.test.mjs` — Scenario B (multi-node) +- `integrationTests/cluster/issue135-fixture/` — fixture component (SearchCount resource + ScoreEvidence schema) +- `core/unitTests/resources/indexSearchAfterRestart.test.js` — unit-level negative results + +The two integration suites are in separate files because `node --test` runs top-level suites concurrently; colocating them caused cluster setup and single-node restart timing to interfere. + +## To reproduce on a real Fabric cluster + +Follow the repro steps in serent-canopy issue #135. The bug appears reliably there but not here, which is meaningful negative data for narrowing the root cause. diff --git a/integrationTests/cluster/ISSUE-135-PLAN.md b/integrationTests/cluster/ISSUE-135-PLAN.md new file mode 100644 index 000000000..7ace708f3 --- /dev/null +++ b/integrationTests/cluster/ISSUE-135-PLAN.md @@ -0,0 +1,142 @@ +# Plan: integration test reproducing serent-canopy issue #135 + +## Goal + +Write a multi-node replication-based integration test for Harper Pro that reproduces serent-canopy issue #135 — `tables.X.search` from inside a Resource returns a subset of rows after a Harper restart on a replicated Fabric cluster. + +If the bug reproduces in this harness, the test fixes the regression scope at the integration-test layer and gives us a guard for the upstream fix. If it does NOT reproduce here, document what was tried and report back so we can pivot to a different repro vector (e.g., schema-change reindex during rolling restart, replication backlog replay). + +The full issue text is at https://github.com/stephengoldberg/serent-canopy/issues/135 — read it first. + +## Bug fingerprint (from issue #135) + +After a Harper Pro `restart` op (or rolling `deploy_component`) on a 2-node Fabric replicated cluster: + +- `SELECT id FROM data.ScoreSnapshot WHERE companyId='…'` via the ops `sql` op → 3 rows (correct). +- `{operation:'search_by_conditions', table:'ScoreEvidence', conditions:[{search_attribute:'snapshotId', search_value:'…'}]}` → 45 rows (correct). +- From inside a custom Resource: `tables.ScoreEvidence.search({ conditions: [{ attribute: 'snapshotId', value: '…' }] })` → 0 rows (wrong). +- Running `{operation:'update', table:'ScoreEvidence', records:[{id:'…'}]}` (a no-op update by id) on each missing row restores visibility until the next restart. + +Affected callers in the user's app: `resources/Dashboard.js`, `resources/ResearchEngine.js`, `resources/ScoreSnapshots.js`, `resources/Companies.js` cascade-delete loop. The Companies cascade-delete is the most damaging variant — silent orphans on delete leak storage permanently. + +The user's repro checklist: + +1. POST `{operation:'restart'}` to one node. +2. Wait ~60s for it to come back up. +3. Hit `/Dashboard/company/` and `/ScoreSnapshots?companyId=` — compare row counts. +4. Compare against `{operation:'sql', sql:'SELECT … FROM data.ScoreSnapshot WHERE companyId=…'}`. +5. If counts diverge: `update` every snapshot row with just `{id}` and re-test — counts should match again. + +## What's already ruled out (do NOT redo this work) + +Unit tests at `core/unitTests/resources/indexSearchAfterRestart.test.js` already exercise four scenarios in a single Node process, and ALL pass cleanly: + +1. Write rows → `resetDatabases()` → search by indexed attribute. Works. +2. Primary store retains rows after `resetDatabases()`. Works. +3. Write rows in a forked child Node process → reader child opens same on-disk DB → search by indexed attribute. Works (real process boundary, fresh rocksdb-js native handles, fresh msgpackr encoder, only shared state is the on-disk RocksDB). +4. Add `@indexed` to an existing attribute (with and without awaiting `indexingOperation`, including a mid-flight `resetDatabases()` interruption that simulates restart-during-reindex). Works. + +This means the bug is NOT in: + +- `RocksIndexStore.getRange` composite-key encoding (round-trips fine). +- `search.ts:373` regular-index path's transaction handling at the single-process level. +- The `runIndexing` backfill / `lastIndexedKey` resumption (in isolation). +- Basic on-disk-only restart semantics. + +The bug almost certainly requires REPLICATION state. Your job is to confirm that by reproducing it in a multi-node harness, or to demonstrate that even multi-node doesn't reproduce it (in which case the cause is narrower — probably the transaction-log replay path on a node that fell behind during a rolling deploy). + +## Reference files (read these before writing code) + +- `/home/kzyp/dev/harper-pro/integrationTests/cluster/replicationLoad.test.mjs` — canonical multi-node integration test. Use the `NODE_COUNT`, `startHarper`, loopback-address-pool, and `add_node` cluster-connect pattern from `before()` and the `connect nodes` test. +- `/home/kzyp/dev/harper-pro/integrationTests/cluster/clusterShared.mjs` — `sendOperation`, `fetchWithRetry`, `concurrent`. Reuse these. +- `/home/kzyp/dev/harper-pro/integrationTests/cluster/fixture/` — example fixture component (`config.yaml`, `schema.graphql`, `resources.js`). Pattern for deploying a custom Resource via `deploy_component`. +- `/home/kzyp/dev/harper-pro/core/integrationTests/server/crash-replay.test.ts` — single-node SIGKILL+restart pattern using `ctx.harper.process.kill('SIGKILL')` followed by `startHarper(ctx)` to bring the same node back. +- `/home/kzyp/dev/harper-pro/core/integrationTests/utils/harperLifecycle.ts` — `startHarper`/`teardownHarper` API. +- `/home/kzyp/dev/harper-pro/core/resources/search.ts` — the search path. The customIndex/HNSW branch at line 357 was fixed May 5 (commits 972a68742, 5b0125afc) for the same class of bug; the regular-index branch at line 373 was NOT updated. Likely surface for the fix once we have a repro. +- `/home/kzyp/dev/harper-pro/core/resources/RocksIndexStore.ts` — composite-key encoding for the regular index. +- `/home/kzyp/dev/harper-pro/core/unitTests/resources/indexSearchAfterRestart.test.js` — the in-process tests that all pass. Read this to understand what's been ruled out and how the bug is described. + +## Required test design + +### Critical: exercise the Resource SDK path, not the ops API + +The user's bug is specifically that the ops API works correctly but the Resource SDK doesn't. So the test MUST query through the Resource SDK code path. There are two reliable ways to do this: + +1. **Deploy a fixture component** with a custom `Resource` class that internally calls `tables.X.search(...)` and returns a result count. Hit it over HTTP from the test. This is the closest analogue to the user's setup (their bug is in `resources/Dashboard.js` calling `tables.ScoreSnapshot.search`). +2. **Hit the auto-generated REST endpoint** on an `@export` table — `GET /TableName?attribute=value` goes through the same Resource code path that `tables.X.search` does internally. + +Prefer #1 because it's the exact code path of the user's bug. + +The ops API (`search_by_conditions`) is your CORRECTNESS ORACLE — it's what the user's tests showed working correctly. So the assertion shape is: + +``` +const expected = (await sendOperation(node, { operation: 'search_by_conditions', ... })).length; +const actual = await getCountViaResourceSDKEndpoint(node); +assert.equal(actual, expected); +``` + +A failing test means `actual < expected`. + +### Test scenarios (in priority order — implement in this order, stop after the first that reproduces) + +**Scenario A: write → graceful restart → search (simplest hypothesis)** + +1. Start a 2-node cluster (mirror the `before()` setup in `replicationLoad.test.mjs`). +2. Create a table with an `@indexed` attribute via `create_table` on node 0. Let it replicate. +3. Deploy the fixture component (with the custom search-Resource) on both nodes via `deploy_component … replicated=true restart=true`. +4. Insert ~100 rows on node 0 with varying values of the indexed attribute. Wait for replication to drain (poll `describe_table.record_count` on node 1 until it matches). +5. Send `{operation:'restart'}` to node 1 via the ops API; poll its health endpoint until it's back up. +6. Hit the fixture's resource search endpoint on node 1 with each distinct indexed value. Compare each count to a `search_by_conditions` ops call against the same node. +7. Assert equality. + +**Scenario B: write → rolling deploy_component restart → search** + +Same as A but instead of `{operation:'restart'}`, trigger a rolling redeploy by re-deploying the fixture component with `restart=rolling replicated=true`. This is closer to the user's environmental trigger. + +**Scenario C: schema-change reindex during rolling restart** + +The user's #135 note says PR #133 (`@indexed createdAt`) is what surfaced the bug. Simulate that: + +1. Set up cluster, deploy fixture, write rows with an attribute that is NOT yet indexed. +2. Trigger a schema upgrade that adds `@indexed` to that attribute, replicated across nodes. +3. Before reindex backfill completes, kick a rolling restart on both nodes. +4. After both nodes are back, query via the resource endpoint and compare to `search_by_conditions`. + +**Scenario D: SIGKILL one node mid-write, then restart** + +Mirrors `crash-replay.test.ts` but in a 2-node cluster. While node 0 is doing replicated writes, SIGKILL node 1. Bring it back. Wait for replication catchup. Query node 1 via the resource endpoint vs ops API. + +### Implementation notes + +- File path: `/home/kzyp/dev/harper-pro/integrationTests/cluster/issue135-resource-search-after-restart.test.mjs`. +- Fixture: create a new fixture under `integrationTests/cluster/issue135-fixture/` (don't reuse `cluster/fixture/` — it has its own Location/blob schema). The fixture should define a small `@table @export` with an indexed attribute and a custom `Resource` class with a GET handler that calls `tables.X.search` and returns `{ count, ids }`. +- Use `HARPER_NO_FLUSH_ON_EXIT: true` env per `replicationLoad.test.mjs` for faster teardown — but consider WHEN to skip it (scenario D wants normal flush behavior so the SIGKILL is the only data-loss vector). +- Replication catchup polling: use the existing pattern in `replicationLoad.test.mjs:181-198` (poll `search_by_value` retry loop until count converges). +- Restart polling: after sending `{operation:'restart'}`, poll the node's health endpoint with backoff (the operations API URL won't respond during the restart window). +- Test timeout: scale up the `suite('…', { timeout: N })` to ~180-300s if needed. +- Run mode: `npm run test:integration -- integrationTests/cluster/issue135-resource-search-after-restart.test.mjs` from `/home/kzyp/dev/harper-pro/`. + +## Acceptance criteria + +A successful outcome is ONE of: + +1. **A failing test that reproduces the bug** — count via resource SDK is less than count via ops API after restart. The test name and assertion message should clearly identify which scenario (A/B/C/D) reproduced it. Commit it as a `.skip` or `xit` so CI doesn't break, OR commit it as expected-failing with a clear comment that ties to issue #135 — your judgment, lean toward `.skip` with a comment. +2. **A passing test plus a documented finding** — if NONE of A/B/C/D reproduce the bug at the integration-test level, commit the most representative test (probably Scenario B or C) as a permanent regression guard for the working behavior, and write a short summary in the commit message and in this plan file describing what you tried and the implication ("multi-node replication + restart does not reproduce in the integration harness; bug is narrower — likely Fabric-specific replication state or production-load-only"). + +In either case, run `npm run format:write` and `npm run lint:required` on any new files before committing. Open a PR against `main` from the branch you're on (`test/issue-135-replication-repro`). PR description should: + +- Link to serent-canopy issue #135 for context. +- Summarize what scenarios were tried and the result. +- Identify the lower-confidence parts of the test for review attention (fixture design, replication-catchup poll, restart-readiness poll). +- Note this was generated by an agent. + +## Pre-flight + +Before writing any code: + +1. Check the current branch with `git branch --show-current` — you should be on `test/issue-135-replication-repro`. If you're somewhere else, `git checkout test/issue-135-replication-repro`. +2. Read `core/AGENTS.md` and `CONTRIBUTING.md` if either exists at the pro level. +3. Read the issue #135 description in full at the URL above. +4. Read the four scenarios above and confirm the design before writing the fixture. + +If anything in this plan is ambiguous or you discover the approach won't work for reasons not anticipated here, STOP and report what you found — don't paper over it. diff --git a/integrationTests/cluster/fullyConnectedReplication.test.mjs b/integrationTests/cluster/fullyConnectedReplication.test.mjs index b16187db0..c1cbf2393 100644 --- a/integrationTests/cluster/fullyConnectedReplication.test.mjs +++ b/integrationTests/cluster/fullyConnectedReplication.test.mjs @@ -5,9 +5,8 @@ import { suite, test, before, after } from 'node:test'; import { equal, deepEqual } from 'node:assert'; import { setTimeout as delay } from 'node:timers/promises'; -import { startHarper, teardownHarper, getNextAvailableLoopbackAddress } from '@harperfast/integration-testing'; +import { startHarper, teardownHarper, getNextAvailableLoopbackAddress, targz } from '@harperfast/integration-testing'; import { join } from 'node:path'; -import { targz } from '../../core/integrationTests/utils/targz.ts'; import { sendOperation, fetchWithRetry } from './clusterShared.mjs'; process.env.HARPER_INTEGRATION_TEST_INSTALL_SCRIPT = join( diff --git a/integrationTests/cluster/issue135-fixture/config.yaml b/integrationTests/cluster/issue135-fixture/config.yaml new file mode 100644 index 000000000..fb8a45440 --- /dev/null +++ b/integrationTests/cluster/issue135-fixture/config.yaml @@ -0,0 +1,5 @@ +rest: true +graphqlSchema: + files: '*.graphql' +jsResource: + files: resources.js diff --git a/integrationTests/cluster/issue135-fixture/resources.js b/integrationTests/cluster/issue135-fixture/resources.js new file mode 100644 index 000000000..cfd99a828 --- /dev/null +++ b/integrationTests/cluster/issue135-fixture/resources.js @@ -0,0 +1,17 @@ +export class SearchCount extends Resource { + static loadAsInstance = false; + + async get(target) { + target.checkPermission = false; + const snapshotId = target.get('snapshotId'); + if (!snapshotId) return { error: 'snapshotId query param required' }; + + const rows = []; + for await (const row of tables.ScoreEvidence.search({ + conditions: [{ attribute: 'snapshotId', value: snapshotId }], + })) { + rows.push(row.id); + } + return { count: rows.length, ids: rows }; + } +} diff --git a/integrationTests/cluster/issue135-fixture/schema.graphql b/integrationTests/cluster/issue135-fixture/schema.graphql new file mode 100644 index 000000000..e4e25790d --- /dev/null +++ b/integrationTests/cluster/issue135-fixture/schema.graphql @@ -0,0 +1,5 @@ +type ScoreEvidence @table @export { + id: ID @primaryKey + snapshotId: String @indexed + data: String +} diff --git a/integrationTests/cluster/issue135-replicated-search-after-restart.test.mjs b/integrationTests/cluster/issue135-replicated-search-after-restart.test.mjs new file mode 100644 index 000000000..bd90f6780 --- /dev/null +++ b/integrationTests/cluster/issue135-replicated-search-after-restart.test.mjs @@ -0,0 +1,184 @@ +/** + * Scenario B for serent-canopy issue #135. + * + * Insert rows on node 0, wait for replication to drain to node 1, restart node 1, + * then query node 1 via the Resource SDK path. This is the most likely repro + * vector — index entries for REPLICATED rows may not survive a restart on the + * receiving node, while the ops API (which scans primaryStore) still sees them. + * + * Kept in its own file because `node --test` runs top-level suites concurrently + * and Scenario A (single-node) interferes with this one when colocated. + */ +import { suite, test, before, after } from 'node:test'; +import { equal } from 'node:assert'; +import { setTimeout as delay } from 'node:timers/promises'; +import { startHarper, teardownHarper, targz, getNextAvailableLoopbackAddress } from '@harperfast/integration-testing'; +import { join } from 'node:path'; +import { sendOperation, fetchWithRetry } from './clusterShared.mjs'; + +process.env.HARPER_INTEGRATION_TEST_INSTALL_SCRIPT = join( + import.meta.dirname ?? module.path, + '..', + '..', + 'dist', + 'bin', + 'harper.js' +); + +const PROJECT_NAME = 'issue135-app'; +const REP_ROW_COUNT = 50; +const REP_SNAPSHOT_ID = 'replicated-snapshot'; +const NODE_COUNT = 2; + +async function pollHealth(node, { retries = 40, intervalMs = 2000 } = {}) { + let last; + for (let i = 0; i < retries; i++) { + try { + const r = await fetch(`${node.operationsAPIURL}/health`); + if (r.ok) return; + last = new Error(`status ${r.status}`); + } catch (err) { + last = err; + } + await delay(intervalMs); + } + throw new Error(`Node ${node.hostname} never became healthy: ${last?.message}`); +} + +suite( + 'Issue #135: Resource SDK search on replication-receiving node after restart (Scenario B)', + { timeout: 300000 }, + (ctx) => { + before(async () => { + ctx.nodes = await Promise.all( + Array(NODE_COUNT) + .fill(null) + .map(async () => { + const nodeCtx = { + name: ctx.name, + harper: { hostname: await getNextAvailableLoopbackAddress() }, + }; + await startHarper(nodeCtx, { + config: { + analytics: { aggregatePeriod: -1 }, + replication: { securePort: nodeCtx.harper.hostname + ':9933' }, + }, + }); + return nodeCtx.harper; + }) + ); + + // Connect node 1 -> node 0 with an auth token. + const tokenResp = await sendOperation(ctx.nodes[0], { + operation: 'create_authentication_tokens', + }); + const token = tokenResp.operation_token; + await sendOperation(ctx.nodes[1], { + operation: 'add_node', + rejectUnauthorized: false, + hostname: ctx.nodes[0].hostname, + authorization: 'Bearer ' + token, + }); + + // Poll cluster_status until both nodes see each other as fully connected. + let retries = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + const statuses = await Promise.all(ctx.nodes.map((n) => sendOperation(n, { operation: 'cluster_status' }))); + const fullyConnected = statuses.every( + (s) => + s.connections.length === NODE_COUNT - 1 && + s.connections.every((c) => c.database_sockets.every((sock) => sock.connected)) + ); + if (fullyConnected) break; + if (retries++ > 20) { + throw new Error('Cluster did not fully connect: ' + JSON.stringify(statuses)); + } + await delay(500 * retries); + } + + // Deploy the fixture component (replicated, so both nodes get it). + const payload = await targz(join(import.meta.dirname, 'issue135-fixture')); + await sendOperation(ctx.nodes[0], { + operation: 'deploy_component', + project: PROJECT_NAME, + payload, + replicated: true, + restart: true, + }); + await delay(35000); + for (const node of ctx.nodes) await pollHealth(node); + }); + + after(async () => { + if (!ctx.nodes) return; + await Promise.all(ctx.nodes.map((node) => teardownHarper({ harper: node }))); + }); + + test('Resource SDK search on receiving node returns full row set after restart', async () => { + // Insert rows on node 0 only. + const records = Array.from({ length: REP_ROW_COUNT }, (_, i) => ({ + id: `rep-${i}`, + snapshotId: REP_SNAPSHOT_ID, + data: `payload-${i}`, + })); + await sendOperation(ctx.nodes[0], { + operation: 'insert', + table: 'ScoreEvidence', + records, + }); + + // Wait for replication to drain to node 1 (poll ops API until it sees them). + let retries = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + const onNode1 = await sendOperation(ctx.nodes[1], { + operation: 'search_by_value', + table: 'ScoreEvidence', + search_attribute: 'snapshotId', + search_value: REP_SNAPSHOT_ID, + }); + if (onNode1.length === REP_ROW_COUNT) break; + if (retries++ > 20) { + throw new Error(`Replication didn't drain to node 1: got ${onNode1.length}/${REP_ROW_COUNT}`); + } + await delay(500 * retries); + } + console.log(`Replication drained: node 1 sees ${REP_ROW_COUNT} rows via ops API`); + + // Restart node 1 (the receiving node). The data on node 1 came from replication, + // not from a local write — this is the case where the bug is hypothesized to live. + await sendOperation(ctx.nodes[1], { operation: 'restart' }).catch(() => {}); + await delay(5000); + await pollHealth(ctx.nodes[1], { retries: 60, intervalMs: 2000 }); + + // Ops API on node 1 (oracle). + const opsAfter = await sendOperation(ctx.nodes[1], { + operation: 'search_by_value', + table: 'ScoreEvidence', + search_attribute: 'snapshotId', + search_value: REP_SNAPSHOT_ID, + }); + equal( + opsAfter.length, + REP_ROW_COUNT, + `ops API on node 1 after restart: expected ${REP_ROW_COUNT}, got ${opsAfter.length}` + ); + + // Resource SDK on node 1 - the path under test. + const sdkResp = await fetchWithRetry( + `${ctx.nodes[1].httpURL}/SearchCount?snapshotId=${encodeURIComponent(REP_SNAPSHOT_ID)}` + ); + const sdkBody = await sdkResp.json(); + console.log(`Resource SDK on node 1 post-restart: ${sdkBody.count} rows`, sdkBody.error ?? ''); + + equal( + sdkBody.count, + REP_ROW_COUNT, + `Resource SDK tables.ScoreEvidence.search on replication-receiving node returned ${sdkBody.count} rows ` + + `but ops API returned ${opsAfter.length} — issue #135 fingerprint: ` + + `regular-index search drops replicated rows after restart.` + ); + }); + } +); diff --git a/integrationTests/cluster/issue135-resource-search-after-restart.test.mjs b/integrationTests/cluster/issue135-resource-search-after-restart.test.mjs new file mode 100644 index 000000000..b5f5086a5 --- /dev/null +++ b/integrationTests/cluster/issue135-resource-search-after-restart.test.mjs @@ -0,0 +1,165 @@ +/** + * Regression repro for serent-canopy issue #135. + * + * After a Harper Pro restart, tables.X.search() from inside a Resource returns a + * subset (or zero) of the rows that SQL and search_by_value both see correctly. + * A no-op update on each missing row restores visibility until the next restart. + * + * This test exercises Scenario A (write → graceful restart → Resource SDK search) + * in a single-node environment, which is sufficient to reproduce if the bug is in + * the search.ts regular-index path rather than requiring multi-node state. + * + * Repro steps mirror issue #135: + * 1. Deploy fixture (defines ScoreEvidence @table with snapshotId @indexed + + * a SearchCount Resource that calls tables.ScoreEvidence.search). + * 2. Insert N rows with a fixed snapshotId. + * 3. Restart Harper gracefully. + * 4. Query via search_by_value (ops API — known working) → expected count. + * 5. Query via GET /SearchCount?snapshotId=… (Resource SDK path) → actual count. + * 6. Assert they match. Failure = bug reproduced. + */ +import { suite, test, before, after } from 'node:test'; +import { equal } from 'node:assert'; +import { setTimeout as delay } from 'node:timers/promises'; +import { startHarper, teardownHarper, targz } from '@harperfast/integration-testing'; +import { join } from 'node:path'; +import { sendOperation, fetchWithRetry } from './clusterShared.mjs'; + +process.env.HARPER_INTEGRATION_TEST_INSTALL_SCRIPT = join( + import.meta.dirname ?? module.path, + '..', + '..', + 'dist', + 'bin', + 'harper.js' +); + +const PROJECT_NAME = 'issue135-app'; +const ROW_COUNT = 100; +const SNAPSHOT_ID = 'test-snapshot-abc'; + +async function pollHealth(node, { retries = 40, intervalMs = 2000 } = {}) { + let last; + for (let i = 0; i < retries; i++) { + try { + // Health endpoint lives on the operations API port, not the REST port. + const r = await fetch(`${node.operationsAPIURL}/health`); + if (r.ok) return; + last = new Error(`status ${r.status}`); + } catch (err) { + last = err; + } + await delay(intervalMs); + } + throw new Error(`Node ${node.hostname} never became healthy: ${last?.message}`); +} + +suite('Issue #135: Resource SDK search after graceful restart (Scenario A)', { timeout: 300000 }, (ctx) => { + before(async () => { + await startHarper(ctx, { + config: { + analytics: { aggregatePeriod: -1 }, + logging: { colors: false, console: true, level: 'info' }, + }, + // No HARPER_NO_FLUSH_ON_EXIT — we need normal flush so data persists across restart. + }); + + // Deploy the fixture component (defines ScoreEvidence schema + SearchCount resource). + const payload = await targz(join(import.meta.dirname, 'issue135-fixture')); + const deployResp = await sendOperation(ctx.harper, { + operation: 'deploy_component', + project: PROJECT_NAME, + payload, + restart: true, + }); + console.log('deploy_component response:', deployResp); + + // Wait for Harper to restart after deploy and come back up. + // 35s matches the delay used in replicationLoad.test.mjs after deploy_component. + await delay(35000); + await pollHealth(ctx.harper); + + // Poll until the schema has actually been applied (deploy + health-OK doesn't guarantee + // the component finished loading the graphql schema — race seen in single-node tests + // where `database 'data' does not exist` comes back from the first insert). + let schemaReady = false; + for (let i = 0; i < 30; i++) { + const desc = await sendOperation(ctx.harper, { + operation: 'describe_table', + table: 'ScoreEvidence', + }).catch((err) => ({ __error: err.message })); + if (desc && !desc.__error && desc.name === 'ScoreEvidence') { + schemaReady = true; + break; + } + await delay(1000); + } + if (!schemaReady) throw new Error('ScoreEvidence table did not become available after deploy'); + console.log('Harper is up after initial deploy and schema is ready'); + }); + + after(async () => { + await teardownHarper(ctx); + }); + + test('Resource SDK search returns same count as ops API after graceful restart', async () => { + const node = ctx.harper; + + // Insert rows via ops API. + const records = Array.from({ length: ROW_COUNT }, (_, i) => ({ + id: `row-${i}`, + snapshotId: SNAPSHOT_ID, + data: `payload-${i}`, + })); + await sendOperation(node, { + operation: 'insert', + table: 'ScoreEvidence', + records, + }); + + // Sanity: ops API sees all rows before restart. + const beforeRestart = await sendOperation(node, { + operation: 'search_by_value', + table: 'ScoreEvidence', + search_attribute: 'snapshotId', + search_value: SNAPSHOT_ID, + }); + equal(beforeRestart.length, ROW_COUNT, `pre-restart ops count should be ${ROW_COUNT}`); + + // Graceful restart. + await sendOperation(node, { operation: 'restart' }).catch(() => {}); // may disconnect before responding + await delay(5000); + await pollHealth(node, { retries: 40, intervalMs: 2000 }); + console.log('Harper is back up after restart'); + + // Ops API after restart (known-working path; confirms data is durable). + const opsAfter = await sendOperation(node, { + operation: 'search_by_value', + table: 'ScoreEvidence', + search_attribute: 'snapshotId', + search_value: SNAPSHOT_ID, + }); + equal(opsAfter.length, ROW_COUNT, `ops API post-restart should still see ${ROW_COUNT} rows`); + console.log(`Ops API post-restart: ${opsAfter.length} rows`); + + // Resource SDK path after restart — the buggy path from issue #135. + const sdkResp = await fetchWithRetry(`${node.httpURL}/SearchCount?snapshotId=${encodeURIComponent(SNAPSHOT_ID)}`); + const sdkBody = await sdkResp.json(); + console.log(`Resource SDK post-restart: ${sdkBody.count} rows`, sdkBody.error ?? ''); + + // This assertion catches issue #135: + // sdkBody.count < ROW_COUNT while opsAfter.length === ROW_COUNT means the bug is present. + equal( + sdkBody.count, + ROW_COUNT, + `Resource SDK tables.ScoreEvidence.search returned ${sdkBody.count} rows ` + + `but ops API returned ${opsAfter.length} — issue #135 fingerprint: ` + + `regular-index search drops rows after restart.` + ); + }); + + // Scenario B (multi-node replicated write → restart receiving node) is in a separate file: + // issue135-replicated-search-after-restart.test.mjs. They're split because node --test runs + // top-level suites concurrently, and the cluster startup interferes with this scenario's + // single-node restart timing when colocated. +}); diff --git a/integrationTests/cluster/replicationLoad.test.mjs b/integrationTests/cluster/replicationLoad.test.mjs index de87237ba..caaccb208 100644 --- a/integrationTests/cluster/replicationLoad.test.mjs +++ b/integrationTests/cluster/replicationLoad.test.mjs @@ -5,9 +5,8 @@ import { suite, test, before, after } from 'node:test'; import { equal, ok } from 'node:assert'; import { setTimeout as delay } from 'node:timers/promises'; -import { startHarper, teardownHarper, getNextAvailableLoopbackAddress } from '@harperfast/integration-testing'; +import { startHarper, teardownHarper, getNextAvailableLoopbackAddress, targz } from '@harperfast/integration-testing'; import { join } from 'node:path'; -import { targz } from '../../core/integrationTests/utils/targz.ts'; import { sendOperation, fetchWithRetry, concurrent } from './clusterShared.mjs'; process.env.HARPER_INTEGRATION_TEST_INSTALL_SCRIPT = join( diff --git a/package-lock.json b/package-lock.json index 072320c25..268aba270 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,7 +96,7 @@ }, "devDependencies": { "@harperdb/code-guidelines": "^0.0.6", - "@harperfast/integration-testing": "^0.2.0", + "@harperfast/integration-testing": "^0.3.0", "@types/fs-extra": "^11.0.4", "@types/gunzip-maybe": "^1.4.3", "@types/jsonwebtoken": "^9.0.10", @@ -347,6 +347,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1037.0.tgz", "integrity": "sha512-DBmA1jAW8ST6C4srBxeL1/RLIir/d8WOm4s4mi59mGp6mBktHM59Kwb7GuURaCO60cotuce5zr0sKpMLPcBQyA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -2296,6 +2297,7 @@ "version": "8.44.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -2539,9 +2541,9 @@ "license": "Apache-2.0" }, "node_modules/@harperfast/integration-testing": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@harperfast/integration-testing/-/integration-testing-0.2.0.tgz", - "integrity": "sha512-DkNJ+zAhHA6kPQU0JlZ9rdAkR/4qE881+jR9fOmgyTOMn4uP3LsusRPP7deYeJ5fVPH3v/QI5hiMADYRu1XoRQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@harperfast/integration-testing/-/integration-testing-0.3.0.tgz", + "integrity": "sha512-q8R6k+aYtYQ7iyVuiWFJ9uB2f1OPEh4hXd07VTv12LxsmUY3XFXGuiLh2buDi36SAB4Y5++IZcF7lZQ/CIDbvA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2626,9 +2628,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2645,9 +2644,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2664,9 +2660,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2683,9 +2676,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3011,6 +3001,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3024,6 +3015,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3037,6 +3029,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3050,6 +3043,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3063,6 +3057,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3076,6 +3071,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3261,9 +3257,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3281,9 +3274,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3301,9 +3291,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3321,9 +3308,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3341,9 +3325,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3361,9 +3342,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3381,9 +3359,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3401,9 +3376,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3580,7 +3552,6 @@ "integrity": "sha512-GW2yqqOTzdz3K6z0XpPO1EjLzOw0kclmAcLeW6cBt0DYM7ZNLRKanpzXxaSXkePpo4ZYMWhddE4WpSWG8e/QaQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -4612,6 +4583,7 @@ "node_modules/@types/node": { "version": "25.4.0", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4710,6 +4682,7 @@ "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", @@ -4913,6 +4886,7 @@ "version": "8.16.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5075,7 +5049,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5087,7 +5060,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5406,32 +5378,6 @@ "version": "1.1.2", "license": "MIT" }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/bufferutil/node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "optional": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/bytestreamjs": { "version": "2.0.1", "license": "BSD-3-Clause", @@ -5534,6 +5480,7 @@ "version": "6.2.2", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5671,7 +5618,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "is-regexp": "^1.0.0", "is-supported-regexp-flag": "^1.0.0" @@ -6200,6 +6146,7 @@ "version": "9.39.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6258,6 +6205,7 @@ "version": "10.1.8", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6480,7 +6428,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "clone-regexp": "^1.0.0" }, @@ -7182,6 +7129,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -7247,7 +7195,6 @@ "integrity": "sha512-RRXMLbbdymiZsHOeg5b+DShzsMvVvkgsG9690BBCc7tzIpDb0CT7EgWEQo+rwCICr35EwZoLjtfwF6mMiCOenA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/lib-storage": "3.964.0", @@ -7350,7 +7297,6 @@ "integrity": "sha512-ro6B04Q5TjPgIKdSWGJ+tj2ordVF1IfZJERwGpYkrwhboNEoXBXuzpfnh2LYBPvMmFJQ+8UXSFw1jkLLgxM+ig==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", @@ -7373,7 +7319,6 @@ "integrity": "sha512-gipd/g0USN8ncvRMdoaru8PxYNUSEJp//+XbLf+3VNDQ6gcSsTcYqyNa3f+oEKIyV0clpOkxzautkN7hVPsn/g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@harperfast/extended-iterable": "1.0.3", "msgpackr": "1.11.9", @@ -7406,7 +7351,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -7424,7 +7368,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -7442,7 +7385,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -7460,7 +7402,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -7478,7 +7419,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -7496,7 +7436,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -7514,7 +7453,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -7532,7 +7470,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -7549,8 +7486,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-darwin-x64": { "version": "3.5.3", @@ -7564,8 +7500,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-linux-arm": { "version": "3.5.3", @@ -7579,8 +7514,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-linux-arm64": { "version": "3.5.3", @@ -7594,8 +7528,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-linux-x64": { "version": "3.5.3", @@ -7609,8 +7542,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-win32-arm64": { "version": "3.5.3", @@ -7624,8 +7556,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/harper/node_modules/@lmdb/lmdb-win32-x64": { "version": "3.5.3", @@ -7639,8 +7570,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/harper/node_modules/asn1js": { "version": "3.0.7", @@ -7648,7 +7578,6 @@ "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", @@ -7664,7 +7593,6 @@ "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -7680,7 +7608,6 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -7695,7 +7622,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@harperfast/extended-iterable": "^1.0.3", "msgpackr": "^1.11.2", @@ -7723,7 +7649,6 @@ "integrity": "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -7734,7 +7659,6 @@ "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" @@ -7751,8 +7675,7 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/harper/node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", @@ -7760,7 +7683,6 @@ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "detect-libc": "^2.0.1" }, @@ -7776,7 +7698,6 @@ "integrity": "sha512-UUmvQ/7KTZt/vHjhRrnyS7h+J7qPBQnpG80V56xmIC+o9IqYmQOw/UIny9S9zYDfRBR0ClouCr464EkBMIT7Fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", @@ -7800,7 +7721,6 @@ "integrity": "sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" @@ -7812,7 +7732,6 @@ "integrity": "sha512-WX0la7n7CbnguuaIQoT4Fc0IJckPDOUldzOwlZ0nwpOcySS+Six/tXBdc0RX17J5o1To0SAr3xDJjDLsOfDFQA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@noble/hashes": "^1.4.0", "asn1js": "^3.0.5", @@ -7830,8 +7749,7 @@ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz", "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/harper/node_modules/readable-stream": { "version": "4.7.0", @@ -7839,7 +7757,6 @@ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", @@ -7871,7 +7788,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -7883,7 +7799,6 @@ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -7897,7 +7812,6 @@ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">= 10.x" } @@ -7908,7 +7822,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -7919,7 +7832,6 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -7942,7 +7854,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -8010,7 +7921,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "parse-columns": "git+https://github.com/int0h/parse-columns.git" } @@ -8270,7 +8180,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" }, @@ -8358,7 +8267,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8370,7 +8278,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9400,9 +9307,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9419,9 +9323,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9438,9 +9339,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9457,9 +9355,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9483,7 +9378,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "number-is-nan": "^1.0.0" }, @@ -9507,7 +9401,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9853,7 +9746,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "escape-string-regexp": "^1.0.3", "execall": "^1.0.0", @@ -9871,7 +9763,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.8.0" } @@ -10160,6 +10051,7 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10424,7 +10316,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "is-finite": "^1.0.0" }, @@ -10933,7 +10824,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "array-uniq": "^1.0.2", "arrify": "^1.0.0", @@ -11299,6 +11189,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11414,6 +11305,7 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11482,32 +11374,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/utf-8-validate/node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "optional": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "license": "MIT" diff --git a/package.json b/package.json index f386f1fa7..0840b5b05 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "lint:fix": "npm run lint -- --fix", "lint:required": "oxlint --quiet .", "test:integration": "HARPER_INTEGRATION_TEST_INSTALL_SCRIPT=dist/bin/harper.js harper-integration-test-run", - "test:integration:all": "npm run test:integration -- integrationTests/**/*.test.ts", + "test:integration:all": "npm run test:integration -- integrationTests/**/*.test.*s", "cluster:ip:local": "pushd utility/dev && docker compose -f docker-compose.ip.yml --project-directory ../.. build && docker compose -f docker-compose.ip.yml --project-directory ../.. up; popd", "cluster:ip:latest": "pushd utility/dev && docker compose -f docker-compose.ip.yml --project-directory ../.. pull && docker compose -f docker-compose.ip.yml --project-directory ../.. up; popd", "cluster:delete": "pushd utility/dev && docker compose -f docker-compose.ip.yml --project-directory ../.. down --remove-orphans; popd", @@ -202,7 +202,7 @@ }, "devDependencies": { "@harperdb/code-guidelines": "^0.0.6", - "@harperfast/integration-testing": "^0.2.0", + "@harperfast/integration-testing": "^0.3.0", "@types/fs-extra": "^11.0.4", "@types/gunzip-maybe": "^1.4.3", "@types/jsonwebtoken": "^9.0.10", diff --git a/scripts/patch-release.js b/scripts/patch-release.js index 80d6ba4c7..4d4a6514f 100755 --- a/scripts/patch-release.js +++ b/scripts/patch-release.js @@ -260,13 +260,16 @@ async function main() { const proVersion = bumpVersion('harper-pro'); // ── Step 5: push ─────────────────────────────────────────────────────────── + // Push the branch and the specific tag explicitly. `npm version` creates a + // lightweight tag, which `--follow-tags` ignores (it only pushes annotated + // tags), so we name the tag in the refspec list. header('Pushing'); if (coreVersion) { log(` Pushing core ${RELEASE_BRANCH} ${coreVersion}...`); - execSync(`git -C "${corePath}" push origin "${RELEASE_BRANCH}" --follow-tags`, { stdio: 'inherit' }); + execSync(`git -C "${corePath}" push origin "${RELEASE_BRANCH}" "${coreVersion}"`, { stdio: 'inherit' }); } log(` Pushing harper-pro ${RELEASE_BRANCH} ${proVersion}...`); - execSync(`git -C "${harperProRoot}" push origin "${RELEASE_BRANCH}" --follow-tags`, { stdio: 'inherit' }); + execSync(`git -C "${harperProRoot}" push origin "${RELEASE_BRANCH}" "${proVersion}"`, { stdio: 'inherit' }); ok('\n✅ Done.'); // ── Step 6: offer to return to original branches ───────────────────────────