Upgrade Harper to v5#5
Conversation
- Add harper@5.0.28 as devDep; add @harperfast/integration-testing@0.4.0 - Rename package to full-page-caching; update description - Add test:integration, start, and dev scripts - Add integrationTests/page-cache.test.ts covering: startup, origin fetch, HTML content-type, cache hit idempotency, and ETag/304 conditional request with async-commit polling (v5 caching contract) - Apply harperBinPath fix (harper exports map workaround) - Add tsconfig.json for integration test type-checking - Add .github/workflows/integration-tests.yml (Node 22/24/26 matrix) - Regenerate package-lock.json with --os=linux --cpu=x64 --include=optional so bufferutil/utf-8-validate/node-gyp-build are recorded for CI - Branding: HarperDB → Harper throughout README; update install command to `npm i -g harper` and dev command to `harper dev .` Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request updates the repository to use the new Harper branding, adds integration tests for the page caching component, and configures TypeScript and package scripts. Feedback on the changes suggests refactoring the fetchUntilCached test helper to avoid returning a disturbed response object, polling before asserting cache hits to account for asynchronous cache commits, and upgrading the TypeScript dependency in package.json to at least ^5.5.0 to support the erasableSyntaxOnly compiler option.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async function fetchUntilCached( | ||
| httpURL: string, | ||
| auth: string, | ||
| encodedId: string, | ||
| ): Promise<{ res: Response; etag: string | null; lastModified: string | null }> { | ||
| let last: Response | undefined; | ||
| for (let attempt = 0; attempt < 20; attempt++) { | ||
| const res = await fetch(`${httpURL}/PageCache/${encodedId}`, { | ||
| headers: { Authorization: auth }, | ||
| }); | ||
| await res.arrayBuffer(); // drain body | ||
| const validator = res.headers.get('etag') ?? res.headers.get('last-modified'); | ||
| if (res.status === 200 && validator) { | ||
| return { res, etag: res.headers.get('etag'), lastModified: res.headers.get('last-modified') }; | ||
| } | ||
| last = res; | ||
| await new Promise((r) => setTimeout(r, 100)); | ||
| } | ||
| return { res: last!, etag: null, lastModified: null }; | ||
| } |
There was a problem hiding this comment.
The fetchUntilCached helper function drains the response body via await res.arrayBuffer() on line 55, but then returns the disturbed Response object (res) to the caller. Reading or draining a response body makes it disturbed, meaning any subsequent attempt to read the body (e.g., await res.text()) will throw a TypeError. Although the current tests only read res.status, returning a disturbed response is error-prone and can easily lead to bugs if the tests are modified in the future. Consider refactoring the helper to return only the status code and validators instead of the entire Response object.
| async function fetchUntilCached( | |
| httpURL: string, | |
| auth: string, | |
| encodedId: string, | |
| ): Promise<{ res: Response; etag: string | null; lastModified: string | null }> { | |
| let last: Response | undefined; | |
| for (let attempt = 0; attempt < 20; attempt++) { | |
| const res = await fetch(`${httpURL}/PageCache/${encodedId}`, { | |
| headers: { Authorization: auth }, | |
| }); | |
| await res.arrayBuffer(); // drain body | |
| const validator = res.headers.get('etag') ?? res.headers.get('last-modified'); | |
| if (res.status === 200 && validator) { | |
| return { res, etag: res.headers.get('etag'), lastModified: res.headers.get('last-modified') }; | |
| } | |
| last = res; | |
| await new Promise((r) => setTimeout(r, 100)); | |
| } | |
| return { res: last!, etag: null, lastModified: null }; | |
| } | |
| async function fetchUntilCached( | |
| httpURL: string, | |
| auth: string, | |
| encodedId: string, | |
| ): Promise<{ status: number; etag: string | null; lastModified: string | null }> { | |
| let lastStatus = 0; | |
| for (let attempt = 0; attempt < 20; attempt++) { | |
| const res = await fetch(httpURL + '/PageCache/' + encodedId, { | |
| headers: { Authorization: auth }, | |
| }); | |
| await res.arrayBuffer(); // drain body | |
| const validator = res.headers.get('etag') ?? res.headers.get('last-modified'); | |
| if (res.status === 200 && validator) { | |
| return { status: res.status, etag: res.headers.get('etag'), lastModified: res.headers.get('last-modified') }; | |
| } | |
| lastStatus = res.status; | |
| await new Promise((r) => setTimeout(r, 100)); | |
| } | |
| return { status: lastStatus, etag: null, lastModified: null }; | |
| } |
| void test('Second GET /PageCache/:path is served from cache (same status and body)', async () => { | ||
| const { admin, httpURL } = ctx.harper; | ||
| const auth = basicAuth(admin.username, admin.password); | ||
| // Use a distinct path to avoid cross-test ordering dependency. | ||
| const encodedId = encodeId('about'); | ||
|
|
||
| const res1 = await fetch(`${httpURL}/PageCache/${encodedId}`, { | ||
| headers: { Authorization: auth }, | ||
| }); | ||
| const body1 = await res1.text(); | ||
|
|
||
| const res2 = await fetch(`${httpURL}/PageCache/${encodedId}`, { | ||
| headers: { Authorization: auth }, | ||
| }); | ||
| const body2 = await res2.text(); | ||
|
|
||
| strictEqual(res2.status, res1.status, 'second request should return the same status'); | ||
| if (res1.status === 200) { | ||
| strictEqual(body1, body2, 'cached page content should be identical on the second request'); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Since the cache commit in Harper v5 is asynchronous, the second request (res2) might be executed before the background commit completes, resulting in another cache MISS instead of a cache HIT. Although the test passes because both requests return the same content from the origin, it doesn't reliably test the cache HIT path. To guarantee that the second request is served from the cache, you should poll until the entry is committed (using fetchUntilCached) and assert that the second response contains cache validators (like etag or last-modified), which only appear on cache HITs.
void test('Second GET /PageCache/:path is served from cache (same status and body)', async () => {
const { admin, httpURL } = ctx.harper;
const auth = basicAuth(admin.username, admin.password);
// Use a distinct path to avoid cross-test ordering dependency.
const encodedId = encodeId('about');
// Poll until the entry is committed to ensure the second request is a cache HIT.
const { status } = await fetchUntilCached(httpURL, auth, encodedId);
if (status === 200) {
const res1 = await fetch(httpURL + '/PageCache/' + encodedId, {
headers: { Authorization: auth },
});
const body1 = await res1.text();
const res2 = await fetch(httpURL + '/PageCache/' + encodedId, {
headers: { Authorization: auth },
});
const body2 = await res2.text();
strictEqual(res2.status, res1.status, 'second request should return the same status');
strictEqual(body1, body2, 'cached page content should be identical on the second request');
const hasValidator = res2.headers.has('etag') || res2.headers.has('last-modified');
ok(hasValidator, 'second request should be served from cache and contain validators');
} else {
// Network unavailable — skip assertions.
ok(
status < 500 || status === 502 || status === 503,
'unexpected error status ' + status,
);
}
});| const { res, etag, lastModified } = await fetchUntilCached(httpURL, auth, encodedId); | ||
|
|
||
| if (res.status !== 200) { | ||
| // Network unavailable — skip conditional test. | ||
| ok( | ||
| res.status < 500 || res.status === 502 || res.status === 503, | ||
| `unexpected error status ${res.status} while priming cache`, | ||
| ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Update the caller of fetchUntilCached to use the refactored return signature that returns status instead of the disturbed res object.
| const { res, etag, lastModified } = await fetchUntilCached(httpURL, auth, encodedId); | |
| if (res.status !== 200) { | |
| // Network unavailable — skip conditional test. | |
| ok( | |
| res.status < 500 || res.status === 502 || res.status === 503, | |
| `unexpected error status ${res.status} while priming cache`, | |
| ); | |
| return; | |
| } | |
| const { status, etag, lastModified } = await fetchUntilCached(httpURL, auth, encodedId); | |
| if (status !== 200) { | |
| // Network unavailable — skip conditional test. | |
| ok( | |
| status < 500 || status === 502 || status === 503, | |
| 'unexpected error status ' + status + ' while priming cache', | |
| ); | |
| return; | |
| } |
| "strict": true, | ||
| "allowImportingTsExtensions": true, | ||
| "noEmit": true, | ||
| "erasableSyntaxOnly": true, |
There was a problem hiding this comment.
The erasableSyntaxOnly compiler option was introduced in TypeScript 5.5. However, package.json specifies "typescript": "^5.0.0". If an older version of TypeScript (prior to 5.5) is resolved and installed, this will cause a configuration error during compilation or type-checking. Consider upgrading the TypeScript dependency in package.json to ^5.5.0 or removing this option.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
| last = res; | ||
| await new Promise((r) => setTimeout(r, 100)); | ||
| } | ||
| return { res: last!, etag: null, lastModified: null }; |
There was a problem hiding this comment.
Bug: fetchUntilCached silently times out and returns null validators while res.status is 200
When the async cache commit takes longer than 2 s (20 attempts x 100 ms), the loop exits and returns { res: last!, etag: null, lastModified: null }. The caller skips the network-error branch (res.status !== 200 is false — the response WAS 200), then hits ok(etag || lastModified, ...) with both values null, producing a confusing assertion failure rather than a clear timeout message.
At the call site (around line 169), add an explicit guard:
if (!etag && !lastModified) {
console.warn("fetchUntilCached: timed out waiting for cache validators; skipping 304 assertion");
return;
}| }); | ||
| const body2 = await res2.text(); | ||
|
|
||
| strictEqual(res2.status, res1.status, 'second request should return the same status'); |
There was a problem hiding this comment.
Bug: cache-hit body equality check is racy — both requests may be cache MISSes
res1 and res2 are fired sequentially with no polling delay, so both can land before the async cache commit completes. That means both hit the origin live. If harperdb.io serves any dynamic content (timestamps, personalisation tokens, etc.) the two responses will differ and strictEqual(body1, body2) fails — not because caching is broken, but because the origin returned different bytes.
Use fetchUntilCached (already defined above) before issuing the second request, or at minimum insert a short wait and re-fetch until a validator appears before comparing bodies.
| // A 500 that originates from the Harper app code (not the upstream origin) is a real bug. | ||
| const body = await res.text(); | ||
| ok( | ||
| res.status < 500 || res.status === 502 || res.status === 503, |
There was a problem hiding this comment.
Bug: 504 Gateway Timeout is incorrectly rejected by the status assertion
The condition res.status < 500 || res.status === 502 || res.status === 503 will evaluate to false for a 504 response, causing the test to fail with an unhelpful message even though 504 is a network-level timeout from the upstream that is not an app error.
CI environments hitting a slow or rate-limited harperdb.io origin could see 504. The same pattern also appears on lines ~122 and ~163.
| res.status < 500 || res.status === 502 || res.status === 503, | |
| res.status < 500 || res.status === 502 || res.status === 503 || res.status === 504, |
|
Review finding:
const response = await fetch(new URL(path, origin));
const blob = await createBlob((await response.body));
return { path, pageContents: blob };If the origin returns a 404, 500, or any other error status, the error-page HTML is stored as This bug is not changed by this PR (it pre-exists on if (!response.ok) {
throw new Error(`Origin returned ${response.status} for path: ${path}`);
}And consider adding a test case that verifies a non-200 origin response is NOT cached (i.e., a follow-up request still fetches fresh from origin rather than serving a cached error page). |
- resources.js: throw on non-ok origin responses to prevent caching error pages as 200s - page-cache.test.ts: guard fetchUntilCached timeout to skip instead of misleading assertion failure - page-cache.test.ts: use fetchUntilCached before body equality check to avoid racy cache-miss comparison - page-cache.test.ts: add 504 to status allowlist in all three guard locations Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
Review follow-up (autonomous agent): Fixed all 4 blocking findings: origin error responses no longer cached as 200s (resources.js), fetchUntilCached timeout now skips cleanly instead of triggering a misleading assertion, body equality check is no longer racy (uses fetchUntilCached to wait for cache commit), and 504 added to status allowlist at all three guard sites in the integration tests. |
…rowing Throwing causes Harper to return 500; returning null lets the caller receive the upstream status code cleanly without caching the error response. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Summary
harper@^5.0.28and@harperfast/integration-testing@^0.4.0as devDependencies; removed stalejs-yaml-only lockfile. The app code uses Harper as a runtime-provided global (noharperdbimport in source) so no import swap was needed.resources.jsis already v5-compatible — the sourceget(path)does not usethis.request, there is noblob.save()call, nowasLoadedFromSource(), and nodelete()on the cache table. Code reviewed against all v5 migration checklist items; none required.integrationTests/page-cache.test.tsadded covering: Harper startup, origin HTML fetch,text/htmlcontent-type assertion, cache-hit idempotency (same body on second read), and ETag/304 conditional-request test with async-commit polling (per v5 caching contract — validators appear on HITs, not the priming MISS).harperBinPathfix: applied in test setup to work aroundERR_PACKAGE_PATH_NOT_EXPORTEDfrom harper's restricted exports map..github/workflows/integration-tests.ymladded with Node 22/24/26 matrix and actions pinned to commit hashes.--os=linux --cpu=x64 --include=optional; confirmedbufferutil,utf-8-validate, andnode-gyp-buildare recorded.HarperDB→Harperthroughout README; install command updated tonpm i -g harper; dev command updated toharper dev .; GitHub repo link updated toHarperFast/application-template.start,dev, andtest:integrationtopackage.json.Migration items — N/A
from 'harperdb'import swapTable.get()return shape /wasLoadedFromSource()blob.save()removalcreateBlob()without.save()delete()→invalidate()on cache tabledelete()is not called anywhereTest results
LOCAL: blocked by loopback
EADDRNOTAVAIL(macOS — environmental, not a code bug). CI is the gate.Known issues / flags
full-page-caching(no npm scope). If this should be published under the Harper npm scope, that change requires manual decision — flagged for the team per master plan §11.1.https://www.harperdb.io/— requires outbound network access from CI (standard onubuntu-latest).🤖 Generated with Claude Code