@raketa-cloud/next 1.0.9 → 1.1.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/README.md +144 -0
- package/package.json +6 -4
- package/src/Cache.js +80 -43
- package/test/Cache.test.js +300 -0
- package/tsconfig.json +2 -1
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# @raketa-cloud/next
|
|
2
|
+
|
|
3
|
+
Next.js integration library for **Raketa Cloud DXP**. Provides GraphQL API clients, in-memory LRU caching, and dynamic URL redirect helpers for Next.js App Router applications.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @raketa-cloud/next
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer & runtime dependencies:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"graphql-request": "^7.1.2",
|
|
19
|
+
"lru-cache": "^11.0.2",
|
|
20
|
+
"react": "^19.0.0",
|
|
21
|
+
"react-dom": "^19.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Exports
|
|
29
|
+
|
|
30
|
+
```javascript
|
|
31
|
+
import {
|
|
32
|
+
Cache,
|
|
33
|
+
DxpPublic,
|
|
34
|
+
DxpPrivate,
|
|
35
|
+
generateCloudPublicHelpers,
|
|
36
|
+
} from "@raketa-cloud/next";
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Quick Reference
|
|
42
|
+
|
|
43
|
+
### 1. In-Memory Cache (`Cache`)
|
|
44
|
+
|
|
45
|
+
```javascript
|
|
46
|
+
import { Cache } from "@raketa-cloud/next";
|
|
47
|
+
|
|
48
|
+
const cache = new Cache({
|
|
49
|
+
max: 2500, // Max entries
|
|
50
|
+
maxSize: 300 * 1024 * 1024, // 300 MB budget
|
|
51
|
+
ttl: 1000 * 60 * 5, // 5 minutes
|
|
52
|
+
debugMode: false,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Fetch with cache & stale-while-revalidate fallback:
|
|
56
|
+
const data = await cache.fetch("my-cache-key", async () => {
|
|
57
|
+
return await fetchFromUpstream();
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 2. Public DXP Client (`DxpPublic`)
|
|
62
|
+
|
|
63
|
+
```javascript
|
|
64
|
+
import { DxpPublic } from "@raketa-cloud/next";
|
|
65
|
+
import getConfig from "lib/getConfig";
|
|
66
|
+
import localCache from "./localCache";
|
|
67
|
+
|
|
68
|
+
const dxpPublic = new DxpPublic(getConfig, localCache);
|
|
69
|
+
|
|
70
|
+
// Find a single resource (e.g. page, article, customer)
|
|
71
|
+
const page = await dxpPublic.resources.find("en", "about-us", "page", {
|
|
72
|
+
withCache: true,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// List resources with filters & pagination
|
|
76
|
+
const articles = await dxpPublic.resources.list("en", {
|
|
77
|
+
pagination: { page: 1, pageSize: 10 },
|
|
78
|
+
filters: { resourceType: "article", tagSlugs: ["news"] },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Fetch website settings
|
|
82
|
+
const settings = await dxpPublic.settings.find("en", { withCache: true });
|
|
83
|
+
|
|
84
|
+
// Fetch a tag
|
|
85
|
+
const tag = await dxpPublic.tags.find("en", "technology", { withCache: true });
|
|
86
|
+
|
|
87
|
+
// Custom GraphQL query
|
|
88
|
+
const queryDoc = dxpPublic.client.gql`
|
|
89
|
+
query MyQuery($input: ResourcesInput!) {
|
|
90
|
+
resources(input: $input) {
|
|
91
|
+
list { id title slug }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
const result = await dxpPublic.client.query(queryDoc, {
|
|
96
|
+
input: { locale: "en" },
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 3. Dynamic Redirects Helper (`generateCloudPublicHelpers`)
|
|
101
|
+
|
|
102
|
+
```javascript
|
|
103
|
+
import { DxpPublic, generateCloudPublicHelpers } from "@raketa-cloud/next";
|
|
104
|
+
import getConfig from "lib/getConfig";
|
|
105
|
+
import redirectsCache from "./redirectsCache";
|
|
106
|
+
|
|
107
|
+
const dxpRedirects = new DxpPublic(getConfig, redirectsCache);
|
|
108
|
+
const { findRedirect } = generateCloudPublicHelpers(dxpRedirects);
|
|
109
|
+
|
|
110
|
+
// In Next.js middleware:
|
|
111
|
+
const redirect = await findRedirect("/old-path");
|
|
112
|
+
// returns: { id, url, destination, httpResponseCode } | null
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 4. Private DXP Client (`DxpPrivate`)
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
import { DxpPrivate } from "@raketa-cloud/next";
|
|
119
|
+
import getConfig from "lib/getConfig";
|
|
120
|
+
|
|
121
|
+
// Server-side (with token resolver function)
|
|
122
|
+
const dxpPrivate = new DxpPrivate(getConfig, async () => {
|
|
123
|
+
return getAdminTokenFromCookies();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Change preview for visual builder
|
|
127
|
+
const preview = await dxpPrivate.resources.changePreview(
|
|
128
|
+
"en",
|
|
129
|
+
resourceId,
|
|
130
|
+
versionNumber,
|
|
131
|
+
"page",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Mini-resources list for admin pickers
|
|
135
|
+
const miniList = await dxpPrivate.resources.miniResources("en", {
|
|
136
|
+
filters: { resourceType: "page" },
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Documentation
|
|
143
|
+
|
|
144
|
+
For a comprehensive guide with complete project structure and Next.js App Router patterns, see the [Monorepo README](../../README.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raketa-cloud/next",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"main": "src/index.js",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
|
@@ -8,15 +9,16 @@
|
|
|
8
9
|
"directory": "packages/raketa-cloud-next"
|
|
9
10
|
},
|
|
10
11
|
"scripts": {
|
|
12
|
+
"test": "node --test test/**/*.test.js",
|
|
11
13
|
"check-types": "tsc --noEmit",
|
|
12
14
|
"format": "prettier --write src/**/*.js"
|
|
13
15
|
},
|
|
14
16
|
"devDependencies": {
|
|
15
17
|
"@repo/typescript-config": "*",
|
|
16
|
-
"@turbo/gen": "^
|
|
18
|
+
"@turbo/gen": "^2.10.13",
|
|
17
19
|
"@types/node": "^20.11.24",
|
|
18
|
-
"@types/react": "
|
|
19
|
-
"@types/react-dom": "
|
|
20
|
+
"@types/react": "19.3.0",
|
|
21
|
+
"@types/react-dom": "19.3.0",
|
|
20
22
|
"prettier": "^3.8.3",
|
|
21
23
|
"typescript": "5.5.4"
|
|
22
24
|
},
|
package/src/Cache.js
CHANGED
|
@@ -7,7 +7,7 @@ function getApproximateMemoryUsage(obj) {
|
|
|
7
7
|
const seen = new WeakSet();
|
|
8
8
|
|
|
9
9
|
function sizeOf(value) {
|
|
10
|
-
// 1. Handle null and undefined explicitly
|
|
10
|
+
// 1. Handle null and undefined explicitly
|
|
11
11
|
if (value === null || value === undefined) return 0;
|
|
12
12
|
|
|
13
13
|
// 2. Handle primitives based on JS specs
|
|
@@ -41,8 +41,6 @@ function getApproximateMemoryUsage(obj) {
|
|
|
41
41
|
|
|
42
42
|
export default class Cache {
|
|
43
43
|
constructor(opts = {}) {
|
|
44
|
-
const self = this;
|
|
45
|
-
|
|
46
44
|
const cacheOptions = {
|
|
47
45
|
max: opts.max || 5000, // Items count
|
|
48
46
|
ttl: opts.ttl || 1000 * 60 * 5, // 5 minutes
|
|
@@ -50,18 +48,18 @@ export default class Cache {
|
|
|
50
48
|
noDeleteOnStaleGet: true,
|
|
51
49
|
};
|
|
52
50
|
|
|
53
|
-
// Conditionally use the
|
|
51
|
+
// Conditionally use the memory calculation
|
|
54
52
|
if (opts.maxSize) {
|
|
55
53
|
cacheOptions.maxSize = opts.maxSize;
|
|
56
54
|
|
|
57
55
|
cacheOptions.sizeCalculation = (value, key) => {
|
|
58
56
|
return getApproximateMemoryUsage(key) + getApproximateMemoryUsage(value);
|
|
59
57
|
};
|
|
60
|
-
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
this.debugMode = opts.debugMode || false;
|
|
64
61
|
this.cacheInstance = new LRUCache(cacheOptions);
|
|
62
|
+
this.inFlight = new Map();
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
_debug(msg) {
|
|
@@ -82,31 +80,41 @@ export default class Cache {
|
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
_cloneObject(obj) {
|
|
85
|
-
if (obj === null || obj === undefined) {
|
|
86
|
-
return obj;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const type = typeof obj;
|
|
90
|
-
|
|
91
|
-
if (type !== "function" || type !== "obj") {
|
|
83
|
+
if (obj === null || obj === undefined || typeof obj !== "object") {
|
|
92
84
|
return obj;
|
|
93
85
|
}
|
|
94
86
|
|
|
95
|
-
// Ah the JS world! See the official docs https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy.
|
|
96
|
-
// Important note - this works only for objects with primitive types to copy. If an object property is
|
|
97
|
-
// a function - well it will not work correctly...
|
|
98
87
|
return JSON.parse(JSON.stringify(obj));
|
|
99
88
|
}
|
|
100
89
|
|
|
101
|
-
_setInCache(key, value) {
|
|
90
|
+
_setInCache(key, value, opts) {
|
|
102
91
|
this.cacheInstance.set(
|
|
103
92
|
key,
|
|
104
|
-
value ? this._cloneObject(value) : NULL_SENTINEL
|
|
93
|
+
value !== null && value !== undefined ? this._cloneObject(value) : NULL_SENTINEL,
|
|
94
|
+
opts
|
|
105
95
|
);
|
|
106
96
|
}
|
|
107
97
|
|
|
108
98
|
clear() {
|
|
109
99
|
this.cacheInstance.clear();
|
|
100
|
+
this.inFlight.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
delete(key) {
|
|
104
|
+
return this.cacheInstance.delete(key);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
has(key) {
|
|
108
|
+
return this.cacheInstance.has(key);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get(key) {
|
|
112
|
+
const { cachedValue } = this._fetchFromCache(key);
|
|
113
|
+
return this._cloneObject(cachedValue);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
set(key, value, opts) {
|
|
117
|
+
this._setInCache(key, value, opts);
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
forEach(callback) {
|
|
@@ -125,48 +133,77 @@ export default class Cache {
|
|
|
125
133
|
return this.cacheInstance.maxSize;
|
|
126
134
|
}
|
|
127
135
|
|
|
136
|
+
get inFlightCount() {
|
|
137
|
+
return this.inFlight.size;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get inFlightKeys() {
|
|
141
|
+
return Array.from(this.inFlight.keys());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async _revalidateInBackground(cacheKey, fetcher, staleValue) {
|
|
145
|
+
if (this.inFlight.has(cacheKey)) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const promise = (async () => {
|
|
150
|
+
try {
|
|
151
|
+
const freshEntity = await fetcher();
|
|
152
|
+
this._setInCache(cacheKey, freshEntity);
|
|
153
|
+
return freshEntity;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (this.debugMode) {
|
|
156
|
+
console.error(`[Cache] Background revalidation failed for "${cacheKey}":`, e);
|
|
157
|
+
}
|
|
158
|
+
// Refresh TTL for the stale value so subsequent immediate requests don't hammer the backend
|
|
159
|
+
this._setInCache(cacheKey, staleValue);
|
|
160
|
+
} finally {
|
|
161
|
+
this.inFlight.delete(cacheKey);
|
|
162
|
+
}
|
|
163
|
+
})();
|
|
164
|
+
|
|
165
|
+
this.inFlight.set(cacheKey, promise);
|
|
166
|
+
}
|
|
167
|
+
|
|
128
168
|
async fetch(cacheKey, fetcher) {
|
|
129
169
|
const { cachedValue, status } = this._fetchFromCache(cacheKey);
|
|
130
170
|
|
|
171
|
+
// 1. Cache HIT: Return immediately
|
|
131
172
|
if (status.get === "hit") {
|
|
132
173
|
this._debug(`Cache HIT: ${cacheKey}`);
|
|
133
174
|
return this._cloneObject(cachedValue);
|
|
134
175
|
}
|
|
135
176
|
|
|
177
|
+
// 2. Cache STALE: Return stale value immediately and revalidate in background
|
|
136
178
|
if (status.get === "stale") {
|
|
137
|
-
this._debug(`Cache STALE: ${cacheKey}`);
|
|
179
|
+
this._debug(`Cache STALE (serving stale, revalidating in background): ${cacheKey}`);
|
|
180
|
+
this._revalidateInBackground(cacheKey, fetcher, cachedValue);
|
|
181
|
+
return this._cloneObject(cachedValue);
|
|
182
|
+
}
|
|
138
183
|
|
|
184
|
+
// 3. Cache MISS: Singleflight deduplication for concurrent requests
|
|
185
|
+
this._debug(`Cache MISS: ${cacheKey}`);
|
|
186
|
+
if (this.inFlight.has(cacheKey)) {
|
|
187
|
+
this._debug(`Cache MISS (joining in-flight fetch): ${cacheKey}`);
|
|
188
|
+
return this.inFlight.get(cacheKey);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const promise = (async () => {
|
|
139
192
|
try {
|
|
140
193
|
const freshEntity = await fetcher();
|
|
141
|
-
|
|
142
194
|
this._setInCache(cacheKey, freshEntity);
|
|
143
|
-
|
|
144
195
|
return this._cloneObject(freshEntity);
|
|
145
196
|
} catch (e) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
197
|
+
if (this.debugMode) {
|
|
198
|
+
console.error(`[Cache] Fetch failed for "${cacheKey}":`, e);
|
|
199
|
+
}
|
|
200
|
+
throw e;
|
|
201
|
+
} finally {
|
|
202
|
+
this.inFlight.delete(cacheKey);
|
|
152
203
|
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
this._debug(`Cache MISS: ${cacheKey}`);
|
|
157
|
-
|
|
158
|
-
const freshEntity = await fetcher();
|
|
204
|
+
})();
|
|
159
205
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return this._cloneObject(freshEntity);
|
|
163
|
-
} catch (e) {
|
|
164
|
-
console.error(e);
|
|
165
|
-
|
|
166
|
-
// Reset TTL
|
|
167
|
-
this._setInCache(cacheKey, cachedValue);
|
|
168
|
-
|
|
169
|
-
return this._cloneObject(cachedValue);
|
|
170
|
-
}
|
|
206
|
+
this.inFlight.set(cacheKey, promise);
|
|
207
|
+
return promise;
|
|
171
208
|
}
|
|
172
209
|
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import Cache from "../src/Cache.js";
|
|
4
|
+
|
|
5
|
+
describe("Cache", () => {
|
|
6
|
+
describe("Basic Caching (Miss & Hit)", () => {
|
|
7
|
+
it("should fetch and store value on cache miss, then return from cache on hit", async () => {
|
|
8
|
+
const cache = new Cache({ ttl: 100, debugMode: false });
|
|
9
|
+
let fetchCount = 0;
|
|
10
|
+
const fetcher = async () => {
|
|
11
|
+
fetchCount++;
|
|
12
|
+
return { id: 1, title: "Homepage", metadata: { published: true } };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// 1. Cache Miss
|
|
16
|
+
const result1 = await cache.fetch("page-home", fetcher);
|
|
17
|
+
assert.deepEqual(result1, { id: 1, title: "Homepage", metadata: { published: true } });
|
|
18
|
+
assert.equal(fetchCount, 1);
|
|
19
|
+
|
|
20
|
+
// 2. Cache Hit
|
|
21
|
+
const result2 = await cache.fetch("page-home", fetcher);
|
|
22
|
+
assert.deepEqual(result2, { id: 1, title: "Homepage", metadata: { published: true } });
|
|
23
|
+
assert.equal(fetchCount, 1, "Fetcher must not be called again on cache hit");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should correctly handle primitive, null, and falsy values", async () => {
|
|
27
|
+
const cache = new Cache({ ttl: 100, debugMode: false });
|
|
28
|
+
|
|
29
|
+
// Null value
|
|
30
|
+
let nullFetches = 0;
|
|
31
|
+
const nullFetcher = async () => {
|
|
32
|
+
nullFetches++;
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
assert.equal(await cache.fetch("null-val", nullFetcher), null);
|
|
36
|
+
assert.equal(await cache.fetch("null-val", nullFetcher), null);
|
|
37
|
+
assert.equal(nullFetches, 1);
|
|
38
|
+
|
|
39
|
+
// Boolean false
|
|
40
|
+
let boolFetches = 0;
|
|
41
|
+
const boolFetcher = async () => {
|
|
42
|
+
boolFetches++;
|
|
43
|
+
return false;
|
|
44
|
+
};
|
|
45
|
+
assert.equal(await cache.fetch("bool-val", boolFetcher), false);
|
|
46
|
+
assert.equal(await cache.fetch("bool-val", boolFetcher), false);
|
|
47
|
+
assert.equal(boolFetches, 1);
|
|
48
|
+
|
|
49
|
+
// Number 0
|
|
50
|
+
let numFetches = 0;
|
|
51
|
+
const numFetcher = async () => {
|
|
52
|
+
numFetches++;
|
|
53
|
+
return 0;
|
|
54
|
+
};
|
|
55
|
+
assert.equal(await cache.fetch("num-val", numFetcher), 0);
|
|
56
|
+
assert.equal(await cache.fetch("num-val", numFetcher), 0);
|
|
57
|
+
assert.equal(numFetches, 1);
|
|
58
|
+
|
|
59
|
+
// Empty string
|
|
60
|
+
let strFetches = 0;
|
|
61
|
+
const strFetcher = async () => {
|
|
62
|
+
strFetches++;
|
|
63
|
+
return "";
|
|
64
|
+
};
|
|
65
|
+
assert.equal(await cache.fetch("str-val", strFetcher), "");
|
|
66
|
+
assert.equal(await cache.fetch("str-val", strFetcher), "");
|
|
67
|
+
assert.equal(strFetches, 1);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("Singleflight / In-Flight Request Deduplication", () => {
|
|
72
|
+
it("should deduplicate concurrent requests on cache miss to a single backend call", async () => {
|
|
73
|
+
const cache = new Cache({ ttl: 100, debugMode: false });
|
|
74
|
+
let backendCalls = 0;
|
|
75
|
+
const slowFetcher = async () => {
|
|
76
|
+
backendCalls++;
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
78
|
+
return { data: "singleflight-result", timestamp: Date.now() };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// 10 concurrent requests for the same uncached key
|
|
82
|
+
const requests = Array.from({ length: 10 }, () =>
|
|
83
|
+
cache.fetch("concurrent-miss-key", slowFetcher)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const results = await Promise.all(requests);
|
|
87
|
+
|
|
88
|
+
// Exactly 1 backend call made
|
|
89
|
+
assert.equal(backendCalls, 1);
|
|
90
|
+
assert.equal(results.length, 10);
|
|
91
|
+
|
|
92
|
+
// All callers receive identical data
|
|
93
|
+
const firstTimestamp = results[0].timestamp;
|
|
94
|
+
for (const r of results) {
|
|
95
|
+
assert.equal(r.data, "singleflight-result");
|
|
96
|
+
assert.equal(r.timestamp, firstTimestamp);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// In-flight map should be empty after completion
|
|
100
|
+
assert.equal(cache.inFlightCount, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should propagate errors to all waiting concurrent callers on cache miss", async () => {
|
|
104
|
+
const cache = new Cache({ ttl: 100, debugMode: false });
|
|
105
|
+
let backendCalls = 0;
|
|
106
|
+
const failingFetcher = async () => {
|
|
107
|
+
backendCalls++;
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
109
|
+
throw new Error("Upstream Network Timeout");
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// 5 concurrent requests expecting failure
|
|
113
|
+
const requests = Array.from({ length: 5 }, () =>
|
|
114
|
+
cache.fetch("failing-key", failingFetcher)
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const results = await Promise.allSettled(requests);
|
|
118
|
+
|
|
119
|
+
assert.equal(backendCalls, 1);
|
|
120
|
+
for (const res of results) {
|
|
121
|
+
assert.equal(res.status, "rejected");
|
|
122
|
+
assert.equal(res.reason.message, "Upstream Network Timeout");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// In-flight map is properly cleaned up after failure
|
|
126
|
+
assert.equal(cache.inFlightCount, 0);
|
|
127
|
+
|
|
128
|
+
// Subsequent attempt can retry
|
|
129
|
+
const successFetcher = async () => ({ recovered: true });
|
|
130
|
+
const recovery = await cache.fetch("failing-key", successFetcher);
|
|
131
|
+
assert.deepEqual(recovery, { recovered: true });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("Stale-While-Revalidate (SWR)", () => {
|
|
136
|
+
it("should return stale value instantly without waiting for revalidation", async () => {
|
|
137
|
+
const cache = new Cache({ ttl: 100, debugMode: false });
|
|
138
|
+
let fetchCount = 0;
|
|
139
|
+
const initialFetcher = async () => {
|
|
140
|
+
fetchCount++;
|
|
141
|
+
return { version: 1, payload: "initial" };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Initial populate
|
|
145
|
+
await cache.fetch("swr-instant-key", initialFetcher);
|
|
146
|
+
assert.equal(fetchCount, 1);
|
|
147
|
+
|
|
148
|
+
// Wait 120ms (TTL is 100ms) to become stale
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
150
|
+
|
|
151
|
+
let backgroundRan = false;
|
|
152
|
+
const slowRevalidator = async () => {
|
|
153
|
+
backgroundRan = true;
|
|
154
|
+
fetchCount++;
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
156
|
+
return { version: 2, payload: "fresh" };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Fetch on stale: must return version 1 IMMEDIATELY (< 15ms), not blocking on 80ms
|
|
160
|
+
const start = Date.now();
|
|
161
|
+
const staleResult = await cache.fetch("swr-instant-key", slowRevalidator);
|
|
162
|
+
const elapsed = Date.now() - start;
|
|
163
|
+
|
|
164
|
+
assert.ok(elapsed < 20, `Stale return took ${elapsed}ms; expected < 20ms`);
|
|
165
|
+
assert.deepEqual(staleResult, { version: 1, payload: "initial" });
|
|
166
|
+
|
|
167
|
+
// Wait 100ms for background revalidation to complete
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
169
|
+
assert.ok(backgroundRan, "Background revalidator must have executed");
|
|
170
|
+
assert.equal(fetchCount, 2);
|
|
171
|
+
|
|
172
|
+
// Next request should now get the freshly revalidated data (version 2)
|
|
173
|
+
const freshResult = await cache.fetch("swr-instant-key", initialFetcher);
|
|
174
|
+
assert.deepEqual(freshResult, { version: 2, payload: "fresh" });
|
|
175
|
+
assert.equal(fetchCount, 2, "Fresh hit should not invoke fetcher");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should deduplicate multiple concurrent requests on a stale entry to 1 background revalidation", async () => {
|
|
179
|
+
const cache = new Cache({ ttl: 100, debugMode: false });
|
|
180
|
+
let revalidateCount = 0;
|
|
181
|
+
await cache.fetch("swr-concurrent-key", async () => ({ v: "v1" }));
|
|
182
|
+
|
|
183
|
+
// Wait for key to become stale
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
185
|
+
|
|
186
|
+
const slowRevalidator = async () => {
|
|
187
|
+
revalidateCount++;
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
189
|
+
return { v: "v2" };
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// 10 concurrent requests on stale key
|
|
193
|
+
const results = await Promise.all(
|
|
194
|
+
Array.from({ length: 10 }, () =>
|
|
195
|
+
cache.fetch("swr-concurrent-key", slowRevalidator)
|
|
196
|
+
)
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// All 10 requests immediately received the stale value
|
|
200
|
+
for (const res of results) {
|
|
201
|
+
assert.deepEqual(res, { v: "v1" });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Wait for background revalidation to finish
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
206
|
+
|
|
207
|
+
// Exactly 1 background revalidation ran
|
|
208
|
+
assert.equal(revalidateCount, 1);
|
|
209
|
+
assert.equal(cache.inFlightCount, 0);
|
|
210
|
+
|
|
211
|
+
// Cache now has fresh value
|
|
212
|
+
assert.deepEqual(cache.get("swr-concurrent-key"), { v: "v2" });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("should retain stale value and refresh TTL when background revalidation fails (429 resilience)", async () => {
|
|
216
|
+
const cache = new Cache({ ttl: 100, debugMode: false });
|
|
217
|
+
await cache.fetch("swr-429-key", async () => ({ status: "good-data" }));
|
|
218
|
+
|
|
219
|
+
// Wait to become stale
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
221
|
+
|
|
222
|
+
// Background revalidation throws upstream 429
|
|
223
|
+
const failingRevalidator = async () => {
|
|
224
|
+
throw new Error("GraphQL Error (Code: 429): Too Many Requests");
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Caller receives stale data safely without throwing
|
|
228
|
+
const staleResult = await cache.fetch("swr-429-key", failingRevalidator);
|
|
229
|
+
assert.deepEqual(staleResult, { status: "good-data" });
|
|
230
|
+
|
|
231
|
+
// Wait for background promise to complete error handling
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
233
|
+
|
|
234
|
+
// Cache still preserves the stale data and cleared inFlight
|
|
235
|
+
assert.deepEqual(cache.get("swr-429-key"), { status: "good-data" });
|
|
236
|
+
assert.equal(cache.inFlightCount, 0);
|
|
237
|
+
assert.ok(cache.getRemainingTTL("swr-429-key") > 0, "TTL should be refreshed for stale entry");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("Lifecycle & Utility Methods", () => {
|
|
242
|
+
it("should support get, set, delete, has, and clear", () => {
|
|
243
|
+
const cache = new Cache({ ttl: 100, debugMode: false });
|
|
244
|
+
|
|
245
|
+
cache.set("item-1", { a: 1 });
|
|
246
|
+
assert.ok(cache.has("item-1"));
|
|
247
|
+
assert.deepEqual(cache.get("item-1"), { a: 1 });
|
|
248
|
+
|
|
249
|
+
cache.delete("item-1");
|
|
250
|
+
assert.ok(!cache.has("item-1"));
|
|
251
|
+
assert.equal(cache.get("item-1"), undefined);
|
|
252
|
+
|
|
253
|
+
cache.set("a", 1);
|
|
254
|
+
cache.set("b", 2);
|
|
255
|
+
cache.set("c", 3);
|
|
256
|
+
assert.equal(cache.has("a"), true);
|
|
257
|
+
assert.equal(cache.has("b"), true);
|
|
258
|
+
|
|
259
|
+
cache.clear();
|
|
260
|
+
assert.equal(cache.has("a"), false);
|
|
261
|
+
assert.equal(cache.has("b"), false);
|
|
262
|
+
assert.equal(cache.has("c"), false);
|
|
263
|
+
assert.equal(cache.inFlightCount, 0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should support forEach iteration and TTL checking", () => {
|
|
267
|
+
const cache = new Cache({ ttl: 100, debugMode: false });
|
|
268
|
+
|
|
269
|
+
cache.set("alpha", { value: 10 });
|
|
270
|
+
cache.set("beta", { value: 20 });
|
|
271
|
+
|
|
272
|
+
const keys = [];
|
|
273
|
+
cache.forEach((_val, key) => {
|
|
274
|
+
keys.push(key);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
assert.ok(keys.includes("alpha"));
|
|
278
|
+
assert.ok(keys.includes("beta"));
|
|
279
|
+
|
|
280
|
+
const ttl = cache.getRemainingTTL("alpha");
|
|
281
|
+
assert.ok(typeof ttl === "number" && ttl > 0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("should calculate approximate memory size when maxSize is configured", () => {
|
|
285
|
+
const sizedCache = new Cache({
|
|
286
|
+
maxSize: 10 * 1024 * 1024, // 10MB
|
|
287
|
+
ttl: 5000,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
sizedCache.set("large-obj", {
|
|
291
|
+
title: "Test",
|
|
292
|
+
items: [1, 2, 3, 4, 5],
|
|
293
|
+
nested: { active: true },
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
assert.ok(sizedCache.calculatedSize > 0, "calculatedSize must be greater than 0");
|
|
297
|
+
assert.equal(sizedCache.maxSize, 10 * 1024 * 1024);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|