Source: util/type-checks.ts

/**
 * @author Michael Hasenstein <hasenstein@yahoo.com>
 * @copyright REFINIO GmbH 2018
 * @license CC-BY-NC-SA-2.5; portions MIT License
 * @version 0.0.1
 */

/**
 * A module with functions that perform runtime type checks. There are very simple checks such
 * as `isString()`, there also are complex checks.
 * @module
 */

/**
 * (Simulated) opaque type name alias for strings that are SHA-256 hashes _pointing to
 * concrete objects_.
 *
 * This type has a generic parameter: A member or a union of {@link OneObjectTypes} as well
 * as the virtual types {@link BLOB} and {@link CLOB}.
 *
 * This generic parameter is used by and passed through all major one.core functions' type
 * definitions. For example, after storing an object the hashes in the object creation
 * result object will be tagged with the typed of the object.
 * @global
 * @typedef {string} SHA256Hash
 */
export type SHA256Hash<T extends HashTypes = OneObjectTypes> = string & {
    _: 'SHA256Hash';
    type: T;
};

/**
 * (Simulated) opaque type name alias for strings that are SHA-256 hashes _pointing to ID
 * objects, i.e. to all past, present and future versions of a versioned object_.
 *
 * This type has a generic parameter: A member or a union of {@link OneObjectTypes} as well
 * as the virtual types {@link BLOB} and {@link CLOB}.
 * @global
 * @typedef {string} SHA256IdHash
 */
export type SHA256IdHash<
    T extends OneVersionedObjectTypes | OneIdObjectTypes = OneVersionedObjectTypes
> = string & {
    _: 'SHA256IdHash';
    type: T;
};

/**
 * This is a TypeScript helper type that extracts the type of the elements of certain container
 * types. If the container is read-only and its elements are known this results in a union type
 * of those elements.
 *
 * Examples:
 * ```
 * const set = new Set(['a', 'b', 'c'] as const);
 * // "a" | "b" | "c"
 * type SSS = ElementType<typeof set>;
 *
 * const arr = ['aa', 'bb', 'cc'] as const;
 * // // "aa" | "bb" | "cc"
 * type AAA = ElementType<typeof arr>;
 *
 * const map = new Map([['a', 1], ['b', 2], ['c', 42]] as const);
 * // 1 | 2 | 42
 * type MMM = ElementType<typeof map>;
 * ```
 * @global
 * @template T
 * @typedef {*} ElementType<T>
 */
export type ElementType<T> = T extends Array<infer U>
    ? U
    : T extends Readonly<Array<infer U>>
    ? U
    : T extends Set<infer U>
    ? U
    : T extends Readonly<Set<infer U>>
    ? U
    : T extends Map<any, infer U>
    ? U
    : T extends Readonly<Map<any, infer U>>
    ? U
    : T extends Promise<infer U>
    ? U
    : T;

import {createError} from '../errors';
import type {
    ArrayValue,
    BagValue,
    HashTypes,
    OneIdObjectTypes,
    OneObjectTypes,
    OneVersionedObjectTypes,
    RecipeRule,
    SetValue,
    ValueType
} from '../recipes';
import type {FileCreation, SimpleReadStream} from '../storage-base-common';
import {CREATION_STATUS} from '../storage-base-common';
import type {AnyObject} from './object';
import type {OneEventSource, OneEventSourceConsumer} from './one-event-source';
import {isFunction, isObject, isString} from './type-checks-basic';

/**
 * A regular expression that can be used to verify that a given string looks like a
 * cryptographic hash string used to represent ONE objects. For SHA-256 it tests if there
 * are 64 characters and that each one of them is between 0-9 or a-f.
 * @private
 * @type {RegExp}
 */
const CRYPTO_HASH_RE = /^[0-9a-f]{64}$/;

/**
 * Used to check request results. Values "new" and "exists".
 * @private
 * @type {Set<string>}
 */
const FILE_CREATION_STATUS_VALUES = new Set(Object.values(CREATION_STATUS));

/**
 * An alternative to `Object.keys(o).length` that is more efficient. Running a test loop
 * comparing the two showed less than half the time for the for-loop option. Also, `Object.keys`
 * creates a temporary array.
 * @static
 * @param {object} o
 * @returns {number}
 */
export function countEnumerableProperties(o: AnyObject): number {
    let count = 0;

    for (const prop in o) {
        if (Object.prototype.hasOwnProperty.call(o, prop)) {
            count += 1;
        }
    }

    return count;
}

/**
 * Valid encodings for file streams are binary (undefined or null), "base64" and "utf8".
 * @static
 * @param {*} thing
 * @returns {boolean}
 */
export function isEncoding(thing: unknown): thing is undefined | 'base64' | 'utf8' {
    return thing === undefined || thing === 'base64' || thing === 'utf8';
}

/**
 * ONE uses SHA-256 hashes in hexadecimal lowercase format to represent the contents of files.
 * This functions tests a given "thing" of any type if it is such a string.
 *
 * For the curious: Why a function?
 *
 * While one might simply test against a regular expression using myRegEx.test(thing) this
 * method has one more or less theoretical problem: If the thing is an object with a toString()
 * method that returns a matching string the test will return "true" even though the thing is an
 * object and not a string. For example,
 * ```javascript
 * /^s+$/.test( {toString: () => 'sss'} );
 * ```
 * will return true.
 * @static
 * @param {*} thing - An argument that can be of any type
 * @returns {boolean} True if the argument is a SHA-256 lowercase hexadecimal string, false if not
 */
export function isHash<T extends HashTypes>(
    thing: unknown
): thing is SHA256Hash<T> | SHA256IdHash<T extends OneVersionedObjectTypes ? T : never> {
    return isString(thing) && CRYPTO_HASH_RE.test(thing);
}

/**
 * Non-regex version of the "isHash" function in an attempt to save a tiny bit of CPU because we
 * don't need a full regex check here. "Premature optimization" vs. "it's cheap and easy", and
 * according to reported real world runtime experience these hash checks can add up and become
 * significant.
 * @param {*} s
 * @returns {boolean}
 */
export function looksLikeHash(s: unknown): boolean {
    return typeof s === 'string' && s.length === 64;
}

/**
 * The function returns the given value after making sure it is a SHA-256 hexadecimal string. It
 * throws an Error if this is not the case.
 * @static
 * @param {*} thing
 * @returns {SHA256Hash}
 * @throws {Error}
 */
export function ensureHash<T extends HashTypes>(thing: unknown): SHA256Hash<T> {
    if (isHash<T>(thing)) {
        return thing as SHA256Hash<T>;
    }

    throw createError('UTC-EHASH', {thing});
}

/**
 * The function returns the given value after making sure it is a SHA-256 hexadecimal string. It
 * throws an Error if this is not the case.
 * @static
 * @param {*} thing
 * @returns {SHA256Hash}
 * @throws {Error}
 */
export function ensureIdHash<T extends OneVersionedObjectTypes = any>(
    thing: unknown
): SHA256IdHash<T> {
    if (isHash<T>(thing)) {
        return thing as SHA256IdHash<T>;
    }

    throw createError('UTC-EHASH', {thing});
}

/**
 * Checks if a given object is a ONE.core {@link OneEventSourceConsumer} object by testing the
 * properties (duck typing).
 * @static
 * @param {*} thing
 * @returns {boolean}
 */
export function isEventSourceConsumer(thing: unknown): thing is OneEventSourceConsumer<unknown> {
    return isObject(thing) && isFunction(thing.addListener) && isFunction(thing.removeListener);
}

/**
 * Checks if a given object is a ONE.core {@link OneEventSource} object by testing the properties
 * (duck typing).
 * @static
 * @param {*} thing
 * @returns {boolean}
 */
export function isEventSource(thing: unknown): thing is OneEventSource<unknown> {
    return isObject(thing) && isEventSourceConsumer(thing.consumer);
}

/**
 * Checks if a given object is a ONE.core {@link SimpleReadStream} object by testing the
 * properties (duck typing).
 * @static
 * @param {*} thing
 * @returns {boolean}
 */
export function isSimpleReadStream(thing: unknown): thing is SimpleReadStream {
    return (
        isObject(thing) &&
        isFunction(thing.pause) &&
        isFunction(thing.resume) &&
        isFunction(thing.cancel) &&
        thing.promise instanceof Promise &&
        isEncoding(thing.thing) &&
        isEventSourceConsumer(thing.onData)
    );
}

/**
 * Checks for {@link FileCreation} objects. They are used to return the result of saving BLOBs
 * and CLOBs to storage.
 * @static
 * @param {*} thing
 * @returns {boolean}
 */
export function isFileCreationResult(thing: unknown): thing is FileCreation<any> {
    return (
        isObject(thing) &&
        countEnumerableProperties(thing) === 2 &&
        isHash(thing.hash) &&
        FILE_CREATION_STATUS_VALUES.has(thing.status)
    );
}

/**
 * @static
 * @param {*} thing - Data e.g. from a network connection expected to be of format SHA256Hash[]
 * @returns {SHA256Hash[]} Returns the data now confirmed to be of type Array of SHA256Hash
 * @throws {Error} Throws an Error when the given data is not an array of (only) SHA-256
 * hashes
 */
export function ensureArrayOfSHA256Hash(thing: unknown): Array<SHA256Hash<HashTypes>> {
    if (Array.isArray(thing) && thing.every(item => isHash(item))) {
        return thing as Array<SHA256Hash<HashTypes>>;
    }

    throw createError('UTC-AHASH', {thing});
}

/**
 * @static
 * @param {RecipeRule} obj
 * @returns {RecipeRule}
 */
export function ruleHasItemType(obj: RecipeRule): obj is RecipeRule & {itemtype: ValueType} {
    return Object.prototype.hasOwnProperty.call(obj, 'itemtype');
}

/**
 * Check if the valueType is a list type: array, bag, or set.
 * @static
 * @param {ValueType} arg
 * @returns {boolean}
 */
export function isListItemType(arg: ValueType): arg is BagValue | ArrayValue | SetValue {
    return arg.type === 'array' || arg.type === 'bag' || arg.type === 'set';
}