mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
428 lines
12 KiB
TypeScript
428 lines
12 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import type { InjectionToken } from 'graphql-modules';
|
|
import ms from 'ms';
|
|
import { UTCDate } from '@date-fns/utc';
|
|
import type { DateRangeInput } from '../__generated__/types';
|
|
import { DateRange } from './entities';
|
|
|
|
export type NullableAndPartial<T> = {
|
|
[P in keyof T]?: T[P] | undefined | null;
|
|
};
|
|
export type NullableDictionary<T> = { [P in keyof T]: T[P] | null };
|
|
|
|
export type Listify<T, K extends keyof T> = Omit<T, K> & {
|
|
[_key in K]: T[K] | readonly T[K][];
|
|
};
|
|
|
|
export type MapToArray<T, K extends keyof T> = Omit<T, K> & {
|
|
[_key in K]: readonly T[K][];
|
|
};
|
|
|
|
export function uuid(len = 13) {
|
|
return Math.random().toString(16).substr(2, len);
|
|
}
|
|
|
|
export function stringifySelector<
|
|
T extends {
|
|
[key: string]: any;
|
|
},
|
|
>(obj: T): string {
|
|
return JSON.stringify(
|
|
Object.keys(obj)
|
|
.sort()
|
|
.map(key => [key, obj[key]]),
|
|
);
|
|
}
|
|
|
|
function validateDateTime(dateTimeString?: string) {
|
|
dateTimeString =
|
|
dateTimeString === null || dateTimeString === void 0 ? void 0 : dateTimeString.toUpperCase();
|
|
|
|
if (!dateTimeString) {
|
|
return false;
|
|
}
|
|
|
|
const RFC_3339_REGEX =
|
|
/^(\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60))(\.\d{1,})?(([Z])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))$/;
|
|
// Validate the structure of the date-string
|
|
if (!RFC_3339_REGEX.test(dateTimeString)) {
|
|
return false;
|
|
}
|
|
// Check if it is a correct date using the javascript Date parse() method.
|
|
const time = Date.parse(dateTimeString);
|
|
if (time !== time) {
|
|
return false;
|
|
}
|
|
// Split the date-time-string up into the string-date and time-string part.
|
|
// and check whether these parts are RFC 3339 compliant.
|
|
const index = dateTimeString.indexOf('T');
|
|
const dateString = dateTimeString.substr(0, index);
|
|
const timeString = dateTimeString.substr(index + 1);
|
|
return validateDate(dateString) && validateTime(timeString);
|
|
}
|
|
|
|
function validateTime(time?: string) {
|
|
time = time === null || time === void 0 ? void 0 : time.toUpperCase();
|
|
|
|
if (!time) {
|
|
return false;
|
|
}
|
|
|
|
const TIME_REGEX =
|
|
/^([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(\.\d{1,})?(([Z])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))$/;
|
|
return TIME_REGEX.test(time);
|
|
}
|
|
|
|
function validateDate(datestring: string) {
|
|
const RFC_3339_REGEX = /^(\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))$/;
|
|
if (!RFC_3339_REGEX.test(datestring)) {
|
|
return false;
|
|
}
|
|
// Verify the correct number of days for
|
|
// the month contained in the date-string.
|
|
const year = Number(datestring.substr(0, 4));
|
|
const month = Number(datestring.substr(5, 2));
|
|
const day = Number(datestring.substr(8, 2));
|
|
switch (month) {
|
|
case 2: // February
|
|
if (leapYear(year) && day > 29) {
|
|
return false;
|
|
}
|
|
if (!leapYear(year) && day > 28) {
|
|
return false;
|
|
}
|
|
return true;
|
|
case 4: // April
|
|
case 6: // June
|
|
case 9: // September
|
|
case 11: // November
|
|
if (day > 30) {
|
|
return false;
|
|
}
|
|
break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function leapYear(year: number) {
|
|
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
}
|
|
|
|
export function parseDateTime(value: number | string | Date): Date {
|
|
if (value instanceof Date) {
|
|
return new UTCDate(value);
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
if (validateDateTime(value)) {
|
|
return new UTCDate(value);
|
|
}
|
|
throw new TypeError(`DateTime cannot represent an invalid date-time-string ${value}.`);
|
|
}
|
|
|
|
if (typeof value === 'number') {
|
|
try {
|
|
return new UTCDate(value);
|
|
} catch (e) {
|
|
throw new TypeError('DateTime cannot represent an invalid Unix timestamp ' + value);
|
|
}
|
|
}
|
|
|
|
throw new TypeError(
|
|
'DateTime cannot be serialized from a non string, ' +
|
|
'non numeric or non Date type ' +
|
|
JSON.stringify(value),
|
|
);
|
|
}
|
|
|
|
export function parseDateRangeInput(period: DateRangeInput): DateRange {
|
|
return {
|
|
from: parseDateTime(period.from),
|
|
to: parseDateTime(period.to),
|
|
};
|
|
}
|
|
|
|
export function createPeriod(period: string): DateRange {
|
|
const to = new UTCDate();
|
|
const from = to.getTime() - ms(period);
|
|
|
|
return {
|
|
from: parseDateTime(from),
|
|
to,
|
|
};
|
|
}
|
|
|
|
export type TypeOfToken<T> = T extends InjectionToken<infer R> ? R : unknown;
|
|
|
|
export type Optional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & Partial<Pick<T, K>>;
|
|
|
|
export function hash(key: string): string {
|
|
return createHash('md5').update(key).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* A function that accepts two arrays and returns a difference
|
|
*/
|
|
export function diffArrays<T>(left: readonly T[], right: readonly T[]): readonly T[] {
|
|
return left.filter(val => !right.includes(val)).concat(right.filter(val => !left.includes(val)));
|
|
}
|
|
|
|
export function pushIfMissing<T>(list: T[], item: T): void {
|
|
if (!list.includes(item)) {
|
|
list.push(item);
|
|
}
|
|
}
|
|
|
|
export type PromiseOrValue<T> = Promise<T> | T;
|
|
|
|
type BatchGroup<TItem, TResult> = {
|
|
args: TItem[];
|
|
callbacks: Array<{ resolve: (result: TResult) => void; reject: (error: Error) => void }>;
|
|
};
|
|
|
|
/**
|
|
* Batch processing of items based on a built key
|
|
*/
|
|
export function batchBy<TItem, TResult>(
|
|
/** Function to determine the batch group. */
|
|
buildBatchKey: (arg: TItem) => unknown,
|
|
/** Loader for each batch group. */
|
|
loader: (args: TItem[]) => Promise<Promise<TResult>[]>,
|
|
/** Maximum amount of items per batch, if it is exceeded a new batch for a given batchKey is created. */
|
|
maxBatchSize = Infinity,
|
|
) {
|
|
let batchGroups = new Map<unknown, BatchGroup<TItem, TResult>>();
|
|
let didSchedule = false;
|
|
|
|
function startLoadingBatch(currentBatch: BatchGroup<TItem, TResult>): void {
|
|
const tickArgs = [...currentBatch.args];
|
|
const tickCallbacks = [...currentBatch.callbacks];
|
|
|
|
if (tickArgs.length !== tickCallbacks.length) {
|
|
for (const cb of tickCallbacks) {
|
|
cb.reject(new Error('Batch args and callbacks should have the same length.'));
|
|
}
|
|
throw new Error('Batch args and callbacks should have the same length.');
|
|
}
|
|
|
|
loader(tickArgs).then(
|
|
promises => {
|
|
for (let i = 0; i < tickCallbacks.length; i++) {
|
|
promises[i].then(
|
|
result => {
|
|
tickCallbacks[i].resolve(result);
|
|
},
|
|
error => {
|
|
tickCallbacks[i].reject(error);
|
|
},
|
|
);
|
|
}
|
|
},
|
|
error => {
|
|
for (let i = 0; i < tickCallbacks.length; i++) {
|
|
tickCallbacks[i].reject(error);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
function getBatchGroup(batchKey: unknown) {
|
|
// get the batch collection for the batch key
|
|
let currentBatch = batchGroups.get(batchKey);
|
|
// if it does not exist or the batch is full, create a new batch
|
|
if (currentBatch === undefined) {
|
|
currentBatch = {
|
|
args: [],
|
|
callbacks: [],
|
|
};
|
|
batchGroups.set(batchKey, currentBatch);
|
|
}
|
|
|
|
return currentBatch;
|
|
}
|
|
|
|
function scheduleExecutionOnNextTick() {
|
|
if (didSchedule) {
|
|
return;
|
|
}
|
|
didSchedule = true;
|
|
process.nextTick(() => {
|
|
for (const currentBatch of batchGroups.values()) {
|
|
startLoadingBatch(currentBatch);
|
|
}
|
|
// reset the batch
|
|
batchGroups = new Map();
|
|
didSchedule = false;
|
|
});
|
|
}
|
|
|
|
return (arg: TItem): Promise<TResult> => {
|
|
const batchKey = buildBatchKey(arg);
|
|
const currentBatch = getBatchGroup(batchKey);
|
|
scheduleExecutionOnNextTick();
|
|
currentBatch.args.push(arg);
|
|
const d = Promise.withResolvers<TResult>();
|
|
currentBatch.callbacks.push({ resolve: d.resolve, reject: d.reject });
|
|
|
|
// if the current batch is full, we already start loading it.
|
|
if (currentBatch.callbacks.length >= maxBatchSize) {
|
|
batchGroups.delete(batchKey);
|
|
startLoadingBatch(currentBatch);
|
|
}
|
|
|
|
return d.promise;
|
|
};
|
|
}
|
|
|
|
export function assertOk<TOk extends { ok: true }, TNot extends { ok: false; message: string }>(
|
|
result: TOk | TNot,
|
|
message: string,
|
|
): asserts result is TOk {
|
|
if (!result.ok) {
|
|
throw new Error(`${message}: ${result.message}`);
|
|
}
|
|
}
|
|
|
|
export function batch<A, R>(loader: (args: A[]) => Promise<Promise<R>[]>): (arg: A) => Promise<R> {
|
|
let currentBatch: {
|
|
args: A[];
|
|
callbacks: Array<{ resolve(r: R): void; reject(error: Error): void }>;
|
|
} = {
|
|
args: [],
|
|
callbacks: [],
|
|
};
|
|
|
|
return (arg: A) => {
|
|
const schedule = currentBatch.args.length === 0;
|
|
|
|
if (schedule) {
|
|
process.nextTick(() => {
|
|
const tickArgs = [...currentBatch.args];
|
|
const tickCallbacks = [...currentBatch.callbacks];
|
|
|
|
// reset the batch
|
|
currentBatch = {
|
|
args: [],
|
|
callbacks: [],
|
|
};
|
|
|
|
loader(tickArgs)
|
|
.then(promises => {
|
|
for (let i = 0; i < tickCallbacks.length; i++) {
|
|
promises[i].then(
|
|
result => {
|
|
tickCallbacks[i].resolve(result);
|
|
},
|
|
error => {
|
|
tickCallbacks[i].reject(error);
|
|
},
|
|
);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
tickCallbacks.forEach(callback => callback.reject(err));
|
|
});
|
|
});
|
|
}
|
|
|
|
currentBatch.args.push(arg);
|
|
return new Promise<R>((resolve, reject) => {
|
|
currentBatch.callbacks.push({ resolve, reject });
|
|
});
|
|
};
|
|
}
|
|
|
|
const atomicPromises = new Map<string, Promise<any>>();
|
|
|
|
export function atomic<A>(hashFn: (arg: A) => string) {
|
|
return (_target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
|
|
const originalMethod = descriptor.value;
|
|
|
|
descriptor.value = function (arg: A, ...rest: any[]) {
|
|
if (rest.length) {
|
|
console.warn(`@atomicPromise detected more than 1 argument in "${propertyKey}"`);
|
|
}
|
|
|
|
const key = `${propertyKey}_${hashFn(arg)}`;
|
|
|
|
if (atomicPromises.has(key)) {
|
|
return atomicPromises.get(key);
|
|
}
|
|
|
|
const promise = originalMethod.call(this, arg);
|
|
atomicPromises.set(key, promise);
|
|
|
|
return promise.finally(() => {
|
|
atomicPromises.delete(key);
|
|
});
|
|
};
|
|
};
|
|
}
|
|
|
|
export function share<T>(setter: () => Promise<T>): () => Promise<T> {
|
|
let sharedPromise: Promise<T> | null = null;
|
|
|
|
return () => {
|
|
if (!sharedPromise) {
|
|
sharedPromise = Promise.resolve().then(setter);
|
|
}
|
|
return sharedPromise;
|
|
};
|
|
}
|
|
|
|
export function cache<TInput>(cacheKeyFn: (arg: TInput) => string): MethodDecorator {
|
|
return (_target, _propertyKey, descriptor) => {
|
|
const cacheSymbol = Symbol('@cache');
|
|
const originalMethod = descriptor.value;
|
|
|
|
function ensureCache(obj: any): Map<string, any> {
|
|
if (!obj[cacheSymbol]) {
|
|
obj[cacheSymbol] = new Map<string, any>();
|
|
}
|
|
|
|
return obj[cacheSymbol];
|
|
}
|
|
|
|
return {
|
|
...descriptor,
|
|
value(this: any, arg: TInput) {
|
|
const cacheMap = ensureCache(this);
|
|
const key = cacheKeyFn(arg);
|
|
const cachedValue = cacheMap.get(key);
|
|
|
|
if (cachedValue !== null && typeof cachedValue !== 'undefined') {
|
|
return cachedValue;
|
|
}
|
|
|
|
const result = (originalMethod as any).call(this, arg);
|
|
|
|
cacheMap.set(key, result);
|
|
|
|
return result;
|
|
},
|
|
} as TypedPropertyDescriptor<any>;
|
|
};
|
|
}
|
|
|
|
const NS_TO_MS = 1e6;
|
|
|
|
export function nsToMs(ns: number) {
|
|
return ns / NS_TO_MS;
|
|
}
|
|
|
|
export function msToNs(ms: number) {
|
|
return ms * NS_TO_MS;
|
|
}
|
|
|
|
/** Typed Object.fromEntries */
|
|
export function objectFromEntries<$Key extends string, $Value>(
|
|
entries: Array<[$Key, $Value]>,
|
|
): Record<$Key, $Value> {
|
|
return Object.fromEntries(entries) as Record<$Key, $Value>;
|
|
}
|
|
|
|
export function objectEntries<$Key extends string, $Value>(
|
|
object: Record<$Key, $Value>,
|
|
): Array<[$Key, $Value]> {
|
|
return Object.entries(object) as Array<[$Key, $Value]>;
|
|
}
|