diff --git a/src/index.ts b/src/index.ts index 1facec8e7..77a916c75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -174,6 +174,7 @@ export * from './storage/conversion/RepresentationConverter'; export * from './storage/conversion/TypedRepresentationConverter'; // Storage/KeyValueStorage +export * from './storage/keyvalue/JsonFileStorage'; export * from './storage/keyvalue/JsonResourceStorage'; export * from './storage/keyvalue/KeyValueStorage'; export * from './storage/keyvalue/MemoryMapStorage'; diff --git a/src/storage/keyvalue/JsonFileStorage.ts b/src/storage/keyvalue/JsonFileStorage.ts new file mode 100644 index 000000000..a6c3580db --- /dev/null +++ b/src/storage/keyvalue/JsonFileStorage.ts @@ -0,0 +1,93 @@ +import { promises as fsPromises } from 'fs'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { isSystemError } from '../../util/errors/SystemError'; +import type { ReadWriteLocker } from '../../util/locking/ReadWriteLocker'; +import type { KeyValueStorage } from './KeyValueStorage'; + +/** + * Uses a JSON file to store key/value pairs. + */ +export class JsonFileStorage implements KeyValueStorage { + private readonly filePath: string; + private readonly locker: ReadWriteLocker; + private readonly lockIdentifier: ResourceIdentifier; + + public constructor(filePath: string, locker: ReadWriteLocker) { + this.filePath = filePath; + this.locker = locker; + + // Using file path as identifier for the lock as it should be unique for this file + this.lockIdentifier = { path: filePath }; + } + + public async get(key: string): Promise { + const json = await this.getJsonSafely(); + return json[key]; + } + + public async has(key: string): Promise { + const json = await this.getJsonSafely(); + return typeof json[key] !== 'undefined'; + } + + public async set(key: string, value: unknown): Promise { + return this.updateJsonSafely((json: NodeJS.Dict): this => { + json[key] = value; + return this; + }); + } + + public async delete(key: string): Promise { + return this.updateJsonSafely((json: NodeJS.Dict): boolean => { + if (typeof json[key] !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete json[key]; + return true; + } + return false; + }); + } + + public async* entries(): AsyncIterableIterator<[ string, unknown ]> { + const json = await this.getJsonSafely(); + yield* Object.entries(json); + } + + /** + * Acquires the data in the JSON file while using a read lock. + */ + private async getJsonSafely(): Promise> { + return this.locker.withReadLock(this.lockIdentifier, this.getJson.bind(this)); + } + + /** + * Updates the data in the JSON file while using a write lock. + * @param updateFn - A function that updates the JSON object. + * + * @returns The return value of `updateFn`. + */ + private async updateJsonSafely(updateFn: (json: NodeJS.Dict) => T): Promise { + return this.locker.withWriteLock(this.lockIdentifier, async(): Promise => { + const json = await this.getJson(); + const result = updateFn(json); + const updatedText = JSON.stringify(json, null, 2); + await fsPromises.writeFile(this.filePath, updatedText, 'utf8'); + return result; + }); + } + + /** + * Reads and parses the data from the JSON file (without locking). + */ + private async getJson(): Promise> { + try { + const text = await fsPromises.readFile(this.filePath, 'utf8'); + return JSON.parse(text); + } catch (error: unknown) { + if (isSystemError(error) && error.code === 'ENOENT') { + return {}; + } + throw error; + } + } +} diff --git a/test/unit/storage/keyvalue/JsonFileStorage.test.ts b/test/unit/storage/keyvalue/JsonFileStorage.test.ts new file mode 100644 index 000000000..9cce448b5 --- /dev/null +++ b/test/unit/storage/keyvalue/JsonFileStorage.test.ts @@ -0,0 +1,57 @@ +import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import { JsonFileStorage } from '../../../../src/storage/keyvalue/JsonFileStorage'; +import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker'; +import { mockFs } from '../../../util/Util'; + +jest.mock('fs'); + +describe('A JsonFileStorage', (): void => { + const rootFilePath = 'files/'; + const jsonPath = 'storage.json'; + let cache: { data: any }; + let locker: ReadWriteLocker; + let storage: JsonFileStorage; + + beforeEach(async(): Promise => { + cache = mockFs(rootFilePath); + locker = { + withReadLock: + jest.fn(async(identifier: ResourceIdentifier, whileLocked: () => any): Promise => await whileLocked()), + withWriteLock: + jest.fn(async(identifier: ResourceIdentifier, whileLocked: () => any): Promise => await whileLocked()), + }; + storage = new JsonFileStorage(`${rootFilePath}${jsonPath}`, locker); + }); + + it('can read and write data.', async(): Promise => { + const key = 'apple'; + const value = { taste: 'sweet' }; + await expect(storage.get(key)).resolves.toBeUndefined(); + await expect(storage.has(key)).resolves.toBe(false); + await expect(storage.delete(key)).resolves.toBe(false); + await expect(storage.set(key, value)).resolves.toBe(storage); + await expect(storage.get(key)).resolves.toEqual(value); + await expect(storage.has(key)).resolves.toBe(true); + expect(JSON.parse(cache.data[jsonPath])).toEqual({ apple: value }); + + const key2 = 'lemon'; + const value2 = { taste: 'sour' }; + await expect(storage.set(key2, value2)).resolves.toBe(storage); + await expect(storage.get(key2)).resolves.toEqual(value2); + await expect(storage.has(key2)).resolves.toBe(true); + expect(JSON.parse(cache.data[jsonPath])).toEqual({ apple: value, lemon: value2 }); + + const json = JSON.parse(cache.data[jsonPath]); + for await (const entry of storage.entries()) { + expect(json[entry[0]]).toEqual(entry[1]); + } + + await expect(storage.delete(key)).resolves.toBe(true); + expect(JSON.parse(cache.data[jsonPath])).toEqual({ lemon: value2 }); + }); + + it('throws an error if something goes wrong reading the JSON.', async(): Promise => { + cache.data[jsonPath] = '} very invalid {'; + await expect(storage.get('anything')).rejects.toThrow(Error); + }); +}); diff --git a/test/util/Util.ts b/test/util/Util.ts index 1661d8e82..d36b8e209 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -162,8 +162,15 @@ export function mockFs(rootFilepath?: string, time?: Date): { data: any } { }, readFile(path: string): string { const { folder, name } = getFolder(path); + if (!folder[name]) { + throwSystemError('ENOENT'); + } return folder[name]; }, + writeFile(path: string, data: string): void { + const { folder, name } = getFolder(path); + folder[name] = data; + }, }, };