feat: new more performant cache

This commit is contained in:
ThaUnknown 2025-10-13 19:21:32 +02:00
parent 49a5e26155
commit 77dc3c28f3
No known key found for this signature in database
4 changed files with 199 additions and 7 deletions

View file

@ -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",

View file

@ -0,0 +1,191 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
SerializedEntries,
SerializedRequest,
StorageAdapter
} from '@urql/exchange-graphcache'
const getRequestPromise = <T>(request: IDBRequest<T>): Promise<T> => {
return new Promise((resolve, reject) => {
request.onerror = () => {
reject(request.error as Error)
}
request.onsuccess = () => {
resolve(request.result)
}
})
}
const getTransactionPromise = (transaction: IDBTransaction): Promise<any> => {
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<any>
}
/** 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<null | SerializedRequest[]> {
return database$.then(
database => {
return getRequestPromise<SerializedRequest[]>(
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<void> {
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<SerializedEntries> {
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()
})
)
}
}
}

View file

@ -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<void>()
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')

View file

@ -34,7 +34,7 @@
<div class='size-full flex justify-center items-center'>
<div class='size-10 relative logo-container' on:animationend|self={navigate}>
<Logo class='size-10' />
<Logo class='size-10 [filter:url(#chromaticAberration)]' />
{#each spotlightData as s, i (i)}
<div class='spotlight absolute blurred origin-left'
style:--to-x={s.tox}
@ -51,7 +51,6 @@
</div>
</div>
<!--
<svg width='0' height='0'>
<filter id='chromaticAberration'>
<feColorMatrix type='matrix'
@ -83,7 +82,7 @@
</feOffset>
<feBlend mode='screen' in='red' in2='blue' />
</filter>
</svg> -->
</svg>
<style>
@property --spotlight-opacity {
@ -132,7 +131,7 @@
}
.spotlight {
filter: blur(3px);
filter: url('#chromaticAberration') blur(3px);
--impect-radius: 192;
--dist-factor: calc((192 - min(var(--to-dist), 192)) / 192);
left: calc(var(--to-x) * 1px);