Skip to content

Commit b6fa1e9

Browse files
committed
feat: wip
1 parent a8da3a8 commit b6fa1e9

File tree

10 files changed

+218
-9
lines changed

10 files changed

+218
-9
lines changed

packages/bentocache/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@boringnode/bus": "^0.7.0",
7676
"@julr/utils": "^1.6.2",
7777
"@poppinss/utils": "^6.9.2",
78+
"@standard-schema/spec": "^1.0.0",
7879
"async-mutex": "^0.5.0",
7980
"hexoid": "^2.0.0",
8081
"lru-cache": "^11.0.2",
@@ -98,7 +99,8 @@
9899
"pino": "^9.6.0",
99100
"pino-loki": "^2.5.0",
100101
"sqlite3": "^5.1.7",
101-
"superjson": "^2.2.2"
102+
"superjson": "^2.2.2",
103+
"zod": "^3.24.1"
102104
},
103105
"prettier": "@julr/tooling-configs/prettier",
104106
"publishConfig": {

packages/bentocache/src/cache/cache_entry/cache_entry_options.ts

+35-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { is } from '@julr/utils/is'
33

44
import { errors } from '../../errors.js'
55
import { resolveTtl } from '../../helpers.js'
6-
import type { Duration, RawCommonOptions } from '../../types/main.js'
6+
import type { Duration, RawCommonOptions, ValidateOption } from '../../types/main.js'
77

88
const toId = hexoid(12)
99

@@ -18,6 +18,38 @@ function resolveGrace(options: RawCommonOptions) {
1818
return resolveTtl(options.grace, null) ?? 0
1919
}
2020

21+
/**
22+
* Resolve validator
23+
*/
24+
function resolveValidate(options: RawCommonOptions & ValidateOption) {
25+
const validate = options.validate
26+
if (!validate) return (value: unknown) => value
27+
28+
if (typeof validate === 'function') {
29+
return (value: unknown) => {
30+
try {
31+
validate(value)
32+
} catch (error) {
33+
throw new errors.E_VALIDATION_ERROR(error, { cause: error })
34+
}
35+
36+
return value
37+
}
38+
}
39+
40+
if (validate && '~standard' in validate) {
41+
return (value: unknown) => {
42+
const result = validate['~standard'].validate(value)
43+
if (result instanceof Promise) throw new TypeError('Validation must be synchronous')
44+
if (result.issues) throw new errors.E_VALIDATION_ERROR(result.issues)
45+
46+
return result.value
47+
}
48+
}
49+
50+
return () => true
51+
}
52+
2153
/**
2254
* Cache Entry Options. Define how a cache operation should behave
2355
*
@@ -27,7 +59,7 @@ function resolveGrace(options: RawCommonOptions) {
2759
* fake class to have way better performance.
2860
*/
2961
export function createCacheEntryOptions(
30-
newOptions: RawCommonOptions = {},
62+
newOptions: RawCommonOptions & ValidateOption = {},
3163
defaults: Partial<RawCommonOptions> = {},
3264
) {
3365
const options = { ...defaults, ...newOptions }
@@ -91,6 +123,7 @@ export function createCacheEntryOptions(
91123
lockTimeout,
92124
onFactoryError: options.onFactoryError ?? defaults.onFactoryError,
93125
suppressL2Errors: options.suppressL2Errors,
126+
validate: resolveValidate(options),
94127

95128
/**
96129
* Returns a new instance of `CacheItemOptions` with the same

packages/bentocache/src/cache/cache_stack.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ export class CacheStack extends BaseDriver {
112112
*/
113113
async set(key: string, value: any, options: ReturnType<typeof createCacheEntryOptions>) {
114114
if (is.undefined(value)) throw new UndefinedValueError(key)
115+
const validatedValue = options.validate(value)
115116

116117
const rawItem = {
117-
value,
118+
value: validatedValue,
118119
logicalExpiration: options.logicalTtlFromNow(),
119120
}
120121

@@ -133,7 +134,7 @@ export class CacheStack extends BaseDriver {
133134
}
134135

135136
await this.publish({ type: CacheBusMessageType.Set, keys: [key] })
136-
this.emit(cacheEvents.written(key, value, this.name))
137+
this.emit(cacheEvents.written(key, validatedValue, this.name))
137138
return true
138139
}
139140
}

packages/bentocache/src/cache/factory_runner.ts

+2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export class FactoryRunner {
4141
await this.#stack.set(key, result, options)
4242
return result
4343
} catch (error) {
44+
if (error instanceof errors.E_VALIDATION_ERROR) throw error
45+
4446
if (!isBackground) throw new errors.E_FACTORY_ERROR(key, error)
4547

4648
options.onFactoryError?.(new errors.E_FACTORY_ERROR(key, error, true))

packages/bentocache/src/cache/get_set/single_tier_handler.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class SingleTierHandler {
4343
this.logger.trace({ key, cache: this.stack.name, opId: options.id }, 'remote cache hit')
4444

4545
this.#emit(cacheEvents.hit(key, item.getValue(), this.stack.name))
46-
return item.getValue()
46+
return options.validate(item.getValue())
4747
}
4848

4949
/**

packages/bentocache/src/errors.ts

+33
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { is } from '@julr/utils/is'
12
import { Exception } from '@poppinss/utils/exception'
3+
import type { StandardSchemaV1 } from '@standard-schema/spec'
24

35
/**
46
* Thrown when a factory has timed out after waiting for soft timeout
@@ -70,8 +72,39 @@ export class UndefinedValueError extends Exception {
7072
}
7173
}
7274

75+
/**
76+
* Thrown when a validation fails
77+
*/
78+
export class ValidationError extends Exception {
79+
static code = 'E_VALIDATION_ERROR'
80+
81+
#isStandardIssues(error: any): error is ReadonlyArray<StandardSchemaV1.Issue> {
82+
return is.array(error) && is.object(error[0]) && 'message' in error[0]
83+
}
84+
85+
#formatStandardIssues(issues: ReadonlyArray<StandardSchemaV1.Issue>) {
86+
return (
87+
'\n' + issues.map((issue) => `- ${issue.message} at "${issue.path?.join('.')}"`).join('\n')
88+
)
89+
}
90+
91+
constructor(error: ReadonlyArray<StandardSchemaV1.Issue> | any, options?: ErrorOptions) {
92+
super()
93+
94+
if (this.#isStandardIssues(error)) {
95+
this.message = this.#formatStandardIssues(error)
96+
} else {
97+
this.message = error
98+
}
99+
100+
this.cause = options?.cause
101+
}
102+
}
103+
73104
export const errors = {
74105
E_FACTORY_ERROR: FactoryError,
75106
E_FACTORY_SOFT_TIMEOUT: FactorySoftTimeout,
76107
E_FACTORY_HARD_TIMEOUT: FactoryHardTimeout,
108+
E_UNDEFINED_VALUE: UndefinedValueError,
109+
E_VALIDATION_ERROR: ValidationError,
77110
}

packages/bentocache/src/types/options/methods_options.ts

+16-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
2+
13
import type { FactoryError } from '../../errors.js'
24
import type { Factory, GetSetFactory, RawCommonOptions } from '../main.js'
35

6+
/**
7+
* Validate option
8+
*/
9+
export type ValidateOption = {
10+
validate?: StandardSchemaV1 | ((value: unknown) => void)
11+
}
12+
413
/**
514
* Options accepted by the `getOrSet` method
615
*/
716
export type SetCommonOptions = Pick<
817
RawCommonOptions,
918
'grace' | 'graceBackoff' | 'suppressL2Errors' | 'lockTimeout' | 'ttl' | 'timeout' | 'hardTimeout'
10-
>
19+
> &
20+
ValidateOption
1121

1222
/**
1323
* Options accepted by the `getOrSet` method when passing an object
@@ -24,10 +34,12 @@ export type GetOrSetOptions<T> = {
2434
export type GetOrSetForeverOptions<T> = {
2535
key: string
2636
factory: GetSetFactory<T>
37+
onFactoryError?: (error: FactoryError) => void
2738
} & Pick<
2839
RawCommonOptions,
2940
'grace' | 'graceBackoff' | 'suppressL2Errors' | 'lockTimeout' | 'timeout' | 'hardTimeout'
30-
>
41+
> &
42+
ValidateOption
3143

3244
/**
3345
* Options accepted by the `set` method
@@ -40,7 +52,8 @@ export type SetOptions = { key: string; value: any } & SetCommonOptions
4052
export type GetOptions<T> = { key: string; defaultValue?: Factory<T> } & Pick<
4153
RawCommonOptions,
4254
'grace' | 'graceBackoff' | 'suppressL2Errors'
43-
>
55+
> &
56+
ValidateOption
4457

4558
/**
4659
* Options accepted by the `delete` method when passing an object

packages/bentocache/tests/bento_cache.spec.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { z } from 'zod'
12
import Emittery from 'emittery'
23
import { Redis } from 'ioredis'
34
import { test } from '@japa/runner'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { z } from 'zod'
2+
import { test } from '@japa/runner'
3+
import { is } from '@julr/utils/is'
4+
5+
import { errors } from '../../src/errors.js'
6+
import { CacheFactory } from '../../factories/cache_factory.js'
7+
8+
test.group('Validation', () => {
9+
test('works with standard schema', async ({ assert }) => {
10+
const { cache } = new CacheFactory().withL1L2Config().create()
11+
12+
await assert.rejects(async () => {
13+
await cache.set({
14+
key: 'foo',
15+
value: { foo: 3 },
16+
validate: z.object({ foo: z.string(), bar: z.number() }),
17+
})
18+
// @ts-ignore
19+
}, errors.E_VALIDATION_ERROR)
20+
})
21+
22+
test('works with fn', async ({ assert }) => {
23+
const { cache } = new CacheFactory().withL1L2Config().create()
24+
25+
await assert.rejects(async () => {
26+
await cache.set({
27+
key: 'foo',
28+
value: { foo: 3 },
29+
validate: (value) => {
30+
if (!is.plainObject(value)) {
31+
throw new TypeError('Value must be an object')
32+
}
33+
34+
if (typeof value.foo !== 'string') {
35+
throw new TypeError('Value must be a string')
36+
}
37+
},
38+
})
39+
// @ts-ignore
40+
}, errors.E_VALIDATION_ERROR)
41+
})
42+
43+
test('doesnt throw when validation passes', async () => {
44+
const { cache } = new CacheFactory().withL1L2Config().create()
45+
46+
await cache.set({
47+
key: 'foo',
48+
value: { foo: '3' },
49+
validate: z.object({ foo: z.string() }),
50+
})
51+
52+
await cache.set({
53+
key: 'foo',
54+
value: { foo: '3' },
55+
validate: (value) => {
56+
if (!is.plainObject(value)) throw new TypeError('Value must be an object')
57+
if (typeof value.foo !== 'string') throw new TypeError('Value must be a string')
58+
},
59+
})
60+
})
61+
62+
test('cache object transformed by schema', async ({ assert }) => {
63+
const { cache } = new CacheFactory().withL1L2Config().create()
64+
65+
await cache.set({
66+
key: 'foo',
67+
value: { foo: 'wesh' },
68+
validate: z.object({ foo: z.string().toUpperCase() }),
69+
})
70+
71+
const r1 = await cache.get({ key: 'foo' })
72+
assert.deepEqual(r1, { foo: 'WESH' })
73+
})
74+
75+
test('works with getOrSet', async ({ assert }) => {
76+
const { cache } = new CacheFactory().withL1L2Config().create()
77+
78+
await assert.rejects(async () => {
79+
await cache.getOrSet({
80+
key: 'foo',
81+
factory: () => ({ foo: 3 }),
82+
validate: z.object({ foo: z.string() }),
83+
})
84+
// @ts-ignore
85+
}, errors.E_VALIDATION_ERROR)
86+
87+
const r1 = await cache.get({ key: 'foo' })
88+
assert.isUndefined(r1)
89+
90+
await cache.getOrSet({
91+
key: 'foo',
92+
factory: () => ({ foo: 'foo' }),
93+
validate: z.object({ foo: z.string() }),
94+
})
95+
})
96+
97+
test('validate when fetching from getOrSet', async () => {
98+
const { cache } = new CacheFactory().withL1L2Config().create()
99+
100+
await cache.set({ key: 'foo', value: { foo: 3 } })
101+
102+
await cache.getOrSet({
103+
key: 'foo',
104+
factory: () => ({ foo: 'foo' }),
105+
validate: z.object({ foo: z.string() }),
106+
})
107+
})
108+
})

pnpm-lock.yaml

+16
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)