Source: storage.js

/**
 * This module provides convenient storage abstractions on top of extension local
 * storage. These abstractions minimize code duplication and opportunities for
 * error, and allow us to switch the underlying storage implementation in future.
 * 
 * Rally studies are welcome to choose any WebExtensions compatible storage option,
 * including this module, extension local storage, IndexedDB, or an IndexedDB wrapper
 * (e.g., Dexie.js).
 * 
 * @see {@link https://dexie.org/}
 * @module storage
 */

import * as permissions from "./permissions.js";

/**
 * Whether permissions have been checked for the module.
 * @type {boolean}
 * @private
 */
let checkedPermissions = false;

/**
 * Check permissions for the module.
 * @private
 */
function checkPermissions() {
    if(checkedPermissions) {
        return;
    }
    checkedPermissions = true;
    permissions.check({
        module: "webScience.storage",
        requiredPermissions: [ "storage" ],
        suggestedPermissions: [ "unlimitedStorage" ]
    });
}

/**
 * Create a key-value storage area.
 * @param {string} storageAreaName - A name that uniquely identifies the storage area.
 * @returns {KeyValueStorage} The new KeyValueStorage object.
 * @example const exampleStorage = webScience.storage.createKeyValueStorage("exampleName");
 */
export function createKeyValueStorage(storageAreaName) {
    checkPermissions();
    return new KeyValueStorage(storageAreaName);
}

/**
 * Class for a key-value storage area, where the key is a string and the value can have
 * any of a number of basic types. The class is modeled on the built-in Map type, but
 * backed by persistent storage and using Promise return values. Create instances of the
 * class with `createKeyValueStorage`.
 * @hideconstructor
 */
class KeyValueStorage {
    /**
     * Create a key-value storage area. Storage is implemented with extension local storage.
     * @param {string} storageAreaName - A name that uniquely identifies the storage area.
     */
    constructor(storageAreaName) {
        this.storageAreaName = storageAreaName;
        return this;
    }

    /**
     * Convert a key used in a storage area to a key in extension local storage. 
     * @param {string} key - The key used in the storage area.
     * @returns {string} A key in extension local storage.
     * @private
     */
    keyToExtensionLocalStorageKey(key) {
        return `webScience.storage.keyValueStorage.${this.storageAreaName}.${key}`;
    } 

    /**
     * Get a value from storage by its key.
     * @param {string} key - The key to use in the storage area.
     * @returns {Promise} A Promise that resolves to the key's value in the storage
     * area, or null if the key is not in the storage area. Note that this is slightly
     * different behavior from Map, which returns undefined if a key is not present.
     */
    async get(key) {
        const storageResult = await browser.storage.local.get({ [this.keyToExtensionLocalStorageKey(key)]: null });
        return storageResult[this.keyToExtensionLocalStorageKey(key)];
    }

    /**
     * Set a key-value pair in storage.
     * @param {string} key - The key to use in the storage area.
     * @param {*} value - The value to store in the storage area for the key.
     */
    async set(key, value) {
        await browser.storage.local.set({ [this.keyToExtensionLocalStorageKey(key)]: value });
    }

    /**
     * Check whether a key is associated with a value in the storage area.
     * @param {string} key - The key to use in the storage area.
     * @returns {Promise<boolean>} Whether the key is associated with a value in the
     * storage area.
     */
    async has(key) {
        const extensionLocalStorageKey = this.keyToExtensionLocalStorageKey(key);
        const storageResult = await browser.storage.local.get(extensionLocalStorageKey);
        return extensionLocalStorageKey in storageResult;
    }

    /**
     * Delete a key-value pair from storage.
     * @param {string} key - The key to use in the storage area.
     * @returns {Promise<boolean>} Whether the key was in use in the storage area.
     */
    async delete(key) {
        const hadKey = await this.has(key);
        if(hadKey) {
            await browser.storage.local.remove(key);
        }
        return hadKey;
    }

    /**
     * Convert the storage area into an object. Note that this function
     * loads and iterates all key-value pairs in extension local storage,
     * so it may have performance implications.
     * @returns {Promise<Object>} A promise that resolves to an object
     * where properties are keys in the storage area and values are stored
     * values.
     */
    async toObject() {
        const storagePrefix = this.keyToExtensionLocalStorageKey("");
        const storageEntries = await browser.storage.local.get();
        const outputEntries = { };
        for(const key in storageEntries) {
            if(key.startsWith(storagePrefix)) {
                outputEntries[key.substring(storagePrefix.length)] = storageEntries[key];
            }
        }
        return outputEntries;
    }

    /**
     * Create an iterator over key-value pairs in the storage area. Note
     * that this function loads and iterates all key-value pairs in extension
     * local storage, so it may have performance implications.
     * @returns {Promise<Object>} A Promise that resolves to an iterator over
     * the key-value pairs in the storage area.
     */
    async entries() {
        return Object.entries(await this.toObject()).values();
    }

