feat: implement cache tag invalidation#46
Conversation
Replaces the stubbed `revalidateTag` with a soft-invalidation pattern backed by a new `nextjs_cache_invalidation` table. Each tag → timestamp entry is hydrated into an in-memory map on first construction and kept fresh across workers via a Harper subscription, so any worker observes invalidations from any other. Adapts the pattern from aeo-page-cache, omitting the background hard-purge: Next.js naturally overwrites stale rows on regeneration, so hard-deleting buys little and would compete with rendering for I/O on the same worker. Cold rows can be reclaimed by the table's expiration. `get` checks both `ctx.revalidatedTags` (the per-request snapshot Next passes the constructor) and the persistent map; if any tag predates the record's `lastModified`, returns null so Next regenerates. `set` extracts tags from `ctx.tags` for FETCH entries and from the `x-next-cache-tags` header for APP_PAGE / APP_ROUTE / PAGES, storing them as a CSV column. Schema adds `tags: String` to `nextjs_isr_cache` and a new `nextjs_cache_invalidation` table (7-day expiration). Wires up the previously unused next-16-caching fixture: corrects the `cacheHandler` reference (was looking for `.js`, dist emits `.cjs`), moves the previously-skipped ISR tests into a dedicated test file against this fixture, and adds a `revalidateTag` end-to-end test using `unstable_cache` + a route handler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CSV form was a holdover from the original aeo pattern, where the
hard-purge used a `contains` substring search on the column. With the
hard-purge dropped, tags are only ever read in-process, so a real array
is simpler.
`NextISRCache.tags` is now `[String]`. `set()` writes the array
directly; `get()` reads it directly. No `.join(',')` / `.split(',')`
churn, and no risk of comma-in-tag mishandling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Caching section was a stub flagged as WIP. Now that `revalidateTag()` is fully implemented end-to-end, document: - how to enable the handler - how tags propagate across the cluster (subscription on the invalidation table) - the soft-invalidation model — no hard-purge, natural overwrite on regeneration - the two tables added to `harperfast_nextjs` - current limitations (no `revalidatePath`, no group-based invalidation) Keeps the `experimentalHarperCache` caveat since the contract may still shift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier I replaced the fixture's hand-rolled cacheHandler config with
`withHarper({}, { experimentalHarperCache: true })`. The motivation was
fixing a real bug — the path pointed at `CacheHandler.js`, but tsc
emits `CacheHandler.cjs` — but switching to withHarper was unnecessary.
The fixture was deliberately written to exercise the cacheHandler
config in isolation from withHarper's other behaviour, and listing
`@harperfast/nextjs` in serverExternalPackages was intentional.
withHarper does not add that entry. The commented turbopack.root note
was also a deliberate placeholder.
Reverts the file to its original shape, with the one-character
correction (`.js` → `.cjs`) that was actually needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Next.js cacheHandler module is loaded by Next.js itself (via `require()` on the `cacheHandler` config path), not by Harper. With turbopack, that load happens inside a build worker thread. Importing `harper` at the top of CacheHandler.cts ran harper's module initialization in that worker thread, which tried to register native worker hooks process-wide — conflicting with the same registration already done by the Harper main process. The result was a stream of "Worker creator already registered" uncaught exceptions; the HTTP worker kept restarting until Harper gave up (`Thread has been restarted undefined times and will not be restarted`), which manifested in tests as the fixture timing out before Harper reached ready. Switch to the same pattern `plugin.ts` uses: import `harper` for types only, and read `databases` from `globalThis` at call time. The module now loads cleanly in any context, and methods short-circuit (return null / no-op) if `databases` isn't available (i.e. we're in a context without Harper globals — which means there's nothing useful we could do anyway). Verified locally: every integration test file passes individually (19/19 tests across all six fixtures including the four new caching tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TEMPORARY — do not merge. Lets the customer install @harperfast/nextjs directly from this branch via github:HarperFast/nextjs#feature-cache-tags without needing a prepare script or a published npm release. Revert before merging to main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The schema declared `data: String`, but the CacheHandler stores the
Next.js IncrementalCacheValue object ({ kind, html, rscData, headers, ... }).
Every put threw "in property data must be a string", leaving the cache
empty and every request a MISS — which broke all next-16-caching
integration tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b57a482 to
7e1e133
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Why
|
Summary
revalidateTag()method on the cache handler was a stub. It now persists invalidations to a newnextjs_cache_invalidationtable and propagates across the cluster via a Harper subscription, so a tag invalidated on one node is observed by every node within milliseconds.get(), the handler returnsnullif any of an entry's tags has an invalidation timestamp newer than the entry'slastModified, which prompts Next.js to regenerate. The new write naturally restores "fresh" status by bumpinglastModified..tags: [String]tonextjs_isr_cacheand a newnextjs_cache_invalidationtable (7-day expiration). Schema diff is inschema.graphql.next-16-cachingfixture: corrects a brokencacheHandlerreference (was.js, dist emits.cjs), useswithHarper(..., { experimentalHarperCache: true })for path resolution, moves the previously-skipped ISR tests out ofnext-16.pw.tsinto a dedicatednext-16-caching.pw.ts, and adds arevalidateTagend-to-end test.revalidatePath()and group-based invalidation are not yet implemented).Where to focus review
src/CacheHandler.cts— main implementation. The interesting bits are the module-level subscription init (idempotent, hydrates from current rows then subscribes withomitCurrent: true) and theisInvalidatedcheck that combinesctx.revalidatedTags(per-request snapshot from Next.js) with the persistent map.extractTags— handles three Next.js cache shapes: FETCH (tags on context), APP_PAGE/APP_ROUTE/PAGES (tags in thex-next-cache-tagsheader on the cached response), and a fallback todata.tags. Mirrors whatFileSystemCachedoes internally.Test plan
npm run build— TypeScript compiles cleannpm run test:integration -- integrationTests/next-16-caching.pw.tsFiles changed
schema.graphql—tags: [String]onnextjs_isr_cache, newnextjs_cache_invalidationtablesrc/CacheHandler.cts— full implementation ofget/set/revalidateTagREADME.md— documents the featureintegrationTests/next-16.pw.ts— removed dead skipped blockintegrationTests/next-16-caching.pw.ts— new test file with 4 testsfixtures/next-16-caching/next.config.mjs— fixed brokencacheHandlerpathfixtures/next-16-caching/app/tagged/page.js— new fixture page usingunstable_cachefixtures/next-16-caching/app/api/revalidate/route.js— new fixture route handler callingrevalidateTag