Home Reference Source Test

src/main/backend/indexeddb/JungleDB.js

/**
 * @implements {IJungleDB}
 */
class JungleDB {
    /**
     * Initiates a new database connection. All changes to the database structure
     * require an increase in the version number.
     * Whenever new object stores need to be created, old ones deleted,
     * or indices created/deleted, the dbVersion number has to be increased.
     * When the version number increases, the given function onUpgradeNeeded is called
     * after modifying the database structure.
     * @param {string} name The name of the database.
     * @param {number} dbVersion The current version of the database.
     * @param {{onUpgradeNeeded:?function(oldVersion:number, newVersion:number, jdb:JungleDB)}} [options]
     */
    constructor(name, dbVersion, options = {}) {
        if (dbVersion <= 0) throw new Error('The version provided must not be less or equal to 0');
        this._databaseDir = name;
        this._dbVersion = dbVersion;
        this._oldDbVersion = null;
        this._onUpgradeNeeded = options.onUpgradeNeeded;
        this._connected = false;
        this._objectStores = new Map();
        this._objectStoreBackends = new Map();
        this._objectStoresToDelete = [];
    }

    /**
     * @type {IDBFactory} The browser's IDB factory.
     * @private
     */
    get _indexedDB() {
        return window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.OIndexedDB || window.msIndexedDB;
    }

    /**
     * Connects to the indexedDB.
     * @returns {Promise.<IDBDatabase>} A promise resolving on successful connection.
     * The raw IDBDatabase object should not be used.
     */
    connect() {
        if (this._db) return Promise.resolve(this._db);

        const request = this._indexedDB.open(this._databaseDir, this._dbVersion);
        const that = this;

        return new Promise((resolve, reject) => {
            request.onsuccess = async () => {
                that._connected = true;
                that._db = request.result;

                // Call user defined function if requested.
                if (that._oldDbVersion !== null && that._onUpgradeNeeded) {
                    await that._onUpgradeNeeded(that._oldDbVersion, that._dbVersion, that);
                }

                resolve(request.result);
            };

            request.onerror = reject;
            request.onupgradeneeded = event => that._initDB(event, request);
        });
    }

    /**
     * Internal method that is called when a db upgrade is required.
     * @param {IDBVersionChangeEvent} event The obupgradeneeded event.
     * @param {IDBRequest} request
     * @returns {Promise.<void>} A promise that resolves after successful completion.
     * @private
     */
    async _initDB(event, request) {
        const db = event.target.result;

        // Delete existing ObjectStores.
        for (const { tableName, upgradeCondition } of this._objectStoresToDelete) {
            if (db.objectStoreNames.contains(tableName) && (upgradeCondition === null || upgradeCondition === true || (typeof upgradeCondition === 'function' && upgradeCondition(event.oldVersion, event.newVersion)))) {
                db.deleteObjectStore(tableName);
            }
        }
        this._objectStoresToDelete = [];

        // Create new ObjectStores.
        for (const [tableName, { backend, upgradeCondition }] of this._objectStoreBackends) {
            let IDBobjStore;
            // Only check upgradeCondition if object store does not already exist!
            if (!db.objectStoreNames.contains(tableName)
                && (upgradeCondition === null || upgradeCondition === true
                    || (typeof upgradeCondition === 'function' && upgradeCondition(event.oldVersion, event.newVersion)))) {
                IDBobjStore = db.createObjectStore(tableName);
            } else {
                IDBobjStore = request.transaction.objectStore(tableName);
            }
            // Create indices.
            backend.init(IDBobjStore, event.oldVersion, event.newVersion);
        }

        this._oldDbVersion = event.oldVersion;

        this._objectStoreBackends.clear();
    }

    /** @type {IDBDatabase} The underlying IDBDatabase. */
    get backend() {
        return this._db;
    }

    /** @type {boolean} Whether a connection is established. */
    get connected() {
        return this._connected;
    }

    /**
     * Returns the ObjectStore object for a given table name.
     * @param {string} tableName The table name to access.
     * @returns {ObjectStore} The ObjectStore object.
     */
    getObjectStore(tableName) {
        return this._objectStores.get(tableName);
    }

    /**
     * Creates a volatile object store (non-persistent).
     * @param {{codec:?ICodec}} [options] An options object.
     * @returns {ObjectStore}
     */
    static createVolatileObjectStore(options = {}) {
        const { codec = null } = options || {};
        return new ObjectStore(new InMemoryBackend('', codec), null);
    }