    /**
     * Create an iterator over keys in the storage area. Note that this
     * function loads and iterates all key-value pairs in extension local
     * storage, so it may have performance implications.
     * @returns {Promise<Object>} A Promise that resolves to an iterator over
     * the keys in the storage area.
     */
    async keys() {
        return Object.keys(await this.toObject()).values();
    }

    /**
     * Create an iterator over values in the storage area. Note that this
     * function loads and iterates all key-value pairs in extension local
     * storage, so it may have performance implications.
     * @returns {Promise<Object>} A Promise that resolves to an iterator over the
     * values in the storage area.
     */
    async values() {
        return Object.values(await this.toObject()).values();
    }

    /**
     * Clear all key-value pairs in the storage area. Note that this
     * function loads and iterates all key-value pairs in extension local
     * storage, so it may have performance implications.
     */
    async clear() {
        const storagePrefix = this.keyToExtensionLocalStorageKey("");
        const storageEntries = await browser.storage.local.get();
        const keysToRemove = [ ];
        for(const key in storageEntries) {
            if(key.startsWith(storagePrefix)) {
                keysToRemove.push(key);
            }
        }
        await browser.storage.local.remove(keysToRemove);
    }
}

/**
 * Create a persistent counter.
 * @param {string} counterName - A name that uniquely identifies the counter.
 * @returns {Promise<Counter>} A Promise that resolves to the new Counter object.
 */
export async function createCounter(counterName) {
    checkPermissions();
    const counter = new Counter(counterName);
    await counter.initialize();
    return counter;
}

/**
 * Class for maintaining persistent counters (e.g., unique IDs). Create instances of the
 * class with `createCounter`.
 * @hideconstructor
 */
class Counter {
    /**
     * Create a persistent counter. Note that, because creating a counter
     * requires asynchronous calls (which cannot happen in a constructor),
     * the counter will not be setup until a subsequent call to `initialize()`.
     * @param {string} counterName - A name that uniquely identifies the counter.
     * @example var exampleCounter = await (new Counter("exampleName")).initialize();
     */
    constructor(counterName) {
        this.counterName = counterName;
        this.counterValue = 0;
    }

    /**
     * Complete creation of the persistent counter. Returns itself for convenience.
     * @returns {Object} The persistent counter.
     * @private
     */
    async initialize() {
        if(Counter.storage === null) {
            Counter.storage = new KeyValueStorage("webScience.storage.counter");
        }
        const initialCounterValue = await Counter.storage.get(this.counterName);
        if(initialCounterValue !== null) {
            this.counterValue = initialCounterValue;
        }
        else {
            await Counter.storage.set(this.counterName, this.counterValue);
        }
        return this;
    }

    /**
     * Get the current value of the counter. The value is cached in memory, which allows
     * this function to be synchronous.
     * @returns {number} The current value of the counter.
     */
    get() {
        return this.counterValue;
    }

    /**
     * Increment the value of the counter by a number and return the incremented value.
     * The cached counter value is synchronously incremented; the stored
     * counter value is asynchronously incremented.
     * @param {number} incrementValue - The amount to increment the counter.
     * @returns {Promise<number>} - The counter value after incrementing.
     */
    async incrementByAndGet(incrementValue) {
        const currentCounterValue = (this.counterValue = this.counterValue + incrementValue);
        await Counter.storage.set(this.counterName, this.counterValue);
        return currentCounterValue;
    }

    /**
     * Increment the value of the counter, ignoring the value. Identical to
     * the Promise returned by `counter.incrementByAndGet.then(value => return)`.
     * @param {number} incrementValue - The amount to increment the counter.
     */
    async incrementBy(incrementValue) {
        await this.incrementByAndGet(incrementValue);
        return;
    }

    /**
     * Increment the value of the counter and return the incremented value.
     * The cached counter value is synchronously incremented; the stored
     * counter value is asynchronously incremented.
     * @returns {Promise<number>} - The counter value after incrementing.
     */
    async incrementAndGet() {
        // Saving the current counter value to avoid race conditions during
        // the asynchronous save to storage
        const currentCounterValue = (this.counterValue = this.counterValue + 1);
        await Counter.storage.set(this.counterName, this.counterValue);
        return currentCounterValue;
    }

    /**
     * Increment the value of the counter and return the value prior to
     * incrementing. Identical to the Promise returned by
     * `counter.incrementAndGet().then(value => return value - 1)`.
     * @returns {Promise<number>} - The counter value before incrementing.
     */
    async getAndIncrement() {
        const ret = await this.incrementAndGet() - 1;
        return ret;
    }

    /**
     * Increment the value of the counter, ignoring the value. Identical to
     * the Promise returned by `counter.incrementAndGet.then(value => return)`.
     */
    async increment() {
        await this.incrementAndGet();
        return;
    }

    async getAndReset() {
        const currentCounterValue = this.counterValue;
        this.counterValue = 0;
        await Counter.storage.set(this.counterName, this.counterValue);
        return currentCounterValue;
    }
}

// Workaround for static class variable
Counter.storage = null;