diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 820c08f1..d2f29a07 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -351,20 +351,58 @@ const resolvers = { facts: history } }, - walletLogs: async (parent, args, { me, models }) => { + walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } - return await models.walletLog.findMany({ - where: { - userId: me.id - }, - orderBy: [ - { createdAt: 'desc' }, - { id: 'desc' } - ] - }) + // we cursoring with the wallet logs on the client + // if we have from, don't use cursor + // regardless, store the state of the cursor for the next call + + const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() } + + let logs = [] + let nextCursor + if (from) { + logs = await models.walletLog.findMany({ + where: { + userId: me.id, + wallet: type ?? undefined, + createdAt: { + gte: from ? new Date(Number(from)) : undefined, + lte: to ? new Date(Number(to)) : undefined + } + }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' } + ] + }) + nextCursor = nextCursorEncoded(decodedCursor, logs.length) + } else { + logs = await models.walletLog.findMany({ + where: { + userId: me.id, + wallet: type ?? undefined, + createdAt: { + lte: decodedCursor.time + } + }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' } + ], + take: LIMIT, + skip: decodedCursor.offset + }) + nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null + } + + return { + cursor: nextCursor, + entries: logs + } } }, Wallet: { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 5a683813..d87af0e0 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -66,7 +66,7 @@ const typeDefs = ` wallets: [Wallet!]! wallet(id: ID!): Wallet walletByType(type: String!): Wallet - walletLogs: [WalletLog]! + walletLogs(type: String, from: String, to: String, cursor: String): WalletLog! } extend type Mutation { @@ -154,6 +154,11 @@ const typeDefs = ` } type WalletLog { + entries: [WalletLogEntry!]! + cursor: String + } + + type WalletLogEntry { id: ID! createdAt: Date! wallet: ID! diff --git a/components/use-indexeddb.js b/components/use-indexeddb.js new file mode 100644 index 00000000..1733970e --- /dev/null +++ b/components/use-indexeddb.js @@ -0,0 +1,273 @@ +import { useState, useEffect, useCallback, useRef } from 'react' + +function useIndexedDB (dbName, storeName, version = 1, indices = []) { + const [db, setDb] = useState(null) + const [error, setError] = useState(null) + const operationQueue = useRef([]) + + const handleError = useCallback((error) => { + console.error('IndexedDB error:', error) + setError(error) + }, []) + + const processQueue = useCallback((db) => { + if (!db) return + + try { + // try to run a noop to see if the db is ready + db.transaction(storeName) + while (operationQueue.current.length > 0) { + const operation = operationQueue.current.shift() + operation(db) + } + } catch (error) { + handleError(error) + } + }, [storeName, handleError]) + + useEffect(() => { + let isMounted = true + const request = window.indexedDB.open(dbName, version) + + request.onerror = (event) => { + handleError(new Error('Error opening database')) + } + + request.onsuccess = (event) => { + if (isMounted) { + const database = event.target.result + database.onversionchange = () => { + database.close() + setDb(null) + handleError(new Error('Database is outdated, please reload the page')) + } + setDb(database) + processQueue(database) + } + } + + request.onupgradeneeded = (event) => { + const database = event.target.result + try { + const store = database.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true }) + + indices.forEach(index => { + store.createIndex(index.name, index.keyPath, index.options) + }) + } catch (error) { + handleError(new Error('Error upgrading database: ' + error.message)) + } + } + + return () => { + isMounted = false + if (db) { + db.close() + } + } + }, [dbName, storeName, version, indices, handleError, processQueue]) + + const queueOperation = useCallback((operation) => { + return new Promise((resolve, reject) => { + const wrappedOperation = (db) => { + try { + const result = operation(db) + resolve(result) + } catch (error) { + reject(error) + } + } + + operationQueue.current.push(wrappedOperation) + processQueue(db) + }) + }, [processQueue, db]) + + const add = useCallback((value) => { + return queueOperation((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + const request = store.add(value) + + request.onerror = () => reject(new Error('Error adding data')) + request.onsuccess = () => resolve(request.result) + }) + }) + }, [queueOperation, storeName]) + + const get = useCallback((key) => { + return queueOperation((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly') + const store = transaction.objectStore(storeName) + const request = store.get(key) + + request.onerror = () => reject(new Error('Error getting data')) + request.onsuccess = () => resolve(request.result ? request.result : undefined) + }) + }) + }, [queueOperation, storeName]) + + const getAll = useCallback(() => { + return queueOperation((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly') + const store = transaction.objectStore(storeName) + const request = store.getAll() + + request.onerror = () => reject(new Error('Error getting all data')) + request.onsuccess = () => resolve(request.result) + }) + }) + }, [queueOperation, storeName]) + + const update = useCallback((key, value) => { + return queueOperation((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + const request = store.get(key) + + request.onerror = () => reject(new Error('Error updating data')) + request.onsuccess = () => { + const updatedValue = { ...request.result, ...value } + const updateRequest = store.put(updatedValue) + updateRequest.onerror = () => reject(new Error('Error updating data')) + updateRequest.onsuccess = () => resolve(updateRequest.result) + } + }) + }) + }, [queueOperation, storeName]) + + const remove = useCallback((key) => { + return queueOperation((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + const request = store.delete(key) + + request.onerror = () => reject(new Error('Error removing data')) + request.onsuccess = () => resolve() + }) + }) + }, [queueOperation, storeName]) + + const clear = useCallback((indexName = null, query = null) => { + return queueOperation((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + + if (!query) { + // Clear all data if no query is provided + const request = store.clear() + request.onerror = () => reject(new Error('Error clearing all data')) + request.onsuccess = () => resolve() + } else { + // Clear data based on the query + const index = indexName ? store.index(indexName) : store + const request = index.openCursor(query) + let deletedCount = 0 + + request.onerror = () => reject(new Error('Error clearing data based on query')) + request.onsuccess = (event) => { + const cursor = event.target.result + if (cursor) { + const deleteRequest = cursor.delete() + deleteRequest.onerror = () => reject(new Error('Error deleting item')) + deleteRequest.onsuccess = () => { + deletedCount++ + cursor.continue() + } + } else { + resolve(deletedCount) + } + } + } + }) + }) + }, [queueOperation, storeName]) + + const getByIndex = useCallback((indexName, key) => { + return queueOperation((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly') + const store = transaction.objectStore(storeName) + const index = store.index(indexName) + const request = index.get(key) + + request.onerror = () => reject(new Error('Error getting data by index')) + request.onsuccess = () => resolve(request.result) + }) + }) + }, [queueOperation, storeName]) + + const getAllByIndex = useCallback((indexName, query, direction = 'next', limit = Infinity) => { + return queueOperation((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly') + const store = transaction.objectStore(storeName) + const index = store.index(indexName) + const request = index.openCursor(query, direction) + const results = [] + + request.onerror = () => reject(new Error('Error getting data by index')) + request.onsuccess = (event) => { + const cursor = event.target.result + if (cursor && results.length < limit) { + results.push(cursor.value) + cursor.continue() + } else { + resolve(results) + } + } + }) + }) + }, [queueOperation, storeName]) + + const getPage = useCallback((page = 1, pageSize = 10, indexName = null, query = null, direction = 'next') => { + return queueOperation((db) => { + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, 'readonly') + const store = transaction.objectStore(storeName) + const target = indexName ? store.index(indexName) : store + const request = target.openCursor(query, direction) + const results = [] + let skipped = 0 + let hasMore = false + + request.onerror = () => reject(new Error('Error getting page')) + request.onsuccess = (event) => { + const cursor = event.target.result + if (cursor) { + if (skipped < (page - 1) * pageSize) { + skipped++ + cursor.continue() + } else if (results.length < pageSize) { + results.push(cursor.value) + cursor.continue() + } else { + hasMore = true + } + } + if (hasMore || !cursor) { + const countRequest = target.count() + countRequest.onsuccess = () => { + resolve({ + data: results, + total: countRequest.result, + hasMore + }) + } + countRequest.onerror = () => reject(new Error('Error counting items')) + } + } + }) + }) + }, [queueOperation, storeName]) + + return { add, get, getAll, update, remove, clear, getByIndex, getAllByIndex, getPage, error } +} + +export default useIndexedDB diff --git a/components/wallet-logger.js b/components/wallet-logger.js index 9e623e4f..569445b0 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -1,18 +1,22 @@ import LogMessage from './log-message' -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import styles from '@/styles/log.module.css' import { Button } from 'react-bootstrap' import { useToast } from './toast' import { useShowModal } from './modal' import { WALLET_LOGS } from '@/fragments/wallet' import { getWalletByType } from 'wallets' -import { gql, useMutation, useQuery } from '@apollo/client' +import { gql, useLazyQuery, useMutation } from '@apollo/client' import { useMe } from './me' +import useIndexedDB from './use-indexeddb' +import { SSR } from '@/lib/constants' export function WalletLogs ({ wallet, embedded }) { - const logs = useWalletLogs(wallet) + const { logs, setLogs, hasMore, loadMore, loadLogs, loading } = useWalletLogs(wallet) + useEffect(() => { + loadLogs() + }, [wallet]) - const tableRef = useRef() const showModal = useShowModal() return ( @@ -21,13 +25,12 @@ export function WalletLogs ({ wallet, embedded }) { { - showModal(onClose => ) + showModal(onClose => ) }} >clear logs -
- {logs.length === 0 &&
empty
} +
{logs.map((log, i) => ( @@ -39,15 +42,20 @@ export function WalletLogs ({ wallet, embedded }) { ))}
-
------ start of logs ------
+ {loading + ?
loading...
+ : logs.length === 0 &&
empty
} + {hasMore + ? + :
------ start of logs ------
}
) } -function DeleteWalletLogsObstacle ({ wallet, onClose }) { +function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) { + const { deleteLogs } = useWalletLogger(wallet, setLogs) const toaster = useToast() - const { deleteLogs } = useWalletLogger(wallet) const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?` return ( @@ -60,7 +68,7 @@ function DeleteWalletLogsObstacle ({ wallet, onClose }) { onClick={ async () => { try { - await deleteLogs() + await deleteLogs(wallet) onClose() toaster.success('deleted wallet logs') } catch (err) { @@ -76,72 +84,31 @@ function DeleteWalletLogsObstacle ({ wallet, onClose }) { ) } -const WalletLoggerContext = createContext() -const WalletLogsContext = createContext() +const INDICES = [ + { name: 'ts', keyPath: 'ts' }, + { name: 'wallet_ts', keyPath: ['wallet', 'ts'] } +] -const initIndexedDB = async (dbName, storeName) => { - return new Promise((resolve, reject) => { - if (!window.indexedDB) { - return reject(new Error('IndexedDB not supported')) - } - - // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB - const request = window.indexedDB.open(dbName, 1) - - let db - request.onupgradeneeded = () => { - // this only runs if version was changed during open - db = request.result - if (!db.objectStoreNames.contains(storeName)) { - const objectStore = db.createObjectStore(storeName, { autoIncrement: true }) - objectStore.createIndex('ts', 'ts') - objectStore.createIndex('wallet_ts', ['wallet', 'ts']) - } - } - - request.onsuccess = () => { - // this gets called after onupgradeneeded finished - db = request.result - resolve(db) - } - - request.onerror = () => { - reject(new Error('failed to open IndexedDB')) - } - }) +function useWalletLogDB () { + const { me } = useMe() + const dbName = `app:storage${me ? `:${me.id}` : ''}` + const idbStoreName = 'wallet_logs' + const { add, getPage, clear, error: idbError } = useIndexedDB(dbName, idbStoreName, 1, INDICES) + return { add, getPage, clear, error: idbError } } -export const WalletLoggerProvider = ({ children }) => { - const { me } = useMe() - const [logs, setLogs] = useState([]) - let dbName = 'app:storage' - if (me) { - dbName = `${dbName}:${me.id}` - } - const idbStoreName = 'wallet_logs' - const idb = useRef() - const logQueue = useRef([]) +export function useWalletLogger (wallet, setLogs) { + const { add, clear } = useWalletLogDB() - useQuery(WALLET_LOGS, { - fetchPolicy: 'network-only', - // required to trigger onCompleted on refetches - notifyOnNetworkStatusChange: true, - onCompleted: ({ walletLogs }) => { - setLogs((prevLogs) => { - const existingIds = prevLogs.map(({ id }) => id) - const logs = walletLogs - .filter(({ id }) => !existingIds.includes(id)) - .map(({ createdAt, wallet: walletType, ...log }) => { - return { - ts: +new Date(createdAt), - wallet: tag(getWalletByType(walletType)), - ...log - } - }) - return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts) - }) + const appendLog = useCallback(async (wallet, level, message) => { + const log = { wallet: tag(wallet), level, message, ts: +new Date() } + try { + await add(log) + setLogs?.(prevLogs => [log, ...prevLogs]) + } catch (error) { + console.error('Failed to append log:', error) } - }) + }, [add]) const [deleteServerWalletLogs] = useMutation( gql` @@ -151,105 +118,25 @@ export const WalletLoggerProvider = ({ children }) => { `, { onCompleted: (_, { variables: { wallet: walletType } }) => { - setLogs((logs) => { - return logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false) - }) + setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false)) } } ) - const saveLog = useCallback((log) => { - if (!idb.current) { - // IDB may not be ready yet - return logQueue.current.push(log) - } - try { - const tx = idb.current.transaction(idbStoreName, 'readwrite') - const request = tx.objectStore(idbStoreName).add(log) - request.onerror = () => console.error('failed to save log:', log) - } catch (e) { - console.error('failed to save log:', log, e) - } - }, []) - - useEffect(() => { - initIndexedDB(dbName, idbStoreName) - .then(db => { - idb.current = db - - // load all logs from IDB - const tx = idb.current.transaction(idbStoreName, 'readonly') - const store = tx.objectStore(idbStoreName) - const index = store.index('ts') - const request = index.getAll() - request.onsuccess = () => { - let logs = request.result - setLogs((prevLogs) => { - if (process.env.NODE_ENV !== 'production') { - // in dev mode, useEffect runs twice, so we filter out duplicates here - const existingIds = prevLogs.map(({ id }) => id) - logs = logs.filter(({ id }) => !existingIds.includes(id)) - } - // sort oldest first to keep same order as logs are appended - return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts) - }) - } - - // flush queued logs to IDB - logQueue.current.forEach(q => { - const isLog = !!q.wallet - if (isLog) saveLog(q) - }) - - logQueue.current = [] - }) - .catch(console.error) - return () => idb.current?.close() - }, []) - - const appendLog = useCallback((wallet, level, message) => { - const log = { wallet: tag(wallet), level, message, ts: +new Date() } - saveLog(log) - setLogs((prevLogs) => [log, ...prevLogs]) - }, [saveLog]) - const deleteLogs = useCallback(async (wallet, options) => { if ((!wallet || wallet.walletType) && !options?.clientOnly) { await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } }) } if (!wallet || wallet.sendPayment) { try { - const tx = idb.current.transaction(idbStoreName, 'readwrite') - const objectStore = tx.objectStore(idbStoreName) - const idx = objectStore.index('wallet_ts') - const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity])) : idx.openCursor() - request.onsuccess = function (event) { - const cursor = event.target.result - if (cursor) { - cursor.delete() - cursor.continue() - } else { - // finished - setLogs((logs) => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false)) - } - } + const walletTag = wallet ? tag(wallet) : null + await clear('wallet_ts', walletTag ? window.IDBKeyRange.bound([walletTag, 0], [walletTag, Infinity]) : null) + setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false)) } catch (e) { console.error('failed to delete logs', e) } } - }, [me, setLogs]) - - return ( - - - {children} - - - ) -} - -export function useWalletLogger (wallet) { - const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext) + }, [clear, deleteServerWalletLogs, setLogs]) const log = useCallback(level => message => { if (!wallet) { @@ -257,9 +144,6 @@ export function useWalletLogger (wallet) { return } - // TODO: - // also send this to us if diagnostics was enabled, - // very similar to how the service worker logger works. appendLog(wallet, level, message) console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message) }, [appendLog, wallet]) @@ -270,10 +154,6 @@ export function useWalletLogger (wallet) { error: (...message) => log('error')(message.join(' ')) }), [log, wallet?.name]) - const deleteLogs = useCallback((options) => { - return innerDeleteLogs(wallet, options) - }, [innerDeleteLogs, wallet]) - return { logger, deleteLogs } } @@ -281,7 +161,79 @@ function tag (wallet) { return wallet?.shortName || wallet?.name } -export function useWalletLogs (wallet) { - const logs = useContext(WalletLogsContext) - return logs.filter(l => !wallet || l.wallet === tag(wallet)) +export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { + const [logs, setLogs] = useState([]) + const [page, setPage] = useState(initialPage) + const [hasMore, setHasMore] = useState(true) + const [total, setTotal] = useState(0) + const [cursor, setCursor] = useState(null) + const [loading, setLoading] = useState(true) + + const { getPage, error: idbError } = useWalletLogDB() + const [getWalletLogs] = useLazyQuery(WALLET_LOGS, SSR ? {} : { fetchPolicy: 'cache-and-network' }) + + const loadLogsPage = useCallback(async (page, pageSize, wallet) => { + try { + let result = { data: [], hasMore: false } + const indexName = wallet ? 'wallet_ts' : 'ts' + const query = wallet ? window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity]) : null + result = await getPage(page, pageSize, indexName, query, 'prev') + // no walletType means we're using the local IDB + if (wallet && !wallet.walletType) { + return result + } + + const { data } = await getWalletLogs({ + variables: { + type: wallet?.walletType, + // if it client logs has more, page based on it's range + from: result?.data[result.data.length - 1]?.ts && result.hasMore ? String(result.data[result.data.length - 1].ts) : null, + // if we have a cursor (this isn't the first page), page based on it's range + to: result?.data[0]?.ts && cursor ? String(result.data[0].ts) : null, + cursor + } + }) + + const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({ + ts: +new Date(createdAt), + wallet: tag(getWalletByType(walletType)), + ...log + })) + const combinedLogs = Array.from(new Set([...result.data, ...newLogs].map(JSON.stringify))).map(JSON.parse).sort((a, b) => b.ts - a.ts) + + setCursor(data.walletLogs.cursor) + return { ...result, data: combinedLogs, hasMore: result.hasMore || !!data.walletLogs.cursor } + } catch (error) { + console.error('Error loading logs from IndexedDB:', error) + return { data: [], total: 0, hasMore: false } + } + }, [getPage, setCursor, cursor]) + + if (idbError) { + console.error('IndexedDB error:', idbError) + } + + const loadMore = useCallback(async () => { + if (hasMore) { + setLoading(true) + const result = await loadLogsPage(page, logsPerPage, wallet) + setLogs(prevLogs => [...prevLogs, ...result.data]) + setHasMore(result.hasMore) + setTotal(result.total) + setPage(prevPage => prevPage + 1) + setLoading(false) + } + }, [loadLogsPage, page, logsPerPage, wallet, hasMore]) + + const loadLogs = useCallback(async () => { + setLoading(true) + const result = await loadLogsPage(1, logsPerPage, wallet) + setLogs(result.data) + setHasMore(result.hasMore) + setTotal(result.total) + setPage(1) + setLoading(false) + }, [wallet, loadLogsPage]) + + return { logs, hasMore, total, loadMore, loadLogs, setLogs, loading } } diff --git a/fragments/wallet.js b/fragments/wallet.js index 140c0b12..89feb7cd 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -197,13 +197,16 @@ export const WALLETS = gql` ` export const WALLET_LOGS = gql` - query WalletLogs { - walletLogs { - id - createdAt - wallet - level - message + query WalletLogs($type: String, $from: String, $to: String, $cursor: String) { + walletLogs(type: $type, from: $from, to: $to, cursor: $cursor) { + cursor + entries { + id + createdAt + wallet + level + message + } } } ` diff --git a/pages/_app.js b/pages/_app.js index 1a1a4359..fd498779 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -17,7 +17,6 @@ import { SSR } from '@/lib/constants' import NProgress from 'nprogress' import 'nprogress/nprogress.css' import { LoggerProvider } from '@/components/logger' -import { WalletLoggerProvider } from '@/components/wallet-logger' import { ChainFeeProvider } from '@/components/chain-fee.js' import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' @@ -107,30 +106,28 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - - - {!router?.query?.disablePrompt && } - - - - - - - - - - - + + + + + + + + + + + + {!router?.query?.disablePrompt && } + + + + + + + + + +