@trieb.work/nextjs-turbo-redis-cache 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.github/workflows/release.yml +8 -157
  2. package/ARCHITECTURE.md +86 -47
  3. package/README.md +87 -24
  4. package/dist/index.d.mts +40 -1
  5. package/dist/index.d.ts +40 -1
  6. package/dist/index.js +264 -96
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +261 -95
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +4 -6
  11. package/release.config.cjs +6 -25
  12. package/src/CacheComponentsHandler.ts +141 -111
  13. package/src/RedisStringsHandler.ts +37 -25
  14. package/src/SyncedMap.ts +16 -2
  15. package/src/index.ts +7 -0
  16. package/src/utils/cacheTtl.ts +71 -0
  17. package/src/utils/compareAndUnlink.ts +29 -0
  18. package/src/utils/redisConnection.ts +17 -0
  19. package/src/utils/tagRevalidation.ts +162 -0
  20. package/test/README.md +10 -10
  21. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
  22. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  23. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  24. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
  25. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  26. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  27. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
  28. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  29. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  30. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
  31. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  32. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  33. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +2 -0
  34. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  35. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  36. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +22 -0
  37. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  38. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  39. package/test/playwright/cache-lab.spec.ts +75 -0
  40. package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
  41. package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +16 -2
  42. package/test/vitest/integration/pages-router.integration.test.ts +16 -6
  43. package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
  44. package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
  45. package/test/vitest/unit/cache-ttl.test.ts +67 -0
  46. package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
  47. package/test/vitest/unit/index.test.ts +17 -0
  48. package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
  49. package/test/vitest/unit/redis-connection.test.ts +39 -0
  50. package/test/vitest/unit/tag-revalidation.test.ts +178 -0
  51. package/CHANGELOG.md +0 -411
