From 2c3d0b8b3cbbb18ff7e931e06778e420e6d0cda1 Mon Sep 17 00:00:00 2001 From: Sben Date: Sat, 20 Dec 2025 00:35:22 +0100 Subject: [PATCH] feat: add non-destructive cache hydration support via isInitialHydration context - Add SetContext type with isInitialHydration flag - Update Handler interface to accept optional SetContext in set() - Implement NX behavior in redis-strings handler when isInitialHydration is true - Update all handlers (local-lru, composite) to accept ctx parameter - Propagate isInitialHydration flag from registerInitialCache to handlers - Prevents runtime cache overwrites during app restarts and horizontal scaling Addresses #23 --- .../src/handlers/cache-handler.ts | 11 +++++--- .../src/handlers/cache-handler.types.ts | 18 ++++++++++++- .../src/handlers/composite.ts | 4 +-- .../src/handlers/local-lru.ts | 4 ++- .../src/handlers/redis-strings.ts | 26 +++++++++++-------- .../instrumentation/register-initial-cache.ts | 3 +++ 6 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/nextjs-cache-handler/src/handlers/cache-handler.ts b/packages/nextjs-cache-handler/src/handlers/cache-handler.ts index 372063f..f9b73b5 100644 --- a/packages/nextjs-cache-handler/src/handlers/cache-handler.ts +++ b/packages/nextjs-cache-handler/src/handlers/cache-handler.ts @@ -13,6 +13,7 @@ import { Handler, OnCreationHook, Revalidate, + SetContext, } from "./cache-handler.types"; import { PrerenderManifest } from "next/dist/build"; import { @@ -579,10 +580,10 @@ export class CacheHandler implements NextCacheHandler { return null; }, - async set(key, cacheHandlerValue) { + async set(key, cacheHandlerValue, ctx) { const operationsResults = await Promise.allSettled( handlersList.map((handler) => - handler.set(key, { ...cacheHandlerValue }), + handler.set(key, { ...cacheHandlerValue }, ctx), ), ); @@ -721,7 +722,7 @@ export class CacheHandler implements NextCacheHandler { internal_lastModified?: number; tags?: string[]; revalidate?: Revalidate; - }, + } & SetContext, ): Promise { await CacheHandler.#ensureConfigured(); @@ -773,7 +774,9 @@ export class CacheHandler implements NextCacheHandler { value: value, }; - await CacheHandler.#mergedHandler.set(cacheKey, cacheHandlerValue); + await CacheHandler.#mergedHandler.set(cacheKey, cacheHandlerValue, { + isInitialHydration: ctx?.isInitialHydration, + }); if ( process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD && diff --git a/packages/nextjs-cache-handler/src/handlers/cache-handler.types.ts b/packages/nextjs-cache-handler/src/handlers/cache-handler.types.ts index 54d5263..768f91c 100644 --- a/packages/nextjs-cache-handler/src/handlers/cache-handler.types.ts +++ b/packages/nextjs-cache-handler/src/handlers/cache-handler.types.ts @@ -46,6 +46,20 @@ export type CacheHandlerParametersGetWithTags = [ string[], ]; +/** + * Context information provided during cache set operations. + */ +export type SetContext = { + /** + * Indicates whether this set operation is part of initial cache hydration. + * When true, cache handlers should use non-destructive write operations (e.g., Redis NX) + * to avoid overwriting fresher runtime-generated cache entries with stale build-time values. + * + * @default false + */ + isInitialHydration?: boolean; +}; + /** * Represents an internal Next.js metadata for a `get` method. * This metadata is available in the `get` method of the cache handler. @@ -125,6 +139,8 @@ export type Handler = { * * @param value - The value to be stored in the cache. See {@link CacheHandlerValue}. * + * @param ctx - Optional context information for the set operation. See {@link SetContext}. + * * @returns A Promise that resolves when the value has been successfully set in the cache. * * @remarks @@ -137,7 +153,7 @@ export type Handler = { * * Use the absolute time (`expireAt`) to set and expiration time for the cache entry in your cache store to be in sync with the file system cache. */ - set: (key: string, value: CacheHandlerValue) => Promise; + set: (key: string, value: CacheHandlerValue, ctx?: SetContext) => Promise; /** * Deletes all cache entries that are associated with the specified tag. * See [fetch `options.next.tags` and `revalidateTag` ↗](https://nextjs.org/docs/app/building-your-application/caching#fetch-optionsnexttags-and-revalidatetag) diff --git a/packages/nextjs-cache-handler/src/handlers/composite.ts b/packages/nextjs-cache-handler/src/handlers/composite.ts index e05d7a9..df95231 100644 --- a/packages/nextjs-cache-handler/src/handlers/composite.ts +++ b/packages/nextjs-cache-handler/src/handlers/composite.ts @@ -37,10 +37,10 @@ export default function createHandler({ return null; }, - async set(key, data) { + async set(key, data, ctx) { const index = strategy?.(data) ?? 0; const handler = handlers[index] ?? handlers[0]!; - await handler.set(key, data); + await handler.set(key, data, ctx); }, async revalidateTag(tag) { diff --git a/packages/nextjs-cache-handler/src/handlers/local-lru.ts b/packages/nextjs-cache-handler/src/handlers/local-lru.ts index dedfdeb..ddb8411 100644 --- a/packages/nextjs-cache-handler/src/handlers/local-lru.ts +++ b/packages/nextjs-cache-handler/src/handlers/local-lru.ts @@ -99,7 +99,9 @@ export default function createHandler({ return Promise.resolve(cacheValue); }, - set(key, cacheHandlerValue) { + set(key, cacheHandlerValue, ctx) { + // LRU cache is in-memory and ephemeral, so NX behavior is not applicable + // We always set the value regardless of isInitialHydration flag lruCacheStore.set(key, cacheHandlerValue); return Promise.resolve(); diff --git a/packages/nextjs-cache-handler/src/handlers/redis-strings.ts b/packages/nextjs-cache-handler/src/handlers/redis-strings.ts index 00b719e..de6a6e3 100644 --- a/packages/nextjs-cache-handler/src/handlers/redis-strings.ts +++ b/packages/nextjs-cache-handler/src/handlers/redis-strings.ts @@ -221,7 +221,7 @@ export default function createHandler({ return cacheValue; }, - async set(key, cacheHandlerValue) { + async set(key, cacheHandlerValue, ctx) { assertClientIsReady(); let setOperation: Promise; @@ -260,23 +260,27 @@ export default function createHandler({ switch (keyExpirationStrategy) { case "EXAT": { + const setOptions = + typeof lifespan?.expireAt === "number" + ? { + EXAT: lifespan.expireAt, + ...(ctx?.isInitialHydration ? { NX: true } : {}), + } + : ctx?.isInitialHydration + ? { NX: true } + : undefined; + setOperation = client .withAbortSignal(AbortSignal.timeout(timeoutMs)) - .set( - keyPrefix + key, - serializedValue, - typeof lifespan?.expireAt === "number" - ? { - EXAT: lifespan.expireAt, - } - : undefined, - ); + .set(keyPrefix + key, serializedValue, setOptions); break; } case "EXPIREAT": { + const setOptions = ctx?.isInitialHydration ? { NX: true } : undefined; + setOperation = client .withAbortSignal(AbortSignal.timeout(timeoutMs)) - .set(keyPrefix + key, serializedValue); + .set(keyPrefix + key, serializedValue, setOptions); expireOperation = lifespan ? client diff --git a/packages/nextjs-cache-handler/src/instrumentation/register-initial-cache.ts b/packages/nextjs-cache-handler/src/instrumentation/register-initial-cache.ts index 89ce7de..1b38792 100644 --- a/packages/nextjs-cache-handler/src/instrumentation/register-initial-cache.ts +++ b/packages/nextjs-cache-handler/src/instrumentation/register-initial-cache.ts @@ -236,6 +236,7 @@ export async function registerInitialCache( revalidate, internal_lastModified: lastModified, tags: getTagsFromHeaders(meta.headers), + isInitialHydration: true, }); } catch (error) { if (debug) { @@ -376,6 +377,7 @@ export async function registerInitialCache( await cacheHandler.set(cachePath, value, { revalidate, internal_lastModified: lastModified, + isInitialHydration: true, }); if (debug) { @@ -490,6 +492,7 @@ export async function registerInitialCache( revalidate, internal_lastModified: lastModified, tags: fetchCache.tags, + isInitialHydration: true, }); } catch (error) { if (debug) {