Source: storage-unversioned-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 SET operation(s) for ONE objects written without version history
 * mechanism. Exactly one file is created. This type is only used for write operations because
 * read operations of objects directly return the object.
 * @global
 * @typedef {object} UnversionedObjectResult
 * @property {OneObjectTypes} [obj] - The ONE object that was stored. This is only set for ONE
 * objects representable as Javascript objects. It is undefined for CLOBs and BLOBs. This link
 * to the original object is included for easier chaining of promises when the next one in the
 * chain also needs access to the object and not just the result of storing it.
 * @property {SHA256Hash} hash - The crypto hash of the microdata representation of the object
 * and also the filename.
 * @property {FileCreationStatus} status - A string constant showing whether the file
 * already existed or if it had to be created.
 */
export interface UnversionedObjectResult<
    T extends OneUnversionedObjectTypes = OneUnversionedObjectTypes
> {
    readonly obj: T;
    hash: SHA256Hash<T>;
    idHash?: void;
    status: FileCreationStatus;
    timestamp?: void;
}

import {createError} from './errors';
import {convertMicrodataToObject} from './microdata-to-object';
import {isVersionedObject} from './object-recipes';
import {convertObjToMicrodata} from './object-to-microdata';
import type {
    OneObjectInterfaces,
    OneObjectTypeNames,
    OneObjectTypes,
    OneUnversionedObjectTypes
} from './recipes';
import {reverseMapUpdater} from './reverse-map-updater';
import type {FileCreationStatus} from './storage-base-common';
import {storeUTF8Clob} from './storage-blob';
import {setIdHash} from './storage-id-hash-cache';
import {readUTF8TextFile} from './system/storage-base';
import {clone} from './util/clone-object';
import type {OneEventSource, OneEventSourceConsumer} from './util/one-event-source';
import {createEventSource} from './util/one-event-source';
import type {SHA256Hash} from './util/type-checks';

/**
 * 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 {OneEventSource<UnversionedObjectResult>}
 */
export const unversionedObjEvent: OneEventSource<UnversionedObjectResult> = createEventSource();

/**
 * @static
 * @type {OneEventSourceConsumer<UnversionedObjectResult>}
 */
export const onUnversionedObj: OneEventSourceConsumer<UnversionedObjectResult> =
    unversionedObjEvent.consumer;

/**
 * Converts the input object to microdata (string) and then calls storeUTF8Clob()
 * @see {@link storage-base-common.module:ts.storeUTF8Clob|storage-base-common.storeUTF8Clob}
 * @static
 * @async
 * @param {OneObjectTypes} obj - An unversioned 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.
 * @returns {Promise<UnversionedObjectResult>} A promise with the result of the object
 * creation.
 */
export async function storeUnversionedObject<T extends OneUnversionedObjectTypes>(
    obj: T
): Promise<UnversionedObjectResult<T>> {
    if (isVersionedObject(obj)) {
        throw createError('SUO-SO1', {obj});
    }

    // Trading (mostly memory-) efficiency for correctness: Insurance against mutation. We
    // return the object in the result, and in that result the included object should match the
    // hash even if the original object is mutated by the caller.
    // TODO Get rid of "obj" in the creation result object
    const clonedObj = clone(obj);

    const objMicrodata = convertObjToMicrodata(clonedObj);

    const objFileWriteResult = await storeUTF8Clob<T>(objMicrodata);

    // Optional, an optimization. Useful when the object written is referenced by another one
    // and the reverseMapUpdater then attempts to find the ID hash because it does not
    // know whether it is looking at a "Reference" to a versioned or to an unversioned object
    // and would have to look at the object to find out.
    setIdHash(objFileWriteResult.hash, null);

    // NOT ATOMIC: We update the *referenced* objects, not the one we just wrote. It is possible
    // for a reverse map read to occur between the writing of the current object here and
    // updates of the reverse maps, so the read would not include the link back to the object
    // just written. We would need to lock all referenced objects before writing the current
    // object here, at this point that seems unnecessary. TODO Is it?
    await reverseMapUpdater(clonedObj, objFileWriteResult);

    const objCreationResult: UnversionedObjectResult<T> = {
        obj: clonedObj,
        hash: objFileWriteResult.hash,
        status: objFileWriteResult.status
    };

    unversionedObjEvent.dispatch(objCreationResult);

    return objCreationResult;
}

