Skip to content

feat: create cache method for caching schema output by input #1170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"eslint-plugin-regexp": "^2.7.0",
"eslint-plugin-security": "^3.0.1",
"jsdom": "^26.0.0",
"quick-lru": "^7.0.1",
"tsm": "^2.3.0",
"tsup": "^8.4.0",
"typescript": "^5.7.3",
Expand Down
62 changes: 62 additions & 0 deletions library/src/methods/cache/cache.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import QuickLRU from 'quick-lru';
import { describe, expectTypeOf, test } from 'vitest';
import type { StringIssue, StringSchema } from '../../schemas/index.ts';
import { string } from '../../schemas/index.ts';
import type {
InferInput,
InferIssue,
InferOutput,
OutputDataset,
} from '../../types/index.ts';
import type { _Cache } from '../../utils/index.ts';
import type { SchemaWithCache } from './cache.ts';
import { cache } from './cache.ts';

describe('cache', () => {
describe('should return schema object', () => {
test('without options', () => {
const schema = string();
expectTypeOf(cache(schema)).toEqualTypeOf<
SchemaWithCache<typeof schema, undefined>
>();
expectTypeOf(cache(schema, undefined)).toEqualTypeOf<
SchemaWithCache<typeof schema, undefined>
>();
});
test('with options', () => {
const schema = string();
expectTypeOf(cache(schema, { maxSize: 10 })).toEqualTypeOf<
SchemaWithCache<typeof schema, { maxSize: 10 }>
>();
});
test('with cache instance', () => {
const schema = string();

expectTypeOf(
cache(schema, { cache: new QuickLRU({ maxSize: 1000 }) })
).toEqualTypeOf<
SchemaWithCache<
typeof schema,
{ cache: QuickLRU<unknown, OutputDataset<string, StringIssue>> }
>
>();
});
});
describe('should infer correct types', () => {
type Schema = SchemaWithCache<StringSchema<undefined>, undefined>;
test('of input', () => {
expectTypeOf<InferInput<Schema>>().toEqualTypeOf<string>();
});
test('of output', () => {
expectTypeOf<InferOutput<Schema>>().toEqualTypeOf<string>();
});
test('of issue', () => {
expectTypeOf<InferIssue<Schema>>().toEqualTypeOf<StringIssue>();
});
test('of cache', () => {
expectTypeOf<Schema['cache']>().toEqualTypeOf<
_Cache<unknown, OutputDataset<string, StringIssue>>
>();
});
});
});
79 changes: 79 additions & 0 deletions library/src/methods/cache/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import QuickLRU from 'quick-lru';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { string } from '../../schemas/index.ts';
import { cache } from './cache.ts';

