1
0

implement wrapper for single IndexedDB database

This commit is contained in:
Adrian Wannenmacher 2026-02-15 03:34:25 +01:00
parent b2ab89a151
commit 80b7203805
Signed by: tfld
GPG Key ID: 19D986ECB1E492D5
3 changed files with 296 additions and 0 deletions

146
data/db.js Normal file
View File

@ -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,
});
}
}

142
data/db.test.js Normal file
View File

@ -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");
});
});
}

View File

@ -1,11 +1,19 @@
"use strict";
import round from "./models/round.test.js"; import round from "./models/round.test.js";
import roundResult from "./models/round_result.test.js"; import roundResult from "./models/round_result.test.js";
import game from "./models/game.test.js"; import game from "./models/game.test.js";
import session from "./models/session.test.js"; import session from "./models/session.test.js";
import db from "./data/db.test.js";
QUnit.module("models", function() { QUnit.module("models", function() {
round(); round();
roundResult(); roundResult();
game(); game();
session(); session();
}); });
QUnit.module("data", function() {
db();
});