From 77dc3c28f3f57a0e6699b8e896f0c8e6213fd688 Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:21:32 +0200 Subject: [PATCH] feat: new more performant cache --- package.json | 2 +- src/lib/modules/anilist/storage.ts | 191 +++++++++++++++++++++++++ src/lib/modules/anilist/urql-client.ts | 6 +- src/routes/+page.svelte | 7 +- 4 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 src/lib/modules/anilist/storage.ts diff --git a/package.json b/package.json index 6f5eeaf..d241a1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "6.4.155", + "version": "6.4.156", "license": "BUSL-1.1", "private": true, "packageManager": "pnpm@9.15.5", diff --git a/src/lib/modules/anilist/storage.ts b/src/lib/modules/anilist/storage.ts new file mode 100644 index 0000000..1c5399d --- /dev/null +++ b/src/lib/modules/anilist/storage.ts @@ -0,0 +1,191 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + SerializedEntries, + SerializedRequest, + StorageAdapter +} from '@urql/exchange-graphcache' + +const getRequestPromise = (request: IDBRequest): Promise => { + return new Promise((resolve, reject) => { + request.onerror = () => { + reject(request.error as Error) + } + + request.onsuccess = () => { + resolve(request.result) + } + }) +} + +const getTransactionPromise = (transaction: IDBTransaction): Promise => { + return new Promise((resolve, reject) => { + transaction.onerror = () => { + reject(transaction.error as Error) + } + + transaction.oncomplete = resolve + }) +} + +export interface StorageOptions { + /** Name of the IndexedDB database that will be used. + * @defaultValue `'graphcache-v4'` + */ + idbName?: string + /** Maximum age of cache entries (in days) after which data is discarded. + * @defaultValue `7` days + */ + maxAge?: number + /** Gets Called when the exchange has hydrated the data from storage. */ + onCacheHydrated?: () => void +} + +/** Sample storage adapter persisting to IndexedDB. */ +export interface DefaultStorage extends StorageAdapter { + /** Clears the entire IndexedDB storage. */ + clear: () => Promise +} + +/** Creates a default {@link StorageAdapter} which uses IndexedDB for storage. + * + * @param opts - A {@link StorageOptions} configuration object. + * @returns the created {@link StorageAdapter}. + * + * @remarks + * The default storage uses IndexedDB to persist the normalized cache for + * offline use. It demonstrates that the cache can be chunked by timestamps. + * + * Note: We have no data on stability of this storage and our Offline Support + * for large APIs or longterm use. Proceed with caution. + */ +export const makeDefaultStorage = (opts?: StorageOptions): DefaultStorage => { + opts ??= {} + + let callback: (() => void) | undefined + + const DB_NAME = opts.idbName || 'graphcache-v4' + const ENTRIES_STORE_NAME = 'entries' + const METADATA_STORE_NAME = 'metadata' + + let batch: SerializedEntries = Object.create(null) + const timestamp = Math.floor(new Date().valueOf() / (1000 * 60 * 60 * 24)) + const maxAge = timestamp - (opts.maxAge || 7) + + const req = indexedDB.open(DB_NAME, 1) + const database$ = getRequestPromise(req) + + req.onupgradeneeded = () => { + req.result.createObjectStore(ENTRIES_STORE_NAME) + req.result.createObjectStore(METADATA_STORE_NAME) + } + + return { + clear () { + return database$.then(database => { + const transaction = database.transaction( + [METADATA_STORE_NAME, ENTRIES_STORE_NAME], + 'readwrite' + ) + transaction.objectStore(METADATA_STORE_NAME).clear() + transaction.objectStore(ENTRIES_STORE_NAME).clear() + batch = Object.create(null) + return getTransactionPromise(transaction) + }) + }, + + readMetadata (): Promise { + return database$.then( + database => { + return getRequestPromise( + database + .transaction(METADATA_STORE_NAME, 'readonly') + .objectStore(METADATA_STORE_NAME) + .get(METADATA_STORE_NAME) + ) + }, + () => null + ) + }, + + writeMetadata (metadata: SerializedRequest[]) { + database$.then( + database => { + return getRequestPromise( + database + .transaction(METADATA_STORE_NAME, 'readwrite') + .objectStore(METADATA_STORE_NAME) + .put(metadata, METADATA_STORE_NAME) + ) + }, + () => { + /* noop */ + } + ) + }, + + writeData (entries: SerializedEntries): Promise { + Object.assign(batch, entries) + const toUndefined = () => undefined + + return database$ + .then(database => { + return getRequestPromise( + database + .transaction(ENTRIES_STORE_NAME, 'readwrite') + .objectStore(ENTRIES_STORE_NAME) + .put(batch, timestamp) + ) + }) + .then(toUndefined, toUndefined) + }, + + readData (): Promise { + return database$ + .then(database => { + const transaction = database.transaction( + ENTRIES_STORE_NAME, + 'readwrite' + ) + + const store = transaction.objectStore(ENTRIES_STORE_NAME) + const request = (store.openKeyCursor || store.openCursor).call(store) + + request.onsuccess = function () { + if (this.result) { + const { key } = this.result + if (typeof key !== 'number' || key < maxAge) { + store.delete(key) + } else { + const request = store.get(key) + request.onsuccess = () => { + if (key === timestamp) { Object.assign(batch, request.result) } + } + } + + this.result.continue() + } + } + + return getTransactionPromise(transaction) + }) + .then( + () => batch, + () => batch + ) + }, + onCacheHydrated: opts.onCacheHydrated, + onOnline (cb: () => void) { + if (callback) { + window.removeEventListener('online', callback) + callback = undefined + } + + window.addEventListener( + 'online', + (callback = () => { + cb() + }) + ) + } + } +} diff --git a/src/lib/modules/anilist/urql-client.ts b/src/lib/modules/anilist/urql-client.ts index 552f9df..ae17408 100644 --- a/src/lib/modules/anilist/urql-client.ts +++ b/src/lib/modules/anilist/urql-client.ts @@ -1,6 +1,5 @@ import { authExchange } from '@urql/exchange-auth' import { offlineExchange } from '@urql/exchange-graphcache' -import { makeDefaultStorage } from '@urql/exchange-graphcache/default-storage' import { Client, fetchExchange } from '@urql/svelte' import Bottleneck from 'bottleneck' import Debug from 'debug' @@ -11,6 +10,7 @@ import gql from './gql' import { CommentFrag, CustomLists, type Entry, FullMedia, FullMediaList, ThreadFrag, type ToggleFavourite, UserLists, Viewer } from './queries' import { refocusExchange } from './refocus' import schema from './schema.json' with { type: 'json' } +import { makeDefaultStorage } from './storage' import type { ResultOf } from 'gql.tada' @@ -34,11 +34,13 @@ class FetchError extends Error { // eslint-disable-next-line @typescript-eslint/no-invalid-void-type export const storagePromise = Promise.withResolvers() export const storage = makeDefaultStorage({ - idbName: 'graphcache-v3', + idbName: 'anilist-cache-v1', onCacheHydrated: () => storagePromise.resolve(), maxAge: 14 // The maximum age of the persisted data in days }) +indexedDB.deleteDatabase('graphcache-v3') // old version + debug('Loading urql client') storagePromise.promise.finally(() => { debug('Graphcache storage initialized') diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6d69c4f..5076506 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -34,7 +34,7 @@
- + {#each spotlightData as s, i (i)}
- +