mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Create KeyValueStorage with a JSON file backend
This commit is contained in:
@@ -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';
|
||||
|
||||
93
src/storage/keyvalue/JsonFileStorage.ts
Normal file
93
src/storage/keyvalue/JsonFileStorage.ts
Normal file
@@ -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
test/unit/storage/keyvalue/JsonFileStorage.test.ts
Normal file
57
test/unit/storage/keyvalue/JsonFileStorage.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user