Skip to content

Commit

Permalink
feat: Create KeyValueStorage with a JSON file backend
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Mar 2, 2021
1 parent 28b077b commit 6288003
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
93 changes: 93 additions & 0 deletions src/storage/keyvalue/JsonFileStorage.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<unknown | undefined> {
const json = await this.getJsonSafely();
return json[key];
}

public async has(key: string): Promise<boolean> {
const json = await this.getJsonSafely();
return typeof json[key] !== 'undefined';
}

public async set(key: string, value: unknown): Promise<this> {
return this.updateJsonSafely((json: NodeJS.Dict<unknown>): this => {
json[key] = value;
return this;
});
}

public async delete(key: string): Promise<boolean> {
return this.updateJsonSafely((json: NodeJS.Dict<unknown>): 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<NodeJS.Dict<unknown>> {
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<T>(updateFn: (json: NodeJS.Dict<unknown>) => T): Promise<T> {
return this.locker.withWriteLock(this.lockIdentifier, async(): Promise<T> => {
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<NodeJS.Dict<unknown>> {
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;
}
}
}
57 changes: 57 additions & 0 deletions test/unit/storage/keyvalue/JsonFileStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
cache = mockFs(rootFilePath);
locker = {
withReadLock:
jest.fn(async(identifier: ResourceIdentifier, whileLocked: () => any): Promise<any> => await whileLocked()),
withWriteLock:
jest.fn(async(identifier: ResourceIdentifier, whileLocked: () => any): Promise<any> => await whileLocked()),
};
storage = new JsonFileStorage(`${rootFilePath}${jsonPath}`, locker);
});

it('can read and write data.', async(): Promise<void> => {
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<void> => {
cache.data[jsonPath] = '} very invalid {';
await expect(storage.get('anything')).rejects.toThrow(Error);
});
});
7 changes: 7 additions & 0 deletions test/util/Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
},
};

Expand Down

0 comments on commit 6288003

Please sign in to comment.