Skip to content

Commit 06f99aa

Browse files
committed
feat: support zod schemas on checkValue
- also attach cause on thrown validation errors - deprecate CheckFreshValueErrorEvent in favor of CheckFreshValueErrorObjEvent - deprecate CheckCachedValueErrorEvent in favor of CheckCachedValueErrorObjEvent - support on-the-fly migrations where the cached value will not be updated with `migrate(newValue, false)` fix #44
1 parent d26a83d commit 06f99aa

File tree

9 files changed

+204
-39
lines changed

9 files changed

+204
-39
lines changed

package-lock.json

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@
4747
"esbuild": "0.17.15",
4848
"jest": "29.5.0",
4949
"lru-cache": "8.0.4",
50-
"redis4": "npm:[email protected]",
5150
"redis-mock": "0.56.3",
51+
"redis4": "npm:[email protected]",
5252
"ts-jest": "29.1.0",
53-
"typescript": "5.0.3"
53+
"typescript": "5.0.3",
54+
"zod": "3.21.4"
5455
}
5556
}

readme.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,31 @@ function getPi() {
318318

319319
> ℹ️ `checkValue` is also invoked with the return value of `getFreshValue`
320320
321+
### Type-safety with [zod](https://github.com/colinhacks/zod)
322+
323+
We can also use zod schemas to ensure correct types
324+
325+
```ts
326+
import type { CacheEntry } from 'cachified';
327+
import LRUCache from 'lru-cache';
328+
import { cachified } from 'cachified';
329+
import z from 'zod';
330+
331+
const lru = new LRUCache<string, CacheEntry<string>>({ max: 1000 });
332+
const userId = 1;
333+
334+
const user = await cachified({
335+
key: `user-${userId}`,
336+
cache: lru,
337+
checkValue: z.object({
338+
email: z.string()
339+
}),
340+
getFreshValue() {
341+
return getUserFromApi(userId)
342+
}
343+
});
344+
```
345+
321346
### Migrating Values
322347

323348
When the format of cached values is changed during the apps lifetime they can

src/cachified.spec.ts

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import LRUCache from 'lru-cache';
22
import { createClient as createRedis3Client } from 'redis-mock';
3+
import z from 'zod';
34
import {
45
cachified,
56
CachifiedOptions,
@@ -212,9 +213,12 @@ describe('cachified', () => {
212213
},
213214
});
214215

215-
await expect(value).rejects.toMatchInlineSnapshot(
216-
`[Error: check failed for fresh value of test]`,
216+
await expect(value).rejects.toThrowErrorMatchingInlineSnapshot(
217+
`"check failed for fresh value of test"`,
217218
);
219+
await expect(
220+
value.catch((err) => err.cause),
221+
).resolves.toMatchInlineSnapshot(`"👮"`);
218222
expect(report(reporter.mock.calls)).toMatchInlineSnapshot(`
219223
"1. init
220224
{key: 'test', metadata: {createdTime: 0, swr: 0, ttl: null}}
@@ -224,7 +228,9 @@ describe('cachified', () => {
224228
5. getFreshValueStart
225229
6. getFreshValueSuccess
226230
{value: 'ONE'}
227-
7. checkFreshValueError
231+
7. checkFreshValueErrorObj
232+
{reason: '👮'}
233+
8. checkFreshValueError
228234
{reason: '👮'}"
229235
`);
230236

@@ -240,8 +246,8 @@ describe('cachified', () => {
240246
return 'ONE';
241247
},
242248
});
243-
await expect(value2).rejects.toMatchInlineSnapshot(
244-
`[Error: check failed for fresh value of test]`,
249+
await expect(value2).rejects.toThrowErrorMatchingInlineSnapshot(
250+
`"check failed for fresh value of test"`,
245251
);
246252
expect(report(reporter2.mock.calls)).toMatchInlineSnapshot(`
247253
"1. init
@@ -252,11 +258,77 @@ describe('cachified', () => {
252258
5. getFreshValueStart
253259
6. getFreshValueSuccess
254260
{value: 'ONE'}
255-
7. checkFreshValueError
261+
7. checkFreshValueErrorObj
262+
{reason: 'unknown'}
263+
8. checkFreshValueError
256264
{reason: 'unknown'}"
257265
`);
258266
});
259267

