From 6caf3afd098ae892b0afdb54e7b792941afbe195 Mon Sep 17 00:00:00 2001 From: Cody Tseng <64680921+CodyTseng@users.noreply.github.com> Date: Sun, 18 Aug 2024 14:44:21 +0800 Subject: [PATCH] feat: rxjs (#29) --- package-lock.json | 16 +-- .../event-repository.interface.spec.ts | 38 +++-- packages/common/__test__/utils/event.spec.ts | 4 +- packages/common/package.json | 3 +- .../interfaces/event-repository.interface.ts | 31 +++- packages/common/src/utils/index.ts | 3 +- packages/common/src/utils/rxjs.util.ts | 12 ++ packages/core/__test__/nostr-relay.spec.ts | 13 +- .../__test__/services/event.service.spec.ts | 136 ++++++++++-------- .../core/__test__/utils/lazy-cache.spec.ts | 8 +- packages/core/package.json | 3 +- packages/core/src/nostr-relay.ts | 22 +-- packages/core/src/services/event.service.ts | 77 ++++------ packages/core/src/utils/lazy-cache.ts | 4 + 14 files changed, 217 insertions(+), 153 deletions(-) create mode 100644 packages/common/src/utils/rxjs.util.ts diff --git a/package-lock.json b/package-lock.json index 04c4661d..cc987fd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3347,9 +3347,9 @@ "dev": true }, "node_modules/axios": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz", - "integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dev": true, "dependencies": { "follow-redirects": "^1.15.6", @@ -9783,7 +9783,6 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, "dependencies": { "tslib": "^2.1.0" } @@ -10590,8 +10589,7 @@ "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tuf-js": { "version": "2.2.1", @@ -11362,7 +11360,8 @@ "license": "MIT", "dependencies": { "@noble/curves": "^1.2.0", - "lru-cache": "^10.1.0" + "lru-cache": "^10.1.0", + "rxjs": "^7.8.1" } }, "packages/common/node_modules/lru-cache": { @@ -11378,7 +11377,8 @@ "version": "0.0.28", "license": "MIT", "dependencies": { - "lru-cache": "^10.1.0" + "lru-cache": "^10.1.0", + "rxjs": "^7.8.1" }, "devDependencies": { "@nostr-relay/common": "^0.0.28" diff --git a/packages/common/__test__/interfaces/event-repository.interface.spec.ts b/packages/common/__test__/interfaces/event-repository.interface.spec.ts index 6b77649a..d36217d5 100644 --- a/packages/common/__test__/interfaces/event-repository.interface.spec.ts +++ b/packages/common/__test__/interfaces/event-repository.interface.spec.ts @@ -1,4 +1,5 @@ -import { EventRepository } from '../../src'; +import { EMPTY, from, Observable } from 'rxjs'; +import { EventRepository, Filter, toPromise } from '../../src'; describe('EventRepository', () => { let eventRepository: EventRepository; @@ -17,11 +18,7 @@ describe('EventRepository', () => { expect(await eventRepository.findOne({})).toBeNull(); expect(eventRepository.find).toHaveBeenCalledWith({ limit: 1 }); - eventRepository.find = jest.fn().mockResolvedValue([]); - expect(await eventRepository.findOne({})).toBeNull(); - expect(eventRepository.find).toHaveBeenCalledWith({ limit: 1 }); - - eventRepository.find = jest.fn().mockReturnValue([]); + eventRepository.find = jest.fn().mockReturnValue(EMPTY); expect(await eventRepository.findOne({})).toBeNull(); expect(eventRepository.find).toHaveBeenCalledWith({ limit: 1 }); }); @@ -36,13 +33,34 @@ describe('EventRepository', () => { expect(await eventRepository.findOne({})).toEqual(event); expect(eventRepository.find).toHaveBeenCalledWith({ limit: 1 }); - eventRepository.find = jest.fn().mockResolvedValue([event]); + eventRepository.find = jest.fn().mockReturnValue(from([event])); expect(await eventRepository.findOne({})).toEqual(event); expect(eventRepository.find).toHaveBeenCalledWith({ limit: 1 }); + }); + }); - eventRepository.find = jest.fn().mockReturnValue([event]); - expect(await eventRepository.findOne({})).toEqual(event); - expect(eventRepository.find).toHaveBeenCalledWith({ limit: 1 }); + describe('find$', () => { + it('should return find result', async () => { + const filter = {} as Filter; + const events = [{ id: 'a' }, { id: 'b' }]; + + eventRepository.find = jest.fn().mockReturnValue(events); + const obs1 = eventRepository.find$(filter); + expect(obs1 instanceof Observable).toBeTruthy(); + expect(await toPromise(obs1)).toEqual(events); + expect(eventRepository.find).toHaveBeenCalledWith(filter); + + eventRepository.find = jest.fn().mockResolvedValue(events); + const obs2 = eventRepository.find$(filter); + expect(obs2 instanceof Observable).toBeTruthy(); + expect(await toPromise(obs2)).toEqual(events); + expect(eventRepository.find).toHaveBeenCalledWith(filter); + + eventRepository.find = jest.fn().mockReturnValue(from(events)); + const obs3 = eventRepository.find$(filter); + expect(obs3 instanceof Observable).toBeTruthy(); + expect(await toPromise(obs3)).toEqual(events); + expect(eventRepository.find).toHaveBeenCalledWith(filter); }); }); }); diff --git a/packages/common/__test__/utils/event.spec.ts b/packages/common/__test__/utils/event.spec.ts index 3dc50f16..7fd22f21 100644 --- a/packages/common/__test__/utils/event.spec.ts +++ b/packages/common/__test__/utils/event.spec.ts @@ -47,7 +47,7 @@ describe('EventUtils', () => { expect( EventUtils.validate( - createEvent({ created_at: getTimestampInSeconds() + 101 }), + createEvent({ created_at: getTimestampInSeconds() + 200 }), { createdAtUpperLimit: 100 }, ), ).toBe( @@ -55,7 +55,7 @@ describe('EventUtils', () => { ); expect( EventUtils.validate( - createEvent({ created_at: getTimestampInSeconds() - 101 }), + createEvent({ created_at: getTimestampInSeconds() - 200 }), { createdAtLowerLimit: 100 }, ), ).toBe( diff --git a/packages/common/package.json b/packages/common/package.json index 9dbfc7de..fb1d595f 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@noble/curves": "^1.2.0", - "lru-cache": "^10.1.0" + "lru-cache": "^10.1.0", + "rxjs": "^7.8.1" } } diff --git a/packages/common/src/interfaces/event-repository.interface.ts b/packages/common/src/interfaces/event-repository.interface.ts index 25c7df60..c0c4fdb8 100644 --- a/packages/common/src/interfaces/event-repository.interface.ts +++ b/packages/common/src/interfaces/event-repository.interface.ts @@ -1,3 +1,4 @@ +import { firstValueFrom, Observable } from 'rxjs'; import { Event } from './event.interface'; import { Filter } from './filter.interface'; @@ -40,7 +41,7 @@ export abstract class EventRepository { * * @param filter Query filter */ - abstract find(filter: Filter): Promise | Event[]; + abstract find(filter: Filter): Promise | Observable | Event[]; /** * This method is called when the event repository should be closed. You can @@ -55,7 +56,33 @@ export abstract class EventRepository { * @param filter Query filter */ async findOne(filter: Filter): Promise { - const [event] = await this.find({ ...filter, limit: 1 }); + const query = this.find({ ...filter, limit: 1 }); + if (query instanceof Observable) { + return await firstValueFrom(query).catch(() => null); + } + const [event] = await query; return event ?? null; } + + /** + * This method doesn't need to be implemented. It's just a helper method for + * transforming the `find` method to an observable. + * + * @param filter Query filter + */ + find$(filter: Filter): Observable { + const query = this.find(filter); + if (query instanceof Observable) { + return query; + } + return new Observable(subscriber => { + (async (): Promise => { + const events = await query; + for (const event of events) { + subscriber.next(event); + } + subscriber.complete(); + })(); + }); + } } diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 64b5d5e2..6b070d46 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -2,5 +2,6 @@ export * from './crypto.util'; export * from './event.util'; export * from './filter.util'; export * from './proof-of-work.util'; -export * from './time.util'; +export * from './rxjs.util'; export * from './shared.util'; +export * from './time.util'; diff --git a/packages/common/src/utils/rxjs.util.ts b/packages/common/src/utils/rxjs.util.ts new file mode 100644 index 00000000..effc0bee --- /dev/null +++ b/packages/common/src/utils/rxjs.util.ts @@ -0,0 +1,12 @@ +import { Observable } from 'rxjs'; + +export function toPromise(observable: Observable): Promise { + return new Promise((resolve, reject) => { + const values: T[] = []; + observable.subscribe({ + next: value => values.push(value), + error: reject, + complete: () => resolve(values), + }); + }); +} diff --git a/packages/core/__test__/nostr-relay.spec.ts b/packages/core/__test__/nostr-relay.spec.ts index 7db83e5f..a6982507 100644 --- a/packages/core/__test__/nostr-relay.spec.ts +++ b/packages/core/__test__/nostr-relay.spec.ts @@ -1,3 +1,4 @@ +import { from } from 'rxjs'; import { Client, ClientContext, @@ -166,8 +167,8 @@ describe('NostrRelay', () => { .spyOn(nostrRelay['subscriptionService'], 'subscribe') .mockImplementation(); const mockFind = jest - .spyOn(nostrRelay['eventService'], 'find') - .mockResolvedValue(events); + .spyOn(nostrRelay['eventService'], 'find$') + .mockReturnValue(from(events)); const result = await nostrRelay.handleReqMessage( client, @@ -230,8 +231,8 @@ describe('NostrRelay', () => { .spyOn(nostrRelay['subscriptionService'], 'subscribe') .mockImplementation(); const mockFind = jest - .spyOn(nostrRelay['eventService'], 'find') - .mockResolvedValue(events); + .spyOn(nostrRelay['eventService'], 'find$') + .mockReturnValue(from(events)); const result = await nostrRelay.handleReqMessage( client, @@ -263,8 +264,8 @@ describe('NostrRelay', () => { .spyOn(nostrRelayWithoutHostname['subscriptionService'], 'subscribe') .mockImplementation(); const mockFind = jest - .spyOn(nostrRelayWithoutHostname['eventService'], 'find') - .mockResolvedValue(events); + .spyOn(nostrRelayWithoutHostname['eventService'], 'find$') + .mockReturnValue(from(events)); const result = await nostrRelayWithoutHostname.handleReqMessage( client, diff --git a/packages/core/__test__/services/event.service.spec.ts b/packages/core/__test__/services/event.service.spec.ts index 19d3a86f..fd213b24 100644 --- a/packages/core/__test__/services/event.service.spec.ts +++ b/packages/core/__test__/services/event.service.spec.ts @@ -1,3 +1,12 @@ +import { + EMPTY, + firstValueFrom, + from, + interval, + lastValueFrom, + map, + take, +} from 'rxjs'; import { ClientContext, ClientReadyState, @@ -7,6 +16,7 @@ import { EventRepository, EventUtils, Filter, + toPromise, } from '../../../common'; import { EventService } from '../../src/services/event.service'; import { PluginManagerService } from '../../src/services/plugin-manager.service'; @@ -26,6 +36,7 @@ describe('eventService', () => { find: jest.fn(), findOne: jest.fn(), destroy: jest.fn(), + find$: jest.fn(), }; subscriptionService = new SubscriptionService( new Map(), @@ -49,33 +60,39 @@ describe('eventService', () => { }); }); - describe('find', () => { + describe('find$', () => { it('should return find result', async () => { const filters = [{}] as Filter[]; const events = [{ id: 'a' }, { id: 'b' }] as Event[]; - jest.spyOn(eventRepository, 'find').mockResolvedValue(events); - expect(await eventService.find(filters)).toEqual(events); - - jest.spyOn(eventRepository, 'find').mockResolvedValue(events); - expect(await eventService.find(filters)).toEqual(events); - - jest.spyOn(eventRepository, 'find').mockReturnValue(events); - expect(await eventService.find(filters)).toEqual(events); + jest.spyOn(eventRepository, 'find$').mockReturnValue(from(events)); + expect(await toPromise(eventService.find$(filters))).toEqual(events); - jest.spyOn(eventRepository, 'find').mockReturnValue(events); - expect(await eventService.find(filters)).toEqual(events); - - expect(await eventService.find([{ search: 'test' }])).toEqual([]); + expect(await toPromise(eventService.find$([{ search: 'test' }]))).toEqual( + [], + ); }); it('should return distinct result', async () => { const filters = [{}] as Filter[]; const events = [{ id: 'a' }, { id: 'a' }] as Event[]; - jest.spyOn(eventRepository, 'find').mockResolvedValue(events); + jest.spyOn(eventRepository, 'find$').mockReturnValue(from(events)); - expect(await eventService.find(filters)).toEqual([events[0]]); + expect(await toPromise(eventService.find$(filters))).toEqual([events[0]]); + }); + + it('should merge multiple results and return distinct result', async () => { + jest + .spyOn(eventRepository, 'find$') + .mockReturnValueOnce(from([{ id: 'a' }, { id: 'b' }] as Event[])); + jest + .spyOn(eventRepository, 'find$') + .mockReturnValueOnce(from([{ id: 'b' }, { id: 'c' }] as Event[])); + + expect(await toPromise(eventService.find$([{}, {}] as Filter[]))).toEqual( + [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + ); }); it('should use cache', async () => { @@ -86,12 +103,49 @@ describe('eventService', () => { new ConsoleLoggerService(), ); const filters = [{}, {}] as Filter[]; - const events = [{ id: 'a' }, { id: 'b' }] as Event[]; + const events = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] as Event[]; + + const events$ = interval(10).pipe( + take(events.length), + map(i => events[i]), + ); + const fakeFind$ = jest + .spyOn(eventRepository, 'find$') + .mockReturnValue(events$); + + expect(await toPromise(eventServiceWithCache.find$(filters))).toEqual( + events, + ); + + await firstValueFrom(events$); + expect(await toPromise(eventServiceWithCache.find$(filters))).toEqual( + events, + ); - jest.spyOn(eventRepository, 'find').mockResolvedValue(events); + await lastValueFrom(events$); + expect(await toPromise(eventServiceWithCache.find$(filters))).toEqual( + events, + ); + expect(fakeFind$).toHaveBeenCalledTimes(1); + }); - expect(await eventServiceWithCache.find(filters)).toEqual(events); - expect(eventRepository.find).toHaveBeenCalledTimes(1); + it('should return empty array if no match', async () => { + const eventServiceWithCache = new EventService( + eventRepository, + subscriptionService, + pluginManagerService, + new ConsoleLoggerService(), + ); + const filters = [{}, {}] as Filter[]; + + const fakeFind$ = jest + .spyOn(eventRepository, 'find$') + .mockReturnValue(EMPTY); + + expect(await toPromise(eventServiceWithCache.find$(filters))).toEqual([]); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(await toPromise(eventServiceWithCache.find$(filters))).toEqual([]); + expect(fakeFind$).toHaveBeenCalledTimes(1); }); }); @@ -212,50 +266,6 @@ describe('eventService', () => { }); }); - describe('mergeSortedEventArrays', () => { - it('should return empty array if no arrays are provided', () => { - expect(eventService['mergeSortedEventArrays']([])).toEqual([]); - }); - - it('should merge sorted arrays', () => { - const arrays = [ - [ - { id: 'a', created_at: 10 }, - { id: 'b', created_at: 9 }, - { id: 'b', created_at: 9 }, - { id: 'c', created_at: 8 }, - { id: 'c', created_at: 8 }, - { id: 'd', created_at: 8 }, - { id: 'e', created_at: 7 }, - ], - [ - { id: 'b', created_at: 9 }, - { id: 'c', created_at: 8 }, - { id: 'd', created_at: 8 }, - { id: 'f', created_at: 6 }, - ], - [ - { id: 'a', created_at: 10 }, - { id: 'b', created_at: 9 }, - { id: 'c', created_at: 8 }, - { id: 'g', created_at: 5 }, - { id: 'h', created_at: 4 }, - ], - ] as Event[][]; - - expect(eventService['mergeSortedEventArrays'](arrays)).toEqual([ - { id: 'a', created_at: 10 }, - { id: 'b', created_at: 9 }, - { id: 'c', created_at: 8 }, - { id: 'd', created_at: 8 }, - { id: 'e', created_at: 7 }, - { id: 'f', created_at: 6 }, - { id: 'g', created_at: 5 }, - { id: 'h', created_at: 4 }, - ]); - }); - }); - describe('destroy', () => { it('should destroy successfully', async () => { const eventServiceWithCache = new EventService( diff --git a/packages/core/__test__/utils/lazy-cache.spec.ts b/packages/core/__test__/utils/lazy-cache.spec.ts index 86189df4..bcda8866 100644 --- a/packages/core/__test__/utils/lazy-cache.spec.ts +++ b/packages/core/__test__/utils/lazy-cache.spec.ts @@ -1,7 +1,7 @@ import { LazyCache } from '../../src/utils'; describe('LazyCache', () => { - let cache: LazyCache>; + let cache: LazyCache | string>; beforeEach(() => { cache = new LazyCache({ max: 1000 }); @@ -26,6 +26,12 @@ describe('LazyCache', () => { }); }); + it('set', () => { + cache.get('hello', async () => 'world'); + cache.set('hello', 'world2'); + expect(cache['cache'].get('hello')).toBe('world2'); + }); + it('clear', () => { cache.get('hello', async () => 'world'); expect(cache['cache'].size).toBe(1); diff --git a/packages/core/package.json b/packages/core/package.json index 9e0b878f..82e68e53 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,7 +28,8 @@ "url": "https://github.com/CodyTseng/nostr-relay/issues" }, "dependencies": { - "lru-cache": "^10.1.0" + "lru-cache": "^10.1.0", + "rxjs": "^7.8.1" }, "devDependencies": { "@nostr-relay/common": "^0.0.28" diff --git a/packages/core/src/nostr-relay.ts b/packages/core/src/nostr-relay.ts index 8205ac69..6bd98545 100644 --- a/packages/core/src/nostr-relay.ts +++ b/packages/core/src/nostr-relay.ts @@ -361,7 +361,6 @@ export class NostrRelay { pubkey?: string, iteratee?: (event: Event) => void, ): Promise { - const events: Event[] = []; if ( this.hostname && filters.some(filter => @@ -374,15 +373,20 @@ export class NostrRelay { ); } - (await this.eventService.find(filters)).forEach(event => { - if (this.hostname && !EventUtils.checkPermission(event, pubkey)) { - return; - } - events.push(event); - iteratee?.(event); + return new Promise((resolve, reject) => { + const events: Event[] = []; + this.eventService.find$(filters).subscribe({ + next: event => { + if (this.hostname && !EventUtils.checkPermission(event, pubkey)) { + return; + } + events.push(event); + iteratee?.(event); + }, + error: reject, + complete: () => resolve(events), + }); }); - - return events; } private getClientContext(client: Client): ClientContext { diff --git a/packages/core/src/services/event.service.ts b/packages/core/src/services/event.service.ts index bea4c14c..17bda7b4 100644 --- a/packages/core/src/services/event.service.ts +++ b/packages/core/src/services/event.service.ts @@ -8,6 +8,7 @@ import { HandleEventResult, Logger, } from '@nostr-relay/common'; +import { distinct, EMPTY, from, merge, Observable, shareReplay } from 'rxjs'; import { LazyCache } from '../utils'; import { PluginManagerService } from './plugin-manager.service'; import { SubscriptionService } from './subscription.service'; @@ -25,7 +26,7 @@ export class EventService { private readonly pluginManagerService: PluginManagerService; private readonly logger: Logger; private readonly findLazyCache?: - | LazyCache> + | LazyCache | Event[]> | undefined; private readonly createdAtUpperLimit: number | undefined; private readonly createdAtLowerLimit: number | undefined; @@ -55,11 +56,10 @@ export class EventService { } } - async find(filters: Filter[]): Promise { - const arrays = await Promise.all( - filters.map(filter => this.findByFilter(filter)), + find$(filters: Filter[]): Observable { + return merge(...filters.map(filter => this.findByFilter$(filter))).pipe( + distinct(event => event.id), ); - return this.mergeSortedEventArrays(arrays); } async handleEvent(event: Event): Promise { @@ -115,20 +115,36 @@ export class EventService { } } - private async findByFilter(filter: Filter): Promise { - const callback = async (): Promise => { + private findByFilter$(filter: Filter): Observable { + const callback = (): Observable => { if ( filter.search !== undefined && !this.eventRepository.isSearchSupported() ) { - return []; + return EMPTY; } - return await this.eventRepository.find(filter); + const share$ = this.eventRepository + .find$(filter) + .pipe(shareReplay({ refCount: true })); + + setImmediate(() => { + const events: Event[] = []; + share$.subscribe({ + next: event => events.push(event), + complete: () => { + this.findLazyCache?.set(JSON.stringify(filter), events); + }, + }); + }); + + return share$; }; - return this.findLazyCache - ? await this.findLazyCache.get(JSON.stringify(filter), callback) - : await callback(); + if (this.findLazyCache) { + const cache = this.findLazyCache.get(JSON.stringify(filter), callback); + return cache instanceof Observable ? cache : from(cache); + } + return callback(); } private async handleEphemeralEvent(event: Event): Promise { @@ -161,43 +177,6 @@ export class EventService { ); } - private mergeSortedEventArrays(arrays: Event[][]): Event[] { - if (arrays.length === 0) { - return []; - } - - function merge(left: Event[], right: Event[]): Event[] { - const result: Event[] = []; - let leftIndex = 0; - let rightIndex = 0; - - while (leftIndex < left.length && rightIndex < right.length) { - const leftEvent = left[leftIndex]; - const rightEvent = right[rightIndex]; - if ( - leftEvent.created_at > rightEvent.created_at || - (leftEvent.created_at === rightEvent.created_at && - leftEvent.id < rightEvent.id) - ) { - result.push(leftEvent); - leftIndex++; - } else { - result.push(rightEvent); - rightIndex++; - } - } - - return result.concat(left.slice(leftIndex), right.slice(rightIndex)); - } - - let result: Event[] = arrays[0]; - for (let i = 1; i < arrays.length; i++) { - result = merge(result, arrays[i]); - } - - return result.filter((e, i, a) => i === 0 || e.id !== a[i - 1]?.id); - } - async destroy(): Promise { this.findLazyCache?.clear(); } diff --git a/packages/core/src/utils/lazy-cache.ts b/packages/core/src/utils/lazy-cache.ts index e09b084e..02a9dcaf 100644 --- a/packages/core/src/utils/lazy-cache.ts +++ b/packages/core/src/utils/lazy-cache.ts @@ -14,6 +14,10 @@ export class LazyCache { return this.cache.get(key)!; } + set(key: K, value: V): void { + this.cache.set(key, value); + } + clear(): void { this.cache.clear(); }