Using UUID primary keys in Dexie
For my point-of-sales application I use Dexie as a wrapper for IndexedDB - and it works great.
I experimented with Dexie.Syncable to sync changes between multiple devices, but we ended up using our own backend with a REST API as the source of truth. Dexie.Syncable has Dexie.Observable as a dependency, and this specific dependency adds - as the name suggests - observability to your database. Unfortunately, this comes at a pretty big performance impact, as every CRUD operation comes at the expense of being registered in the _changes
table as well.
Dexie.Observable adds a few meta tables your database to maintain change tracking - and to be able to track changes consistently across devices / tabs - you cannot rely on auto-incrementing indices. That's why Observable adds the capability to define an auto-generated UUID as a primary key in your table by using the $$
prefix as follows:
db.version(1).stores({
friends: "$$uuid, name"
});
This automatic UUID generation feature is pretty handy, so I decided to extract it into its own plugin: Dexie.UUIDPrimaryKey.js
/**
* This class allows you to use UUID primary keys in Dexie by defining them in the store using two dollar signs ($$), eg:
*
* db.version(1).stores({
* orders: '$$id, price',
* order_products: '$$id, product, order_id'
* });
*
* Parts are adapted from dexie-observable.js.
*/
import Dexie from 'dexie';
import { v4 as uuidv4 } from 'uuid';
/**
* DexieUUIDPrimaryKey plugin
* @param db
* @constructor
*/
function DexieUUIDPrimaryKey(db) {
// Override the _parseStoresSpec method with our own implementation
db.Version.prototype._parseStoresSpec = Dexie.override(db.Version.prototype._parseStoresSpec, overrideParseStoresSpec);
// Override the open method with our own implementation
db.open = Dexie.override(db.open, prepareOverrideOpen(db));
}
/**
* This function overrides the parseStoresSpec method of Dexie to allow for UUID primary keys.
* @param origFunc
* @returns {(function(*, *): void)|*}
*/
function overrideParseStoresSpec(origFunc) {
return function(stores, dbSchema) {
origFunc.call(this, stores, dbSchema);
Object.keys(dbSchema).forEach(function(tableName) {
let schema = dbSchema[tableName];
if (schema.primKey.name.indexOf('$$') === 0) {
schema.primKey.uuid = true;
schema.primKey.name = schema.primKey.name.substr(2);
schema.primKey.keyPath = schema.primKey.keyPath.substr(2);
}
});
};
}
/**
* This function prepares the hook that will trigger on creation of a new record
* @param table
* @returns {function(*, *): undefined}
*/
function initCreatingHook(table) {
return function creatingHook(primKey, obj) {
let rv = undefined;
if (primKey === undefined && table.schema.primKey.uuid) {
primKey = rv = uuidv4();
if (table.schema.primKey.keyPath) {
Dexie.setByKeyPath(obj, table.schema.primKey.keyPath, primKey);
}
}
return rv;
};
}
/**
* This function prepares the hook that will trigger on opening the database and will loop through all tables to add the creating hook.
* @param db
* @returns {function(*): function(): *}
*/
function prepareOverrideOpen(db) {
return function overrideOpen(origOpen) {
return function () {
Object.keys(db._allTables).forEach(tableName => {
let table = db._allTables[tableName];
table.hook('creating').subscribe(initCreatingHook(table));
});
return origOpen.apply(this, arguments);
}
}
}
// Register addon:
Dexie.UUIDPrimaryKey = DexieUUIDPrimaryKey;
Dexie.addons.push(DexieUUIDPrimaryKey);
export default Dexie.UUIDPrimaryKey;
By importing this class, you're able to add UUID primary keys using the $$
prefix, without using the entire Observable class - so you can keep your CRUD operations smooth & performant!