Source: version-map-query.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
 */

/**
 * System-internal version maps for versioned objects are CSV files with LF (Unix style newline)
 * line endings and comma-separated pairs of timestamp (left-padded with "0"s to always use 16
 * characters), object-hash. All object hashes in a given version map are versions of the same
 * ID object.
 *
 * Since we only append to the file the latest version can be found at the very end, and the
 * order is a chronological log of version creation.
 *
 * Format:
 *
 * ```
 * timestamp,objectHash
 * timestamp,objectHash
 * timestamp,objectHash
 * ...
 * ```
 *
 * The `timestamp` is the time when the version map entry was made.
 *
 * @module
 */

import type {OneVersionedObjectTypes} from './recipes';
import {STORAGE} from './storage-base-common';
import {readTextFileSection, readUTF8TextFile} from './system/storage-base';
import {calculateIdHashOfObj} from './util/object';
import {serializeWithType} from './util/promise';
import {substrForceMemCopy} from './util/string';
import type {SHA256Hash, SHA256IdHash} from './util/type-checks';
import {ensureHash} from './util/type-checks';
import type {VersionMapEntry} from './version-map-updater';
import {TIMESTAMP_LENGTH} from './version-map-updater';

/**
 * Each entry is "timestamp,hash\n" where the timestamp is a fixed-length string and the SHA-256
 * hash is 64 (hexadecimal) characters long.
 * Total: TIMESTAMP_LENGTH + 1 + 64 + 1
 * @private
 * @type {number}
 */
const LINE_LENGTH = TIMESTAMP_LENGTH + 66;

/**
 * @private
 * @param {SHA256IdHash} idHash - The filename of the map to be loaded, which is the ID hash of a
 * versioned object
 * @param {number} [index=-1] - The zero-based integer index (0, 1,...) in the version map from
 * which the object hash 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, or -2 for the last but one, etc. If no index is given the default is -1 for the latest
 * version.
 * @returns {Promise<string>} Returns a promise resolving with the n-th entry (line) in the version
 * map. THERE ARE NO SANITY CHECKS: We expect this data loaded from storage to be correct.
 */
function getNthLineSerialized(idHash: SHA256IdHash, index: number = -1): Promise<string> {
    return serializeWithType('VersionMap ' + idHash, () =>
        readTextFileSection(idHash, index * LINE_LENGTH, LINE_LENGTH, STORAGE.VMAPS)
    );
}

/**
 * @static
 * @async
 * @param {SHA256IdHash} idHash - The filename of the map to be loaded, which is the ID hash of a
 * versioned object
 * @param {number} [index=-1] - The zero-based integer index (0, 1,...) in the version map from
 * which the object hash 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, or -2 for the last but one, etc. If no index is given the default is -1 for the latest
 * version.
 * @returns {Promise<SHA256Hash>} Returns a promise resolving with the SHA-256 object hash stored
 * for the given index
 */
export async function getNthVersionMapHash<T extends OneVersionedObjectTypes>(
    idHash: SHA256IdHash<T>,
    index: number = -1
): Promise<SHA256Hash<T>> {
    // Line format:
    // timestamp,object-hash\n
    const entry = await getNthLineSerialized(idHash, index);

    return ensureHash(substrForceMemCopy(entry, TIMESTAMP_LENGTH + 1, 64));
}

/**
 * @static
 * @async
 * @param {SHA256IdHash} idHash - The filename of the map to be loaded, which is the ID hash of a
 * versioned object
 * @param {number} [index=-1] - The zero-based integer index (0, 1,...) in the version map from
 * which the object hash 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, or -2 for the last but one, etc. If no index is given the default is -1 for the latest
 * version.
 * @returns {Promise<VersionMapEntry>} Returns a VersionMapEntry object
 */
export async function getNthVersionMapEntry<T extends OneVersionedObjectTypes>(
    idHash: SHA256IdHash<T>,
    index: number = -1
): Promise<VersionMapEntry<T>> {
    // timestamp,object-hash\n
    const entry = await getNthLineSerialized(idHash, index);
    return {
        timestamp: +entry.substr(0, TIMESTAMP_LENGTH),
        hash: ensureHash(substrForceMemCopy(entry, TIMESTAMP_LENGTH + 1, 64))
    };
}

/**
 * Reads the version map with the given ID hash
 * @static
 * @async
 * @param {SHA256IdHash} idHash - The filename of the map to be loaded, which is the ID hash of a
 * versioned object
 * @returns {Promise<VersionMapEntry[]>} Returns a promise resolving with an array
 * of {@link VersionMapEntry} objects
 */
export function getAllVersionMapEntries<T extends OneVersionedObjectTypes>(
    idHash: SHA256IdHash<T>
): Promise<Array<VersionMapEntry<T>>> {
    async function getEntries(): Promise<Array<VersionMapEntry<T>>> {
        const mapData = await readUTF8TextFile(idHash, STORAGE.VMAPS);

        return mapData
            .slice(0, -1) // Remove the final newline character
            .split('\n')
            .map(entry => ({
                timestamp: +entry.substr(0, TIMESTAMP_LENGTH),
                hash: ensureHash(substrForceMemCopy(entry, TIMESTAMP_LENGTH + 1, 64))
            }));
    }

    return serializeWithType('VersionMap ' + idHash, getEntries);
}

/**
 * Reads the version map with the given ID hash
 * @static
 * @async
 * @param {SHA256IdHash} idHash - The filename of the map to be loaded, which is the ID hash of a
 * versioned object
 * @param {SHA256Hash} hash - The hash under the given ID hash for which the creation timestamp
 * should be returned.
 * @returns {Promise<number|undefined>} Returns a promise resolving with the timestamp of the
 * given hash under the given ID hash, or `undefined` if no entry was found for the given hash
 * in the given version map identified by the given ID hash.
 */
export function getTimestampForHash<T extends OneVersionedObjectTypes>(
    idHash: SHA256IdHash<T>,
    hash: SHA256Hash<T>
): Promise<number | void> {
    async function getTimestamp(): Promise<number | void> {
        const mapData = await readUTF8TextFile(idHash, STORAGE.VMAPS);

        // Start from the end to get the latest, instead of the first, entry for that hash
        let pos = mapData.length;

        while (pos > 0) {
            // Each entry is: "timestamp,hash\n"
            pos -= TIMESTAMP_LENGTH + 66;

            if (mapData.substr(pos + TIMESTAMP_LENGTH + 1, 64) === hash) {
                return +mapData.substr(pos, TIMESTAMP_LENGTH);
            }
        }
    }

    return serializeWithType('VersionMap ' + idHash, getTimestamp);
}

/**
 * Frontend for
 * {@link storage.module:ts.getAllVersionMapEntries|`getAllVersionMapEntries`} that accepts an ID
 * object and calculates its crypto hash before calling that function. This function allows to
 * retrieve a list of available versions for a given object ID. It returns an array of numbers
 * (dates in milliseconds since 1/1/1970) and object hashes which can then be used to retrieve a
 * specific version.
 * @static
 * @async
 * @param {OneVersionedObjectTypes} obj - A versioned ONE object 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.
 * @returns {Promise<VersionMapEntry[]>} An array of {@link VersionMapEntry} objects
 */
export async function listVersionsByIdObj(
    obj: Readonly<OneVersionedObjectTypes>
): Promise<VersionMapEntry[]> {
    const idHash = await calculateIdHashOfObj(obj);
    return await getAllVersionMapEntries(idHash);
}