diff --git a/data/db.js b/data/db.js new file mode 100644 index 0000000..1d5db7e --- /dev/null +++ b/data/db.js @@ -0,0 +1,146 @@ +"use strict"; + +export default class WbDb extends EventTarget { + static get EVENT_CHANGE() { return "change"; } + + static get DB_NAME() { return "watterblock"; } + static get DB_NAME_TEST() { return "test-watterblock"; } + static get DB_VERSION() { return 1; } + + static get OS_SESSIONS() { return "sessions"; } + + /** Whether the WbDb constructor may be called. */ + static #mayConstruct = false; + /** The single instance of this class. + * @type {?WbDb} */ + static #instance = null; + + constructor(testing, version) { + if (!WbDb.#mayConstruct) { + throw new TypeError("WbDb may not be constructed externally"); + } + WbDb.#mayConstruct = false; + + super(); + + let req = indexedDB.open( + testing ? WbDb.DB_NAME_TEST : WbDb.DB_NAME, + testing ? version : WbDb.DB_VERSION); + req.addEventListener("blocked", this.#handleBlocked.bind(this)); + req.addEventListener("upgradeneeded", this.#handleUpgrade.bind(this)); + req.addEventListener("error", this.#handleError.bind(this)); + req.addEventListener("success", this.#handleSuccess.bind(this)); + } + + /** Get an instance of `WbDb`. + * + * If `testing` is `true`, a oneof instance is created, that connects to a + * testing database. In that case `version` can be used to set the database + * up for a specific version, so it can be tested. + * + * Otherwise all calls return the same instance. + * + * @param {boolean=} testing Whether to open a regular or testing connection. + * @param {number=} version The version to open a testing connection for. + */ + static get(testing, version) { + if (testing) { + WbDb.#mayConstruct = true; + return new WbDb(true, version ?? WbDb.DB_VERSION); + } + + if (WbDb.#instance === null) { + WbDb.#mayConstruct = true; + WbDb.#instance = new WbDb(); + } + + return WbDb.#instance; + } + + /** The actual IndexedDB. + * @type{?IDBDatabase} + */ + #db = null; + /** Whether the opening of the DB is currently blocked. */ + #blocked = false; + /** Whether opening the DB has failed. */ + #failed = false; + + /** Get the actual database. */ + get db() { + if (this.#db === null) + throw new Error("WbDb is not yet open"); + + return this.#db; + } + + /** Shorthand for `WbDb.get().db()`; */ + static getDb() { + return WbDb.get().db(); + } + + /** Check whether the database opening has been blocked. */ + get blocked() { + return this.#blocked; + } + + /** Check whether the database is opened yet. */ + get open() { + return this.#db !== null; + } + + /** Check whether opening the database has failed. */ + get failed() { + return this.#failed; + } + + /** Handle the `blocked` event for opening the DB. + * @param {IDBVersionChangeEvent} event The actual event. + */ + #handleBlocked(event) { + this.#blocked = true; + this.dispatchEvent(new CustomEvent(WbDb.EVENT_CHANGE)); + } + + /** Handle the `upgradeneeded` event from opening the DB. + * @param {IDBVersionChangeEvent} event The actual event. + */ + #handleUpgrade(event) { + let { + oldVersion: old, + newVersion: now, + target: { result: db, transaction: trans }, + } = event; + + if (old < 1 && now >= 1) + this.#version1(db, trans); + } + + /** Handle the `error` event from opening the DB. + * @param {Event} event The actual event. + */ + #handleError(event) { + this.#failed = true; + this.dispatchEvent(new CustomEvent(WbDb.EVENT_CHANGE)); + } + + /** Handle the `success` event from opening the DB. + * @param {Event} event The actual event. + */ + #handleSuccess(event) { + this.#blocked = false; + this.#db = event.target.result; + this.dispatchEvent(new CustomEvent(WbDb.EVENT_CHANGE)); + } + + /** Do the migration for db version 1. + * @param {IDBDatabase} db The db to upgrade. + * @param {IDBTransaction} trans The db transaction. + */ + #version1(db, trans) { + db.createObjectStore(WbDb.OS_SESSIONS, { + keyPath: "id", + autoIncrement: true, + }); + } +} diff --git a/data/db.test.js b/data/db.test.js new file mode 100644 index 0000000..9ab1039 --- /dev/null +++ b/data/db.test.js @@ -0,0 +1,142 @@ +"use strict"; + +import { Round } from "../models/round.js"; +import WbDb from "./db.js"; + +/** The instance used for the current test. + * + * Put test instances of `WbDb` into this variable. If it is used, the + * connection is automatically closed after the test is done. That in turn + * means that the database can then be deleted immediately, thus speeding up + * the next test. + * + * @type {WbDb} + */ +let inst = null; + +/** Wait for the `WbDb.EVENT_CHANGE` to be fired on `instance`. + * + * Uses the `inst` global variable if no `instance` parameter is passed. + * + * @param {WbDb=} instance The instance to wait on. + */ +function waitForChange(instance) { + return new Promise(function(resolve) { + (instance ?? inst).addEventListener(WbDb.EVENT_CHANGE, function() { + resolve(); + }); + }); +} + +export default function() { + QUnit.module("db", function(hooks) { + + // delete test database before each test + hooks.beforeEach(function() { + return new Promise(function(resolve) { + let req = indexedDB.deleteDatabase(WbDb.DB_NAME_TEST); + req.onsuccess = function() { + resolve(); + }; + }); + }); + + hooks.afterEach(function() { + if (inst !== null) + if (inst.open) + inst.db.close(); + inst = null; + }); + + QUnit.test.if( + "cleanup works", + // not yet baseline widely available, should be in November 2026 + // TODO: check in November 2026, make unconditional if possible then + "databases" in indexedDB, + async function(assert) { + let dbs = await indexedDB.databases(); + assert.true( + dbs.every(({name}) => name !== WbDb.DB_NAME_TEST), + "no testing db"); + }); + + QUnit.test("cannot call constructor", function(assert) { + assert.throws(function() { + new WbDb(); + }); + + assert.throws(function() { + new WbDb(true); + }); + + assert.throws(function() { + new WbDb(true, 1); + }); + }); + + QUnit.test("open db", async function(assert) { + inst = WbDb.get(true); + await waitForChange(); + + assert.false(inst.blocked, "not blocked"); + assert.false(inst.failed, "not failed"); + assert.true(inst.open, "is open"); + assert.notStrictEqual(inst.db, null, "getting db succeeds"); + assert.strictEqual(inst.db.version, WbDb.DB_VERSION, "correct version"); + }); + + QUnit.test("opening is blocked", async function(assert) { + let first = WbDb.get(true, 1); + await waitForChange(first); + assert.true(first.open, "first instance opened"); + assert.strictEqual(first.db.version, 1, "first instance is version 1"); + + inst = WbDb.get(true, 2); + await waitForChange(); + assert.true(inst.blocked, "second instance blocked"); + assert.false(inst.open, "second instance not open"); + assert.false(inst.failed, "second instance not failed"); + + first.db.close(); + await waitForChange(); + assert.false(inst.blocked, "second instance not blocked"); + assert.true(inst.open, "second instance open"); + assert.false(inst.failed, "second instance not failed"); + assert.strictEqual(inst.db.version, 2, "second instance is version 2"); + }); + + QUnit.test("opening fails", async function(assert) { + let first = WbDb.get(true, 2); + await waitForChange(first); + assert.true(first.open, "first instance opened"); + assert.strictEqual(first.db.version, 2, "first instance is version 2"); + first.db.close(); + + let second = WbDb.get(true, 1) + await waitForChange(second); + if (second.blocked) + await waitForChange(second); + + assert.false(second.blocked, "second instance not blocked"); + assert.false(second.open, "second instance not open"); + assert.true(second.failed, "second instance failed"); + }); + + QUnit.test("schema version 1", async function(assert) { + inst = WbDb.get(true, 1); + await waitForChange(); + assert.true(inst.open, "db is opened"); + assert.strictEqual(inst.db.version, 1, "db is version 1"); + + let osn = inst.db.objectStoreNames; + assert.strictEqual(osn.length, 1, "correct number of object stores"); + assert.true(osn.contains(WbDb.OS_SESSIONS), "contains sessions"); + + let trans = inst.db.transaction(osn); + let sessions = trans.objectStore(WbDb.OS_SESSIONS); + assert.strictEqual(sessions.keyPath, "id", "sessions keyPath"); + assert.true(sessions.autoIncrement, "sessions autoIncrement"); + assert.strictEqual(sessions.indexNames.length, 0, "sessions no indexes"); + }); + }); +} diff --git a/test.js b/test.js index 9311c67..92fb8a4 100644 --- a/test.js +++ b/test.js @@ -1,11 +1,19 @@ +"use strict"; + import round from "./models/round.test.js"; import roundResult from "./models/round_result.test.js"; import game from "./models/game.test.js"; import session from "./models/session.test.js"; +import db from "./data/db.test.js"; + QUnit.module("models", function() { round(); roundResult(); game(); session(); }); + +QUnit.module("data", function() { + db(); +});