Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 145 additions & 61 deletions lib/collector.js
Original file line number Diff line number Diff line change
@@ -1,73 +1,105 @@
'use strict';

class Collector {
done = false;
data = {};
keys = [];
count = 0;
exact = true;
reassign = false;
timeout = 0;
defaults = {};
validate = null;
#done = false;
#data = {};
#keys = [];
#count = 0;
#exact = true;
#reassign = false;
#timeout = 0;
#defaults = {};
#validate = null;
#fulfill = null;
#reject = null;
#cause = null;
#controller = null;
#signal = null;
#timeout = null;
#timeoutSignal = null;

constructor(keys, options = {}) {
const { exact = true, reassign = false } = options;
const { timeout = 0, defaults = {}, validate } = options;
if (validate) this.validate = validate;
this.keys = keys;
if (exact === false) this.exact = false;
if (reassign === false) this.reassign = reassign;
if (typeof defaults === 'object') this.defaults = defaults;
if (!Array.isArray(keys)) {
throw new TypeError('Collector: keys must be an array');
}
for (const key of keys) {
if (typeof key !== 'string' && typeof key !== 'symbol') {
throw new TypeError('Collector: key must be a string or symbol');
}
}

const {
exact = true,
reassign = false,
timeout = 0,
defaults = {},
validate,
} = options;

if (
typeof timeout !== 'number' ||
timeout < 0 ||
!Number.isFinite(timeout)
) {
throw new TypeError('Collector: timeout must be a non-negative number');
}
if (
defaults === null ||
typeof defaults !== 'object' ||
Array.isArray(defaults)
) {
throw new TypeError('Collector: defaults must be a plain object');
}
if (validate !== undefined && typeof validate !== 'function') {
throw new TypeError('Collector: validate must be a function');
}

this.#keys = [...keys];
this.#exact = exact !== false;
this.#reassign = reassign === true;
this.#timeout = timeout;
this.#defaults = { ...defaults };
if (validate) this.#validate = validate;
this.#controller = new AbortController();
this.#signal = this.#controller.signal;
if (typeof timeout === 'number' && timeout > 0) {
this.#timeout = AbortSignal.timeout(timeout);
this.#signal = AbortSignal.any([this.#signal, this.#timeout]);
this.#signal.addEventListener('abort', () => {
if (Object.keys(this.defaults).length > 0) this.#default();
if (this.done) return;
this.fail(this.#signal.reason);
});
}
this.#listenTimeout();
}

#default() {
for (const entry of Object.entries(this.defaults)) {
const key = entry[0];
const value = entry[1];
if (this.data[key] === undefined) this.set(key, value);
}
get done() {
return this.#done;
}

get count() {
return this.#count;
}