@@ -0,0 +1,350 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const hoisted = vi.hoisted(() => {
4
+ const store = new Map<string, string>();
5
+ const unlinkedKeys: string[] = [];
6
+ return { store, unlinkedKeys };
7
+ });
8
+
9
+ vi.mock('redis', () => {
10
+ const createClient = () => {
11
+ const client = {
12
+ isReady: true,
13
+ isOpen: true,
14
+ on: vi.fn(),
15
+ connect: vi.fn(async () => undefined),
16
+ disconnect: vi.fn(),
17
+ quit: vi.fn(),
18
+ duplicate: vi.fn(() => ({
19
+ connect: vi.fn(async () => undefined),
20
+ subscribe: vi.fn(async () => undefined),
21
+ on: vi.fn(),
22
+ quit: vi.fn(async () => undefined),
23
+ configGet: vi.fn(async () => ({ 'notify-keyspace-events': 'Exe' })),
24
+ isOpen: true,
25
+ isReady: true,
26
+ })),
27
+ get: vi.fn(
28
+ async (_opts: unknown, key: string) => hoisted.store.get(key) ?? null,
29
+ ),
30
+ hScan: vi.fn(async () => ({ cursor: 0, tuples: [] })),
31
+ scan: vi.fn(async () => ({ cursor: 0, keys: [] })),
32
+ hSet: vi.fn(async () => 1),
33
+ hDel: vi.fn(async () => 1),
34
+ publish: vi.fn(async () => 1),
35
+ unlink: vi.fn(async (keys: string | string[]) => {
36
+ const list = Array.isArray(keys) ? keys : [keys];
37
+ for (const key of list) {
38
+ hoisted.unlinkedKeys.push(key);
39
+ hoisted.store.delete(key);
40
+ }
41
+ return list.length;
42
+ }),
43
+ eval: vi.fn(
44
+ async (
45
+ _script: string,
46
+ options: { keys: string[]; arguments: string[] },
47
+ ) => {
48
+ const key = options.keys[0];
49
+ const expected = options.arguments[0];
50
+ if (hoisted.store.get(key) === expected) {
51
+ hoisted.unlinkedKeys.push(key);
52
+ hoisted.store.delete(key);
53
+ return 1;
54
+ }
55
+ return 0;
56
+ },
57
+ ),
58
+ set: vi.fn(async (key: string, value: string) => {
59
+ hoisted.store.set(key, value);
60
+ return 'OK';
61
+ }),
62
+ };
63
+ return client;
64
+ };
65
+
66
+ return {
67
+ createClient,
68
+ commandOptions: vi.fn((opts) => opts),
69
+ };
70
+ });
71
+
72
+ function cachedEntry(tags: string[], timestamp: number) {
73
+ return {
74
+ value: new ReadableStream<Uint8Array>({
75
+ start(controller) {
76
+ controller.enqueue(new TextEncoder().encode('cached'));
77
+ controller.close();
78
+ },
79
+ }),
80
+ tags,
81
+ stale: 1,
82
+ timestamp,
83
+ expire: 3600,
84
+ revalidate: 60,
85
+ };
86
+ }
87
+
88
+ describe('RedisCacheComponentsHandler.updateTags vs Next.js default handler', () => {
89
+ beforeEach(() => {
90
+ vi.useFakeTimers();
91
+ hoisted.store.clear();
92
+ hoisted.unlinkedKeys.length = 0;
93
+ vi.resetModules();
94
+ });
95
+
96
+ afterEach(() => {
97
+ vi.useRealTimers();
98
+ });
99
+
100
+ async function createHandler(keyPrefix: string) {
101
+ const { getRedisCacheComponentsHandler } = await import(
102
+ '../../../src/CacheComponentsHandler'
103
+ );
104
+
105
+ return getRedisCacheComponentsHandler({
106
+ keyPrefix,
107
+ redisGetDeduplication: false,
108
+ });
109
+ }
110
+
111
+ it('SWR expire keeps the entry and sets revalidate=-1 until the window elapses', async () => {
112
+ const handler = await createHandler('swr:');
113
+ const createdAt = Date.now();
114
+
115
+ await handler.set(
116
+ 'page',
117
+ Promise.resolve(cachedEntry(['posts'], createdAt)),
118
+ );
119
+ // Next.js areTagsStale/areTagsExpired require stale/expired > entry.timestamp.
120
+ await vi.advanceTimersByTimeAsync(1);
121
+ const revalidatedAt = Date.now();
122
+ await handler.updateTags(['posts'], { expire: 2 });
123
+
124
+ expect(await handler.getExpiration(['posts'])).toBe(revalidatedAt + 2_000);
125
+
126
+ const served = await handler.get('page', []);
127
+ expect(served).toBeDefined();
128
+ expect(served?.revalidate).toBe(-1);
129
+ expect(hoisted.unlinkedKeys).toEqual([]);
130
+
131
+ await vi.advanceTimersByTimeAsync(2_000);
132
+ expect(await handler.get('page', [])).toBeUndefined();
133
+ });
134
+
135
+ it('cacheLife max expire (~1y) is SWR, not a delay before staleness', async () => {
136
+ const handler = await createHandler('max:');
137
+ const createdAt = Date.now();
138
+ const oneYear = 60 * 60 * 24 * 365;
139
+
140
+ await handler.set(
141
+ 'page',
142
+ Promise.resolve(cachedEntry(['posts'], createdAt)),
143
+ );
144
+ await vi.advanceTimersByTimeAsync(1);
145
+ const revalidatedAt = Date.now();
146
+ await handler.updateTags(['posts'], { expire: oneYear });
147
+
148
+ expect(await handler.getExpiration(['posts'])).toBe(
149
+ revalidatedAt + oneYear * 1000,
150
+ );
151
+ const served = await handler.get('page', []);
152
+ expect(served?.revalidate).toBe(-1);
153
+ expect(await handler.get('page', [])).toBeDefined();
154
+ });
155
+
156
+ it('omitted durations is a blocking miss (updateTag / deprecated revalidateTag)', async () => {
157
+ const handler = await createHandler('hard:');
158
+ const createdAt = Date.now();
159
+
160
+ await handler.set(
161
+ 'page',
162
+ Promise.resolve(cachedEntry(['posts'], createdAt)),
163
+ );
164
+ await vi.advanceTimersByTimeAsync(1);
165
+ const revalidatedAt = Date.now();
166
+ await handler.updateTags(['posts']);
167
+
168
+ expect(await handler.getExpiration(['posts'])).toBe(revalidatedAt);
169
+ expect((handler as any).revalidatedTagsMap.get('posts')).toBe(
170
+ revalidatedAt,
171
+ );
172
+ expect(await handler.get('page', [])).toBeUndefined();
173
+ });
174
+
175
+ it('{ expire: 0 } is a blocking miss', async () => {
176
+ const handler = await createHandler('zero:');
177
+ const createdAt = Date.now();
178
+
179
+ await handler.set(
180
+ 'page',
181
+ Promise.resolve(cachedEntry(['posts'], createdAt)),
182
+ );
183
+ await vi.advanceTimersByTimeAsync(1);
184
+ const revalidatedAt = Date.now();
185
+ await handler.updateTags(['posts'], { expire: 0 });
186
+
187
+ expect(await handler.getExpiration(['posts'])).toBe(revalidatedAt);
188
+ expect((handler as any).revalidatedTagsMap.get('posts')).toBe(
189
+ revalidatedAt,
190
+ );
191
+ expect(await handler.get('page', [])).toBeUndefined();
192
+ });
193
+
194
+ it('persists stale+expired so a later getExpiration still sees the window', async () => {
195
+ const handler = await createHandler('persist:');
196
+ const startedAt = Date.now();
197
+
198
+ await handler.updateTags(['posts'], { expire: 2 });
199
+ const stored = (handler as any).revalidatedTagsMap.get('posts');
200
+
201
+ expect(stored).toEqual({
202
+ stale: startedAt,
203
+ expired: startedAt + 2_000,
204
+ });
205
+ });
206
+
207
+ it('legacy plain number in Redis hard-expires once the timestamp is in the past', async () => {
208
+ const handler = await createHandler('legacy-past:');
209
+ const createdAt = Date.now() - 10_000;
210
+
211
+ await handler.set(
212
+ 'page',
213
+ Promise.resolve(cachedEntry(['posts'], createdAt)),
214
+ );
215
+
216
+ // Older package versions stored Date.now() directly (not Next.js).
217
+ (handler as any).revalidatedTagsMap.set('posts', Date.now() - 5_000);
218
+
219
+ expect(await handler.get('page', [])).toBeUndefined();
220
+ });
221
+
222
+ it('legacy plain number ahead of local clock is clamped and hard-expires immediately', async () => {
223
+ vi.setSystemTime(new Date(1_000_000));
224
+ const handler = await createHandler('legacy-skew:');
225
+ const createdAt = 900_000;
226
+
227
+ await handler.set(
228
+ 'page',
229
+ Promise.resolve(cachedEntry(['posts'], createdAt)),
230
+ );
231
+
232
+ // Another instance with a faster clock wrote this value to Redis.
233
+ (handler as any).revalidatedTagsMap.set('posts', 1_005_000);
234
+
235
+ expect(await handler.get('page', [])).toBeUndefined();
236
+ });
237
+
238
+ it.each([
239
+ ['seconds', 60],
240
+ ['minutes', 60 * 60],
241
+ ['hours', 60 * 60 * 24],
242
+ ['days', 60 * 60 * 24 * 7],
243
+ ['weeks', 60 * 60 * 24 * 30],
244
+ ['max', 60 * 60 * 24 * 365],
245
+ ['default', 0xfffffffe],
246
+ ] as const)(
247
+ 'cacheLife %s expire writes getExpiration=now+expire*1000 and SWR',
248
+ async (_profile, expireSeconds) => {
249
+ const handler = await createHandler(`preset-${_profile}:`);
250
+ const createdAt = Date.now();
251
+
252
+ await handler.set(
253
+ 'page',
254
+ Promise.resolve(cachedEntry(['posts'], createdAt)),
255
+ );
256
+ await vi.advanceTimersByTimeAsync(1);
257
+ const revalidatedAt = Date.now();
258
+ await handler.updateTags(['posts'], { expire: expireSeconds });
259
+
260
+ expect(await handler.getExpiration(['posts'])).toBe(
261
+ revalidatedAt + expireSeconds * 1000,
262
+ );
263
+ const served = await handler.get('page', []);
264
+ expect(served?.revalidate).toBe(-1);
265
+ expect(await handler.get('page', [])).toBeDefined();
266
+ },
267
+ );
268
+
269
+ it('does not UNLINK a Redis replacement after a stale deduped read', async () => {
270
+ const { getRedisCacheComponentsHandler } = await import(
271
+ '../../../src/CacheComponentsHandler'
272
+ );
273
+ const handler = getRedisCacheComponentsHandler({
274
+ keyPrefix: 'dedup-unlink:',
275
+ redisGetDeduplication: true,
276
+ inMemoryCachingTime: 10_000,
277
+ });
278
+
279
+ const createdAt = Date.now();
280
+ await handler.set(
281
+ 'page',
282
+ Promise.resolve(cachedEntry(['posts'], createdAt)),
283
+ );
284
+ const redisKey = 'dedup-unlink:page';
285
+ const oldSerialized = hoisted.store.get(redisKey)!;
286
+
287
+ await vi.advanceTimersByTimeAsync(1);
288
+ await handler.updateTags(['posts']);
289
+
290
+ const freshAt = Date.now();
291
+ hoisted.store.set(
292
+ redisKey,
293
+ JSON.stringify({
294
+ value: Buffer.from('fresh').toString('base64'),
295
+ tags: ['posts'],
296
+ stale: 1,
297
+ timestamp: freshAt,
298
+ expire: 3600,
299
+ revalidate: 60,
300
+ }),
301
+ );
302
+ (handler as any).redisDeduplicationHandler.seedRequestReturn(
303
+ 'page',
304
+ oldSerialized,
305
+ );
306
+
307
+ hoisted.unlinkedKeys.length = 0;
308
+ const served = await handler.get('page', []);
309
+
310
+ expect(hoisted.unlinkedKeys).toEqual([]);
311
+ expect(served).toBeDefined();
312
+ expect(hoisted.store.get(redisKey)).toBeTruthy();
313
+ });
314
+
315
+ it('keeps a later stale-only update after a hard expire and replacement', async () => {
316
+ const handler = await createHandler('stale-after-hard:');
317
+ const createdAt = Date.now();
318
+
319
+ await handler.set(
320
+ 'page',
321
+ Promise.resolve(cachedEntry(['posts'], createdAt)),
322
+ );
323
+ await vi.advanceTimersByTimeAsync(1);
324
+ const hardExpiredAt = Date.now();
325
+ await handler.updateTags(['posts']);
326
+ expect((handler as any).revalidatedTagsMap.get('posts')).toBe(
327
+ hardExpiredAt,
328
+ );
329
+
330
+ await vi.advanceTimersByTimeAsync(1);
331
+ const replacementAt = Date.now();
332
+ await handler.set(
333
+ 'page',
334
+ Promise.resolve(cachedEntry(['posts'], replacementAt)),
335
+ );
336
+
337
+ await vi.advanceTimersByTimeAsync(1);
338
+ const staleAt = Date.now();
339
+ await handler.updateTags(['posts'], {});
340
+
341
+ expect((handler as any).revalidatedTagsMap.get('posts')).toEqual({
342
+ stale: staleAt,
343
+ expired: hardExpiredAt,
344
+ });
345
+
346
+ const served = await handler.get('page', []);
347
+ expect(served).toBeDefined();
348
+ expect(served?.revalidate).toBe(-1);
349
+ });
350
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveCacheEntryTtlSeconds } from '../../../src/utils/cacheTtl';
3
+
4
+ const estimateExpireAge = (staleAge: number) => staleAge * 1.2;
5
+ const defaultStaleAge = 60 * 60 * 24 * 14;
6
+
7
+ describe('resolveCacheEntryTtlSeconds', () => {
8
+ it('prefers cacheControl.expire over revalidate (SWR-safe TTL)', () => {
9
+ const ttl = resolveCacheEntryTtlSeconds(
10
+ { cacheControl: { revalidate: 60, expire: 3600 } },
11
+ { kind: 'APP_PAGE' },
12
+ { estimateExpireAge, defaultStaleAge },
13
+ );
14
+
15
+ expect(ttl).toBe(3600);
16
+ });
17
+
18
+ it('falls back to estimateExpireAge(revalidate) when expire is omitted', () => {
19
+ const ttl = resolveCacheEntryTtlSeconds(
20
+ { cacheControl: { revalidate: 60, expire: undefined } },
21
+ { kind: 'PAGES' },
22
+ { estimateExpireAge, defaultStaleAge },
23
+ );
24
+
25
+ expect(ttl).toBe(72);
26
+ });
27
+
28
+ it('uses FETCH data.revalidate when cacheControl is absent', () => {
29
+ const ttl = resolveCacheEntryTtlSeconds(
30
+ {},
31
+ { kind: 'FETCH', revalidate: 30 },
32
+ { estimateExpireAge, defaultStaleAge },
33
+ );
34
+
35
+ expect(ttl).toBe(36);
36
+ });
37
+
38
+ it('uses defaultStaleAge when revalidate is false', () => {
39
+ const ttl = resolveCacheEntryTtlSeconds(
40
+ { cacheControl: { revalidate: false, expire: undefined } },
41
+ { kind: 'PAGES' },
42
+ { estimateExpireAge, defaultStaleAge },
43
+ );
44
+
45
+ expect(ttl).toBe(estimateExpireAge(defaultStaleAge));
46
+ });
47
+
48
+ it('returns undefined when no expire or revalidate is provided', () => {
49
+ const ttl = resolveCacheEntryTtlSeconds(
50
+ { cacheControl: { revalidate: undefined, expire: undefined } },
51
+ { kind: 'APP_ROUTE' },
52
+ { estimateExpireAge, defaultStaleAge },
53
+ );
54
+
55
+ expect(ttl).toBeUndefined();
56
+ });
57
+
58
+ it('supports legacy ctx.revalidate (Next.js 15.0.3)', () => {
59
+ const ttl = resolveCacheEntryTtlSeconds(
60
+ { revalidate: 60 },
61
+ { kind: 'PAGES' },
62
+ { estimateExpireAge, defaultStaleAge },
63
+ );
64
+
65
+ expect(ttl).toBe(72);
66
+ });
67
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { compareAndUnlink } from '../../../src/utils/compareAndUnlink';
3
+
4
+ describe('compareAndUnlink', () => {
5
+ it('evals GET+UNLINK and reports whether the key was removed', async () => {
6
+ const evalFn = vi.fn(async () => 1);
7
+ await expect(compareAndUnlink({ eval: evalFn }, 'k', 'old')).resolves.toBe(
8
+ true,
9
+ );
10
+ expect(evalFn).toHaveBeenCalledWith(
11
+ expect.stringContaining("redis.call('UNLINK'"),
12
+ { keys: ['k'], arguments: ['old'] },
13
+ );
14
+ });
15
+
16
+ it('returns false when the value no longer matches', async () => {
17
+ const evalFn = vi.fn(async () => 0);
18
+ await expect(compareAndUnlink({ eval: evalFn }, 'k', 'old')).resolves.toBe(
19
+ false,
20
+ );
21
+ });
22
+ });
@@ -113,6 +113,23 @@ describe('RedisStringsHandler', () => {
113
113
  vi.useRealTimers();
114
114
  }
115
115
  });
116
+
117
+ it('skips Redis work in revalidateTag during next build', async () => {
118
+ vi.stubEnv('NEXT_PHASE', 'phase-production-build');
119
+ const { default: Handler } = await import(
120
+ '../../../src/RedisStringsHandler'
121
+ );
122
+ const handler = new Handler({
123
+ redisUrl: 'redis://localhost:6379',
124
+ keyPrefix: 'build:',
125
+ database: 0,
126
+ redisGetDeduplication: false,
127
+ });
128
+ const unlink = (handler as any).client.unlink as ReturnType<typeof vi.fn>;
129
+
130
+ await expect(handler.revalidateTag('posts')).resolves.toBeUndefined();
131
+ expect(unlink).not.toHaveBeenCalled();
132
+ });
116
133
  });