/**
 * Reads the microdata string of a ONE object from storage and converts it to a Javascript
 * representation.
 * @static
 * @async
 * @param {SHA256Hash} hash - A filename
 * @returns {Promise<OneObjectTypes>} Resolves with a ONE object created from the contents of the
 * file referenced by "hash" - if possible. The promise is rejected with an Error whose name
 * property is set to "FileNotFoundError" if the object does not exist.
 */
export async function getObject<T extends OneObjectTypes>(hash: SHA256Hash<T>): Promise<T> {
    // Only the asynchronous function is inside the try block, convertObjToMicrodata() is
    // not because it is synchronous and if function throws it already produces a full stack
    // trace that we don't want to add anything to.
    const microdata = await readUTF8TextFile(hash);

    // NO TYPE GUARANTEE: During runtime hashes have no type-tag, so we cannot check if the type
    // given through the type annotation is correct. Only an additional parameter available at
    // runtime can achieve type safety: see getObjectWithType
    return convertMicrodataToObject(microdata) as T;
}

// OVERLOADED DEFINITIONS for this function's different parameter options
export function getObjectWithType<T extends OneObjectTypes>(hash: SHA256Hash<T>): Promise<T>;
export function getObjectWithType<T extends OneObjectTypes>(
    hash: SHA256Hash<T>,
    type: '*'
): Promise<OneObjectTypes>;
export function getObjectWithType<T extends OneObjectTypeNames>(
    hash: SHA256Hash,
    type: T
): Promise<OneObjectInterfaces[T]>;
export function getObjectWithType<T extends OneObjectTypeNames>(
    hash: SHA256Hash,
    type: T[]
): Promise<OneObjectInterfaces[T]>;

/**
 * Same as {@link storage-unversioned-objects.module:ts.getObject|getObject}, but during loading
 * the microdata-to-object converter checks the type. This is especially useful when
 * programming with a type checker. Unlike not statically checking types or using type-casts and
 * assuming the loaded object has the correct type using this function provides both static
 * development-time type checks (if the type checker "TypeScript" is used) and runtime type
 * checks, which catches errors when the code is wrong about what kind of ONE object it expects
 * from a given hash.
 * @see {@link storage-unversioned-objects.module:ts.getObject|getObject}
 * @static
 * @async
 * @param {SHA256Hash} hash - A filename
 * @param {(OneObjectTypeNames|OneObjectTypeNames[])} type - Any one of the type string
 * constant from ONE object recipes for any ONE object, or an array of those names.
 * @returns {Promise<OneObjectTypes>} Resolves with a ONE object created from the contents of the
 * file referenced by "hash" - if possible. 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 getObjectWithType<T extends OneObjectTypeNames>(
    hash: SHA256Hash<OneObjectInterfaces[T]>,
    type: T | T[] | '*' = '*'
): Promise<OneObjectTypes> {
    // Only the asynchronous function is inside the try block, microdata-to-object.js
    // convertMicrodataToObject() is not because it is synchronous and if function throws it
    // already produces a full stack trace that we don't want to add anything to.
    const microdata = await readUTF8TextFile(hash);

    // UNDOCUMENTED in the interface: "type" can also be '*' for any "type". This is a trick so
    // that this function can be called by getObjectByIdHash, where the "type" parameter is
    // optional, but where we want to benefit from the type checks if one is given. This
    // does not affect the JS behavior, this function was made extra in addition to getObject
    // instead of just having an optional "type" parameter there solely to benefit from type
    // checks, and this still works, when a type is given.

    return convertMicrodataToObject(microdata, type);
}