import { Meta, META_KEY, ObjectMeta, StoreInterface, StoreInterfaceStoreName, } from './utils/PersistedObject.ts'; // Any time these are updates to the data format or new stores are added, // the version must be updated. // onupgradeneeded will be called, which is where you can // move objects from one idb to another. // We create a new IDB for each version change instead of // using their built-in versioning because they have no ability // to roll back and if multiple tabs are active, then you'll just // be stuck. const version = 6; const storeNames = ['kv', 'querySubs', 'syncSubs'] as const; // Check that we're not missing a store name in storeNames type MissingStoreNames = Exclude< StoreInterfaceStoreName, (typeof storeNames)[number] >; const _exhaustiveCheck: never = null as MissingStoreNames; function logErrorCb(source: string) { return function logError(event) { console.error('Error in IndexedDB event', { source, event }); }; } async function existingDb(name: string): Promise { return new Promise((resolve) => { const request = indexedDB.open(name); request.onerror = (_event) => { resolve(null); }; request.onsuccess = (event) => { const target = event.target as IDBOpenDBRequest; const db = target.result; resolve(db); }; request.onupgradeneeded = (event) => { const target = event.target as IDBOpenDBRequest; target.transaction?.abort(); resolve(null); }; }); } async function upgradeQuerySubs5To6( hash: string, value: any, querySubStore: IDBObjectStore, ): Promise { const subs = // Backwards compatibility for older versions where we JSON.stringified before storing typeof value === 'string' ? JSON.parse(value) : value; if (!subs) { return; } const putReqs: Set> = new Set(); return new Promise((resolve, reject) => { const objects = {}; for (const [hash, v] of Object.entries(subs)) { const value = typeof v === 'string' ? JSON.parse(v) : v; if (value.lastAccessed) { const objectMeta: ObjectMeta = { createdAt: value.lastAccessed, updatedAt: value.lastAccessed, size: value.result?.store?.triples?.length ?? 0, }; objects[hash] = objectMeta; } const putReq = querySubStore.put(value, hash); putReqs.add(putReq); } const meta: Meta = { objects }; const metaPutReq = querySubStore.put(meta, META_KEY); putReqs.add(metaPutReq); for (const r of putReqs) { r.onsuccess = () => { putReqs.delete(r); if (putReqs.size === 0) { resolve(); } }; r.onerror = (event) => { logErrorCb(`Move ${hash} to querySubs store failed`); reject(event); }; } }); } async function moveKvEntry5To6( k: string, value: any, kvStore: IDBObjectStore, ): Promise { const request = kvStore.put(value, k); return new Promise((resolve, reject) => { request.onsuccess = () => resolve(); request.onerror = (event) => reject(event); }); } async function upgrade5To6(appId: string, v6Db: IDBDatabase): Promise { const v5db = await existingDb(`instant_${appId}_5`); if (!v5db) { return; } const data: Array<[string, any]> = await new Promise((resolve, reject) => { const v5Tx = v5db.transaction(['kv'], 'readonly'); const objectStore = v5Tx.objectStore('kv'); const cursorReq = objectStore.openCursor(); cursorReq.onerror = (event) => { reject(event); }; const data: Array<[string, any]> = []; cursorReq.onsuccess = () => { const cursor = cursorReq.result; if (cursor) { const key = cursor.key as string; const value = cursor.value; data.push([key, value]); cursor.continue(); } else { resolve(data); } }; cursorReq.onerror = (event) => { reject(event); }; }); const v6Tx = v6Db.transaction(['kv', 'querySubs'], 'readwrite'); const kvStore = v6Tx.objectStore('kv'); const querySubStore = v6Tx.objectStore('querySubs'); const promises: Promise[] = []; const kvMeta: Meta = { objects: {} }; for (const [key, value] of data) { switch (key) { case 'querySubs': { const p = upgradeQuerySubs5To6(key, value, querySubStore); promises.push(p); break; } default: { const p = moveKvEntry5To6(key as string, value, kvStore); promises.push(p); const objectMeta: ObjectMeta = { createdAt: Date.now(), updatedAt: Date.now(), size: 0, }; kvMeta.objects[key] = objectMeta; break; } } } const p = moveKvEntry5To6(META_KEY, kvMeta, kvStore); promises.push(p); await Promise.all(promises); await new Promise((resolve, reject) => { v6Tx.oncomplete = (e) => resolve(e); v6Tx.onerror = (e) => reject(e); v6Tx.onabort = (e) => reject(e); }); } // We create many IndexedDBStorage instances that talk to the same // underlying db, but we only get one `onupgradeneeded` event. This holds // the upgrade promises so that we wait until upgrade finishes before // we start writing. const upgradePromises = new Map(); export default class IndexedDBStorage extends StoreInterface { dbName: string; _storeName: StoreInterfaceStoreName; _appId: string; _prefix: string; _dbPromise: Promise; constructor(appId: string, storeName: StoreInterfaceStoreName) { super(appId, storeName); this.dbName = `instant_${appId}_${version}`; this._storeName = storeName; this._appId = appId; this._dbPromise = this._init(); } _init(): Promise { return new Promise((resolve, reject) => { let requiresUpgrade = false; const request = indexedDB.open(this.dbName, 1); request.onerror = (event) => { reject(event); }; request.onsuccess = (event) => { const target = event.target as IDBOpenDBRequest; const db = target.result; // Browsers can close IndexedDB connections unexpectedly // (e.g. backgrounded tabs, memory pressure, version changes from // other tabs). Re-init so the next operation gets a fresh connection. db.onclose = () => { this._dbPromise = this._init(); }; db.onversionchange = () => { db.close(); }; if (!requiresUpgrade) { const p = upgradePromises.get(this.dbName); if (!p) { resolve(db); } else { p.then(() => resolve(db)).catch(() => resolve(db)); } } else { const p = upgrade5To6(this._appId, db).catch((e) => { logErrorCb('Error upgrading store from version 5 to 6.')(e); }); upgradePromises.set(this.dbName, p); p.then(() => resolve(db)).catch(() => resolve(db)); } }; request.onupgradeneeded = (event) => { requiresUpgrade = true; this._upgradeStore(event); }; }); } _upgradeStore(event: IDBVersionChangeEvent) { const target = event.target as IDBOpenDBRequest; const db = target.result; for (const storeName of storeNames) { if (!db.objectStoreNames.contains(storeName)) { db.createObjectStore(storeName); } } } // Browsers can close IndexedDB connections unexpectedly (backgrounded tabs, // memory pressure, cross-tab version changes, etc.), causing // `db.transaction()` to throw InvalidStateError. This helper catches that // error and retries once with a fresh connection. async _withRetry(fn: (db: IDBDatabase) => Promise): Promise { try { const db = await this._dbPromise; return await fn(db); } catch (e) { if (e instanceof DOMException && e.name === 'InvalidStateError') { this._dbPromise = this._init(); const db = await this._dbPromise; return await fn(db); } throw e; } } async getItem(k: string): Promise { return this._withRetry((db) => { return new Promise((resolve, reject) => { const transaction = db.transaction([this._storeName], 'readonly'); const objectStore = transaction.objectStore(this._storeName); const request = objectStore.get(k); request.onerror = () => { reject(request.error); }; request.onsuccess = () => { if (request.result) { resolve(request.result); } else { resolve(null); } }; }); }); } async setItem(k: string, v: any): Promise { return this._withRetry((db) => { return new Promise((resolve, reject) => { const transaction = db.transaction([this._storeName], 'readwrite'); const objectStore = transaction.objectStore(this._storeName); objectStore.put(v, k); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); }); }); } async multiSet(keyValuePairs: Array<[string, any]>): Promise { return this._withRetry((db) => { return new Promise((resolve, reject) => { const transaction = db.transaction([this._storeName], 'readwrite'); const objectStore = transaction.objectStore(this._storeName); for (const [k, v] of keyValuePairs) { objectStore.put(v, k); } transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); }); }); } async removeItem(k: string): Promise { return this._withRetry((db) => { return new Promise((resolve, reject) => { const transaction = db.transaction([this._storeName], 'readwrite'); const objectStore = transaction.objectStore(this._storeName); objectStore.delete(k); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); }); }); } async getAllKeys(): Promise { return this._withRetry((db) => { return new Promise((resolve, reject) => { const transaction = db.transaction([this._storeName], 'readonly'); const objectStore = transaction.objectStore(this._storeName); const request = objectStore.getAllKeys(); request.onerror = () => { reject(request.error); }; request.onsuccess = () => { resolve(request.result.filter((x) => typeof x === 'string')); }; }); }); } }