117
134
 
118
135
  describe('Public exports', () => {
@@ -145,6 +145,17 @@ describe('RedisStringsHandler Pages Router kinds', () => {
145
145
  expect(ex).toBe(expireAge(DEFAULT_STALE_AGE));
146
146
  });
147
147
 
148
+ it('set() prefers cacheControl.expire over revalidate for the Redis TTL', async () => {
149
+ const handler = createHandler();
150
+ await handler.set('/blog/post-swr', pagesData, {
151
+ ...baseCtx,
152
+ cacheControl: { revalidate: 60, expire: 3600 },
153
+ });
154
+
155
+ const { ex } = storedEntry('/blog/post-swr');
156
+ expect(ex).toBe(3600);
157
+ });
158
+
148
159
  it('set() derives the TTL from the legacy ctx.revalidate argument (Next.js 15.0.3)', async () => {
149
160
  const handler = createHandler();
150
161
  await handler.set('/blog/post-legacy', pagesData, {
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ PHASE_PRODUCTION_BUILD,
4
+ shouldDeferRedisConnection,
5
+ } from '../../../src/utils/redisConnection';
6
+
7
+ describe('shouldDeferRedisConnection', () => {
8
+ const originalPhase = process.env.NEXT_PHASE;
9
+
10
+ beforeEach(() => {
11
+ delete process.env.NEXT_PHASE;
12
+ });
13
+
14
+ afterEach(() => {
15
+ if (originalPhase === undefined) {
16
+ delete process.env.NEXT_PHASE;
17
+ } else {
18
+ process.env.NEXT_PHASE = originalPhase;
19
+ }
20
+ });
21
+
22
+ it('returns false when NEXT_PHASE is not set', () => {
23
+ expect(shouldDeferRedisConnection()).toBe(false);
24
+ });
25
+
26
+ it('returns true when NEXT_PHASE is phase-production-build', () => {
27
+ process.env.NEXT_PHASE = 'phase-production-build';
28
+ expect(shouldDeferRedisConnection()).toBe(true);
29
+ });
30
+
31
+ it('returns false for other Next.js phases', () => {
32
+ process.env.NEXT_PHASE = 'phase-production-server';
33
+ expect(shouldDeferRedisConnection()).toBe(false);
34
+ });
35
+
36
+ it('uses the Next.js PHASE_PRODUCTION_BUILD string', () => {
37
+ expect(PHASE_PRODUCTION_BUILD).toBe('phase-production-build');
38
+ });
39
+ });
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ applyTagUpdate,
4
+ areTagsExpired,
5
+ areTagsStale,
6
+ maxExpiredTimestamp,
7
+ normalizeTagManifest,
8
+ persistableTagManifest,
9
+ type TagManifestEntry,
10
+ } from '../../../src/utils/tagRevalidation';
11
+
12
+ // Expire seconds from next/dist/server/config-shared.js cacheLife presets
13
+ // (identical in Next.js 16.0.11, 16.2.6, 16.3.0).
14
+ const CACHE_LIFE_EXPIRE_SECONDS = {
15
+ default: 0xfffffffe, // INFINITE_CACHE in next/dist/lib/constants.js
16
+ seconds: 60,
17
+ minutes: 60 * 60,
18
+ hours: 60 * 60 * 24,
19
+ days: 60 * 60 * 24 * 7,
20
+ weeks: 60 * 60 * 24 * 30,
21
+ max: 60 * 60 * 24 * 365,
22
+ };
23
+
24
+ describe('applyTagUpdate (Next.js DefaultCacheHandler.updateTags)', () => {
25
+ const now = 1_000_000;
26
+
27
+ it('with no durations sets expired=now and does not set stale', () => {
28
+ expect(applyTagUpdate({ stale: 9 }, undefined, now)).toEqual({
29
+ stale: 9,
30
+ expired: now,
31
+ });
32
+ });
33
+
34
+ it('with { expire: 0 } sets stale=now and expired=now (blocking miss)', () => {
35
+ expect(applyTagUpdate({}, { expire: 0 }, now)).toEqual({
36
+ stale: now,
37
+ expired: now,
38
+ });
39
+ });
40
+
41
+ it('with { expire: N } sets stale=now and expired=now+N*1000 (SWR window)', () => {
42
+ expect(applyTagUpdate({}, { expire: 2 }, now)).toEqual({
43
+ stale: now,
44
+ expired: now + 2_000,
45
+ });
46
+ });
47
+
48
+ it('uses each built-in cacheLife expire for revalidateTag(tag, profile)', () => {
49
+ for (const [profile, expire] of Object.entries(CACHE_LIFE_EXPIRE_SECONDS)) {
50
+ const result = applyTagUpdate({}, { expire }, now);
51
+ expect(result.stale, profile).toBe(now);
52
+ expect(result.expired, profile).toBe(now + expire * 1000);
53
+ }
54
+ });
55
+
56
+ it('with durations but expire undefined only marks stale (keeps prior expired)', () => {
57
+ expect(applyTagUpdate({ expired: 50 }, { expire: undefined }, now)).toEqual(
58
+ { stale: now, expired: 50 },
59
+ );
60
+ });
61
+ });
62
+
63
+ describe('areTagsExpired / areTagsStale (Next.js tags-manifest.external)', () => {
64
+ const lookup = (entry: { stale?: number; expired?: number }) => () => entry;
65
+
66
+ it('hard-expires when expired has elapsed and is newer than the entry', () => {
67
+ expect(areTagsExpired(['t'], 100, 1_000, lookup({ expired: 500 }))).toBe(
68
+ true,
69
+ );
70
+ });
71
+
72
+ it('does not hard-expire while expired is still in the future', () => {
73
+ expect(areTagsExpired(['t'], 100, 1_000, lookup({ expired: 2_000 }))).toBe(
74
+ false,
75
+ );
76
+ });
77
+
78
+ it('does not hard-expire when expiredAt equals the entry timestamp', () => {
79
+ expect(areTagsExpired(['t'], 500, 1_000, lookup({ expired: 500 }))).toBe(
80
+ false,
81
+ );
82
+ });
83
+
84
+ it('does not hard-expire entries created after the expired timestamp', () => {
85
+ expect(
86
+ areTagsExpired(['t'], 2_000, 3_000, lookup({ expired: 1_000 })),
87
+ ).toBe(false);
88
+ });
89
+
90
+ it('is stale when stale is newer than the entry timestamp', () => {
91
+ expect(areTagsStale(['t'], 100, lookup({ stale: 500 }))).toBe(true);
92
+ });
93
+
94
+ it('is not stale when stale is older than the entry', () => {
95
+ expect(areTagsStale(['t'], 1_000, lookup({ stale: 500 }))).toBe(false);
96
+ });
97
+ });
98
+
99
+ describe('maxExpiredTimestamp (Next.js getExpiration)', () => {
100
+ it('returns 0 when no tag was expired', () => {
101
+ expect(maxExpiredTimestamp(['a'], () => ({}))).toBe(0);
102
+ });
103
+
104
+ it('returns a future expired timestamp (SWR window has not elapsed)', () => {
105
+ expect(
106
+ maxExpiredTimestamp(['a'], () => ({ stale: 1, expired: 9_000 })),
107
+ ).toBe(9_000);
108
+ });
109
+ });
110
+
111
+ describe('normalizeTagManifest', () => {
112
+ it('reads Next.js { stale, expired } records', () => {
113
+ expect(normalizeTagManifest({ stale: 1, expired: 2 })).toEqual({
114
+ stale: 1,
115
+ expired: 2,
116
+ });
117
+ });
118
+
119
+ it('maps a legacy numeric timestamp to expired', () => {
120
+ expect(normalizeTagManifest(1_234)).toEqual({ expired: 1_234 });
121
+ });
122
+ });
123
+
124
+ describe('legacy plain numbers (rolling upgrade, not Next.js)', () => {
125
+ const lookup = (entry: TagManifestEntry | undefined) => (tag: string) =>
126
+ tag === 't' ? entry : undefined;
127
+
128
+ it('past plain number hard-expires entries older than the revalidation', () => {
129
+ const revalidatedAt = 500;
130
+ const manifest = normalizeTagManifest(revalidatedAt);
131
+
132
+ expect(areTagsExpired(['t'], 100, 1_000, lookup(manifest))).toBe(true);
133
+ expect(areTagsStale(['t'], 100, lookup(manifest))).toBe(false);
134
+ });
135
+
136
+ it('clamps future plain numbers to now (cross-instance clock skew)', () => {
137
+ // Instance A (clock ahead) wrote Date.now() = 1_005_000 into Redis.
138
+ // This reader's clock is still at 1_000_000 — clamp treats it as now.
139
+ const manifest = normalizeTagManifest(1_005_000, 1_000_000);
140
+
141
+ expect(manifest).toEqual({ expired: 1_000_000 });
142
+ expect(areTagsExpired(['t'], 900_000, 1_000_000, lookup(manifest))).toBe(
143
+ true,
144
+ );
145
+ });
146
+
147
+ it('intentional SWR uses stale + future expired, not a plain number', () => {
148
+ const now = 1_000_000;
149
+ const swr = applyTagUpdate({}, { expire: 2 }, now);
150
+
151
+ expect(swr).toEqual({ stale: now, expired: now + 2_000 });
152
+ expect(areTagsStale(['t'], 900_000, lookup(swr))).toBe(true);
153
+ expect(areTagsExpired(['t'], 900_000, now + 500, lookup(swr))).toBe(false);
154
+ });
155
+ });
156
+
157
+ describe('persistableTagManifest', () => {
158
+ const now = 1_000_000;
159
+
160
+ it('writes a plain number for immediate hard-expires (rolling-upgrade readers)', () => {
161
+ expect(persistableTagManifest({ expired: now }, now)).toBe(now);
162
+ expect(persistableTagManifest({ stale: now, expired: now }, now)).toBe(now);
163
+ });
164
+
165
+ it('keeps { stale, expired } objects for SWR windows', () => {
166
+ const swr = { stale: now, expired: now + 2_000 };
167
+ expect(persistableTagManifest(swr, now)).toEqual(swr);
168
+ });
169
+
170
+ it('keeps the object when a newer stale-only update outruns a past expired', () => {
171
+ const expired = now - 5_000;
172
+ const stale = now;
173
+ expect(persistableTagManifest({ stale, expired }, now)).toEqual({
174
+ stale,
175
+ expired,
176
+ });
177
+ });
178
+ });