get keys() {
return [...this.#keys];
}

get data() {
return { ...this.#data };
}

get signal() {
return this.#signal;
}

set(key, value) {
if (this.done) return;
const expected = this.keys.includes(key);
if (!expected && this.exact) {
if (this.#done) return;
const expected = this.#isExpected(key);
if (!expected && this.#exact) {
this.fail(new Error(`Unexpected key: ${key}`));
return;
}
const has = this.data[key] !== undefined;
if (has && !this.reassign) {
const error = new Error('Collector reassign mode is off');
return void this.fail(error);
const has = this.#hasValue(key);
if (has && !this.#reassign) {
this.fail(new Error('Collector reassign mode is off'));
return;
}
if (!has && expected) this.count++;
this.data[key] = value;
if (this.count === this.keys.length) {
this.done = true;
this.#timeout = null;
if (this.#fulfill) this.#fulfill(this.data);
if (!has && expected) this.#count++;
this.#data[key] = value;
if (this.#count === this.#keys.length) {
this.#complete();
}
}

Expand Down Expand Up @@ -98,12 +130,7 @@ class Collector {
}

fail(error) {
this.done = true;
this.#timeout = null;
const cause = error || new Error('Collector aborted');
this.#cause = cause;
this.#controller.abort();
if (this.#reject) this.#reject(cause);
this.#rejectWith(error);
}

abort() {
Expand All @@ -114,18 +141,75 @@ class Collector {
return new Promise((resolve, reject) => {
this.#fulfill = resolve;
this.#reject = reject;
if (!this.done) return;
if (this.validate) {
try {
this.validate(this.data);
} catch (error) {
this.#cause = error;
}
}
if (this.#cause) reject(this.#cause);
else resolve(this.data);
if (!this.#done) return;
this.#tryResolve();
}).then(onFulfilled, onRejected);
}

#listenTimeout() {
if (this.#timeout <= 0) return;
this.#timeoutSignal = AbortSignal.timeout(this.#timeout);
this.#signal = AbortSignal.any([this.#signal, this.#timeoutSignal]);
this.#signal.addEventListener('abort', () => {
if (Object.keys(this.#defaults).length > 0) this.#applyDefaults();
if (this.#done) return;
this.fail(this.#signal.reason);
});
}

#applyDefaults() {
for (const entry of Object.entries(this.#defaults)) {
const key = entry[0];
const value = entry[1];
if (this.#data[key] === undefined) this.set(key, value);
}
}

#isExpected(key) {
return this.#keys.includes(key);
}

#hasValue(key) {
return this.#data[key] !== undefined;
}

#complete() {
this.#done = true;
this.#timeoutSignal = null;
this.#tryResolve();
}

#validateData() {
if (!this.#validate) return;
try {
this.#validate(this.#data);
} catch (error) {
this.#cause = error;
}
}

#tryResolve() {
if (this.#cause) {
if (this.#reject) this.#reject(this.#cause);
return;
}
if (!this.#fulfill) return;
this.#validateData();
if (this.#cause) {
if (this.#reject) this.#reject(this.#cause);
return;
}
this.#fulfill({ ...this.#data });
}

#rejectWith(error) {
this.#done = true;
this.#timeoutSignal = null;
const cause = error || new Error('Collector aborted');
this.#cause = cause;
this.#controller.abort();
if (this.#reject) this.#reject(cause);
}
}

const collect = (keys, options) => new Collector(keys, options);
Expand Down
40 changes: 20 additions & 20 deletions metautil.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,41 +254,41 @@ export function sizeToBytes(size: string): number;

export interface CollectorOptions {
exact?: boolean;
defaults?: object;
timeout?: number;
reassign?: boolean;
validate?: (data: Record<string, unknown>) => unknown;
timeout?: number;
defaults?: Dictionary;
validate?: (data: Dictionary) => unknown;
}

type AsyncFunction = (...args: Array<unknown>) => Promise<unknown>;

export class Collector {
done: boolean;
data: Dictionary;
keys: Array<string>;
count: number;
exact: boolean;
timeout: number;
defaults: object;
reassign: boolean;
validate?: (data: Record<string, unknown>) => unknown;
signal: AbortSignal;
constructor(keys: Array<string>, options?: CollectorOptions);
set(key: string, value: unknown): void;
constructor(keys: Array<string | symbol>, options?: CollectorOptions);
readonly done: boolean;
readonly count: number;
readonly keys: Array<string | symbol>;
readonly data: Dictionary;
readonly signal: AbortSignal;
set(key: string | symbol, value: unknown): void;
take(key: string | symbol, fn: Function, ...args: Array<unknown>): void;
wait(
key: string,
key: string | symbol,
fn: AsyncFunction | Promise<unknown>,
...args: Array<unknown>
): void;
take(key: string, fn: Function, ...args: Array<unknown>): void;
collect(sources: Record<string, Collector>): void;
fail(error: Error): void;
fail(error?: Error): void;
abort(): void;
then(onFulfilled: Function, onRejected?: Function): Promise<unknown>;
then<TResult1 = Dictionary, TResult2 = never>(
onFulfilled?:
| ((value: Dictionary) => TResult1 | PromiseLike<TResult1>)
| null,
onRejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2>;
}

export function collect(
keys: Array<string>,
keys: Array<string | symbol>,
options?: CollectorOptions,
): Collector;

Expand Down
Loading
Loading