@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.
- package/.github/workflows/release.yml +8 -157
- package/ARCHITECTURE.md +86 -47
- package/README.md +87 -24
- package/dist/index.d.mts +40 -1
- package/dist/index.d.ts +40 -1
- package/dist/index.js +264 -96
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +261 -95
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -6
- package/release.config.cjs +6 -25
- package/src/CacheComponentsHandler.ts +141 -111
- package/src/RedisStringsHandler.ts +37 -25
- package/src/SyncedMap.ts +16 -2
- package/src/index.ts +7 -0
- package/src/utils/cacheTtl.ts +71 -0
- package/src/utils/compareAndUnlink.ts +29 -0
- package/src/utils/redisConnection.ts +17 -0
- package/src/utils/tagRevalidation.ts +162 -0
- package/test/README.md +10 -10
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/playwright/cache-lab.spec.ts +75 -0
- package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
- package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +16 -2
- package/test/vitest/integration/pages-router.integration.test.ts +16 -6
- package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
- package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
- package/test/vitest/unit/cache-ttl.test.ts +67 -0
- package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
- package/test/vitest/unit/index.test.ts +17 -0
- package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
- package/test/vitest/unit/redis-connection.test.ts +39 -0
- package/test/vitest/unit/tag-revalidation.test.ts +178 -0
- package/CHANGELOG.md +0 -411
|
@@ -15,6 +15,21 @@ describe('Next.js 16 Cache Components Integration', () => {
|
|
|
15
15
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
async function waitForRedisKeys(
|
|
19
|
+
pattern: string,
|
|
20
|
+
minCount = 1,
|
|
21
|
+
timeoutMs = 10_000,
|
|
22
|
+
) {
|
|
23
|
+
for (let elapsed = 0; elapsed < timeoutMs; elapsed += 100) {
|
|
24
|
+
const keys = await redisClient.keys(pattern);
|
|
25
|
+
if (keys.length >= minCount) {
|
|
26
|
+
return keys;
|
|
27
|
+
}
|
|
28
|
+
await delay(100);
|
|
29
|
+
}
|
|
30
|
+
return redisClient.keys(pattern);
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
beforeAll(async () => {
|
|
19
34
|
// Connect to Redis
|
|
20
35
|
redisClient = createClient({
|
|
@@ -106,12 +121,13 @@ describe('Next.js 16 Cache Components Integration', () => {
|
|
|
106
121
|
});
|
|
107
122
|
|
|
108
123
|
it('should store cache entry in Redis', async () => {
|
|
109
|
-
|
|
124
|
+
// expire-matrix is dynamic (`ƒ`); static prerendered `use cache` routes may
|
|
125
|
+
// not write through to Redis at runtime on some Next.js versions.
|
|
126
|
+
await fetch(`${BASE_URL}/api/expire-matrix?id=redis-smoke`);
|
|
110
127
|
|
|
111
|
-
|
|
112
|
-
const keys = await redisClient.keys(`${keyPrefix}*`);
|
|
128
|
+
const keys = await waitForRedisKeys(`${keyPrefix}*`);
|
|
113
129
|
expect(keys.length).toBeGreaterThan(0);
|
|
114
|
-
});
|
|
130
|
+
}, 15_000);
|
|
115
131
|
});
|
|
116
132
|
|
|
117
133
|
describe('cacheTag functionality', () => {
|
|
@@ -128,46 +144,36 @@ describe('Next.js 16 Cache Components Integration', () => {
|
|
|
128
144
|
expect(data2.counter).toBe(data1.counter);
|
|
129
145
|
});
|
|
130
146
|
|
|
131
|
-
it('should invalidate cache when tag is revalidated
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
const data1 = await res1.json();
|
|
147
|
+
it('should invalidate cache when tag is revalidated', async () => {
|
|
148
|
+
const id = `tag-invalidation-${Date.now()}`;
|
|
149
|
+
const matrixUrl = `${BASE_URL}/api/expire-matrix?id=${encodeURIComponent(id)}`;
|
|
135
150
|
|
|
136
|
-
|
|
137
|
-
await fetch(
|
|
138
|
-
|
|
139
|
-
headers: { 'Content-Type': 'application/json' },
|
|
140
|
-
body: JSON.stringify({ tag: 'test-tag' }),
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// The cache should be invalidated - verify by making multiple requests
|
|
144
|
-
// until we get fresh data (with retries for async revalidation)
|
|
145
|
-
let freshDataReceived = false;
|
|
146
|
-
// Next.js tag revalidation can be async and may take longer under some runtimes.
|
|
147
|
-
// Use a more tolerant window to avoid flaky failures.
|
|
148
|
-
for (let i = 0; i < 60; i++) {
|
|
149
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
150
|
-
const res = await fetch(`${BASE_URL}/api/cached-with-tag`);
|
|
151
|
-
const data = await res.json();
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
data.counter !== data1.counter ||
|
|
155
|
-
data.timestamp !== data1.timestamp
|
|
156
|
-
) {
|
|
157
|
-
freshDataReceived = true;
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
151
|
+
const data1 = await (await fetch(matrixUrl)).json();
|
|
152
|
+
const data2 = await (await fetch(matrixUrl)).json();
|
|
153
|
+
expect(data2.counter).toBe(data1.counter);
|
|
161
154
|
|
|
162
|
-
|
|
163
|
-
|
|
155
|
+
const revalidateRes = await fetch(
|
|
156
|
+
`${BASE_URL}/api/expire-matrix/revalidate`,
|
|
157
|
+
{
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
tag: `expire-matrix-${id}`,
|
|
162
|
+
profile: { expire: 0 },
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
expect(revalidateRes.status).toBe(200);
|
|
167
|
+
|
|
168
|
+
const after = await (await fetch(matrixUrl)).json();
|
|
169
|
+
expect(after.counter).toBeGreaterThan(data1.counter);
|
|
170
|
+
});
|
|
164
171
|
});
|
|
165
172
|
|
|
166
173
|
describe('cacheLife functionality', () => {
|
|
167
174
|
it('should respect expire window and eventually return refreshed data', async () => {
|
|
168
175
|
const res1 = await fetch(`${BASE_URL}/api/cached-with-cachelife`);
|
|
169
176
|
const data1 = await res1.json();
|
|
170
|
-
expect(data1.counter).toBe(1);
|
|
171
177
|
|
|
172
178
|
const res2 = await fetch(`${BASE_URL}/api/cached-with-cachelife`);
|
|
173
179
|
const data2 = await res2.json();
|
|
@@ -198,6 +204,158 @@ describe('Next.js 16 Cache Components Integration', () => {
|
|
|
198
204
|
}, 20_000);
|
|
199
205
|
});
|
|
200
206
|
|
|
207
|
+
describe('revalidateTag expire settings (next start)', () => {
|
|
208
|
+
// Expire seconds from next/dist/server/config-shared.js cacheLife presets
|
|
209
|
+
// (identical in Next.js 16.0.11, 16.2.6, 16.3.0). `default.expire` is
|
|
210
|
+
// INFINITE_CACHE = 0xfffffffe.
|
|
211
|
+
const CACHE_LIFE_EXPIRE_SECONDS = {
|
|
212
|
+
default: 0xfffffffe,
|
|
213
|
+
seconds: 60,
|
|
214
|
+
minutes: 60 * 60,
|
|
215
|
+
hours: 60 * 60 * 24,
|
|
216
|
+
days: 60 * 60 * 24 * 7,
|
|
217
|
+
weeks: 60 * 60 * 24 * 30,
|
|
218
|
+
max: 60 * 60 * 24 * 365,
|
|
219
|
+
} as const;
|
|
220
|
+
|
|
221
|
+
async function getMatrix(id: string) {
|
|
222
|
+
const res = await fetch(
|
|
223
|
+
`${BASE_URL}/api/expire-matrix?id=${encodeURIComponent(id)}`,
|
|
224
|
+
);
|
|
225
|
+
expect(res.status).toBe(200);
|
|
226
|
+
return res.json() as Promise<{
|
|
227
|
+
counter: number;
|
|
228
|
+
timestamp: number;
|
|
229
|
+
id: string;
|
|
230
|
+
}>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function revalidateMatrix(
|
|
234
|
+
id: string,
|
|
235
|
+
profile: string | { expire: number } | null,
|
|
236
|
+
) {
|
|
237
|
+
const tag = `expire-matrix-${id}`;
|
|
238
|
+
const body = profile === null ? { tag, profile: null } : { tag, profile };
|
|
239
|
+
const res = await fetch(`${BASE_URL}/api/expire-matrix/revalidate`, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'Content-Type': 'application/json' },
|
|
242
|
+
body: JSON.stringify(body),
|
|
243
|
+
});
|
|
244
|
+
expect(res.status).toBe(200);
|
|
245
|
+
return res.json() as Promise<{ timestamp: number }>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function readTagManifest(tag: string) {
|
|
249
|
+
const hashKey = `${keyPrefix}__cacheComponents_revalidated_tags__`;
|
|
250
|
+
for (let elapsed = 0; elapsed < 5_000; elapsed += 50) {
|
|
251
|
+
const raw = await redisClient.hGet(hashKey, tag);
|
|
252
|
+
if (raw) {
|
|
253
|
+
const parsed: unknown = JSON.parse(raw);
|
|
254
|
+
if (typeof parsed === 'number') {
|
|
255
|
+
return { expired: parsed } as { stale?: number; expired?: number };
|
|
256
|
+
}
|
|
257
|
+
return parsed as { stale?: number; expired?: number };
|
|
258
|
+
}
|
|
259
|
+
await delay(50);
|
|
260
|
+
}
|
|
261
|
+
throw new Error(`tag manifest missing for ${tag} in ${hashKey}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function expectApproxMs(actual: number | undefined, expected: number) {
|
|
265
|
+
expect(actual, `expected ~${expected}, got ${actual}`).toBeDefined();
|
|
266
|
+
expect(Math.abs(actual! - expected)).toBeLessThan(5_000);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
it.each(Object.entries(CACHE_LIFE_EXPIRE_SECONDS) as [string, number][])(
|
|
270
|
+
'revalidateTag(tag, %s) marks stale now and expired=now+expire*1000, then SWR',
|
|
271
|
+
async (profile, expireSeconds) => {
|
|
272
|
+
const id = `profile-${profile}`;
|
|
273
|
+
await getMatrix(id);
|
|
274
|
+
const before = await getMatrix(id);
|
|
275
|
+
|
|
276
|
+
const startedAt = Date.now();
|
|
277
|
+
await revalidateMatrix(id, profile);
|
|
278
|
+
const manifest = await readTagManifest(`expire-matrix-${id}`);
|
|
279
|
+
|
|
280
|
+
expectApproxMs(manifest.stale, startedAt);
|
|
281
|
+
expectApproxMs(manifest.expired, startedAt + expireSeconds * 1000);
|
|
282
|
+
|
|
283
|
+
const after = await getMatrix(id);
|
|
284
|
+
expect(after.counter).toBe(before.counter);
|
|
285
|
+
expect(after.timestamp).toBe(before.timestamp);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
it('{ expire: 5 } uses the object expire, not a named profile', async () => {
|
|
290
|
+
const id = 'object-expire-5';
|
|
291
|
+
await getMatrix(id);
|
|
292
|
+
const before = await getMatrix(id);
|
|
293
|
+
|
|
294
|
+
const startedAt = Date.now();
|
|
295
|
+
await revalidateMatrix(id, { expire: 5 });
|
|
296
|
+
const manifest = await readTagManifest(`expire-matrix-${id}`);
|
|
297
|
+
|
|
298
|
+
expectApproxMs(manifest.stale, startedAt);
|
|
299
|
+
expectApproxMs(manifest.expired, startedAt + 5_000);
|
|
300
|
+
|
|
301
|
+
const after = await getMatrix(id);
|
|
302
|
+
expect(after.counter).toBe(before.counter);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('{ expire: 0 } writes expired=now and the next GET is a blocking miss', async () => {
|
|
306
|
+
const id = 'expire-zero';
|
|
307
|
+
await getMatrix(id);
|
|
308
|
+
const before = await getMatrix(id);
|
|
309
|
+
|
|
310
|
+
const startedAt = Date.now();
|
|
311
|
+
await revalidateMatrix(id, { expire: 0 });
|
|
312
|
+
const manifest = await readTagManifest(`expire-matrix-${id}`);
|
|
313
|
+
|
|
314
|
+
expectApproxMs(manifest.expired, startedAt);
|
|
315
|
+
expect(manifest.stale).toBeUndefined();
|
|
316
|
+
|
|
317
|
+
const after = await getMatrix(id);
|
|
318
|
+
expect(after.counter).toBeGreaterThan(before.counter);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('omitted profile writes expired=now without stale and the next GET misses', async () => {
|
|
322
|
+
const id = 'omit-profile';
|
|
323
|
+
await getMatrix(id);
|
|
324
|
+
const before = await getMatrix(id);
|
|
325
|
+
|
|
326
|
+
const startedAt = Date.now();
|
|
327
|
+
await revalidateMatrix(id, null);
|
|
328
|
+
const manifest = await readTagManifest(`expire-matrix-${id}`);
|
|
329
|
+
|
|
330
|
+
expect(manifest.stale).toBeUndefined();
|
|
331
|
+
expectApproxMs(manifest.expired, startedAt);
|
|
332
|
+
|
|
333
|
+
const after = await getMatrix(id);
|
|
334
|
+
expect(after.counter).toBeGreaterThan(before.counter);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('{ expire: 2 } serves stale, then hard-expires after the window', async () => {
|
|
338
|
+
const id = 'expire-two';
|
|
339
|
+
await getMatrix(id);
|
|
340
|
+
const before = await getMatrix(id);
|
|
341
|
+
|
|
342
|
+
const startedAt = Date.now();
|
|
343
|
+
await revalidateMatrix(id, { expire: 2 });
|
|
344
|
+
const manifest = await readTagManifest(`expire-matrix-${id}`);
|
|
345
|
+
|
|
346
|
+
expectApproxMs(manifest.stale, startedAt);
|
|
347
|
+
expectApproxMs(manifest.expired, startedAt + 2_000);
|
|
348
|
+
|
|
349
|
+
const stale = await getMatrix(id);
|
|
350
|
+
expect(stale.counter).toBe(before.counter);
|
|
351
|
+
|
|
352
|
+
await delay(3_000);
|
|
353
|
+
|
|
354
|
+
const afterWindow = await getMatrix(id);
|
|
355
|
+
expect(afterWindow.counter).toBeGreaterThan(before.counter);
|
|
356
|
+
}, 15_000);
|
|
357
|
+
});
|
|
358
|
+
|
|
201
359
|
describe('Redis cache handler integration', () => {
|
|
202
360
|
it('should call cache handler get and set methods', async () => {
|
|
203
361
|
// Make request to trigger cache (don't clear first)
|
|
@@ -313,6 +313,15 @@ describe('Next.js Turbo Redis Cache Integration', () => {
|
|
|
313
313
|
const ttl = await redisClient.ttl(
|
|
314
314
|
process.env.VERCEL_URL + '/api/revalidated-fetch',
|
|
315
315
|
);
|
|
316
|
+
|
|
317
|
+
// Next.js 16.3+ passes cacheControl.expire for ISR fetch routes (~1 year).
|
|
318
|
+
// TTL is keyed on expire (SWR-safe), not 2 × revalidate — skip the
|
|
319
|
+
// long-running expiry wait in that case.
|
|
320
|
+
if (NEXT_TEST_APP.includes('16-3')) {
|
|
321
|
+
expect(ttl).toBeGreaterThan(2 * revalidatedFetchRouteRevalidate);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
316
325
|
expect(ttl).toBeLessThan(2 * revalidatedFetchRouteRevalidate);
|
|
317
326
|
expect(ttl).toBeGreaterThan(
|
|
318
327
|
2 * revalidatedFetchRouteRevalidate -
|
|
@@ -923,9 +932,14 @@ describe('Next.js Turbo Redis Cache Integration', () => {
|
|
|
923
932
|
expect(timestamp).toBeDefined();
|
|
924
933
|
expect(Number(timestamp)).toBeGreaterThan(Number(firstTimestamp));
|
|
925
934
|
|
|
926
|
-
//
|
|
935
|
+
// Next.js 16.3 revalidates nested fetch entries when the parent page path
|
|
936
|
+
// is revalidated; older versions keep the nested fetch cache intact.
|
|
927
937
|
expect(secondCounter).toBeDefined();
|
|
928
|
-
|
|
938
|
+
if (NEXT_TEST_APP === 'next-app-16-3-0') {
|
|
939
|
+
expect(Number(secondCounter)).toBeGreaterThan(Number(firstCounter));
|
|
940
|
+
} else {
|
|
941
|
+
expect(secondCounter).toBe(firstCounter);
|
|
942
|
+
}
|
|
929
943
|
});
|
|
930
944
|
});
|
|
931
945
|
|
|
@@ -48,6 +48,18 @@ async function delay(ms: number) {
|
|
|
48
48
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function expectIsrRedisTtl(ttl: number) {
|
|
52
|
+
// Next.js 16.3+ passes cacheControl.expire (~1 year) for ISR pages.
|
|
53
|
+
// Redis TTL is keyed on expire, not 2 × revalidate.
|
|
54
|
+
if (NEXT_PAGES_TEST_APP.includes('16-3')) {
|
|
55
|
+
expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// VERCEL_ENV=production -> expire age is 2 * revalidate
|
|
59
|
+
expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
|
|
60
|
+
expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
async function runCommand(cmd: string, args: string[], cwd: string) {
|
|
52
64
|
return new Promise((resolve, reject) => {
|
|
53
65
|
let stderr = '';
|
|
@@ -233,9 +245,7 @@ describe('Pages Router Redis cache integration (two instances)', () => {
|
|
|
233
245
|
const ttl = await redisClient.ttl(
|
|
234
246
|
process.env.VERCEL_URL + '/isr/prebuilt',
|
|
235
247
|
);
|
|
236
|
-
|
|
237
|
-
expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
|
|
238
|
-
expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
|
|
248
|
+
expectIsrRedisTtl(ttl);
|
|
239
249
|
});
|
|
240
250
|
|
|
241
251
|
it('instance B serves the identical cached HTML from the shared Redis', async () => {
|
|
@@ -342,12 +352,12 @@ describe('Pages Router Redis cache integration (two instances)', () => {
|
|
|
342
352
|
tags: ['_N_T_/isr/not-found'],
|
|
343
353
|
});
|
|
344
354
|
|
|
345
|
-
// TTL
|
|
355
|
+
// TTL: Next.js 16.3+ keys on cacheControl.expire (SWR-safe); older
|
|
356
|
+
// versions use estimateExpireAge(revalidate).
|
|
346
357
|
const ttl = await redisClient.ttl(
|
|
347
358
|
process.env.VERCEL_URL + '/isr/not-found',
|
|
348
359
|
);
|
|
349
|
-
|
|
350
|
-
expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
|
|
360
|
+
expectIsrRedisTtl(ttl);
|
|
351
361
|
});
|
|
352
362
|
|
|
353
363
|
it('instance B serves the 404 from the shared cache', async () => {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SyncedMap } from '../../../src/SyncedMap';
|
|
3
|
+
|
|
4
|
+
class HashMockClient {
|
|
5
|
+
hash = new Map<string, string>();
|
|
6
|
+
stringKeys: string[] = [];
|
|
7
|
+
hDelCalls: string[][] = [];
|
|
8
|
+
isOpen = true;
|
|
9
|
+
isReady = true;
|
|
10
|
+
|
|
11
|
+
on() {
|
|
12
|
+
return this;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async connect() {
|
|
16
|
+
this.isOpen = true;
|
|
17
|
+
this.isReady = true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async configGet() {
|
|
21
|
+
return { 'notify-keyspace-events': 'Exe' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async subscribe() {}
|
|
25
|
+
|
|
26
|
+
async quit() {}
|
|
27
|
+
|
|
28
|
+
async disconnect() {
|
|
29
|
+
this.isOpen = false;
|
|
30
|
+
this.isReady = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
duplicate() {
|
|
34
|
+
return new HashMockClient();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async hScan() {
|
|
38
|
+
return {
|
|
39
|
+
cursor: 0,
|
|
40
|
+
tuples: [...this.hash.entries()].map(([field, value]) => ({
|
|
41
|
+
field,
|
|
42
|
+
value,
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async scan() {
|
|
48
|
+
return { cursor: 0, keys: this.stringKeys };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async hSet(_key: string, field: string, value: string) {
|
|
52
|
+
this.hash.set(field, value);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async hDel(_key: string, fields: string | string[]) {
|
|
57
|
+
const list = Array.isArray(fields) ? fields : [fields];
|
|
58
|
+
this.hDelCalls.push(list);
|
|
59
|
+
for (const field of list) {
|
|
60
|
+
this.hash.delete(field);
|
|
61
|
+
}
|
|
62
|
+
return list.length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async publish() {
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('SyncedMap orphan cleanup', () => {
|
|
71
|
+
it('deletes hash fields whose names are not Redis string keys', async () => {
|
|
72
|
+
const client = new HashMockClient();
|
|
73
|
+
const map = new SyncedMap<number>({
|
|
74
|
+
client: client as never,
|
|
75
|
+
keyPrefix: 'app:',
|
|
76
|
+
redisKey: '__revalidated_tags__',
|
|
77
|
+
database: 0,
|
|
78
|
+
querySize: 50,
|
|
79
|
+
filterKeys: () => true,
|
|
80
|
+
});
|
|
81
|
+
await map.waitUntilReady();
|
|
82
|
+
await map.set('posts', 1);
|
|
83
|
+
|
|
84
|
+
client.stringKeys = ['app:some-cache-entry'];
|
|
85
|
+
await (map as any).initialSync();
|
|
86
|
+
|
|
87
|
+
expect(client.hDelCalls.flat()).toContain('posts');
|
|
88
|
+
expect(map.get('posts')).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('keeps tag-manifest fields when withoutOrphanCleanup is set', async () => {
|
|
92
|
+
const client = new HashMockClient();
|
|
93
|
+
const map = new SyncedMap<number>({
|
|
94
|
+
client: client as never,
|
|
95
|
+
keyPrefix: 'app:',
|
|
96
|
+
redisKey: '__cacheComponents_revalidated_tags__',
|
|
97
|
+
database: 0,
|
|
98
|
+
querySize: 50,
|
|
99
|
+
filterKeys: () => true,
|
|
100
|
+
customizedSync: { withoutOrphanCleanup: true },
|
|
101
|
+
});
|
|
102
|
+
await map.waitUntilReady();
|
|
103
|
+
await map.set('posts', 1);
|
|
104
|
+
|
|
105
|
+
client.stringKeys = ['app:some-cache-entry'];
|
|
106
|
+
await (map as any).initialSync();
|
|
107
|
+
|
|
108
|
+
expect(client.hDelCalls).toEqual([]);
|
|
109
|
+
expect(map.get('posts')).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
});
|