diff --git a/README.md b/README.md index cde1d52..22eb984 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,11 @@ interface CachifiedOptions { /** * Validator that checks every cached and fresh value to ensure type safety * - * Can be a zod schema or a custom validator function + * Can be a standard schema validator or a custom validator function + * @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec * * Value considered ok when: - * - zod schema.parseAsync succeeds + * - schema succeeds * - validator returns * - true * - migrate(newValue) @@ -188,7 +189,7 @@ interface CachifiedOptions { * - null * * Value considered bad when: - * - zod schema.parseAsync throws + * - schema throws * - validator: * - returns false * - returns reason as string @@ -200,7 +201,10 @@ interface CachifiedOptions { * * Default: `undefined` - no validation */ - checkValue?: CheckValue | Schema; + checkValue?: + | CheckValue + | StandardSchemaV1 + | Schema; /** * Set true to not even try reading the currently cached value * @@ -406,9 +410,9 @@ console.log(await getUserById(1)); > ℹī¸ `checkValue` is also invoked with the return value of `getFreshValue` -### Type-safety with [zod](https://github.com/colinhacks/zod) +### Type-safety with [schema libraries](https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec) -We can also use zod schemas to ensure correct types +We can also use zod, valibot or other libraries implementing the standard schema spec to ensure correct types diff --git a/package-lock.json b/package-lock.json index 462cdeb..f956f44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "jest": "29.7.0", "ts-jest": "29.2.5", "typescript": "5.7.3", - "zod": "3.24.1" + "zod-legacy": "npm:zod@3.23.5" } }, "node_modules/@ampproject/remapping": { @@ -4144,10 +4144,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zod": { - "version": "3.24.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", - "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "node_modules/zod-legacy": { + "name": "zod", + "version": "3.23.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.5.tgz", + "integrity": "sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==", "dev": true, "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -7127,10 +7128,10 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true }, - "zod": { - "version": "3.24.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", - "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "zod-legacy": { + "version": "npm:zod@3.23.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.5.tgz", + "integrity": "sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==", "dev": true } } diff --git a/package.json b/package.json index 9c1085a..06255b6 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,6 @@ "jest": "29.7.0", "ts-jest": "29.2.5", "typescript": "5.7.3", - "zod": "3.24.1" + "zod-legacy": "npm:zod@3.23.5" } } diff --git a/src/StandardSchemaV1.ts b/src/StandardSchemaV1.ts new file mode 100644 index 0000000..f77d7e8 --- /dev/null +++ b/src/StandardSchemaV1.ts @@ -0,0 +1,70 @@ +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + /** The Standard Schema properties. */ + readonly '~standard': StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + export interface Props { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Validates unknown input values. */ + readonly validate: ( + value: unknown, + ) => Result | Promise>; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + } + + /** The result interface of the validate function. */ + export type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + export interface SuccessResult { + /** The typed output value. */ + readonly value: Output; + /** The non-existent issues. */ + readonly issues?: undefined; + } + + /** The result interface if validation fails. */ + export interface FailureResult { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + } + + /** The issue interface of the failure output. */ + export interface Issue { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: ReadonlyArray | undefined; + } + + /** The path segment interface of the issue. */ + export interface PathSegment { + /** The key representing a path segment. */ + readonly key: PropertyKey; + } + + /** The Standard Schema types interface. */ + export interface Types { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; + } + + /** Infers the input type of a Standard Schema. */ + export type InferInput = NonNullable< + Schema['~standard']['types'] + >['input']; + + /** Infers the output type of a Standard Schema. */ + export type InferOutput = NonNullable< + Schema['~standard']['types'] + >['output']; +} diff --git a/src/cachified.spec.ts b/src/cachified.spec.ts index 3d77f55..243e894 100644 --- a/src/cachified.spec.ts +++ b/src/cachified.spec.ts @@ -1,4 +1,8 @@ -import z from 'zod'; +/* + TODO(next-major): remove zod-legacy in favor of zod@>3.24 + and update tests to only use standard schema +*/ +import z from 'zod-legacy'; import { cachified, CachifiedOptions, @@ -12,6 +16,7 @@ import { } from './index'; import { Deferred } from './createBatch'; import { delay, report } from './testHelpers'; +import { StandardSchemaV1 } from './StandardSchemaV1'; import { configure } from './configure'; jest.mock('./index', () => { @@ -389,6 +394,52 @@ describe('cachified', () => { expect(await getValue()).toBe(123); }); + it('supports Standard Schema as validators', async () => { + // Implement the schema interface + const checkValue: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'cachified-test', + validate(value) { + return typeof value === 'string' + ? { value: parseInt(value, 10) } + : { issues: [{ message: '🙅' }] }; + }, + }, + }; + + const cache = new Map(); + + const value = await cachified({ + cache, + key: 'test', + checkValue, + getFreshValue() { + return '123'; + }, + }); + + expect(value).toBe(123); + + const invalidValue = cachified({ + cache, + key: 'test-2', + checkValue, + getFreshValue() { + return { invalid: 'value' }; + }, + }); + + await expect(invalidValue).rejects.toThrowErrorMatchingInlineSnapshot( + `"check failed for fresh value of test-2"`, + ); + await ignoreNode14(() => + expect( + invalidValue.catch((err) => err.cause[0].message), + ).resolves.toMatchInlineSnapshot(`"🙅"`), + ); + }); + it('supports migrating cached values', async () => { const cache = new Map(); const reporter = createReporter(); diff --git a/src/common.ts b/src/common.ts index 956538f..7f3c5e1 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,4 +1,5 @@ import type { CreateReporter, Reporter } from './reporter'; +import { StandardSchemaV1 } from './StandardSchemaV1'; export interface CacheMetadata { createdTime: number; @@ -51,10 +52,17 @@ export type ValueCheckResultInvalid = false | string; export type ValueCheckResult = | ValueCheckResultOk | ValueCheckResultInvalid; + export type CheckValue = ( value: unknown, migrate: (value: Value, updateCache?: boolean) => MigratedValue, ) => ValueCheckResult | Promise>; + +/** + * @deprecated use a library supporting Standard Schema + * @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec + * @todo remove in next major version + */ export interface Schema { _input: InputValue; parseAsync(value: unknown): Promise; @@ -122,10 +130,11 @@ export interface CachifiedOptions { /** * Validator that checks every cached and fresh value to ensure type safety * - * Can be a zod schema or a custom validator function + * Can be a standard schema validator or a custom validator function + * @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec * * Value considered ok when: - * - zod schema.parseAsync succeeds + * - schema succeeds * - validator returns * - true * - migrate(newValue) @@ -133,7 +142,7 @@ export interface CachifiedOptions { * - null * * Value considered bad when: - * - zod schema.parseAsync throws + * - schema throws * - validator: * - returns false * - returns reason as string @@ -141,11 +150,14 @@ export interface CachifiedOptions { * * A validator function receives two arguments: * 1. the value - * 2. a migrate callback, see https://github.com/Xiphe/cachified#migrating-values + * 2. a migrate callback, see https://github.com/epicweb-dev/cachified#migrating-values * * Default: `undefined` - no validation */ - checkValue?: CheckValue | Schema; + checkValue?: + | CheckValue + | StandardSchemaV1 + | Schema; /** * Set true to not even try reading the currently cached value * @@ -195,7 +207,7 @@ export type CachifiedOptionsWithSchema = Omit< CachifiedOptions, 'checkValue' | 'getFreshValue' > & { - checkValue: Schema; + checkValue: StandardSchemaV1 | Schema; getFreshValue: GetFreshValue; }; @@ -210,6 +222,33 @@ export interface Context metadata: CacheMetadata; } +function validateWithSchema( + checkValue: StandardSchemaV1 | Schema, +): CheckValue { + return async (value, migrate) => { + let validatedValue; + + /* Standard Schema validation + https://github.com/standard-schema/standard-schema?tab=readme-ov-file#how-do-i-accept-standard-schemas-in-my-library */ + if ('~standard' in checkValue) { + let result = checkValue['~standard'].validate(value); + if (result instanceof Promise) result = await result; + + if (result.issues) { + throw result.issues; + } + + validatedValue = result.value; + } else { + /* Legacy Schema validation for zod only + TODO: remove in next major version */ + validatedValue = await checkValue.parseAsync(value); + } + + return migrate(validatedValue, false); + }; +} + export function createContext( { fallbackToCache, checkValue, ...options }: CachifiedOptions, reporter?: CreateReporter, @@ -220,8 +259,7 @@ export function createContext( typeof checkValue === 'function' ? checkValue : typeof checkValue === 'object' - ? (value, migrate) => - checkValue.parseAsync(value).then((v) => migrate(v, false)) + ? validateWithSchema(checkValue) : () => true; const contextWithoutReport = {