-
Notifications
You must be signed in to change notification settings - Fork 125
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Create KeyValueStorage with a JSON file backend
- Loading branch information
Showing
4 changed files
with
158 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters