Source: storage-versioned-objects.ts

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

/**
 * @private
 * @module
 */

/**
 * The type returned by *write* operation(s) for *versioned* ONE objects as well as by
 * {@link getObjectByIdHash} and {@link getObjectByIdObject}. This is similar to
 * {@link UnversionedObjectResult} except that it also includes the `idHash` property which
 * identifies all versions of a versioned object as well as the timestamp written into the
 * version map.
 *
 * @global
 * @typedef {object} VersionedObjectResult
 * @property {OneVersionedObjectTypes} obj - The versioned ONE object that was stored. This is only
 * set for ONE objects representable as Javascript objects.
 * @property {SHA256Hash} hash - The crypto hash of the microdata representation of the object
 * and also the filename.
 * @property {SHA256IdHash} idHash - The crypto hash of the ID-object of the ONE object.
 * @property {number} [timestamp] - Creation date-time of the version-map entry for the object.
 * This is not the creation date-time of the object! The timestamp is only provided if a version
 * map entry was made. Version map entries can be made repeatedly for already existing objects
 * to make an already existing object the most current version without creating it (since it
 * already exists...)
 * @property {FileCreationStatus} status - A string constant showing whether the file
 * already existed or if it had to be created or if a new version was added.
 */
export interface VersionedObjectResult<
    T extends OneVersionedObjectTypes = OneVersionedObjectTypes
> {
    readonly obj: T;
    hash: SHA256Hash<T>;
    idHash: SHA256IdHash<T>;
    status: FileCreationStatus;
    timestamp: undefined | number;
}

/**
 * Result value for storing versioned objects without altering the version map.
 */
export interface VersionedObjectOmitVersionMapResult<
    T extends OneVersionedObjectTypes = OneVersionedObjectTypes
> {
    readonly obj: T;
    hash: SHA256Hash<T>;
    idHash?: void;
    status: FileCreationStatus;
    timestamp?: void;
}

/**
 * Return result object of creating ID objects with function
 * `storeIdObject(obj: OneVersionedObjectTypes | OneIdObjectTypes)`.
 * @global
 * @typedef {object} IdFileCreation
 * @property {SHA256IdHash} idHash - The SHA-256 hash of the ID ONE object
 * @property {FileCreationStatus} status - A string constant showing whether the file
 * already existed or if it had to be created.
 */
export interface IdFileCreation<
    T extends OneVersionedObjectTypes | OneIdObjectTypes =
        | OneVersionedObjectTypes
        | OneIdObjectTypes
> {
    idHash: SHA256IdHash<OneVersionedObjectInterfaces[T['$type$']]>;
    status: FileCreationStatus;
}

import type {OneIdObjectInterfaces, OneVersionedObjectInterfaces} from '@OneObjectInterfaces';
import {createError} from './errors';
import {extractIdObject} from './microdata-to-id-hash';
import {convertIdMicrodataToObject} from './microdata-to-object';
import {isVersionedObject} from './object-recipes';
import {convertObjToIdMicrodata, convertObjToMicrodata} from './object-to-microdata';
import type {
    OneIdObjectTypes,
    OneVersionedObjectTypeNames,
    OneVersionedObjectTypes
} from './recipes';
import {reverseMapUpdater, reverseMapUpdaterForIdObject} from './reverse-map-updater';
import type {FileCreationStatus} from './storage-base-common';
import {CREATION_STATUS} from './storage-base-common';
import {storeUTF8Clob} from './storage-blob';
import {setIdHash} from './storage-id-hash-cache';
import {getObjectWithType} from './storage-unversioned-objects';
import {createCryptoHash} from './system/crypto-helpers';
import {readUTF8TextFile, writeUTF8TextFile} from './system/storage-base';
import {clone} from './util/clone-object';
import {calculateIdHashOfObj} from './util/object';
import type {OneEventSource, OneEventSourceConsumer} from './util/one-event-source';
import {createEventSource} from './util/one-event-source';
import {serializeWithType} from './util/promise';
import type {SHA256Hash, SHA256IdHash} from './util/type-checks';
import {isObject} from './util/type-checks-basic';
import {getNthVersionMapEntry} from './version-map-query';
import type {VersionUpdatePolicyValues} from './version-map-updater';
import {createVersionMapOrAppendEntry, VERSION_UPDATES} from './version-map-updater';

/**
 * Exported for plan-existing-result.js to be able to send events about previously created
 * objects. Depending on the version map update policy those may still lead to actual storage
 * state changes, and depending on the application's use of those events even no storage state
 * change, just the fact that the object was to be created (even if it already exists), may lead
 * to state application changes.
 * @private
 * @type {EventSource<VersionedObjectResult>}
 */
export const versionedObjEvent: OneEventSource<VersionedObjectResult> = createEventSource();

/**
 * @static
 * @type {EventSourceConsumer<VersionedObjectResult>}
 */
export const onVersionedObj: OneEventSourceConsumer<VersionedObjectResult> =
    versionedObjEvent.consumer;

/**
 * This event triggers on newly stored id-objects.
 * @type {OneEventSource<VersionedObjectResult<OneVersionedObjectTypes>>}
 */
export const idObjEvent: OneEventSource<IdFileCreation> = createEventSource();

/**
 * @static
 * @type {OneEventSourceConsumer<VersionedObjectResult>}
 */
export const onIdObj: OneEventSourceConsumer<IdFileCreation> = idObjEvent.consumer;

/**
 * @static
 * @async
 * @param {OneIdObjectTypes} obj - An ID object of a versioned object type, i.e. it has only
 * ID properties (`isId` is `true` for the property in the recipe)
 * @returns {Promise<IdFileCreation>} Returns an {@link IdFileCreation} object
 */
export async function storeIdObject<T extends OneVersionedObjectTypes | OneIdObjectTypes>(
    obj: T
): Promise<IdFileCreation<T>> {
    const idObjMicrodata = convertObjToIdMicrodata(obj);
    const idHash = (await createCryptoHash(idObjMicrodata)) as unknown as SHA256IdHash<
        OneVersionedObjectInterfaces[T['$type$']]
    >;
    const result = {idHash, status: await writeUTF8TextFile(idObjMicrodata, idHash)};

    await reverseMapUpdaterForIdObject(obj, result);

    idObjEvent.dispatch(result);

    return result;
}

/**
 * Returns the ID object for the given ID hash. Those are stored under the idHash as filename
 * in the 'objects' storage space. There can be "empty" versioned objects for which only an ID
 * object exists but no concrete versions yet, so that it is possible to ID-hash-link to ID
 * objects without existing versions and still be able to find out the concrete ID properties
 * which lead to the given ID hash.
 * @static
 * @async
 * @param {SHA256IdHash} idHash - Hash of a ONE ID object
 * @returns {Promise<OneIdObjectTypes>} The promise is rejected with an Error whose name
 * property is set to "FileNotFoundError" if the object does not exist.
 */
export async function getIdObject<T extends OneVersionedObjectTypes | OneIdObjectTypes>(
    idHash: SHA256IdHash<T>
): Promise<OneIdObjectInterfaces[T['$type$']]> {
    const idObjMicrodata = await readUTF8TextFile(idHash);
    return convertIdMicrodataToObject<T['$type$']>(idObjMicrodata);
}

/**
 * Object writing policy:
 * a) If the exact same object already exists do nothing, just return the hash of the object.
 * b) If a version of the object exists (based on ID-object) but not exactly the same, create
 *    a new Object. Update the version map.
 * c) If there is no such object yet (based on ID-object), create a new object and its
 *    accompanying Object-map.
 * In any case, always return both hashes for the object and the id-object.
 *
 * 1 Calculate object-ID (hash) from the object (obj => objID)
 *   The object's id hash also is the name of the version map hash
 * 2 Store object (obj => hash)
 * 3 Attempt to update the version map. Its filename is fixed based on the empty version map and
 * the
 *   respective ID-object hash. If this succeeds we know the map and therefore previous
 *   version(s) of the object exist. The promise rejects when the version map does not exist.
 * IF version map does NOT exist:
 *   4 Create a new version map.
 * END
 *
 * @static
 * @async
 * @param {OneVersionedObjectTypes} obj - A versioned ONE object **which is cloned** to not be
 * affected if the calling code mutates the object later. This is especially important because
 * the (cloned) object (reference) is included in the result returned by this function. Cloning
 * ensures the hash data matches the object even if the original object is mutated.
 * @param {VersionUpdatePolicyValues} [versionMapUpdatePolicy=1]
 * @returns {Promise<VersionedObjectResult>} Resolves with the crypto hashes of the object
 * and of the accompanying (virtual) ID- and (real) object. The "status" is CREATION_STATUS.NEW
 * if this particular object is new, CREATION_STATUS.EXISTS if it already exists.
 */
export async function storeVersionedObject<T extends OneVersionedObjectTypes>(
    obj: T,
    versionMapUpdatePolicy: VersionUpdatePolicyValues = VERSION_UPDATES.NONE_IF_LATEST
): Promise<VersionedObjectResult<T>> {
    if (!isObject(obj)) {
        throw createError('SVO-SO1', {type: obj === null ? 'null' : typeof obj, obj});
    }

    if (!isVersionedObject(obj)) {
        throw createError('SVO-SO2', {obj});
    }

    // Trading (mostly memory-) efficiency for correctness: Insurance against mutation
    // TODO Get rid of "obj" in the creation result object so that we can stop worrying about
    //  mutability of the object to which we only got a pointer
    const clonedObj = clone(obj);
    const objMicrodata = convertObjToMicrodata(clonedObj);
    // This is the ID hash of the object, not the hash of the object. The version map that
    // stores the hashes of all versions of this object is stored using this hash as map name.
    const objId = (await createCryptoHash(
        // This is a string: We know we have a versioned object, so we will not get undefined
        extractIdObject(objMicrodata) as string
    )) as unknown as SHA256IdHash<T>;

    async function serializedStore(): Promise<VersionedObjectResult<T>> {
        // THE MAIN STEP. If this object already exists the promise will be *rejected* with an
        // error, so subsequent "then()" steps chained to this one below are not executed. We don't
        // check for existence of the object because we subscribe to the node.js philosophy of not
        // adding a useless I/O call when we get the exact same information when we try to write to
        // it anyway. We don't tell storeUTF8Clob() to ignore EEXIST errors because when an object
        // already exists we don't want to update the version map, but we simply do nothing.
        const objFileWriteResult = await storeUTF8Clob<T>(objMicrodata);

        // Optional, an optimization. If we don't put it into the cache, and it is queried, it would
        // have to be loaded from storage and calculated. We assume that there is a significant
        // probability that when we store a versioned object its ID hash will be needed very soon,
        // for example to write reverse maps when shortly hereafter another object is written
        // referencing this one.
        setIdHash(objFileWriteResult.hash, objId);

        // The reverse and version map updates are independent and can be run in parallel.
        const [, {status, timestamp}] = await Promise.all([
            reverseMapUpdater(clonedObj, objFileWriteResult),
            createVersionMapOrAppendEntry(
                objId, // map filename
                objFileWriteResult.hash, // value
                objFileWriteResult.status, // 'NEW' or 'EXISTS'
                versionMapUpdatePolicy
            )
        ]);

        if (status === CREATION_STATUS.NEW) {
            await storeIdObject(obj);
        }

        const objCreationResult: VersionedObjectResult<T> = {
            obj: clonedObj,
            hash: objFileWriteResult.hash,
            idHash: objId,
            status: objFileWriteResult.status,
            timestamp
        };

        versionedObjEvent.dispatch(objCreationResult);

        return objCreationResult;
    }

    return serializeWithType(`ID ${objId}`, serializedStore);
}

/**
 * Stores the passed versioned object without appending it to the version map.
 *
 * @param {T} obj
 * @returns {Promise<VersionedObjectOmitVersionMapResult<T>>}
 */
export async function storeVersionedObjectOmitVersionMap<T extends OneVersionedObjectTypes>(
    obj: T
): Promise<VersionedObjectOmitVersionMapResult<T>> {
    if (!isObject(obj)) {
        throw createError('SVOOV-SO1', {type: obj === null ? 'null' : typeof obj, obj});
    }

    if (!isVersionedObject(obj)) {
        throw createError('SVOOV-SO2', {obj});
    }

    const clonedObj = clone(obj);
    const objMicrodata = convertObjToMicrodata(clonedObj);
    const objFileWriteResult = await storeUTF8Clob<T>(objMicrodata);

    if (objFileWriteResult.status === CREATION_STATUS.NEW) {
        await reverseMapUpdater(clonedObj, objFileWriteResult);

        // This writes the id object more often than necessary, because the version map / id-object
        // might already exist. Better would be checking for the existence of the version map, but
        // there is no call at the moment that does this.
        await storeIdObject(obj);
    }

    return {
        obj: clonedObj,
        hash: objFileWriteResult.hash,
        status: objFileWriteResult.status
    };
}

// OVERLOADED DEFINITIONS for this function's different parameter options
export function getObjectByIdHash<T extends OneVersionedObjectTypes>(
    idHash: SHA256IdHash<T>
): Promise<VersionedObjectResult<T>>;
export function getObjectByIdHash<T extends OneVersionedObjectTypes>(
    idHash: SHA256IdHash<T>,
    type: '*',
    index?: number
): Promise<VersionedObjectResult<T>>;
export function getObjectByIdHash<T extends OneVersionedObjectTypeNames>(
    idHash: SHA256IdHash<OneVersionedObjectInterfaces[T]>,
    type: T,
    index?: number
): Promise<VersionedObjectResult<OneVersionedObjectInterfaces[T]>>;
export function getObjectByIdHash<T extends OneVersionedObjectTypeNames>(
    idHash: SHA256IdHash<OneVersionedObjectInterfaces[T]>,
    type: T[],
    index?: number
): Promise<VersionedObjectResult<OneVersionedObjectInterfaces[T]>>;

/**
 * Returns the latest version of the object specified by the ID hash or the version specified by
 * the optional index parameter, or a rejected promise if there is no such object.
 * @static
 * @async
 * @param {SHA256IdHash} idHash - Hash of a ONE ID object
 * @param {OneVersionedObjectTypeNames|OneVersionedObjectTypeNames[]} [type='*'] - Any one of the
 * type string constant from ONE object recipes for a versioned object. If a type name or an
 * array of type names are provided the object must be of this type or an `Error` will be
 * thrown, if the parameter is left undefined or set to '*' (the default) no type check will be
 * performed.
 * @param {number} [index=-1] - An optional zero-based integer index (0, 1,...) in the version map
 * from which the object should be returned. If the index is *negative* the count starts at
 * the end of the list of versions. For example, to get the latest entry you can use an index of
 * -1, -2 for the last but one, etc. If no index is provided the latest version will be returned,
 * equivalent to an index of -1.
 * @returns {Promise<VersionedObjectResult<T>>} The promise is rejected with an Error whose name
 * property is set to "FileNotFoundError" if the object does not exist, or with an Error if it
 * is of the wrong type.
 */
export async function getObjectByIdHash(
    idHash: SHA256IdHash,
    type: any | any[] = '*',
    index: number = -1
): Promise<VersionedObjectResult> {
    const {timestamp, hash} = await getNthVersionMapEntry(idHash, index);
    // When checking the type TypeScript says obj is "any", but it really does find the right
    // type when this function is actually called, using the overloaded definitions for this
    // function.
    const obj = (await getObjectWithType(hash, type)) as OneVersionedObjectTypes;

    return {
        obj,
        hash,
        idHash,
        status: CREATION_STATUS.EXISTS,
        timestamp
    };
}

// // OVERLOADED DEFINITIONS for this function's different parameter options
// export async function getObjectByIdObj<T extends OneVersionedObjectTypes>(
//     obj: T,
//     index?: number
// ): Promise<VersionedObjectResult<OneVersionedObjectInterfaces[T['$type$']]>>;
// export async function getObjectByIdObj<T extends OneIdObjectTypes>(
//     obj: T,
//     index?: number
// ): Promise<VersionedObjectResult<OneVersionedObjectInterfaces[T['$type$']]>>;

/**
 * Frontend for getObjectByIdHash() that accepts an ID object and calculates its crypto hash
 * before calling that function. Returns the latest version of the object specified by the
 * ID hash, or a rejected promise if there is no such object. A type string parameter like in
 * function {@link storage-versioned-objects.module:ts.getObjectByIdHash|getObjectByIdHash} is not
 * needed because we can use the `type` property of the given object.
 * @static
 * @async
 * @param {(OneVersionedObjectTypes|OneIdObjectTypes)} obj - A versioned ONE object of type
 * `<T>` which is used to create its ID hash. Note that it does not have to be an ID object,
 * i.e. it can have more properties than just ID properties, they will be ignored.
 * @param {number} [index=-1] - An optional zero-based integer index (0, 1,...) in the version map
 * from which the object should be returned. If the index is *negative* the count starts at
 * the end of the list of versions. For example, to get the latest entry you can use an index of
 * -1, -2 for the last but one, etc. If no index is provided the latest version will be returned,
 * equivalent to an index of -1.
 * @returns {Promise<VersionedObjectResult<T>>} The promise is rejected with an Error whose name
 * property is set to "FileNotFoundError" if the object does not exist, or with an Error if it
 * is of the wrong type.
 */
export async function getObjectByIdObj<T extends OneVersionedObjectTypes | OneIdObjectTypes>(
    obj: T,
    index: number = -1
): Promise<VersionedObjectResult<OneVersionedObjectInterfaces[T['$type$']]>> {
    const idHash = await calculateIdHashOfObj<T>(obj);
    return (await getObjectByIdHash(idHash, obj.$type$, index)) as VersionedObjectResult<
        OneVersionedObjectInterfaces[T['$type$']]
    >;
}