/** * This file is part of Haketilo. * * Function: Facilitate use of IndexedDB within Haketilo. * * Copyright (C) 2021 Wojtek Kosior * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * As additional permission under GNU GPL version 3 section 7, you * may distribute forms of that code without the copy of the GNU * GPL normally required by section 4, provided you include this * license notice and, in case of non-source distribution, a URL * through which recipients can access the Corresponding Source. * If you modify file(s) with this exception, you may extend this * exception to your version of the file(s), but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * As a special exception to the GPL, any HTML file which merely * makes function calls to this code, and for that purpose * includes it by reference shall be deemed a separate work for * copyright law purposes. If you modify this code, you may extend * this exception to your version of the code, but you are not * obligated to do so. If you do not wish to do so, delete this * exception statement from your version. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * I, Wojtek Kosior, thereby promise not to sue for violation of this file's * license. Although I request that you do not make use this code in a * proprietary program, I am not going to enforce this in court. */ /* * IMPORTS_START * IMPORT initial_data * IMPORT entities * IMPORTS_END */ /* Update when changes are made to database schema. Must have 3 elements */ const db_version = [1, 0, 0]; const nr_reductor = ([i, s], num) => [i - 1, s + num * 1024 ** i]; const version_nr = ver => Array.reduce(ver.slice(0, 3), nr_reductor, [2, 0])[1]; const stores = [ ["files", {keyPath: "hash_key"}], ["file_uses", {keyPath: "hash_key"}], ["resources", {keyPath: "identifier"}], ["mappings", {keyPath: "identifier"}] ]; let db = null; /* Generate a Promise that resolves when an IndexedDB request succeeds. */ async function wait_request(idb_request) { let resolve, reject; const waiter = new Promise((...cbs) => [resolve, reject] = cbs); [idb_request.onsuccess, idb_request.onerror] = [resolve, reject]; return waiter; } /* asynchronous wrapper for IDBObjectStore's get() method. */ async function idb_get(transaction, store_name, key) { const req = transaction.objectStore(store_name).get(key); return (await wait_request(req)).target.result; } /* asynchronous wrapper for IDBObjectStore's put() method. */ async function idb_put(transaction, store_name, object) { return wait_request(transaction.objectStore(store_name).put(object)); } /* asynchronous wrapper for IDBObjectStore's delete() method. */ async function idb_del(transaction, store_name, key) { return wait_request(transaction.objectStore(store_name).delete(key)); } /* Open haketilo database, asynchronously return an IDBDatabase object. */ async function get_db(data=initial_data) { if (db) return db; let resolve, reject; const waiter = new Promise((...cbs) => [resolve, reject] = cbs); const request = indexedDB.open("haketilo", version_nr(db_version)); request.onsuccess = resolve; request.onerror = ev => reject("db error: " + ev.target.errorCode); request.onupgradeneeded = resolve; const event = await waiter; const opened_db = event.target.result; if (event instanceof IDBVersionChangeEvent) { /* * When we move to a new database schema, we will add upgrade logic * here. */ if (event.oldVersion > 0) throw "bad db version: " + event.oldVersion; let store; for (const [store_name, key_mode] of stores) store = opened_db.createObjectStore(store_name, key_mode); const context = make_context(store.transaction, data.files); await _save_items(data.resources, data.mappings, context); } db = opened_db; return db; } /* Helper function used by start_items_transaction() and get_db(). */ function make_context(transaction, files) { files = files || {}; const context = {transaction, files, file_uses: {}}; let resolve, reject; context.result = new Promise((...cbs) => [resolve, reject] = cbs); context.transaction.oncomplete = resolve; context.transaction.onerror = reject; return context; } /* * item_store_names should be an array with either string "mappings", string * "resources" or both. files should be a dict with values being contents of * files that are to be possibly saved in this transaction and keys of the form * `sha256-`. * * Returned is a context object wrapping the transaction and handling the * counting of file references in IndexedDB. */ async function start_items_transaction(item_store_names, files) { const db = await haketilodb.get(); const scope = [...item_store_names, "files", "file_uses"]; return make_context(db.transaction(scope, "readwrite"), files); } async function incr_file_uses(context, file_ref, by=1) { const hash_key = file_ref.hash_key; let uses = context.file_uses[hash_key]; if (uses === undefined) { uses = await idb_get(context.transaction, "file_uses", hash_key); if (uses) [uses.new, uses.initial] = [false, uses.uses]; else uses = {hash_key, uses: 0, new: true, initial: 0}; context.file_uses[hash_key] = uses; } uses.uses = uses.uses + by; } const decr_file_uses = (ctx, file_ref) => incr_file_uses(ctx, file_ref, -1); async function finalize_items_transaction(context) { for (const uses of Object.values(context.file_uses)) { if (uses.uses < 0) console.error("internal error: uses < 0 for file " + uses.hash_key); const is_new = uses.new; const initial_uses = uses.initial; const hash_key = uses.hash_key; delete uses.new; delete uses.initial; if (uses.uses < 1) { if (!is_new) { idb_del(context.transaction, "file_uses", hash_key); idb_del(context.transaction, "files", hash_key); } continue; } if (uses.uses === initial_uses) continue; idb_put(context.transaction, "file_uses", uses); if (initial_uses > 0) continue; const file = context.files[hash_key]; if (file === undefined) { context.transaction.abort(); throw "file not present: " + hash_key; } idb_put(context.transaction, "files", {hash_key, contents: file}); } return context.result; } async function with_items_transaction(cb, item_store_names, files={}) { const context = await start_items_transaction(item_store_names, files); await cb(context); await finalize_items_transaction(context); } /* * How a sample data argument to the function below might look like: * * data = { * resources: { * "resource1": { * "1": { * // some stuff * }, * "1.1": { * // some stuff * } * }, * "resource2": { * "0.4.3": { * // some stuff * } * }, * }, * mappings: { * "mapping1": { * "2": { * // some stuff * } * }, * "mapping2": { * "0.1": { * // some stuff * } * }, * }, * files: { * "sha256-f9444510dc7403e41049deb133f6892aa6a63c05591b2b59e4ee5b234d7bbd99": "console.log(\"hello\");\n", * "sha256-b857cd521cc82fff30f0d316deba38b980d66db29a5388eb6004579cf743c6fd": "console.log(\"bye\");" * } * } */ async function save_items(transaction, data) { const items_store_names = ["resources", "mappings"]; const context = start_items_transaction(items_store_names, data.files); return _save_items(data.resources, data.mappings, context); } async function _save_items(resources, mappings, context) { resources = Object.values(resources || {}).map(entities.get_newest); mappings = Object.values(mappings || {}).map(entities.get_newest); for (const item of resources.concat(mappings)) await save_item(item, context); await finalize_items_transaction(context); } /* * Save given definition of a resource/mapping to IndexedDB. If the definition * (passed as `item`) references files that are not already present in * IndexedDB, those files should be present as values of the `files_sha256` * object with keys being of the form `sha256-`. * * context should be one returned from start_items_transaction() and should be * later passed to finalize_items_transaction() so that files depended on are * added to IndexedDB and files that are no longer depended on after this * operation are removed from IndexedDB. */ async function save_item(item, context) { const store_name = {resource: "resources", mapping: "mappings"}[item.type]; for (const file_ref of entities.get_files(item)) await incr_file_uses(context, file_ref); await _remove_item(store_name, item.identifier, context, false); await idb_put(context.transaction, store_name, item); } /* Helper function used by remove_item() and save_item(). */ async function _remove_item(store_name, identifier, context) { const item = await idb_get(context.transaction, store_name, identifier); if (item !== undefined) { for (const file_ref of entities.get_files(item)) await decr_file_uses(context, file_ref); } } /* * Remove definition of a resource/mapping from IndexedDB. * * context should be one returned from start_items_transaction() and should be * later passed to finalize_items_transaction() so that files depended on are * added to IndexedDB and files that are no longer depended on after this * operation are removed from IndexedDB. */ async function remove_item(store_name, identifier, context) { await _remove_item(store_name, identifier, context); await idb_del(context.transaction, store_name, identifier); } const remove_resource = (identifier, ctx) => remove_item("resources", identifier, ctx); const remove_mapping = (identifier, ctx) => remove_item("mappings", identifier, ctx); const haketilodb = { get: get_db, save_items, save_item, remove_resource, remove_mapping, start_items_transaction, finalize_items_transaction }; /* * EXPORTS_START * EXPORT haketilodb * EXPORT idb_get * EXPORT idb_put * EXPORT idb_del * EXPORTS_END */