@daaku/kombat-indexed-db
Version:
Kombat storage implemented using IndexedDB.
303 lines • 8.98 kB
JavaScript
import { SyncDB } from '@daaku/kombat';
import { loadDatasetMem, LocalIndexedDB, syncDatasetMem, } from './index.js';
import { dequal } from 'dequal';
import { openDB } from 'idb';
const isPrimitive = (v) => {
if (typeof v === 'object') {
return v === null;
}
return typeof v !== 'function';
};
class DBProxy {
#store;
constructor(s) {
this.#store = s;
}
get(_, dataset) {
return this.#store.datasetProxy(dataset);
}
set() {
throw new TypeError('cannot set on DB');
}
deleteProperty() {
throw new TypeError('cannot delete on DB');
}
ownKeys() {
return Object.keys(this.#store.mem);
}
has(_, dataset) {
return dataset in this.#store.mem;
}
defineProperty() {
throw new TypeError('cannot defineProperty on DB');
}
getOwnPropertyDescriptor(_, p) {
return {
value: this.#store.datasetProxy(p),
writable: true,
enumerable: true,
configurable: true,
};
}
}
class DatasetProxy {
#store;
#dataset;
constructor(s, dataset) {
this.#store = s;
this.#dataset = dataset;
}
#getDataset() {
let dataset = this.#store.mem[this.#dataset];
if (!dataset) {
dataset = this.#store.mem[this.#dataset] = {};
}
return dataset;
}
get(target, id) {
if (this.has(target, id)) {
return new Proxy({}, new RowProxy(this.#store, this.#dataset, id));
}
}
set(_, id, value) {
if (typeof value !== 'object') {
throw new Error(`cannot use non object value in dataset "${this.#dataset}" with row id "${id}"`);
}
// work with a clone, since we may modify it
value = structuredClone(value);
// ensure we have an ID and it is what we expect
if ('id' in value) {
if (id !== value.id) {
const valueID = value.id;
throw new Error(`id mismatch in dataset "${this.#dataset}" with row id "${id}" and valud id ${valueID}`);
}
}
else {
value.id = id;
}
const dataset = this.#getDataset();
// only send messages for changed values.
const existing = dataset[id] ?? {};
this.#store.send(
// @ts-expect-error typescript doesn't understand filter
[
// update changed properties
...Object.entries(value)
.map(([k, v]) => {
if (existing && dequal(existing[k], v)) {
return;
}
return {
dataset: this.#dataset,
row: id,
column: k,
value: v,
};
})
.filter(v => v),
// drop missing properties
...Object.keys(existing)
.map(k => {
if (k in value) {
return;
}
return {
dataset: this.#dataset,
row: id,
column: k,
value: undefined,
};
})
.filter(v => v),
]);
// synchronously update our in-memory dataset.
dataset[id] = value;
return true;
}
deleteProperty(_, id) {
this.#store.send([
{
dataset: this.#dataset,
row: id,
column: 'tombstone',
value: true,
},
]);
const dataset = this.#getDataset();
if (id in dataset) {
dataset[id].tombstone = true;
}
else {
dataset[id] = { tombstone: true };
}
return true;
}
ownKeys() {
const dataset = this.#getDataset();
return Object.keys(dataset).filter(r => !dataset[r].tombstone);
}
has(_, id) {
const row = this.#store.mem[this.#dataset]?.[id];
return row && !row.tombstone;
}
defineProperty() {
throw new TypeError(`cannot defineProperty on dataset "${this.#dataset}"`);
}
getOwnPropertyDescriptor(target, id) {
const value = this.get(target, id);
if (value) {
return {
value,
writable: true,
enumerable: true,
configurable: true,
};
}
}
}
class RowProxy {
#store;
#dataset;
#id;
constructor(store, dataset, id) {
this.#store = store;
this.#dataset = dataset;
this.#id = id;
}
get(_, prop) {
const row = this.#store.mem[this.#dataset]?.[this.#id];
if (!row) {
return;
}
const val = row[prop];
// hasOwn allows pass-thru of prototype properties like constructor
if (isPrimitive(val) || !Object.hasOwn(row, prop)) {
return val;
}
throw new Error(`non primitive value for dataset "${this.#dataset}" row with id "${this.#id}" and property "${prop}" of type "${typeof val}" and value "${val}"`);
}
set(_, prop, value) {
this.#store.send([
{
dataset: this.#dataset,
row: this.#id,
column: prop,
value: value,
},
]);
let dataset = this.#store.mem[this.#dataset];
if (!dataset) {
dataset = this.#store.mem[this.#dataset] = {};
}
let row = dataset[this.#id];
if (!row) {
row = dataset[this.#id] = { id: this.#id };
}
row[prop] = value;
return true;
}
deleteProperty(_, prop) {
this.#store.send([
{
dataset: this.#dataset,
row: this.#id,
column: prop,
value: undefined,
},
]);
delete this.#store.mem[this.#dataset]?.[this.#id]?.[prop];
return true;
}
ownKeys() {
const row = this.#store.mem[this.#dataset]?.[this.#id];
return row ? Object.keys(row) : [];
}
has(_, p) {
const row = this.#store.mem[this.#dataset]?.[this.#id];
return row ? p in row : false;
}
defineProperty() {
throw new TypeError(`cannot defineProperty on dataset "${this.#dataset}" with row id ${this.#id}`);
}
getOwnPropertyDescriptor(target, prop) {
const value = this.get(target, prop);
if (value) {
return {
value,
writable: true,
enumerable: true,
configurable: true,
};
}
}
}
// TheStore is the internal concrete implementation which is returned. The
// TypeScript API is limited by the interface it implements. The other bits are
// for internal consumption.
class TheStore {
#dbProxy;
#pending = new Set();
#datasetProxies = {};
#idb;
#local;
syncDB;
mem;
constructor(idb, local, syncDB, mem) {
this.#idb = idb;
this.#local = local;
this.syncDB = syncDB;
this.mem = mem;
this.#dbProxy = new Proxy({}, new DBProxy(this));
}
static async new(opts) {
const mem = {};
const local = new LocalIndexedDB();
local.listenChanges(syncDatasetMem(mem));
const idb = await openDB(opts.dbName, 1, {
upgrade: db => local.upgradeDB(db),
blocking: () => idb.close(),
});
await loadDatasetMem(mem, idb);
local.setDB(idb);
const syncDB = await SyncDB.new(opts.remote, local);
const store = new TheStore(idb, local, syncDB, mem);
// start initial sync, and make it pending for settle
const r = syncDB.sync();
store.#pending.add(r);
r.finally(() => store.#pending.delete(r));
return store;
}
close() {
this.#idb?.close();
this.mem = null;
}
async settle() {
await Promise.allSettled(this.#pending.values());
await this.syncDB.settle();
}
listenChanges(cb) {
return this.#local.listenChanges(cb);
}
get db() {
// @ts-expect-error type bypass
return this.#dbProxy;
}
datasetProxy(dataset) {
let proxy = this.#datasetProxies[dataset];
if (!proxy) {
this.#datasetProxies[dataset] = proxy = new Proxy({}, new DatasetProxy(this, dataset));
}
return proxy;
}
// wrap the syncDB send and hold on to the promises until they settle,
// allowing callers to let things settle.
send(...args) {
const r = this.syncDB.send(...args);
this.#pending.add(r);
r.finally(() => this.#pending.delete(r));
}
}
export const initStore = (opts) =>
// @ts-expect-error type bypass
TheStore.new(opts);
//# sourceMappingURL=store.js.map