    /**
     * Creates a new object store (and allows to access it).
     * This method always has to be called before connecting to the database.
     * If it is not called, the object store will not be accessible afterwards.
     * If a call is newly introduced, but the database version did not change,
     * the table does not exist yet.
     * @param {string} tableName The name of the object store.
     * @param {ObjectStoreConfig} [options] An options object.
     * @returns {IObjectStore}
     */
    createObjectStore(tableName, options = {}) {
        const { codec = null, persistent = true, upgradeCondition = null, enableLruCache = true, lruCacheSize = CachedBackend.MAX_CACHE_SIZE, rawLruCacheSize = 0 } = options || {};

        if (this._connected) throw new Error('Cannot create ObjectStore while connected');
        if (this._objectStores.has(tableName)) {
            return this._objectStores.get(tableName);
        }

        // Create backend
        let backend = null;
        if (persistent) {
            backend = new IDBBackend(this, tableName, codec);
        } else {
            backend = new InMemoryBackend(tableName, codec);
        }
        // Create cache if enabled
        let cachedBackend = backend;
        if (persistent && enableLruCache) {
            cachedBackend = new CachedBackend(backend, lruCacheSize, rawLruCacheSize);
        }

        const objStore = new ObjectStore(cachedBackend, this, tableName);
        this._objectStores.set(tableName, objStore);
        this._objectStoreBackends.set(tableName, { backend, upgradeCondition });
        return objStore;
    }

    /**
     * Deletes an object store.
     * This method has to be called before connecting to the database.
     * @param {string} tableName
     * @param {{upgradeCondition:?boolean|?function(oldVersion:number, newVersion:number):boolean}, indexNames:Array.<string>} [options]
     */
    deleteObjectStore(tableName, options = {}) {
        let { upgradeCondition = null } = options || {};

        if (this._connected) throw new Error('Cannot delete ObjectStore while connected');
        this._objectStoresToDelete.push({ tableName, upgradeCondition });
    }

    /**
     * Closes the database connection.
     * @returns {Promise} The promise resolves after closing the database.
     */
    async close() {
        if (this._connected) {
            this._connected = false;
            this.backend.close();
        }
    }

    /**
     * Fully deletes the database.
     * @returns {Promise} The promise resolves after deleting the database.
     */
    async destroy() {
        await this.close();
        return new Promise((resolve, reject) => {
            const req = this._indexedDB.deleteDatabase(this._databaseDir);
            req.onsuccess = resolve;
            req.onerror = reject;
        });
    }

    /**
     * Is used to commit multiple transactions atomically.
     * This guarantees that either all transactions are written or none.
     * The method takes a list of transactions (at least two transactions).
     * If the commit was successful, the method returns true, and false otherwise.
     * @param {Transaction|CombinedTransaction} tx1 The first transaction
     * (a CombinedTransaction object is only used internally).
     * @param {Transaction} tx2 The second transaction.
     * @param {...Transaction} txs A list of further transactions to commit together.
     * @returns {Promise.<boolean>} A promise of the success outcome.
     */
    static async commitCombined(tx1, tx2, ...txs) {
        // If tx1 is a CombinedTransaction, flush it to the database.
        if (tx1 instanceof CombinedTransaction) {
            const functions = [];
            /** @type {Array.<EncodedTransaction>} */
            const encodedTxs = [];
            const tableNames = [];

            const infos = await Promise.all(tx1.transactions.map(tx => tx.objectStore._backend.applyCombined(tx)));
            for (const info of infos) {
                let tmp = info;
                if (!Array.isArray(info)) {
                    tmp = [info];
                }
                for (const innerInfo of tmp) {
                    if (typeof innerInfo === 'function') {
                        functions.push(innerInfo);
                    } else {
                        encodedTxs.push(innerInfo);
                        tableNames.push(innerInfo.tableName);
                    }
                }
            }

            const db = tx1.backend !== null ? tx1.backend.backend : null;
            return new Promise((resolve, reject) => {
                if (tableNames.length > 0) {
                    const idbTx = db.transaction(tableNames, 'readwrite');

                    for (const encodedTx of encodedTxs) {
                        const objSt = idbTx.objectStore(encodedTx.tableName);

                        if (encodedTx.truncated) {
                            objSt.clear();
                        }
                        for (const key of encodedTx.removed) {
                            objSt.delete(key);
                        }
                        for (const [key, value] of encodedTx.modified) {
                            objSt.put(value, key);
                        }
                    }

                    idbTx.oncomplete = () => {
                        Promise.all(functions.map(f => f())).then(() => {
                            resolve(true);
                        });
                    };
                    idbTx.onerror = reject;
                    idbTx.onabort = reject;
                } else {
                    Promise.all(functions.map(f => f())).then(() => {
                        resolve(true);
                    });
                }
            });
        }
        txs.push(tx1);
        txs.push(tx2);
        if (!txs.every(tx => tx instanceof Transaction)) {
            throw new Error('Invalid arguments supplied');
        }
        const ctx = new CombinedTransaction(...txs);
        return ctx.commit();
    }

    toString() {
        return `JungleDB{name=${this._databaseDir}}`;
    }
}
/**
 * Empty encodings.
 */
JungleDB.JSON_ENCODING = {};
JungleDB.BINARY_ENCODING = {};
JungleDB.STRING_ENCODING = {};
JungleDB.NUMBER_ENCODING = {};
JungleDB.GENERIC_ENCODING = {};
Class.register(JungleDB);