promised-db
v3.0.0
Published
Use IndexedDB with Promises and quality of life features
Downloads
9
Maintainers
Readme
PromisedDB
A library with TypeScript support for a better experience using IndexedDB, wrapping the event handlers and clumsy cursor API while still being able to use IndexedDB as normal as possible.
You also get timeouts in transactions, a migration-based workflow and promise-based event signals to coordinate database version conflicts.
Opening a database
IndexedDB databases are separated by origin (domain name + port + protocol) and are stored on the end-user's computer. Each database has a unique name and a version, which is like a revision.
Creating a PromisedDB instance opens up an existing database or creates a new one if no database by that name exists. When a new database is created or if your code introduces changes to the database schema, the database needs to be upgraded. The easiest way to do this is via a list of migrations:
import { PromisedDB } from "promised-db";
const migrations = [
(db: IDBDatabase) => {
// first migration, create initial stores and indexes (version 0 -> 1)
const users = db.createObjectStore("myUsers", { keyPath: "userID" });
users.createIndex("userEmail", "email", { unique: true });
},
(db: IDBDatabase) => {
// second migration, first update to the schema (version 1 -> 2)
const users = db.objectStore("myUsers");
users.name = "users";
const things = db.createObjectStore("things", { keyPath: "thing_name" });
}
// etc
];
const pdb = new PromisedDB("mydb", migrations);
Each function runs one migration similar to how they are managed in many server frameworks. The number of migrations equals the current version of the database. PromisedDB will automatically call the correct migrations for new and existing databases.
Manual Versioning
If you need more fine-grained control over versions and the upgrade process you can also specify a version number and a single upgrade function:
const pdb = new PromisedDB("mydb", 2,
(db, versionOnDisk) => {
if (versionOnDisk < 1) {
const stuff = db.createObjectStore("stuff", { keyPath: "index" });
stuff.createIndex("userID", "userID", { unique: true });
}
if (versionOnDisk < 2) {
// changes from v1 to v2
}
// ...etc
});
Versions are integers (whole numbers) and cannot be less than zero.
Handling version conflicts
IndexedDB is typically used in web apps that may stay open in a tab for long periods of time, sometimes days or even longer. If a user opens a new tab with your app then your new code may have changed to use a newer version of the database.
Both the app trying to upgrade and any apps running with older versions will be
notified of this situation and you can attach handlers to the blocked
and
outdated
promises on your pdb instance to handle it:
let waiting = false;
const pdb = new PromisedDB(...);
// `blocked` and `opened` are for the newer app that is trying to upgrade the database
pdb.blocked.then(() => {
waiting = true;
// Show some UI to ask the user to reload or close any other tabs running the same app.
});
pdb.opened.then(() => {
if (waiting) {
waiting = false;
// Outdated connections have been closed and the database was upgraded, continue as per usual.
}
});
// The `outdated` promise will resolve on apps running older code to notify them that they
// are blocking the newer code from proceeding.
pdb.outdated.then(() => {
// Recommended course of action is to save any outstanding data
// and then close the connection or to reload the current window if
// that would not put the app in a state that would surprise the user.
saveData();
pdb.close();
showReloadUI();
});
Handling this situation is optional. If you do not act on blocked
or outdated
signals
the newer code will not connect and the older code will continue blissfully unaware and your
users will curse your name and think your app is broken.
Transactions
Every read/write operation on the db is done in a transaction, start one using the
transaction
method:
// as with IDB, you specify the stores involved in this request
// and either "readonly" or "readwrite" as access type
const trans = pdb.transaction(["stuff", "morestuff"], "readwrite",
// you pass a function that constitutes the actual transaction
// you get the IDBTransaction and a helpers object as parameters (see doc below)
(tx, {request, timeout, cursor, keyCursor}) => {
// have this request timeout and abort after 5 seconds (optional)
timeout(5000);
// tx is a standard IDBTransaction interface
const stuff = tx.objectStore("stuff");
// use request(r: IDBRequest) to Promise-wrap any IDB request
// this includes: get(), put(), update(), delete(), count(), getAll(), getAllKeys(), etc.
// provide the type of the result to get a typed promise
const itemProm = request<MyItem>(stuff.get(someKey));
// Use cursor or keyCursor to build a fluent cursor object to iterate
// over either all rows or those within a `range`, if provided.
// `direction` is "next" | "prev" | "nextunique" | "prevunique", default "next"
cursor(stuff, { range, direction })
.next(cur => {
// cur is an IDBCursor, `value` will be present for non-key cursors
myProcessFunc(cur.value);
// NOTE: you still have to call cur.continue() to proceed to the next record
// or use calls like cur.continuePrimaryKey(...) for paged views etc.
cur.continue();
})
.complete(() => {
// (optional) do something when the cursor has iterated to the end of the range
})
.catch((error: DOMException, event: ErrorEvent) => {
// (optional) handle an error occurring inside cursor handling
// you can call `event.preventDefault()` to have failures not cause
// the whole transaction to abort
});
// if you don't care about the result you don't have to wrap requests
stuff.delete(someOtherKey);
// the optional return value of this function is the result type of
// the transaction function's Promise.
return Promise.all([itemProm, allRecords]);
});
// Then handle the transaction's Promise:
trans
.then(result => {
// ... process whatever you returned in your transaction function
})
.catch(error => {
// ... handle any errors, including timeouts
});
Ensuring transaction durability
transaction
also takes an optional 3rd argument to specify the durability
of the transaction. This can be done to ensure quick flushing of critical data.
⚠️ This feature is not yet widely implemented. In environments where it is not available, this option is ignored and "relaxed" durability (the default) is used.
const trans = pdb.transaction(["top_secret"], "readwrite",
{
durability: "strict"
},
(tx, {request}) => {
// This transaction will only complete once all changes have been
// flushed to disk. This can be an expensive and/or lengthy operation so
// only do this for critical data.
});
Closing the Database
Normally you don't need to close a database explicitly, the main use case for
this is in response to an outdated
signal. Closing the connection allows
upgrade events in other instances of your app to continue.
pdb.close();
Get notified when the database is closed externally
IndexedDB instances may be closed forcibly if, for example, the user chooses
to clear out caches or if the allotted space for databases is running low. To be
notified when this happens you can listen for the closed
promise to resolve
and take any action needed, like showing some UI to inform the user.
Note that this promise does not resolve if you close the database yourself. This is purely a notification for when the database is closed outside of your control.
const pdb = new PromisedDB(...);
pdb.closed.then(() => {
// oh no
});
List available databases
You can request a list of databases, getting the name
and version
of each.
This function is a promise-wrapped indexedDB.databases()
.
⚠️ This feature is not yet widely implemented. listDatabases
will reject with a
DOMException
of type NotSupportedError
if the feature is missing.
import { listDatabases } from "promised-db";
// dbs is an array of { name: string; version: number; } records
const dbs = await listDatabases();
Deleting a Database
To delete a database, pass the name of the database you wish to delete.
This function is a promise-wrapped indexedDB.deleteDatabase()
.
import { deleteDatabase } from "promised-db";
deleteDatabase("mydb")
.then(() => { /* success */ })
.catch(err => { /* handle error */ });
Deleting will fail if the named database doesn't exist or is still in use.
Testing the relative order of keys
You can manually query the relative order of 2 keys by passing them to
compareKeys
. This function is equivalent to indexedDB.cmp()
and mainly
provided for consistency.
import { compareKeys } from "promised-db";
// ordering is -1 if keyA < keyB, 1 if keyA > keyB and 0 if the keys are equal
let ordering = compareKeys(keyA, keyB);
This function will throw if either keyA or keyB is not a valid IndexedDB key.
More info
The following resources give more info on working with IndexedDB in general. PromisedDB changes the event model but the core principles of stores, indexes, cursors, keys and transasctions is shared.
License: MIT License (c) 2016-Present by @zenmumbler