UNPKG

@daaku/kombat-indexed-db

Version:

Kombat storage implemented using IndexedDB.

303 lines 8.98 kB
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