describe('cache', () => {
test('should cache output', () => {
const baseSchema = string();
const runSpy = vi.spyOn(baseSchema, '~run');
const schema = cache(baseSchema);
expect(schema['~run']({ value: 'foo' }, {})).toBe(
schema['~run']({ value: 'foo' }, {})
);
expect(runSpy).toHaveBeenCalledTimes(1);
});
test('should allow custom max size', () => {
const schema = cache(string(), { maxSize: 2 });
expect(schema.options.maxSize).toBe(2);

const fooDataset = schema['~run']({ value: 'foo' }, {});
expect(schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);

expect(schema['~run']({ value: 'bar' }, {})).toBe(
schema['~run']({ value: 'bar' }, {})
);

expect(schema['~run']({ value: 'baz' }, {})).toBe(
schema['~run']({ value: 'baz' }, {})
);

expect(schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});
describe('should allow custom duration', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});

test('and clear expired values', () => {
const schema = cache(string(), { duration: 1000 });

const fooDataset = schema['~run']({ value: 'foo' }, {});
expect(schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);
vi.advanceTimersByTime(1001);
expect(schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});

test('and reset expiry on get', () => {
const schema = cache(string(), { duration: 1000 });
const fooDataset = schema['~run']({ value: 'foo' }, {});
expect(schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);
vi.advanceTimersByTime(500);
expect(schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);
vi.advanceTimersByTime(1001);
expect(schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});
});
test('should expose cache for manual clearing', () => {
const schema = cache(string());
const fooDataset = schema['~run']({ value: 'foo' }, {});
expect(schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);
schema.cache.clear();
expect(schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});
test('should allow custom cache instance', () => {
const schema = cache(string(), {
cache: new QuickLRU({ maxSize: 1000 }),
});
expect(schema.cache).toBeInstanceOf(QuickLRU);

const fooDataset = schema['~run']({ value: 'foo' }, {});
expect(schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);

schema.cache.clear();
expect(schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});
});
85 changes: 85 additions & 0 deletions library/src/methods/cache/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type {
BaseIssue,
BaseSchema,
InferIssue,
InferOutput,
OutputDataset,
} from '../../types/index.ts';
import type { CacheOptions } from '../../utils/index.ts';
import { _Cache, _getStandardProps } from '../../utils/index.ts';
import type { CacheInstanceOptions } from './types.ts';

export type SchemaWithCache<
TSchema extends BaseSchema<unknown, unknown, BaseIssue<unknown>>,
TOptions extends CacheOptions | CacheInstanceOptions<TSchema> | undefined,
> = TSchema & {
/**
* The cache options.
*/
readonly options: Readonly<TOptions>;
/**
* The cache instance.
*/
readonly cache: TOptions extends { cache: infer TCache }
? TCache
: _Cache<unknown, OutputDataset<InferOutput<TSchema>, InferIssue<TSchema>>>;
};

/**
* Caches the output of a schema.
*
* @param schema The schema to cache.
*
* @returns The cached schema.
*/
export function cache<
const TSchema extends BaseSchema<unknown, unknown, BaseIssue<unknown>>,
>(schema: TSchema): SchemaWithCache<TSchema, undefined>;

/**
* Caches the output of a schema.
*
* @param schema The schema to cache.
* @param options Either the cache options or an instance.
*
* @returns The cached schema.
*/
export function cache<
const TSchema extends BaseSchema<unknown, unknown, BaseIssue<unknown>>,
const TOptions extends
| CacheOptions
| CacheInstanceOptions<TSchema>
| undefined,
>(schema: TSchema, options: TOptions): SchemaWithCache<TSchema, TOptions>;

// @__NO_SIDE_EFFECTS__
export function cache(
schema: BaseSchema<unknown, unknown, BaseIssue<unknown>>,
options?:
| CacheOptions
| CacheInstanceOptions<BaseSchema<unknown, unknown, BaseIssue<unknown>>>
): SchemaWithCache<
BaseSchema<unknown, unknown, BaseIssue<unknown>>,
| CacheOptions
| CacheInstanceOptions<BaseSchema<unknown, unknown, BaseIssue<unknown>>>
| undefined
> {
return {
...schema,
options,
cache: options && 'cache' in options ? options.cache : new _Cache(options),
get '~standard'() {
return _getStandardProps(this);
},
'~run'(dataset, config) {
let outputDataset = this.cache.get(dataset.value);
if (!outputDataset) {
this.cache.set(
dataset.value,
(outputDataset = schema['~run'](dataset, config))
);
}
return outputDataset;
},
};
}
63 changes: 63 additions & 0 deletions library/src/methods/cache/cacheAsync.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import QuickLRU from 'quick-lru';
import { describe, expectTypeOf, test } from 'vitest';
import type { StringIssue, StringSchema } from '../../schemas/index.ts';
import { string } from '../../schemas/index.ts';
import type {
InferInput,
InferIssue,
InferOutput,
OutputDataset,
} from '../../types/index.ts';
import type { _Cache } from '../../utils/index.ts';
import type { SchemaWithCacheAsync } from './cacheAsync.ts';
import { cacheAsync } from './cacheAsync.ts';

describe('cacheAsync', () => {
describe('should return schema object', () => {
test('without options', () => {
const schema = string();
expectTypeOf(cacheAsync(schema)).toEqualTypeOf<
SchemaWithCacheAsync<typeof schema, undefined>
>();
expectTypeOf(cacheAsync(schema, undefined)).toEqualTypeOf<
SchemaWithCacheAsync<typeof schema, undefined>
>();
});
test('with options', () => {
const schema = string();
expectTypeOf(cacheAsync(schema, { maxSize: 10 })).toEqualTypeOf<
SchemaWithCacheAsync<typeof schema, { maxSize: 10 }>
>();
});
test('with cache instance', () => {
const schema = string();

expectTypeOf(
cacheAsync(schema, { cache: new QuickLRU({ maxSize: 1000 }) })
).toEqualTypeOf<
SchemaWithCacheAsync<
typeof schema,
{ cache: QuickLRU<unknown, OutputDataset<string, StringIssue>> }
>
>();
});
});

describe('should infer correct types', () => {
type Schema = SchemaWithCacheAsync<StringSchema<undefined>, undefined>;
test('of input', () => {
expectTypeOf<InferInput<Schema>>().toEqualTypeOf<string>();
});
test('of output', () => {
expectTypeOf<InferOutput<Schema>>().toEqualTypeOf<string>();
});
test('of issue', () => {
expectTypeOf<InferIssue<Schema>>().toEqualTypeOf<StringIssue>();
});
test('of cache', () => {
expectTypeOf<Schema['cache']>().toEqualTypeOf<
_Cache<unknown, OutputDataset<string, StringIssue>>
>();
});
});
});
78 changes: 78 additions & 0 deletions library/src/methods/cache/cacheAsync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import QuickLRU from 'quick-lru';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { string } from '../../schemas/index.ts';
import { cacheAsync } from './cacheAsync.ts';

describe('cacheAsync', () => {
test('should cache output', async () => {
const baseSchema = string();
const runSpy = vi.spyOn(baseSchema, '~run');
const schema = cacheAsync(baseSchema);
expect(await schema['~run']({ value: 'foo' }, {})).toBe(
await schema['~run']({ value: 'foo' }, {})
);
expect(runSpy).toHaveBeenCalledTimes(1);
});
test('should allow custom max size', async () => {
const schema = cacheAsync(string(), { maxSize: 2 });
expect(schema.options.maxSize).toBe(2);

const fooDataset = await schema['~run']({ value: 'foo' }, {});
expect(await schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);

expect(await schema['~run']({ value: 'bar' }, {})).toBe(
await schema['~run']({ value: 'bar' }, {})
);

expect(await schema['~run']({ value: 'baz' }, {})).toBe(
await schema['~run']({ value: 'baz' }, {})
);

expect(await schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});
describe('should allow custom duration', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});

test('and clear expired values', async () => {
const schema = cacheAsync(string(), { duration: 1000 });
const fooDataset = await schema['~run']({ value: 'foo' }, {});
expect(await schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);
vi.advanceTimersByTime(1001);
expect(await schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});

test('and reset expiry on get', async () => {
const schema = cacheAsync(string(), { duration: 1000 });
const fooDataset = await schema['~run']({ value: 'foo' }, {});
expect(await schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);
vi.advanceTimersByTime(501);
expect(await schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);
vi.advanceTimersByTime(1001);
expect(await schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});
});
test('should expose cache for manual clearing', async () => {
const schema = cacheAsync(string());
const fooDataset = await schema['~run']({ value: 'foo' }, {});
expect(await schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);
schema.cache.clear();
expect(await schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});
test('should allow custom cache instance', async () => {
const schema = cacheAsync(string(), {
cache: new QuickLRU({ maxSize: 1000 }),
});
expect(schema.cache).toBeInstanceOf(QuickLRU);

const fooDataset = await schema['~run']({ value: 'foo' }, {});
expect(await schema['~run']({ value: 'foo' }, {})).toBe(fooDataset);

schema.cache.clear();
expect(await schema['~run']({ value: 'foo' }, {})).not.toBe(fooDataset);
});
});
Loading