268+
it('supports zod validation with checkValue', async () => {
269+
const cache = new Map<string, CacheEntry>();
270+
271+
const value = await cachified({
272+
cache,
273+
key: 'test',
274+
checkValue: z.string(),
275+
getFreshValue() {
276+
return 'ONE';
277+
},
278+
});
279+
280+
expect(value).toBe('ONE');
281+
282+
const value2 = cachified({
283+
cache,
284+
key: 'test-2',
285+
checkValue: z.string(),
286+
getFreshValue() {
287+
/* pretend API returns an unexpected value */
288+
return 1 as unknown as string;
289+
},
290+
});
291+
292+
await expect(value2).rejects.toThrowErrorMatchingInlineSnapshot(
293+
`"check failed for fresh value of test-2"`,
294+
);
295+
await expect(value2.catch((err) => err.cause)).resolves
296+
.toMatchInlineSnapshot(`
297+
[ZodError: [
298+
{
299+
"code": "invalid_type",
300+
"expected": "string",
301+
"received": "number",
302+
"path": [],
303+
"message": "Expected string, received number"
304+
}
305+
]]
306+
`);
307+
});
308+
309+
/* I don't think this is a good idea, but it's possible */
310+
it('supports zod transforms', async () => {
311+
const cache = new Map<string, CacheEntry>();
312+
313+
const getValue = () =>
314+
cachified({
315+
cache,
316+
key: 'test',
317+
checkValue: z.string().transform((s) => s.toUpperCase()),
318+
getFreshValue() {
319+
return 'one';
320+
},
321+
});
322+
323+
expect(await getValue()).toBe('ONE');
324+
325+
/* Stores original value in cache */
326+
expect(cache.get('test')?.value).toBe('one');
327+
328+
/* Gets transformed value from cache */
329+
expect(await getValue()).toBe('ONE');
330+
});
331+
260332
it('supports migrating cached values', async () => {
261333
const cache = new Map<string, CacheEntry>();
262334
const reporter = createReporter();
@@ -308,8 +380,8 @@ describe('cachified', () => {
308380
},
309381
});
310382

311-
await expect(value).rejects.toMatchInlineSnapshot(
312-
`[Error: check failed for fresh value of weather]`,
383+
await expect(value).rejects.toThrowErrorMatchingInlineSnapshot(
384+
`"check failed for fresh value of weather"`,
313385
);
314386
expect(report(reporter.mock.calls)).toMatchInlineSnapshot(`
315387
"1. init
@@ -320,7 +392,9 @@ describe('cachified', () => {
320392
5. getFreshValueStart
321393
6. getFreshValueSuccess
322394
{value: '☁️'}
323-
7. checkFreshValueError
395+
7. checkFreshValueErrorObj
396+
{reason: [Error: Bad Weather]}
397+
8. checkFreshValueError
324398
{reason: 'Bad Weather'}"
325399
`);
326400

@@ -340,8 +414,8 @@ describe('cachified', () => {
340414
},
341415
});
342416

343-
await expect(value2).rejects.toMatchInlineSnapshot(
344-
`[Error: check failed for fresh value of weather]`,
417+
await expect(value2).rejects.toThrowErrorMatchingInlineSnapshot(
418+
`"check failed for fresh value of weather"`,
345419
);
346420
});
347421

@@ -1029,12 +1103,14 @@ describe('cachified', () => {
10291103
2. getCachedValueStart
10301104
3. getCachedValueRead
10311105
{entry: {metadata: {createdTime: 0, swr: 0, ttl: null}, value: 'ONE'}}
1032-
4. checkCachedValueError
1106+
4. checkCachedValueErrorObj
10331107
{reason: 'unknown'}
1034-
5. getFreshValueStart
1035-
6. getFreshValueSuccess
1108+
5. checkCachedValueError
1109+
{reason: 'unknown'}
1110+
6. getFreshValueStart
1111+
7. getFreshValueSuccess
10361112
{value: 'TWO'}
1037-
7. writeFreshValueSuccess
1113+
8. writeFreshValueSuccess
10381114
{metadata: {createdTime: 0, swr: 0, ttl: null}, migrated: false, written: true}"
10391115
`);
10401116

@@ -1058,12 +1134,14 @@ describe('cachified', () => {
10581134
2. getCachedValueStart
10591135
3. getCachedValueRead
10601136
{entry: {metadata: {createdTime: 0, swr: 0, ttl: null}, value: 'ONE'}}
1061-
4. checkCachedValueError
1137+
4. checkCachedValueErrorObj
10621138
{reason: '🖕'}
1063-
5. getFreshValueStart
1064-
6. getFreshValueSuccess
1139+
5. checkCachedValueError
1140+
{reason: '🖕'}
1141+
6. getFreshValueStart
1142+
7. getFreshValueSuccess
10651143
{value: 'TWO'}
1066-
7. writeFreshValueSuccess
1144+
8. writeFreshValueSuccess
10671145
{metadata: {createdTime: 0, swr: 0, ttl: null}, migrated: false, written: true}"
10681146
`);
10691147
});

