Skip to content

Commit

Permalink
refactor(config): rename files & fix UT (#144)
Browse files Browse the repository at this point in the history
  • Loading branch information
fraxken authored Jul 20, 2024
1 parent 6d495cb commit 0068e47
Show file tree
Hide file tree
Showing 7 changed files with 335 additions and 263 deletions.
10 changes: 2 additions & 8 deletions src/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Create a simple json file for your project
Now, create a new Configuration instance and read it

```js
import { AsynchronousConfig } from "@openally/config";
import { AsynchronousConfig } from "@openally/config";

const config = new AsynchronousConfig("./path/to/config.json");
await config.read();
Expand Down Expand Up @@ -98,7 +98,7 @@ Available options are:
Will read the local configuration on disk. A default `payload` value can be provided in case the file doesn't exist !

> [!CAUTION]
> When the file doesn't exist, the configuration is written at the next loop iteration (with `lazyWriteOnDisk`).
> When the file doesn't exist, the configuration is written at the next loop iteration
### `AsynchronousConfig.setupHotReload(): void`

Expand Down Expand Up @@ -156,11 +156,5 @@ Return a deep clone of the configuration payload.

Write the configuration payload on the local disk.

### `AsynchronousConfig.lazyWriteOnDisk(): void`

Write the configuration payload on the local disk at the next loop iteration.

Use `configWritten` event to know when the configuration has been written on the disk.

## License
MIT
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ const kDefaultSchema = {
additionalProperties: true
};

type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>

export interface ConfigOptions<T> {
createOnNoEntry?: boolean;
autoReload?: boolean;
Expand All @@ -46,15 +44,19 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a
#createOnNoEntry: boolean;
#autoReload: boolean;
#writeOnSet: boolean;
#scheduledLazyWrite: NodeJS.Immediate;
#autoReloadActivated = false;
#configHasBeenRead = false;
#subscriptionObservers: ([string, ZenObservable.SubscriptionObserver<any>])[] = [];
#jsonSchema?: object;
#jsonSchema?: JSONSchemaType<T>;
#cleanupTimeout: NodeJS.Timeout;
#watcherSignal = new AbortController();
#fs!: WithRequired<ConfigOptions<T>, "fs">["fs"];
#watcher: nodeFs.FSWatcher;
#fs: Required<ConfigOptions<T>>["fs"];

constructor(configFilePath: string, options: ConfigOptions<T> = Object.create(null)) {
constructor(
configFilePath: string,
options: ConfigOptions<T> = Object.create(null)
) {
super();
if (typeof configFilePath !== "string") {
throw new TypeError("The configPath must be a string");
Expand Down Expand Up @@ -131,15 +133,16 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a
if (isDeepStrictEqual(this[kPayloadIdentifier], newPayload)) {
return;
}
const isValidPayload = this[kSchemaIdentifier](structuredClone(newPayload));
if (isValidPayload === false) {

const tempPayload = structuredClone(newPayload);
if (this[kSchemaIdentifier](tempPayload) === false) {
const ajvErrors = utils.formatAjvErrors(this[kSchemaIdentifier].errors);
const errorMessage = `Config.payload (setter) - AJV Validation failed with error(s) => ${ajvErrors}`;

throw new Error(errorMessage);
}

this[kPayloadIdentifier] = newPayload;
this[kPayloadIdentifier] = tempPayload;
for (const [fieldPath, subscriptionObservers] of this.#subscriptionObservers) {
subscriptionObservers.next(this.get(fieldPath));
}
Expand Down Expand Up @@ -185,8 +188,7 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a
JSONSchema = JSON.parse(schemaFileContent);
}
catch (err) {
const fileExists = Reflect.has(err, "code") && err.code !== "ENOENT";
if (fileExists) {
if (Reflect.has(err, "code") && err.code !== "ENOENT") {
throw err;
}

Expand All @@ -198,6 +200,9 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a

if (!this.#configHasBeenRead) {
// Cleanup closed subscription every second
if (this.#cleanupTimeout) {
clearInterval(this.#cleanupTimeout);
}
this.#cleanupTimeout = setInterval(() => {
this.#subscriptionObservers = this.#subscriptionObservers.filter(
([, subscriptionObservers]) => !subscriptionObservers.closed
Expand All @@ -207,7 +212,14 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a
}

this.#configHasBeenRead = true;
this.payload = JSONConfig;

try {
this.payload = JSONConfig;
}
catch (error) {
this.#configHasBeenRead = false;
throw error;
}

// Write the configuraton on the disk for the first time (if there is no one available!).
if (writeOnDisk) {
Expand All @@ -217,7 +229,7 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a
this.removeListener("configWritten", autoReload);
});
this.once("configWritten", autoReload);
this.lazyWriteOnDisk();
this.#lazyWriteOnDisk();
}
else {
this.setupAutoReload();
Expand All @@ -235,25 +247,38 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a
return false;
}

this.#fs.watch(this.#configFilePath, { signal: this.#watcherSignal.signal }, async() => {
try {
if (!this.#configHasBeenRead) {
return;
this.#watcher = this.#fs.watch(
this.#configFilePath,
{ persistent: false },
async() => {
try {
if (!this.#configHasBeenRead) {
return;
}
await this.read();
this.emit("reload");
}
catch (err) {
this.emit("error", err);
}
await this.read();
this.emit("reload");
}
catch (err) {
this.emit("error", err);
}
});
);
this.#autoReloadActivated = true;

this.emit("watcherInitialized");

return true;
}

observableOf(fieldPath: string, depth = Infinity) {
const fieldValue = this.get(fieldPath, depth);

return new Observable((observer) => {
observer.next(fieldValue);
this.#subscriptionObservers.push([fieldPath, observer]);
});
}

get<Y = any>(fieldPath: string, depth = Infinity): Y | null {
if (!this.#configHasBeenRead) {
throw new Error("You must read config first before getting a field!");
Expand All @@ -273,15 +298,6 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a
return value;
}

observableOf(fieldPath: string, depth = Infinity) {
const fieldValue = this.get(fieldPath, depth);

return new Observable((observer) => {
observer.next(fieldValue);
this.#subscriptionObservers.push([fieldPath, observer]);
});
}

set(fieldPath: string, fieldValue: any) {
if (!this.#configHasBeenRead) {
throw new Error("You must read config first before setting a field!");
Expand All @@ -291,10 +307,8 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a
}

this.payload = utils.deepSet<T>(this.payload, fieldPath, fieldValue);
Object.assign(this.payload, { [fieldPath]: fieldValue });

if (this.#writeOnSet) {
this.lazyWriteOnDisk();
this.#lazyWriteOnDisk();
}

return this;
Expand All @@ -305,46 +319,43 @@ export class AsynchronousConfig<T extends Record<string, any> = Record<string, a
throw new Error("You must read config first before writing it on the disk!");
}

const data = this.#isTOML ? TOML.stringify(this[kPayloadIdentifier]) : JSON.stringify(this[kPayloadIdentifier], null, 2);
const data = this.#isTOML ?
TOML.stringify(this[kPayloadIdentifier]) :
JSON.stringify(this[kPayloadIdentifier], null, 2);
await this.#fs.promises.writeFile(this.#configFilePath, data);

this.emit("configWritten");
}

lazyWriteOnDisk() {
if (!this.#configHasBeenRead) {
throw new Error("You must read config first before writing it on the disk!");
#lazyWriteOnDisk(): void {
if (this.#scheduledLazyWrite) {
clearImmediate(this.#scheduledLazyWrite);
}

setImmediate(async() => {
try {
await this.writeOnDisk();
}
catch (error) {
this.emit("error", error);
}
});
this.#scheduledLazyWrite = setImmediate(
() => this.writeOnDisk().catch((error) => this.emit("error", error))
);
}
async close() {

async close(): Promise<void> {
if (!this.#configHasBeenRead) {
throw new Error("Cannot close unreaded configuration");
return;
}

clearImmediate(this.#scheduledLazyWrite);
if (this.#autoReloadActivated) {
this.#watcherSignal.abort();
this.#watcher.close();
this.#autoReloadActivated = false;
}

await this.writeOnDisk();
this.#configHasBeenRead = false;

for (const [, subscriptionObservers] of this.#subscriptionObservers) {
subscriptionObservers.complete();
}
this.#subscriptionObservers = [];

clearInterval(this.#cleanupTimeout);

await this.writeOnDisk();
this.#configHasBeenRead = false;

this.emit("close");
}
}
6 changes: 1 addition & 5 deletions src/config/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,2 @@
// Import Internal Dependencies
import { AsynchronousConfig } from "./asyncConfig.js";

export {
AsynchronousConfig
};
export * from "./AsynchronousConfig.class.js";
Loading

0 comments on commit 0068e47

Please sign in to comment.