import { SSR } from '@/lib/constants' import { useMe } from './me' import { useEffect, useState } from 'react' import createTaskQueue from '@/lib/task-queue' const VERSION = 1 /** * A react hook to use the local storage * It handles the lifecycle of the storage, opening and closing it as needed. * * @param {*} options * @param {string} options.database - the database name * @param {[string]} options.namespace - the namespace of the storage * @returns {[object]} - the local storage */ export default function useLocalStorage ({ database = 'default', namespace = ['default'] }) { const { me } = useMe() if (!Array.isArray(namespace)) namespace = [namespace] const joinedNamespace = namespace.join(':') const [storage, setStorage] = useState(openLocalStorage({ database, userId: me?.id, namespace })) useEffect(() => { const currentStorage = storage const newStorage = openLocalStorage({ database, userId: me?.id, namespace }) setStorage(newStorage) if (currentStorage) currentStorage.close() return () => { newStorage.close() } }, [me, database, joinedNamespace]) return [storage] } /** * Open a local storage. * This is an abstraction on top of IndexedDB or, when not available, an in-memory storage. * A combination of userId, database and namespace is used to efficiently separate different storage units. * Namespaces can be an array of strings, that will be internally joined to form a single namespace. * * @param {*} options * @param {string} options.userId - the user that owns the storage (anon if not provided) * @param {string} options.database - the database name (default if not provided) * @param {[string]} options.namespace - the namespace of the storage (default if not provided) * @returns {object} - the local storage * @throws Error if the namespace is invalid */ export function openLocalStorage ({ userId, database = 'default', namespace = ['default'] }) { if (!userId) userId = 'anon' if (!Array.isArray(namespace)) namespace = [namespace] if (SSR) return createMemBackend(userId, namespace) let backend = newIdxDBBackend(userId, database, namespace) if (!backend) { console.warn('no local storage backend available, fallback to in memory storage') backend = createMemBackend(userId, namespace) } return backend } export async function listLocalStorages ({ userId, database }) { if (SSR) return [] return await listIdxDBBackendNamespaces(userId, database) } /** * In memory storage backend (volatile/dummy storage) */ function createMemBackend (userId, namespace) { const joinedNamespace = userId + ':' + namespace.join(':') let memory = window?.snMemStorage?.[joinedNamespace] if (!memory) { memory = {} if (window) { if (!window.snMemStorage) window.snMemStorage = {} window.snMemStorage[joinedNamespace] = memory } } return { set: (key, value) => { memory[key] = value }, get: (key) => memory[key], unset: (key) => { delete memory[key] }, clear: () => { Object.keys(memory).forEach(key => delete memory[key]) }, list: () => Object.keys(memory), close: () => { } } } /** * Open an IndexedDB connection * @param {*} userId * @param {*} database * @param {*} onupgradeneeded * @param {*} queue * @returns {object} - an open connection * @throws Error if the connection cannot be opened */ async function openIdxDB (userId, database, onupgradeneeded, queue) { const fullDbName = `${database}:${userId}` // we keep a reference to every open indexed db connection // to reuse them whenever possible if (window && !window.snIdxDB) window.snIdxDB = {} let openConnection = window?.snIdxDB?.[fullDbName] const close = () => { const conn = openConnection conn.ref-- if (conn.ref === 0) { // close the connection for real if nothing is using it if (window?.snIdxDB) delete window.snIdxDB[fullDbName] queue.enqueue(() => { conn.db.close() }) } } // if for any reason the connection is outdated, we close it if (openConnection && openConnection.version !== VERSION) { close() openConnection = undefined } // an open connections is not available, so we create a new one if (!openConnection) { openConnection = { version: VERSION, ref: 1, // we need a ref count to know when to close the connection for real db: null, close } openConnection.db = await new Promise((resolve, reject) => { const request = window.indexedDB.open(fullDbName, VERSION) request.onupgradeneeded = (event) => { const db = event.target.result if (onupgradeneeded) onupgradeneeded(db) } request.onsuccess = (event) => { const db = event.target.result if (!db?.transaction) reject(new Error('unsupported implementation')) else resolve(db) } request.onerror = reject }) window.snIdxDB[fullDbName] = openConnection } else { // increase the reference count openConnection.ref++ } return openConnection } /** * An IndexedDB based persistent storage * @param {string} userId - the user that owns the storage * @param {string} database - the database name * @returns {object} - an indexedDB persistent storage * @throws Error if the namespace is invalid */ function newIdxDBBackend (userId, database, namespace) { if (!window.indexedDB) return undefined if (!namespace) throw new Error('missing namespace') if (!Array.isArray(namespace) || !namespace.length || namespace.find(n => !n || typeof n !== 'string')) throw new Error('invalid namespace. must be a non-empty array of strings') if (namespace.find(n => n.includes(':'))) throw new Error('invalid namespace. must not contain ":"') namespace = namespace.join(':') const queue = createTaskQueue() let openConnection = null const initialize = async () => { if (!openConnection) { openConnection = await openIdxDB(userId, database, (db) => { db.createObjectStore(database, { keyPath: ['namespace', 'key'] }) }, queue) } } return { set: async (key, value) => { await queue.enqueue(async () => { await initialize() const tx = openConnection.db.transaction([database], 'readwrite') const objectStore = tx.objectStore(database) objectStore.put({ namespace, key, value }) await new Promise((resolve, reject) => { tx.oncomplete = resolve tx.onerror = reject }) }) }, get: async (key) => { return await queue.enqueue(async () => { await initialize() const tx = openConnection.db.transaction([database], 'readonly') const objectStore = tx.objectStore(database) const request = objectStore.get([namespace, key]) return await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result?.value) request.onerror = reject }) }) }, unset: async (key) => { await queue.enqueue(async () => { await initialize() const tx = openConnection.db.transaction([database], 'readwrite') const objectStore = tx.objectStore(database) objectStore.delete([namespace, key]) await new Promise((resolve, reject) => { tx.oncomplete = resolve tx.onerror = reject }) }) }, clear: async () => { await queue.enqueue(async () => { await initialize() const tx = openConnection.db.transaction([database], 'readwrite') const objectStore = tx.objectStore(database) objectStore.clear() await new Promise((resolve, reject) => { tx.oncomplete = resolve tx.onerror = reject }) }) }, list: async () => { return await queue.enqueue(async () => { await initialize() const tx = openConnection.db.transaction([database], 'readonly') const objectStore = tx.objectStore(database) const keys = [] return await new Promise((resolve, reject) => { const request = objectStore.openCursor() request.onsuccess = (event) => { const cursor = event.target.result if (cursor) { if (cursor.key[0] === namespace) { keys.push(cursor.key[1]) // Push only the 'key' part of the composite key } cursor.continue() } else { resolve(keys) } } request.onerror = reject }) }) }, close: async () => { queue.enqueue(async () => { if (openConnection) await openConnection.close() }) } } } /** * List all the namespaces used in an IndexedDB database * @param {*} userId - the user that owns the storage * @param {*} database - the database name * @returns {array} - an array of namespace names */ async function listIdxDBBackendNamespaces (userId, database) { if (!window?.indexedDB) return [] const queue = createTaskQueue() const openConnection = await openIdxDB(userId, database, null, queue) try { const list = await queue.enqueue(async () => { const objectStore = openConnection.db.transaction([database], 'readonly').objectStore(database) const namespaces = new Set() return await new Promise((resolve, reject) => { const request = objectStore.openCursor() request.onsuccess = (event) => { const cursor = event.target.result if (cursor) { namespaces.add(cursor.key[0]) cursor.continue() } else { resolve(Array.from(namespaces).map(n => n.split(':'))) } } request.onerror = reject }) }) return list } finally { openConnection.close() } }