diff --git a/.changeset/dry-lions-occur.md b/.changeset/dry-lions-occur.md new file mode 100644 index 0000000..f0a9d0c --- /dev/null +++ b/.changeset/dry-lions-occur.md @@ -0,0 +1,5 @@ +--- +'@chialab/sveltekit-utils': minor +--- + +Use `@heyputer/kv.js` for in-memory cache, simplify code and increase test coverage, add `clearPattern()` method to base cache interface. diff --git a/package.json b/package.json index 337989b..ef8fd8c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "lint-fix-all": "yarn run eslint-fix && yarn run prettier-fix" }, "dependencies": { + "@heyputer/kv.js": "^0.1.9", "cookie": "^1.0.2", "pino": "^9.5.0", "redis": "^4.7.0", diff --git a/src/lib/server/cache/base.ts b/src/lib/server/cache/base.ts index c829252..3e1b6ce 100644 --- a/src/lib/server/cache/base.ts +++ b/src/lib/server/cache/base.ts @@ -1,9 +1,10 @@ import type { JitterFn, JitterMode } from '../../utils/misc.js'; +import type { StorageReadWriter } from '../storage.js'; /** * Base class for caching. */ -export abstract class BaseCache { +export abstract class BaseCache implements StorageReadWriter { /** * Read an item from the cache, if present. * @@ -47,6 +48,13 @@ export abstract class BaseCache { */ public abstract clear(prefix?: string): Promise; + /** + * Flush cache removing all items matching a pattern. + * + * @param pattern Pattern to clear. May include the wildcard `*`. + */ + public abstract clearPattern(pattern: string): Promise; + /** * Read or set an item in the cache. * @@ -74,12 +82,12 @@ export abstract class BaseCache { jitter?: JitterMode | JitterFn | undefined, ): Promise { const cached = await this.get(key); - if (typeof cached !== 'undefined') { + if (cached !== undefined) { return cached; } const value = await callback(); - if (typeof value !== 'undefined') { + if (value !== undefined) { await this.set(key, value, ttl, jitter); } diff --git a/src/lib/server/cache/in-memory.ts b/src/lib/server/cache/in-memory.ts index 0c71689..39ec2c3 100644 --- a/src/lib/server/cache/in-memory.ts +++ b/src/lib/server/cache/in-memory.ts @@ -1,10 +1,9 @@ +import kvjs from '@heyputer/kv.js'; import { createJitter, JitterMode, type JitterFn } from '../../utils/misc.js'; +import { addPrefix, stripPrefix } from '../../utils/string.js'; import { BaseCache } from './base.js'; -type ValueWrapper = { value: T; expire: number | undefined }; - type InMemoryCacheOptions = { - maxItems?: number; keyPrefix?: string; defaultTTL?: number; defaultJitter?: JitterMode | JitterFn; @@ -13,17 +12,17 @@ type InMemoryCacheOptions = { /** Simple cache with TTL and cap to maximum items stored. */ export class InMemoryCache extends BaseCache { readonly #options: InMemoryCacheOptions; - readonly #inner: Map> = new Map(); + readonly #inner: kvjs; public static init(options: InMemoryCacheOptions): InMemoryCache { return new this(options); } - private constructor(options: InMemoryCacheOptions, map?: Map>) { + private constructor(options: InMemoryCacheOptions, store?: kvjs) { super(); this.#options = Object.freeze({ ...options }); - this.#inner = map ?? new Map(); + this.#inner = store ?? new kvjs(); } public child( @@ -34,24 +33,14 @@ export class InMemoryCache extends BaseCache { { ...this.#options, ...options, - keyPrefix: this.#key(keyPrefix), + keyPrefix: addPrefix(this.#options.keyPrefix, keyPrefix), }, - this.#inner as Map<`${typeof keyPrefix}${string}`, ValueWrapper>, + this.#inner, ); } public async get(key: string): Promise { - const cached = this.#inner.get(this.#key(key)); - if (typeof cached === 'undefined') { - return undefined; - } - if (!InMemoryCache.#isValid(cached)) { - this.#inner.delete(this.#key(key)); - - return undefined; - } - - return cached.value; + return this.#inner.get(addPrefix(this.#options.keyPrefix, key)) as V | undefined; } public async set( @@ -61,112 +50,34 @@ export class InMemoryCache extends BaseCache { jitter?: JitterMode | JitterFn | undefined, ): Promise { ttl ??= this.#options.defaultTTL; - const jitterFn = createJitter(jitter ?? this.#options.defaultJitter ?? JitterMode.None); - this.#inner.set(this.#key(key), { - value, - expire: typeof ttl === 'number' ? Date.now() + jitterFn(ttl * 1000) : undefined, - }); - - this.#housekeeping(); - } + if (ttl === undefined) { + this.#inner.set(addPrefix(this.#options.keyPrefix, key), value); - public async delete(key: string): Promise { - this.#inner.delete(this.#key(key)); - } - - public async *keys(prefix?: string): AsyncGenerator { - for (const key of this.#inner.keys()) { - const strippedKey = this.#stripPrefix(key); - if (typeof strippedKey !== 'undefined' && strippedKey.startsWith(prefix ?? '')) { - yield strippedKey; - } + return; } - } - public async clear(prefix?: string): Promise { - if (typeof prefix !== 'undefined') { - this.#processBatch(this.#inner.entries(), (key) => key.startsWith(prefix)); - } else if (typeof this.#options.keyPrefix !== 'undefined') { - this.#processBatch(this.#inner.entries(), () => true); - } else { - this.#inner.clear(); - } - } - - /** - * Check if a cached value has expired. - * - * @param cached Cached value. - * @param now Point-in-time to evaluate expiration against. - */ - static #isValid(cached: ValueWrapper, now?: number): boolean { - if (typeof cached.expire !== 'number') { - return true; - } - - return cached.expire >= (now ?? Date.now()); - } - - /** - * Run housekeeping tasks on cache instance. - * - * Expired items will be removed, and if the cache is over capacity, - * excess items will be randomly evicted in an attempt to cut size down. - * - * @param batchSize Number of cache items to evaluate at every tick. Keep this low to avoid locking for too long. - */ - #housekeeping(batchSize = 1000): void { - const now = Date.now(); - const dropProbability = - typeof this.#options.maxItems !== 'undefined' - ? Math.max( - 0, - [...this.#inner.keys()].filter((key) => typeof this.#stripPrefix(key) !== 'undefined').length / // Number of items in this cache. - this.#options.maxItems - - 1, - ) - : 0; - - setImmediate(() => - this.#processBatch( - this.#inner.entries(), - (_, cached) => !InMemoryCache.#isValid(cached, now) || (dropProbability > 0 && Math.random() < dropProbability), - batchSize, - ), + this.#inner.setex( + addPrefix(this.#options.keyPrefix, key), + value, + createJitter(jitter ?? this.#options.defaultJitter ?? JitterMode.None)(ttl * 1000), ); } - #key(key: string | undefined) { - return (this.#options.keyPrefix ?? '') + (key ?? ''); + public async delete(key: string): Promise { + this.#inner.del(addPrefix(this.#options.keyPrefix, key)); } - #stripPrefix(key: string) { - const prefix = this.#options.keyPrefix ?? ''; - if (!key.startsWith(prefix)) { - return undefined; - } - - return key.substring(prefix.length); + public async *keys(prefix?: string): AsyncGenerator { + yield* this.#inner + .keys(addPrefix(this.#options.keyPrefix, `${prefix ?? ''}*`)) + .map((key) => stripPrefix(this.#options.keyPrefix, key)!); } - #processBatch( - iterator: IterableIterator<[string, ValueWrapper]>, - filter: (key: string, cached: ValueWrapper) => boolean, - batchSize = 1000, - ): void { - for (let i = 0; i < batchSize; i++) { - const next = iterator.next(); - if (next.done === true) { - return; - } - - const [key, cached] = next.value; - const strippedKey = this.#stripPrefix(key); - if (typeof strippedKey !== 'undefined' && filter(strippedKey, cached)) { - this.#inner.delete(key); - } - } + public clear(prefix?: string): Promise { + return this.clearPattern((prefix ?? '') + '*'); + } - setImmediate(() => this.#processBatch(iterator, filter, batchSize)); + public async clearPattern(pattern: string): Promise { + this.#inner.del(...this.#inner.keys(addPrefix(this.#options.keyPrefix, pattern))); } } diff --git a/src/lib/server/cache/redis.ts b/src/lib/server/cache/redis.ts index 2aba710..a5c102e 100644 --- a/src/lib/server/cache/redis.ts +++ b/src/lib/server/cache/redis.ts @@ -1,15 +1,16 @@ import { createClient, createCluster, - type RedisClientType, type RedisClientOptions, - type RedisClusterType, + type RedisClientType, type RedisClusterOptions, + type RedisClusterType, type RedisDefaultModules, } from 'redis'; import { logger } from '../../logger.js'; -import { BaseCache } from './base.js'; import { createJitter, JitterMode, type JitterFn } from '../../utils/misc.js'; +import { addPrefix, stripPrefix } from '../../utils/string.js'; +import { BaseCache } from './base.js'; type RedisCacheOptions = { keyPrefix?: string; @@ -60,7 +61,7 @@ export class RedisCache extends BaseCache> { } async #connect(): Promise { - if (typeof this.#connectPromise === 'undefined') { + if (this.#connectPromise === undefined) { this.#connectPromise = this.#client.connect(); } @@ -77,7 +78,7 @@ export class RedisCache extends BaseCache> { { ...this.#options, ...options, - keyPrefix: this.#key(keyPrefix), + keyPrefix: addPrefix(this.#options.keyPrefix, keyPrefix), }, this.#client.duplicate(), ); @@ -85,7 +86,7 @@ export class RedisCache extends BaseCache> { public async get(key: string): Promise | undefined> { const client = await this.#connect(); - const val = await client.get(this.#key(key)); + const val = await client.get(addPrefix(this.#options.keyPrefix, key)); if (val === null) { return undefined; } @@ -114,11 +115,11 @@ export class RedisCache extends BaseCache> { const client = await this.#connect(); const val = JSON.stringify(value); try { - if (typeof ttl === 'undefined') { - await client.set(this.#key(key), val); + if (ttl === undefined) { + await client.set(addPrefix(this.#options.keyPrefix, key), val); } else { const jitterFn = createJitter(jitter ?? this.#options.defaultJitter ?? JitterMode.None); - await client.setEx(this.#key(key), Math.round(jitterFn(ttl)), val); + await client.setEx(addPrefix(this.#options.keyPrefix, key), Math.round(jitterFn(ttl)), val); } } catch (err) { logger.error({ key, err }, 'Got error while trying to set cache key'); @@ -128,18 +129,18 @@ export class RedisCache extends BaseCache> { public async delete(key: string): Promise { const client = await this.#connect(); - await client.del(this.#key(key)); + await client.del(addPrefix(this.#options.keyPrefix, key)); } public async *keys(prefix?: string): AsyncGenerator { - const matchFilter = this.#key(`${prefix ?? ''}*`); + const matchFilter = addPrefix(this.#options.keyPrefix, `${prefix ?? ''}*`); const client = await this.#connect(); const clients = 'masters' in client ? client.masters.map(({ client }) => client!) : [client]; for (const clientPromise of clients) { const client = await clientPromise; for await (const key of client.scanIterator({ MATCH: matchFilter })) { - yield this.#stripPrefix(key)!; + yield stripPrefix(this.#options.keyPrefix, key)!; } } } @@ -152,7 +153,7 @@ export class RedisCache extends BaseCache> { const scanNode = async (client: RedisClientType) => { let cursor = 0; do { - const res = await client.scan(cursor, { MATCH: this.#key(pattern) }); + const res = await client.scan(cursor, { MATCH: addPrefix(this.#options.keyPrefix, pattern) }); cursor = res.cursor; if (res.keys.length > 0) { await client.del(res.keys); @@ -165,22 +166,4 @@ export class RedisCache extends BaseCache> { 'masters' in client ? client.masters.map(async ({ client }) => scanNode(await client!)) : [scanNode(client)], ); } - - /** - * Return key including prefix. - * - * @param key Key without prefix. - */ - #key(key: string): string { - return (this.#options.keyPrefix ?? '') + key; - } - - #stripPrefix(key: string) { - const prefix = this.#options.keyPrefix ?? ''; - if (!key.startsWith(prefix)) { - return undefined; - } - - return key.substring(prefix.length); - } } diff --git a/tests/server/cache/base.test.ts b/tests/server/cache/base.test.ts new file mode 100644 index 0000000..fa16c84 --- /dev/null +++ b/tests/server/cache/base.test.ts @@ -0,0 +1,41 @@ +import { BaseCache } from '$lib/server/cache/base'; +import { InMemoryCache } from '$lib/server/cache/in-memory'; +import { beforeEach, describe, expect, it } from 'vitest'; + +describe(BaseCache.name, () => { + describe('remember', () => { + const cache = InMemoryCache.init({}); + beforeEach(async () => { + await cache.clear(); + await cache.set('foo', 'bar'); + }); + + it('should return the pre-existing value', async () => { + await expect( + cache.remember('foo', () => { + expect.unreachable(); + }), + ).resolves.equals('bar'); + }); + + it('should generate and remember a missing value', async () => { + await expect(cache.remember('bar', async () => 'new value')).resolves.equals('new value'); + await expect(cache.get('bar')).resolves.equals('new value'); + await expect(Array.fromAsync(cache.keys())).resolves.to.has.members(['foo', 'bar']); + }); + + it('should generate and remember a missing value', async () => { + await expect(cache.remember('bar', async () => undefined)).resolves.toBeUndefined(); + await expect(cache.get('bar')).resolves.toBeUndefined(); + await expect(Array.fromAsync(cache.keys())).resolves.to.has.members(['foo']); + }); + + it('should re-throw any errors thrown by the callback', async () => { + const reason = new Error('rejected because reasons'); + + await expect(cache.remember('bar', () => Promise.reject(reason))).rejects.toThrow(reason); + await expect(cache.get('bar')).resolves.toBeUndefined(); + await expect(Array.fromAsync(cache.keys())).resolves.to.has.members(['foo']); + }); + }); +}); diff --git a/tests/server/cache/in-memory.test.ts b/tests/server/cache/in-memory.test.ts new file mode 100644 index 0000000..16b26ff --- /dev/null +++ b/tests/server/cache/in-memory.test.ts @@ -0,0 +1,224 @@ +import { InMemoryCache } from '$lib/server/cache/in-memory'; +import kvjs from '@heyputer/kv.js'; +import { beforeEach, describe, expect, it } from 'vitest'; + +describe(InMemoryCache.name, () => { + it('should create an in-memory cache', () => { + expect(InMemoryCache.init({ defaultTTL: 200 })).to.be.an.instanceOf(InMemoryCache); + }); + + describe('child', () => { + it('should create key-prefixed distinct caches', async () => { + const base = InMemoryCache.init({}); + + const foo = base.child('foo:'); + expect(foo).to.be.an.instanceOf(InMemoryCache); + await expect(foo.set('answer', 42)).resolves.toBeUndefined(); + await expect(base.get('answer')).resolves.toBeUndefined(); + await expect(foo.get('answer')).resolves.equals(42); + await expect(base.get('foo:answer')).resolves.equals(42); + + const bar = base.child('bar:'); + expect(bar).to.be.an.instanceOf(InMemoryCache); + await expect(bar.set('answer', 'hello world')).resolves.toBeUndefined(); + await expect(base.get('answer')).resolves.toBeUndefined(); + await expect(bar.get('answer')).resolves.equals('hello world'); + await expect(base.get('bar:answer')).resolves.equals('hello world'); + await expect(foo.get('answer')).resolves.equals(42); + await expect(base.get('foo:answer')).resolves.equals(42); + + await expect(base.clearPattern('*:answer')).resolves.toBeUndefined(); + await expect(foo.get('answer')).resolves.toBeUndefined(); + await expect(bar.get('answer')).resolves.toBeUndefined(); + await expect(base.get('foo:answer')).resolves.toBeUndefined(); + await expect(bar.get('bar:answer')).resolves.toBeUndefined(); + }); + }); + + describe('get', () => { + const store = new kvjs(); + // @ts-expect-error We're deliberately using a private constructor here. + const cache = new InMemoryCache({ keyPrefix: 'foo:' }, store); + + beforeEach(() => { + store.flushall(); + store.set('bar', 'hello'); + store.set('baz', 'world!'); + store.set('foo:bar', 'baz'); + }); + + it('should return the correct value respecting prefix', async () => { + await expect(cache.get('bar')).resolves.equals('baz'); + }); + + it('should return undefined when the key is missing, respecting prefix', async () => { + await expect(cache.get('baz')).resolves.toBeUndefined(); + }); + }); + + describe('set', () => { + const store = new kvjs(); + // @ts-expect-error We're deliberately using a private constructor here. + const cache = new InMemoryCache({ keyPrefix: 'foo:' }, store); + + beforeEach(() => { + store.flushall(); + store.set('bar', 'hello'); + store.set('baz', 'world!'); + store.set('foo:bar', 'baz'); + }); + + it('should overwrite the correct value respecting prefix', async () => { + await expect(cache.set('bar', 'foo bar!')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members(['foo:bar']); + expect(store.keys('*')).to.have.members(['foo:bar', 'bar', 'baz']); + expect(store.get('bar')).to.equals('hello'); + expect(store.get('foo:bar')).to.equals('foo bar!'); + }); + + it('should create a new value respecting prefix', async () => { + await expect(cache.set('baz', 'foo bar!')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members(['foo:bar', 'foo:baz']); + expect(store.keys('*')).to.have.members(['foo:bar', 'foo:baz', 'bar', 'baz']); + expect(store.get('baz')).to.equals('world!'); + expect(store.get('foo:baz')).to.equals('foo bar!'); + }); + }); + + describe('delete', () => { + const store = new kvjs(); + // @ts-expect-error We're deliberately using a private constructor here. + const cache = new InMemoryCache({ keyPrefix: 'foo:' }, store); + + beforeEach(() => { + store.flushall(); + store.set('bar', 'hello'); + store.set('baz', 'world!'); + store.set('foo:bar', 'baz'); + }); + + it('should delete the correct value respecting prefix', async () => { + await expect(cache.delete('bar')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.not.have.members(['foo:bar']); + expect(store.keys('*')).to.have.members(['bar', 'baz']); + expect(store.get('bar')).to.equals('hello'); + expect(store.get('foo:bar')).toBeUndefined(); + }); + + it('should silently ignore request to delete an inexistent key', async () => { + await expect(cache.delete('baz')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members(['foo:bar']); + expect(store.keys('*')).to.have.members(['foo:bar', 'bar', 'baz']); + expect(store.get('baz')).to.equals('world!'); + expect(store.get('foo:baz')).toBeUndefined(); + }); + }); + + describe('keys', () => { + const store = new kvjs(); + // @ts-expect-error We're deliberately using a private constructor here. + const cache = new InMemoryCache({ keyPrefix: 'foo:' }, store); + + beforeEach(() => { + store.flushall(); + store.set('bar', 'hello'); + store.set('baz', 'world!'); + store.set('foo:bar', 'baz'); + store.set('foo:baz:1', 'one baz'); + store.set('foo:baz:2', 'two bazs'); + store.set('foo:baz:3', 'three bazs'); + }); + + it('should list all the keys in the cache respecting the base prefix', async () => { + const keys = []; + for await (const key of cache.keys()) { + keys.push(key); + } + + expect(keys).to.have.members(['bar', 'baz:1', 'baz:2', 'baz:3']); + }); + + it('should list all the keys in the cache that have the requested prefix, including the base', async () => { + const keys = []; + for await (const key of cache.keys('baz:')) { + keys.push(key); + } + + expect(keys).to.have.members(['baz:1', 'baz:2', 'baz:3']); + }); + }); + + describe('clear', () => { + const store = new kvjs(); + // @ts-expect-error We're deliberately using a private constructor here. + const cache = new InMemoryCache({ keyPrefix: 'foo:' }, store); + + beforeEach(() => { + store.flushall(); + store.set('bar', 'hello'); + store.set('baz', 'world!'); + store.set('foo:bar', 'baz'); + store.set('foo:baz:1', 'one baz'); + store.set('foo:baz:2', 'two bazs'); + store.set('foo:baz:3', 'three bazs'); + }); + + it('should delete all keys in the cache', async () => { + await expect(cache.clear()).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members([]); + expect(store.keys('*')).to.have.members(['bar', 'baz']); + }); + + it('should delete all keys in the cache with a requested prefix', async () => { + await expect(cache.clear('baz:')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members(['foo:bar']); + expect(store.keys('*')).to.have.members(['bar', 'baz', 'foo:bar']); + }); + + it('should silently ignore request to clear an inexistent prefix', async () => { + await expect(cache.clear('non-existent-prefix:')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members(['foo:bar', 'foo:baz:1', 'foo:baz:2', 'foo:baz:3']); + expect(store.keys('*')).to.have.members(['bar', 'baz', 'foo:bar', 'foo:baz:1', 'foo:baz:2', 'foo:baz:3']); + }); + }); + + describe('clearPattern', () => { + const store = new kvjs(); + // @ts-expect-error We're deliberately using a private constructor here. + const cache = new InMemoryCache({ keyPrefix: 'foo:' }, store); + + beforeEach(() => { + store.flushall(); + store.set('bar', 'hello'); + store.set('baz', 'world!'); + store.set('foo:bar', 'baz'); + store.set('foo:baz:1', 'one baz'); + store.set('foo:baz:2', 'two bazs'); + store.set('foo:baz:3', 'three bazs'); + }); + + it('should delete all keys in the cache', async () => { + await expect(cache.clearPattern('*')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members([]); + expect(store.keys('*')).to.have.members(['bar', 'baz']); + }); + + it('should delete all keys in the cache with the requested pattern', async () => { + await expect(cache.clearPattern('baz:*')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members(['foo:bar']); + expect(store.keys('*')).to.have.members(['bar', 'baz', 'foo:bar']); + }); + + it('should delete all keys in the cache with the requested with multiple wildcards', async () => { + await expect(cache.clearPattern('*:*')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members(['foo:bar']); + expect(store.keys('*')).to.have.members(['bar', 'baz', 'foo:bar']); + }); + + it('should silently ignore request to clear an inexistent pattern', async () => { + await expect(cache.clearPattern('non-existent-prefix:*')).resolves.toBeUndefined(); + expect(store.keys('foo:*')).to.have.members(['foo:bar', 'foo:baz:1', 'foo:baz:2', 'foo:baz:3']); + expect(store.keys('*')).to.have.members(['bar', 'baz', 'foo:bar', 'foo:baz:1', 'foo:baz:2', 'foo:baz:3']); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index b6d703f..be45546 100644 --- a/yarn.lock +++ b/yarn.lock @@ -431,6 +431,13 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@heyputer/kv.js@^0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@heyputer/kv.js/-/kv.js-0.1.9.tgz#23876bb85b1733b57ee25b50dcd229050a95fe41" + integrity sha512-4zmS/kMp/glMmw4h+OYkD+AribMAdfFj1/1AyLpO/bgQELjM7ZsGX7VeaqtbgiNAT5loeDd3ER9PtP9KUULuHg== + dependencies: + minimatch "^9.0.0" + "@humanwhocodes/config-array@^0.13.0": version "0.13.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" @@ -2161,7 +2168,7 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^9.0.4: +minimatch@^9.0.0, minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==