src/checkValue.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ export async function checkValue<Value>(
66
value: unknown,
77
): Promise<
88
| { success: true; value: Value; migrated: boolean }
9-
| { success: false; reason: string }
9+
| { success: false; reason: unknown }
1010
> {
1111
try {
12-
const checkResponse = await context.checkValue(value, (value) => ({
13-
[MIGRATED]: true,
12+
const checkResponse = await context.checkValue(
1413
value,
15-
}));
14+
(value, updateCache = true) => ({
15+
[MIGRATED]: updateCache,
16+
value,
17+
}),
18+
);
1619

1720
if (typeof checkResponse === 'string') {
1821
return { success: false, reason: checkResponse };
@@ -26,10 +29,10 @@ export async function checkValue<Value>(
2629
};
2730
}
2831

29-
if (checkResponse && checkResponse[MIGRATED] === true) {
32+
if (checkResponse && Object.hasOwn(checkResponse, MIGRATED)) {
3033
return {
3134
success: true,
32-
migrated: true,
35+
migrated: checkResponse[MIGRATED],
3336
value: checkResponse.value,
3437
};
3538
}
@@ -38,7 +41,7 @@ export async function checkValue<Value>(
3841
} catch (err) {
3942
return {
4043
success: false,
41-
reason: err instanceof Error ? err.message : String(err),
44+
reason: err,
4245
};
4346
}
4447
}

src/common.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export type GetFreshValue<Value> = {
3737
};
3838
export const MIGRATED = Symbol();
3939
export type MigratedValue<Value> = {
40-
[MIGRATED]: true;
40+
[MIGRATED]: boolean;
4141
value: Value;
4242
};
4343

@@ -51,6 +51,13 @@ export type ValueCheckResultInvalid = false | string;
5151
export type ValueCheckResult<Value> =
5252
| ValueCheckResultOk<Value>
5353
| ValueCheckResultInvalid;
54+
export type CheckValue<Value> = (
55+
value: unknown,
56+
migrate: (value: Value, updateCache?: boolean) => MigratedValue<Value>,
57+
) => ValueCheckResult<Value> | Promise<ValueCheckResult<Value>>;
58+
export interface Schema<Value> {
59+
parseAsync(value: unknown): Promise<Value>;
60+
}
5461

5562
export interface CachifiedOptions<Value> {
5663
/**
@@ -119,10 +126,7 @@ export interface CachifiedOptions<Value> {
119126
*
120127
* @type {function(): boolean | undefined | string | MigratedValue} Optional, default makes no value check
121128
*/
122-
checkValue?: (
123-
value: unknown,
124-
migrate: (value: Value) => MigratedValue<Value>,
125-
) => ValueCheckResult<Value> | Promise<ValueCheckResult<Value>>;
129+
checkValue?: CheckValue<Value> | Schema<Value>;
126130
/**
127131
* Set true to not even try reading the currently cached value
128132
*
@@ -161,8 +165,9 @@ export interface CachifiedOptions<Value> {
161165
export interface Context<Value>
162166
extends Omit<
163167
Required<CachifiedOptions<Value>>,
164-
'fallbackToCache' | 'reporter'
168+
'fallbackToCache' | 'reporter' | 'checkValue'
165169
> {
170+
checkValue: CheckValue<Value>;
166171
report: Reporter<Value>;
167172
fallbackToCache: number;
168173
metadata: CacheMetadata;
@@ -171,12 +176,21 @@ export interface Context<Value>
171176
export function createContext<Value>({
172177
fallbackToCache,
173178
reporter,
179+
checkValue,
174180
...options
175181
}: CachifiedOptions<Value>): Context<Value> {
176182
const ttl = options.ttl ?? Infinity;
177183
const staleWhileRevalidate = options.staleWhileRevalidate ?? 0;
184+
const checkValueCompat: CheckValue<Value> =
185+
typeof checkValue === 'function'
186+
? checkValue
187+
: typeof checkValue === 'object'
188+
? (value, migrate) =>
189+
checkValue.parseAsync(value).then((v) => migrate(v, false))
190+
: () => true;
191+
178192
const contextWithoutReport = {
179-
checkValue: () => true,
193+
checkValue: checkValueCompat,
180194
ttl,
181195
staleWhileRevalidate,
182196
fallbackToCache:

0 commit comments

Comments
 (0)