diff --git a/config/storage/middleware/default.json b/config/storage/middleware/default.json index 6b4f041c5..5ccff216b 100644 --- a/config/storage/middleware/default.json +++ b/config/storage/middleware/default.json @@ -6,6 +6,12 @@ "files-scs:config/storage/middleware/stores/patching.json" ], "@graph": [ + { + "comment": "A cache to prevent duplicate existence checks on resources.", + "@id": "urn:solid-server:default:CachedResourceSet", + "@type": "CachedResourceSet", + "source": { "@id": "urn:solid-server:default:ResourceStore" } + }, { "comment": "Sets up a stack of utility stores used by most instances.", "@id": "urn:solid-server:default:ResourceStore", diff --git a/src/index.ts b/src/index.ts index 51bc27da2..2f30aa872 100644 --- a/src/index.ts +++ b/src/index.ts @@ -349,6 +349,7 @@ export * from './storage/validators/QuotaValidator'; export * from './storage/AtomicResourceStore'; export * from './storage/BaseResourceStore'; export * from './storage/BasicConditions'; +export * from './storage/CachedResourceSet'; export * from './storage/Conditions'; export * from './storage/DataAccessorBasedStore'; export * from './storage/IndexRepresentationStore'; diff --git a/src/storage/CachedResourceSet.ts b/src/storage/CachedResourceSet.ts new file mode 100644 index 000000000..0e81a967d --- /dev/null +++ b/src/storage/CachedResourceSet.ts @@ -0,0 +1,24 @@ +import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; +import type { ResourceSet } from './ResourceSet'; + +/** + * Caches resource existence in a `WeakMap` tied to the `ResourceIdentifier` object. + */ +export class CachedResourceSet implements ResourceSet { + private readonly source: ResourceSet; + private readonly cache: WeakMap; + + public constructor(source: ResourceSet) { + this.source = source; + this.cache = new WeakMap(); + } + + public async hasResource(identifier: ResourceIdentifier): Promise { + if (this.cache.has(identifier)) { + return this.cache.get(identifier)!; + } + const result = await this.source.hasResource(identifier); + this.cache.set(identifier, result); + return result; + } +} diff --git a/test/unit/storage/CachedResourceSet.test.ts b/test/unit/storage/CachedResourceSet.test.ts new file mode 100644 index 000000000..c609f9680 --- /dev/null +++ b/test/unit/storage/CachedResourceSet.test.ts @@ -0,0 +1,39 @@ +import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; +import { CachedResourceSet } from '../../../src/storage/CachedResourceSet'; +import type { ResourceSet } from '../../../src/storage/ResourceSet'; + +describe('A CachedResourceSet', (): void => { + const identifier: ResourceIdentifier = { path: 'http://example.com/foo' }; + let source: jest.Mocked; + let set: CachedResourceSet; + + beforeEach(async(): Promise => { + source = { + hasResource: jest.fn().mockResolvedValue(true), + }; + + set = new CachedResourceSet(source); + }); + + it('calls the source.', async(): Promise => { + await expect(set.hasResource(identifier)).resolves.toBe(true); + expect(source.hasResource).toHaveBeenCalledTimes(1); + expect(source.hasResource).toHaveBeenLastCalledWith(identifier); + }); + + it('caches the result.', async(): Promise => { + await expect(set.hasResource(identifier)).resolves.toBe(true); + await expect(set.hasResource(identifier)).resolves.toBe(true); + expect(source.hasResource).toHaveBeenCalledTimes(1); + expect(source.hasResource).toHaveBeenLastCalledWith(identifier); + }); + + it('caches on the identifier object itself.', async(): Promise => { + const copy = { ...identifier }; + await expect(set.hasResource(identifier)).resolves.toBe(true); + await expect(set.hasResource(copy)).resolves.toBe(true); + expect(source.hasResource).toHaveBeenCalledTimes(2); + expect(source.hasResource).toHaveBeenNthCalledWith(1, identifier); + expect(source.hasResource).toHaveBeenNthCalledWith(2, copy); + }); +});