Fallbacks for IndexedDB cascading errors (#957)
* Harden IndexedDB failures and ChannelTabs sizing * Remove README.MD check feature.
This commit is contained in:
parent
aa2054d323
commit
0e0a398623
9 changed files with 294 additions and 391 deletions
|
|
@ -18,14 +18,31 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
|
||||
const logger = new Logger("DataStore");
|
||||
let loggedFailure = false;
|
||||
|
||||
function handleFailure(error: unknown) {
|
||||
if (loggedFailure) return;
|
||||
loggedFailure = true;
|
||||
logger.error("IndexedDB unavailable, using empty storage.", error);
|
||||
}
|
||||
|
||||
function withFailureFallback<T>(promise: Promise<T>, fallback: T): Promise<T> {
|
||||
return promise.catch(error => {
|
||||
handleFailure(error);
|
||||
return fallback;
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifyRequest<T = undefined>(
|
||||
request: IDBRequest<T> | IDBTransaction,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
// @ts-expect-error - file size hacks
|
||||
request.oncomplete = request.onsuccess = () => resolve(request.result);
|
||||
// @ts-expect-error - file size hacks
|
||||
request.onabort = request.onerror = () => reject(request.error);
|
||||
const target = request as IDBRequest<T> & IDBTransaction;
|
||||
target.oncomplete = target.onsuccess = () => resolve(target.result);
|
||||
target.onabort = target.onerror = () => reject(target.error);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -34,10 +51,15 @@ export function createStore(dbName: string, storeName: string): UseStore {
|
|||
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
||||
const dbp = promisifyRequest(request);
|
||||
|
||||
return (txMode, callback) =>
|
||||
dbp.then(db =>
|
||||
callback(db.transaction(storeName, txMode).objectStore(storeName)),
|
||||
);
|
||||
return async (txMode, callback) => {
|
||||
try {
|
||||
const db = await dbp;
|
||||
return await callback(db.transaction(storeName, txMode).objectStore(storeName));
|
||||
} catch (error) {
|
||||
handleFailure(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type UseStore = <T>(
|
||||
|
|
@ -54,87 +76,51 @@ function defaultGetStore() {
|
|||
return defaultGetStoreFunc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value by its key.
|
||||
*
|
||||
* @param key
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function get<T = any>(
|
||||
key: IDBValidKey,
|
||||
customStore = defaultGetStore(),
|
||||
): Promise<T | undefined> {
|
||||
return customStore("readonly", store => promisifyRequest(store.get(key)));
|
||||
return withFailureFallback(customStore("readonly", store => promisifyRequest(store.get(key))), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value with a key.
|
||||
*
|
||||
* @param key
|
||||
* @param value
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function set(
|
||||
key: IDBValidKey,
|
||||
value: any,
|
||||
customStore = defaultGetStore(),
|
||||
): Promise<void> {
|
||||
return customStore("readwrite", store => {
|
||||
return withFailureFallback(customStore("readwrite", store => {
|
||||
store.put(value, key);
|
||||
return promisifyRequest(store.transaction);
|
||||
});
|
||||
}), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple values at once. This is faster than calling set() multiple times.
|
||||
* It's also atomic – if one of the pairs can't be added, none will be added.
|
||||
*
|
||||
* @param entries Array of entries, where each entry is an array of `[key, value]`.
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function setMany(
|
||||
entries: [IDBValidKey, any][],
|
||||
customStore = defaultGetStore(),
|
||||
): Promise<void> {
|
||||
return customStore("readwrite", store => {
|
||||
return withFailureFallback(customStore("readwrite", store => {
|
||||
entries.forEach(entry => store.put(entry[1], entry[0]));
|
||||
return promisifyRequest(store.transaction);
|
||||
});
|
||||
}), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple values by their keys
|
||||
*
|
||||
* @param keys
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function getMany<T = any>(
|
||||
keys: IDBValidKey[],
|
||||
customStore = defaultGetStore(),
|
||||
): Promise<T[]> {
|
||||
return customStore("readonly", store =>
|
||||
return withFailureFallback(customStore("readonly", store =>
|
||||
Promise.all(keys.map(key => promisifyRequest(store.get(key)))),
|
||||
);
|
||||
), []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a value. This lets you see the old value and update it as an atomic operation.
|
||||
*
|
||||
* @param key
|
||||
* @param updater A callback that takes the old value and returns a new value.
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function update<T = any>(
|
||||
key: IDBValidKey,
|
||||
updater: (oldValue: T | undefined) => T,
|
||||
customStore = defaultGetStore(),
|
||||
): Promise<void> {
|
||||
return customStore(
|
||||
return withFailureFallback(customStore(
|
||||
"readwrite",
|
||||
store =>
|
||||
// Need to create the promise manually.
|
||||
// If I try to chain promises, the transaction closes in browsers
|
||||
// that use a promise polyfill (IE10/11).
|
||||
new Promise((resolve, reject) => {
|
||||
store.get(key).onsuccess = function () {
|
||||
try {
|
||||
|
|
@ -145,51 +131,34 @@ export function update<T = any>(
|
|||
}
|
||||
};
|
||||
}),
|
||||
);
|
||||
), undefined as void);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a particular key from the store.
|
||||
*
|
||||
* @param key
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function del(
|
||||
key: IDBValidKey,
|
||||
customStore = defaultGetStore(),
|
||||
): Promise<void> {
|
||||
return customStore("readwrite", store => {
|
||||
return withFailureFallback(customStore("readwrite", store => {
|
||||
store.delete(key);
|
||||
return promisifyRequest(store.transaction);
|
||||
});
|
||||
}), undefined as void);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple keys at once.
|
||||
*
|
||||
* @param keys List of keys to delete.
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function delMany(
|
||||
keys: IDBValidKey[],
|
||||
customStore = defaultGetStore(),
|
||||
): Promise<void> {
|
||||
return customStore("readwrite", (store: IDBObjectStore) => {
|
||||
keys.forEach((key: IDBValidKey) => store.delete(key));
|
||||
return withFailureFallback(customStore("readwrite", store => {
|
||||
keys.forEach(key => store.delete(key));
|
||||
return promisifyRequest(store.transaction);
|
||||
});
|
||||
}), undefined as void);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all values in the store.
|
||||
*
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function clear(customStore = defaultGetStore()): Promise<void> {
|
||||
return customStore("readwrite", store => {
|
||||
return withFailureFallback(customStore("readwrite", store => {
|
||||
store.clear();
|
||||
return promisifyRequest(store.transaction);
|
||||
});
|
||||
}), undefined as void);
|
||||
}
|
||||
|
||||
function eachCursor(
|
||||
|
|
@ -204,76 +173,42 @@ function eachCursor(
|
|||
return promisifyRequest(store.transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys in the store.
|
||||
*
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function keys<KeyType extends IDBValidKey>(
|
||||
customStore = defaultGetStore(),
|
||||
): Promise<KeyType[]> {
|
||||
return customStore("readonly", store => {
|
||||
// Fast path for modern browsers
|
||||
return withFailureFallback(customStore("readonly", store => {
|
||||
if (store.getAllKeys) {
|
||||
return promisifyRequest(
|
||||
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
|
||||
);
|
||||
return promisifyRequest(store.getAllKeys() as unknown as IDBRequest<KeyType[]>);
|
||||
}
|
||||
|
||||
const items: KeyType[] = [];
|
||||
|
||||
return eachCursor(store, cursor =>
|
||||
items.push(cursor.key as KeyType),
|
||||
).then(() => items);
|
||||
});
|
||||
return eachCursor(store, cursor => items.push(cursor.key as KeyType)).then(() => items);
|
||||
}), [] as KeyType[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values in the store.
|
||||
*
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function values<T = any>(customStore = defaultGetStore()): Promise<T[]> {
|
||||
return customStore("readonly", store => {
|
||||
// Fast path for modern browsers
|
||||
return withFailureFallback(customStore("readonly", store => {
|
||||
if (store.getAll) {
|
||||
return promisifyRequest(store.getAll() as IDBRequest<T[]>);
|
||||
}
|
||||
|
||||
const items: T[] = [];
|
||||
|
||||
return eachCursor(store, cursor => items.push(cursor.value as T)).then(
|
||||
() => items,
|
||||
);
|
||||
});
|
||||
return eachCursor(store, cursor => items.push(cursor.value as T)).then(() => items);
|
||||
}), [] as T[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entries in the store. Each entry is an array of `[key, value]`.
|
||||
*
|
||||
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
||||
*/
|
||||
export function entries<KeyType extends IDBValidKey, ValueType = any>(
|
||||
customStore = defaultGetStore(),
|
||||
): Promise<[KeyType, ValueType][]> {
|
||||
return customStore("readonly", store => {
|
||||
// Fast path for modern browsers
|
||||
// (although, hopefully we'll get a simpler path some day)
|
||||
return withFailureFallback(customStore("readonly", store => {
|
||||
if (store.getAll && store.getAllKeys) {
|
||||
return Promise.all([
|
||||
promisifyRequest(
|
||||
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
|
||||
),
|
||||
promisifyRequest(store.getAllKeys() as unknown as IDBRequest<KeyType[]>),
|
||||
promisifyRequest(store.getAll() as IDBRequest<ValueType[]>),
|
||||
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]]));
|
||||
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]] as [KeyType, ValueType]));
|
||||
}
|
||||
|
||||
const items: [KeyType, ValueType][] = [];
|
||||
|
||||
return customStore("readonly", store =>
|
||||
eachCursor(store, cursor =>
|
||||
items.push([cursor.key as KeyType, cursor.value]),
|
||||
).then(() => items),
|
||||
);
|
||||
});
|
||||
return eachCursor(store, cursor => items.push([cursor.key as KeyType, cursor.value])).then(() => items);
|
||||
}), [] as [KeyType, ValueType][]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { getGuildAcronym, getIntlMessage, getUniqueUsername } from "@utils/disco
|
|||
import { classes } from "@utils/misc";
|
||||
import { Channel, Guild, User } from "@vencord/discord-types";
|
||||
import { findComponentByCodeLazy, findCssClassesLazy } from "@webpack";
|
||||
import { ActiveJoinedThreadsStore, Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, ThemeStore, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
|
||||
import { ActiveJoinedThreadsStore, Avatar, ChannelStore, ContextMenuApi, GuildStore, PresenceStore, ReadStateStore, TypingStore, useDrag, useDrop, useEffect, useRef, UserStore, useState, useStateFromStores } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
|
||||
import { TabContextMenu } from "./ContextMenus";
|
||||
|
|
@ -469,11 +469,8 @@ export default function ChannelTab(props: ChannelTabsProps & { index: number; })
|
|||
|
||||
// check if quests running (questify momentLet)
|
||||
const hasActiveQuests = activeQuestIntervals.size > 0;
|
||||
console.log(ThemeStore.theme);
|
||||
return <div
|
||||
className={cl("tab",
|
||||
ThemeStore.theme === "light" ? "light-theme" : "dark-theme",
|
||||
{
|
||||
className={cl("tab", {
|
||||
"tab-compact": compact,
|
||||
"tab-selected": isTabSelected(id),
|
||||
"tab-entering": isEntering,
|
||||
|
|
|
|||
|
|
@ -123,8 +123,7 @@
|
|||
}
|
||||
|
||||
.vc-channeltabs-tab-scroller-following {
|
||||
width: fit-content;
|
||||
flex: unset;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.vc-channeltabs-tab-scroller::-webkit-scrollbar {
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ export const EXTENSIONS_CATALOG: ExtensionDefinition[] = [
|
|||
|
||||
export const extensionDefinitionsById = new Map(EXTENSIONS_CATALOG.map(extension => [extension.id, extension]));
|
||||
|
||||
export const EQUICORD_REPOSITORY_RAW_BASE_URL = "https://raw.githubusercontent.com/Equicord/Equicord/main";
|
||||
export const EQUICORD_REPOSITORY_BLOB_BASE_URL = "https://github.com/Equicord/Equicord/blob/main";
|
||||
|
||||
export function normalizeRepositoryPath(path: string): string {
|
||||
|
|
@ -132,27 +131,3 @@ export function toRepositoryBlobUrl(path: string): string {
|
|||
const normalizedPath = normalizeRepositoryPath(path);
|
||||
return `${EQUICORD_REPOSITORY_BLOB_BASE_URL}/${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function toRepositoryRawUrl(path: string): string {
|
||||
const normalizedPath = normalizeRepositoryPath(path);
|
||||
return `${EQUICORD_REPOSITORY_RAW_BASE_URL}/${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function getExtensionReadmePaths(extension: ExtensionDefinition): string[] {
|
||||
const paths: string[] = [];
|
||||
const addPath = (path: string | undefined) => {
|
||||
if (!path) return;
|
||||
const normalizedPath = normalizeRepositoryPath(path);
|
||||
if (!normalizedPath) return;
|
||||
if (paths.includes(normalizedPath)) return;
|
||||
paths.push(normalizedPath);
|
||||
};
|
||||
|
||||
addPath(extension.readmePath);
|
||||
if (extension.sourcePath) {
|
||||
addPath(`${extension.sourcePath}/README.md`);
|
||||
addPath(`${extension.sourcePath}/readme.md`);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,20 +137,6 @@ function createExtensionDetailCommands(deps: ExtensionsProviderDeps): CommandEnt
|
|||
});
|
||||
}
|
||||
|
||||
const availableReadmePath = extensionsState.getAvailableReadmePath(extension.id);
|
||||
if (availableReadmePath) {
|
||||
commands.push({
|
||||
id: `extension-detail-${extension.id}-readme`,
|
||||
label: "Open Readme",
|
||||
description: "Open extension documentation.",
|
||||
keywords: ["readme", "docs", "documentation", "extension"],
|
||||
categoryId: extension.detailCategoryId,
|
||||
hiddenInSearch: true,
|
||||
tags: [TAG_NAVIGATION, TAG_PLUGINS],
|
||||
handler: () => deps.openExternalUrl(toRepositoryBlobUrl(availableReadmePath))
|
||||
});
|
||||
}
|
||||
|
||||
if (extension.sourcePath) {
|
||||
commands.push({
|
||||
id: `extension-detail-${extension.id}-source`,
|
||||
|
|
@ -173,10 +159,7 @@ export function registerExtensionProviders(deps: ExtensionsProviderDeps) {
|
|||
id: EXTENSIONS_CATALOG_CATEGORY_ID,
|
||||
getCommands: () => createExtensionCatalogCommands(deps.extensionsState.listExtensions()),
|
||||
subscribe: refresh => {
|
||||
void deps.extensionsState.ready.finally(() => {
|
||||
deps.extensionsState.ensureAllFileChecks();
|
||||
refresh();
|
||||
});
|
||||
void deps.extensionsState.ready.finally(refresh);
|
||||
return () => undefined;
|
||||
}
|
||||
});
|
||||
|
|
@ -185,10 +168,7 @@ export function registerExtensionProviders(deps: ExtensionsProviderDeps) {
|
|||
id: EXTENSIONS_DETAIL_PROVIDER_ID,
|
||||
getCommands: () => createExtensionDetailCommands(deps),
|
||||
subscribe: refresh => {
|
||||
void deps.extensionsState.ready.finally(() => {
|
||||
deps.extensionsState.ensureAllFileChecks();
|
||||
refresh();
|
||||
});
|
||||
void deps.extensionsState.ready.finally(refresh);
|
||||
return () => undefined;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,17 +6,7 @@
|
|||
|
||||
import { DataStore } from "@api/index";
|
||||
|
||||
import {
|
||||
DEFAULT_EXTENSION_KEYBINDS,
|
||||
EXTENSION_KEYBINDS_KEY,
|
||||
extensionDefinitionsById,
|
||||
EXTENSIONS_CATALOG,
|
||||
EXTENSIONS_DETAIL_PROVIDER_ID,
|
||||
EXTENSIONS_PACK_PROVIDER_ID,
|
||||
getExtensionReadmePaths,
|
||||
INSTALLED_EXTENSIONS_KEY,
|
||||
toRepositoryRawUrl
|
||||
} from "./catalog";
|
||||
import { DEFAULT_EXTENSION_KEYBINDS, EXTENSION_KEYBINDS_KEY, extensionDefinitionsById, EXTENSIONS_CATALOG, EXTENSIONS_DETAIL_PROVIDER_ID, EXTENSIONS_PACK_PROVIDER_ID, INSTALLED_EXTENSIONS_KEY } from "./catalog";
|
||||
import type { ExtensionKeybindMap } from "./types";
|
||||
|
||||
export interface ExtensionsState {
|
||||
|
|
@ -27,8 +17,6 @@ export interface ExtensionsState {
|
|||
isInstalled(extensionId: string): boolean;
|
||||
install(extensionId: string): Promise<boolean>;
|
||||
uninstall(extensionId: string): Promise<boolean>;
|
||||
getAvailableReadmePath(extensionId: string): string | null;
|
||||
ensureAllFileChecks(): void;
|
||||
}
|
||||
|
||||
function toExtensionKeybindRecord(extensionKeybinds: Map<string, ExtensionKeybindMap>) {
|
||||
|
|
@ -39,24 +27,12 @@ function toExtensionKeybindRecord(extensionKeybinds: Map<string, ExtensionKeybin
|
|||
return record;
|
||||
}
|
||||
|
||||
async function checkExtensionFileExists(path: string): Promise<boolean> {
|
||||
const rawUrl = toRepositoryRawUrl(path);
|
||||
const headResponse = await fetch(rawUrl, { method: "HEAD" }).catch(() => null);
|
||||
if (headResponse?.ok) return true;
|
||||
if (headResponse?.status === 404) return false;
|
||||
|
||||
const getResponse = await fetch(rawUrl).catch(() => null);
|
||||
return getResponse?.ok ?? false;
|
||||
}
|
||||
|
||||
export function createExtensionsState(
|
||||
refreshContextProvider: (id: string) => void,
|
||||
bumpRegistryVersion: () => void
|
||||
): ExtensionsState {
|
||||
const installedExtensionIds = new Set<string>();
|
||||
const extensionKeybinds = new Map<string, ExtensionKeybindMap>();
|
||||
const extensionFileAvailability = new Map<string, boolean>();
|
||||
const extensionFileCheckInflight = new Set<string>();
|
||||
|
||||
const persistInstalledExtensions = async () => {
|
||||
try {
|
||||
|
|
@ -74,31 +50,6 @@ export function createExtensionsState(
|
|||
}
|
||||
};
|
||||
|
||||
const ensureExtensionFileCheck = (path?: string) => {
|
||||
if (!path) return;
|
||||
|
||||
if (extensionFileAvailability.has(path)) return;
|
||||
if (extensionFileCheckInflight.has(path)) return;
|
||||
|
||||
extensionFileCheckInflight.add(path);
|
||||
void checkExtensionFileExists(path)
|
||||
.then(exists => {
|
||||
extensionFileAvailability.set(path, exists);
|
||||
refreshContextProvider(EXTENSIONS_DETAIL_PROVIDER_ID);
|
||||
})
|
||||
.finally(() => {
|
||||
extensionFileCheckInflight.delete(path);
|
||||
});
|
||||
};
|
||||
|
||||
const ensureAllFileChecks = () => {
|
||||
for (const extension of EXTENSIONS_CATALOG) {
|
||||
for (const path of getExtensionReadmePaths(extension)) {
|
||||
ensureExtensionFileCheck(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ready = (async () => {
|
||||
try {
|
||||
const storedInstalled = await DataStore.get<string[]>(INSTALLED_EXTENSIONS_KEY);
|
||||
|
|
@ -129,7 +80,6 @@ export function createExtensionsState(
|
|||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
ensureAllFileChecks();
|
||||
refreshContextProvider(EXTENSIONS_PACK_PROVIDER_ID);
|
||||
refreshContextProvider(EXTENSIONS_DETAIL_PROVIDER_ID);
|
||||
bumpRegistryVersion();
|
||||
|
|
@ -179,15 +129,6 @@ export function createExtensionsState(
|
|||
refreshContextProvider(EXTENSIONS_DETAIL_PROVIDER_ID);
|
||||
bumpRegistryVersion();
|
||||
return true;
|
||||
},
|
||||
getAvailableReadmePath: extensionId => {
|
||||
const extension = extensionDefinitionsById.get(extensionId);
|
||||
if (!extension) return null;
|
||||
for (const path of getExtensionReadmePaths(extension)) {
|
||||
if (extensionFileAvailability.get(path) === true) return path;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
ensureAllFileChecks
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
*/
|
||||
|
||||
import { Flogger, settings } from ".";
|
||||
import { addMessageIDB, db, DBMessageStatus, deleteMessagesBulkIDB, getOlderThanTimestampForGuildsIDB, getOldestMessagesIDB } from "./db";
|
||||
import { addMessageIDB, countMessagesIDB, DBMessageStatus, deleteMessagesBulkIDB, getOlderThanTimestampForGuildsIDB, getOldestMessagesIDB } from "./db";
|
||||
import { LoggedMessage, LoggedMessageJSON } from "./types";
|
||||
import { cleanupMessage } from "./utils";
|
||||
import { cacheMessageImages } from "./utils/saveImage";
|
||||
|
|
@ -47,7 +47,7 @@ export const addMessage = async (message: LoggedMessage | LoggedMessageJSON, sta
|
|||
}
|
||||
|
||||
if (settings.store.messageLimit > 0) {
|
||||
const currentMessageCount = await db.count("messages");
|
||||
const currentMessageCount = await countMessagesIDB();
|
||||
if (currentMessageCount > settings.store.messageLimit) {
|
||||
const messagesToDelete = currentMessageCount - settings.store.messageLimit;
|
||||
if (messagesToDelete <= 0 || messagesToDelete >= settings.store.messageLimit) return;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { ChannelStore, Toasts } from "@webpack/common";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { DBSchema, IDBPDatabase, openDB } from "idb";
|
||||
|
||||
import { LoggedMessageJSON } from "./types";
|
||||
|
|
@ -37,13 +38,57 @@ export interface MLIDB extends DBSchema {
|
|||
by_timestamp_and_message_id: [string, string];
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export let db: IDBPDatabase<MLIDB>;
|
||||
const logger = new Logger("MessageLoggerEnhanced");
|
||||
let db: IDBPDatabase<MLIDB> | null = null;
|
||||
let dbPromise: Promise<IDBPDatabase<MLIDB> | null> | null = null;
|
||||
let loggedFailure = false;
|
||||
|
||||
export const cachedMessages = new Map<string, LoggedMessageJSON>();
|
||||
|
||||
// this is probably not the best way to do this
|
||||
function handleDbFailure(error: unknown) {
|
||||
if (loggedFailure) return;
|
||||
loggedFailure = true;
|
||||
logger.error("Message storage unavailable, using memory only.", error);
|
||||
}
|
||||
|
||||
async function ensureDb() {
|
||||
if (db) return db;
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<MLIDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(database) {
|
||||
const messageStore = database.createObjectStore("messages", { keyPath: "message_id" });
|
||||
messageStore.createIndex("by_channel_id", "channel_id");
|
||||
messageStore.createIndex("by_status", "status");
|
||||
messageStore.createIndex("by_timestamp", "message.timestamp");
|
||||
messageStore.createIndex("by_timestamp_and_message_id", ["channel_id", "message.timestamp"]);
|
||||
}
|
||||
}).then(database => {
|
||||
db = database;
|
||||
return database;
|
||||
}).catch(error => {
|
||||
handleDbFailure(error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
db = await dbPromise;
|
||||
return db;
|
||||
}
|
||||
|
||||
async function withDb<T>(fallback: T, callback: (database: IDBPDatabase<MLIDB>) => Promise<T>): Promise<T> {
|
||||
const database = await ensureDb();
|
||||
if (!database) return fallback;
|
||||
|
||||
try {
|
||||
return await callback(database);
|
||||
} catch (error) {
|
||||
handleDbFailure(error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheRecords(records: DBMessageRecord[]) {
|
||||
for (const r of records) {
|
||||
cacheRecord(r);
|
||||
|
|
@ -56,6 +101,7 @@ async function cacheRecords(records: DBMessageRecord[]) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
|
|
@ -68,56 +114,56 @@ async function cacheRecord(record?: DBMessageRecord | null) {
|
|||
}
|
||||
|
||||
export async function initIDB() {
|
||||
db = await openDB<MLIDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
const messageStore = db.createObjectStore("messages", { keyPath: "message_id" });
|
||||
messageStore.createIndex("by_channel_id", "channel_id");
|
||||
messageStore.createIndex("by_status", "status");
|
||||
messageStore.createIndex("by_timestamp", "message.timestamp");
|
||||
messageStore.createIndex("by_timestamp_and_message_id", ["channel_id", "message.timestamp"]);
|
||||
}
|
||||
});
|
||||
await ensureDb();
|
||||
}
|
||||
initIDB();
|
||||
void initIDB();
|
||||
|
||||
export async function hasMessageIDB(message_id: string) {
|
||||
return cachedMessages.has(message_id) || (await db.count("messages", message_id)) > 0;
|
||||
if (cachedMessages.has(message_id)) return true;
|
||||
return withDb(false, database => database.count("messages", message_id).then(count => count > 0));
|
||||
}
|
||||
|
||||
export async function countMessagesIDB() {
|
||||
if (!db) return cachedMessages.size;
|
||||
return db.count("messages");
|
||||
}
|
||||
|
||||
export async function countMessagesByStatusIDB(status: DBMessageStatus) {
|
||||
return db.countFromIndex("messages", "by_status", status);
|
||||
return withDb(0, database => database.countFromIndex("messages", "by_status", status));
|
||||
}
|
||||
|
||||
export async function getAllMessagesIDB() {
|
||||
return cacheRecords(await db.getAll("messages"));
|
||||
return withDb([], async database => cacheRecords(await database.getAll("messages")));
|
||||
}
|
||||
|
||||
export async function getMessagesForChannelIDB(channel_id: string) {
|
||||
return cacheRecords(await db.getAllFromIndex("messages", "by_channel_id", channel_id));
|
||||
return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_channel_id", channel_id)));
|
||||
}
|
||||
|
||||
export async function getMessageIDB(message_id: string) {
|
||||
return cacheRecord(await db.get("messages", message_id));
|
||||
return withDb<DBMessageRecord | null>(null, async database => {
|
||||
const record = await database.get("messages", message_id);
|
||||
await cacheRecord(record);
|
||||
return record ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMessagesByStatusIDB(status: DBMessageStatus) {
|
||||
return cacheRecords(await db.getAllFromIndex("messages", "by_status", status));
|
||||
return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_status", status)));
|
||||
}
|
||||
|
||||
export async function getOldestMessagesIDB(limit: number) {
|
||||
return cacheRecords(await db.getAllFromIndex("messages", "by_timestamp", undefined, limit));
|
||||
return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_timestamp", undefined, limit)));
|
||||
}
|
||||
|
||||
export async function* iterateAllMessagesIDB(batchSize = 100) {
|
||||
if (!db && !(await ensureDb())) return;
|
||||
|
||||
let lastId: string | undefined;
|
||||
while (true) {
|
||||
const batch: DBMessageRecord[] = [];
|
||||
// new transaction for each batch to avoid timeouts during yield
|
||||
const tx = db.transaction("messages");
|
||||
|
||||
const tx = db!.transaction("messages");
|
||||
const range = lastId ? IDBKeyRange.lowerBound(lastId, true) : undefined;
|
||||
let cursor = await tx.store.openCursor(range);
|
||||
|
||||
|
|
@ -129,7 +175,6 @@ export async function* iterateAllMessagesIDB(batchSize = 100) {
|
|||
if (batch.length === 0) break;
|
||||
|
||||
lastId = batch[batch.length - 1].message_id;
|
||||
|
||||
yield await cacheRecords(batch);
|
||||
|
||||
if (batch.length < batchSize) break;
|
||||
|
|
@ -137,22 +182,20 @@ export async function* iterateAllMessagesIDB(batchSize = 100) {
|
|||
}
|
||||
|
||||
export async function getOlderThanTimestampIDB(timestamp: string) {
|
||||
const tx = db.transaction("messages", "readonly");
|
||||
const { store } = tx;
|
||||
const index = store.index("by_timestamp");
|
||||
return withDb([], async database => {
|
||||
const tx = database.transaction("messages", "readonly");
|
||||
const index = tx.store.index("by_timestamp");
|
||||
const cursor = await index.openCursor(IDBKeyRange.upperBound(timestamp));
|
||||
|
||||
const cursor = await index.openCursor(IDBKeyRange.upperBound(timestamp));
|
||||
if (!cursor) return [];
|
||||
|
||||
if (!cursor) {
|
||||
return [];
|
||||
}
|
||||
const messages: DBMessageRecord[] = [];
|
||||
for await (const c of cursor) {
|
||||
messages.push(c.value);
|
||||
}
|
||||
|
||||
const messages: DBMessageRecord[] = [];
|
||||
for await (const c of cursor) {
|
||||
messages.push(c.value);
|
||||
}
|
||||
|
||||
return cacheRecords(messages);
|
||||
return cacheRecords(messages);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOlderThanTimestampForGuildsIDB(timestamp: string, currentChannelId?: string, preserveCurrentChannel?: boolean) {
|
||||
|
|
@ -167,99 +210,106 @@ export async function getOlderThanTimestampForGuildsIDB(timestamp: string, curre
|
|||
}
|
||||
|
||||
export async function getDateStortedMessagesByStatusIDB(newest: boolean, limit: number, status: DBMessageStatus) {
|
||||
const tx = db.transaction("messages", "readonly");
|
||||
const { store } = tx;
|
||||
const index = store.index("by_status");
|
||||
return withDb([], async database => {
|
||||
const tx = database.transaction("messages", "readonly");
|
||||
const index = tx.store.index("by_status");
|
||||
const direction = newest ? "prev" : "next";
|
||||
const cursor = await index.openCursor(IDBKeyRange.only(status), direction);
|
||||
|
||||
const direction = newest ? "prev" : "next";
|
||||
const cursor = await index.openCursor(IDBKeyRange.only(status), direction);
|
||||
if (!cursor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!cursor) {
|
||||
console.log("No messages found");
|
||||
return [];
|
||||
}
|
||||
const messages: DBMessageRecord[] = [];
|
||||
for await (const c of cursor) {
|
||||
messages.push(c.value);
|
||||
if (messages.length >= limit) break;
|
||||
}
|
||||
|
||||
const messages: DBMessageRecord[] = [];
|
||||
for await (const c of cursor) {
|
||||
messages.push(c.value);
|
||||
if (messages.length >= limit) break;
|
||||
}
|
||||
|
||||
return cacheRecords(messages);
|
||||
return cacheRecords(messages);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMessagesByChannelAndAfterTimestampIDB(channel_id: string, start: string) {
|
||||
const tx = db.transaction("messages", "readonly");
|
||||
const { store } = tx;
|
||||
const index = store.index("by_timestamp_and_message_id");
|
||||
return withDb([], async database => {
|
||||
const tx = database.transaction("messages", "readonly");
|
||||
const index = tx.store.index("by_timestamp_and_message_id");
|
||||
const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"]));
|
||||
|
||||
const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"]));
|
||||
if (!cursor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!cursor) {
|
||||
console.log("No messages found in range");
|
||||
return [];
|
||||
}
|
||||
const messages: DBMessageRecord[] = [];
|
||||
for await (const c of cursor) {
|
||||
messages.push(c.value);
|
||||
}
|
||||
|
||||
const messages: DBMessageRecord[] = [];
|
||||
for await (const c of cursor) {
|
||||
messages.push(c.value);
|
||||
}
|
||||
|
||||
return cacheRecords(messages);
|
||||
return cacheRecords(messages);
|
||||
});
|
||||
}
|
||||
|
||||
export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessageStatus) {
|
||||
stripTransientRenderState(message);
|
||||
|
||||
await db.put("messages", {
|
||||
channel_id: message.channel_id,
|
||||
message_id: message.id,
|
||||
status,
|
||||
message,
|
||||
});
|
||||
|
||||
cachedMessages.set(message.id, message);
|
||||
|
||||
return withDb(void 0, async database => {
|
||||
await database.put("messages", {
|
||||
channel_id: message.channel_id,
|
||||
message_id: message.id,
|
||||
status,
|
||||
message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function addMessagesBulkIDB(messages: LoggedMessageJSON[], status?: DBMessageStatus) {
|
||||
messages.forEach(stripTransientRenderState);
|
||||
|
||||
const tx = db.transaction("messages", "readwrite");
|
||||
const { store } = tx;
|
||||
|
||||
await Promise.all([
|
||||
...messages.map(message => store.add({
|
||||
channel_id: message.channel_id,
|
||||
message_id: message.id,
|
||||
status: status ?? getMessageStatus(message),
|
||||
message,
|
||||
})),
|
||||
tx.done
|
||||
]);
|
||||
|
||||
messages.forEach(message => cachedMessages.set(message.id, message));
|
||||
|
||||
return withDb(void 0, async database => {
|
||||
const tx = database.transaction("messages", "readwrite");
|
||||
const { store } = tx;
|
||||
|
||||
await Promise.all([
|
||||
...messages.map(message => store.add({
|
||||
channel_id: message.channel_id,
|
||||
message_id: message.id,
|
||||
status: status ?? getMessageStatus(message),
|
||||
message,
|
||||
})),
|
||||
tx.done
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMessageIDB(message_id: string) {
|
||||
await db.delete("messages", message_id);
|
||||
|
||||
cachedMessages.delete(message_id);
|
||||
|
||||
return withDb(void 0, database => database.delete("messages", message_id));
|
||||
}
|
||||
|
||||
export async function deleteMessagesBulkIDB(message_ids: string[]) {
|
||||
const tx = db.transaction("messages", "readwrite");
|
||||
const { store } = tx;
|
||||
|
||||
await Promise.all([...message_ids.map(id => store.delete(id)), tx.done]);
|
||||
message_ids.forEach(id => cachedMessages.delete(id));
|
||||
|
||||
return withDb(void 0, async database => {
|
||||
const tx = database.transaction("messages", "readwrite");
|
||||
const { store } = tx;
|
||||
await Promise.all([
|
||||
...message_ids.map(id => store.delete(id)),
|
||||
tx.done
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearMessagesIDB() {
|
||||
await db.clear("messages");
|
||||
cachedMessages.clear();
|
||||
Toasts.show({
|
||||
type: Toasts.Type.MESSAGE,
|
||||
message: "Cleared message log database and cache.",
|
||||
id: Toasts.genId()
|
||||
|
||||
return withDb(void 0, database => database.clear("messages")).then(() => {
|
||||
Toasts.show({
|
||||
type: Toasts.Type.MESSAGE,
|
||||
message: "Cleared message log database and cache.",
|
||||
id: Toasts.genId()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,9 @@ const DB_VERSION = 2;
|
|||
|
||||
const VOICES_STORE = "voices";
|
||||
|
||||
import { Logger } from "@utils/Logger";
|
||||
|
||||
const logger = new Logger("VcNarratorCustom");
|
||||
const META_STORE = "voices_meta";
|
||||
|
||||
type VoiceMeta = {
|
||||
|
|
@ -206,49 +209,76 @@ type VoiceMeta = {
|
|||
lastAccess: number;
|
||||
};
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = event => {
|
||||
const db = request.result;
|
||||
const upgradeTx = request.transaction;
|
||||
if (!db.objectStoreNames.contains(VOICES_STORE)) {
|
||||
db.createObjectStore(VOICES_STORE);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||
const meta = db.createObjectStore(META_STORE);
|
||||
meta.createIndex("by_lastAccess", "lastAccess");
|
||||
}
|
||||
let openDbPromise: Promise<IDBDatabase | null> | undefined;
|
||||
let loggedFailure = false;
|
||||
|
||||
if (upgradeTx && (event?.oldVersion ?? 0) < 2) {
|
||||
const now = Date.now();
|
||||
const voices = upgradeTx.objectStore(VOICES_STORE);
|
||||
const meta = upgradeTx.objectStore(META_STORE);
|
||||
const cursorReq = voices.openCursor();
|
||||
cursorReq.onsuccess = () => {
|
||||
const cursor = cursorReq.result;
|
||||
if (!cursor) return;
|
||||
const blob = cursor.value as Blob | undefined;
|
||||
meta.put(
|
||||
{
|
||||
size: blob?.size ?? 0,
|
||||
createdAt: now,
|
||||
lastAccess: now,
|
||||
} satisfies VoiceMeta,
|
||||
String(cursor.key)
|
||||
);
|
||||
cursor.continue();
|
||||
};
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
function handleDbFailure(error: unknown) {
|
||||
if (loggedFailure) return;
|
||||
loggedFailure = true;
|
||||
logger.error("Voice cache storage unavailable, falling back to memory.", error);
|
||||
}
|
||||
|
||||
function openDB(): Promise<IDBDatabase | null> {
|
||||
if (!openDbPromise) {
|
||||
openDbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = event => {
|
||||
const db = request.result;
|
||||
const upgradeTx = request.transaction;
|
||||
if (!db.objectStoreNames.contains(VOICES_STORE)) {
|
||||
db.createObjectStore(VOICES_STORE);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||
const meta = db.createObjectStore(META_STORE);
|
||||
meta.createIndex("by_lastAccess", "lastAccess");
|
||||
}
|
||||
|
||||
if (upgradeTx && (event?.oldVersion ?? 0) < 2) {
|
||||
const now = Date.now();
|
||||
const voices = upgradeTx.objectStore(VOICES_STORE);
|
||||
const meta = upgradeTx.objectStore(META_STORE);
|
||||
const cursorReq = voices.openCursor();
|
||||
cursorReq.onsuccess = () => {
|
||||
const cursor = cursorReq.result;
|
||||
if (!cursor) return;
|
||||
const blob = cursor.value as Blob | undefined;
|
||||
meta.put(
|
||||
{
|
||||
size: blob?.size ?? 0,
|
||||
createdAt: now,
|
||||
lastAccess: now,
|
||||
} satisfies VoiceMeta,
|
||||
String(cursor.key)
|
||||
);
|
||||
cursor.continue();
|
||||
};
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
}).then(db => db).catch(error => {
|
||||
handleDbFailure(error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
return openDbPromise;
|
||||
}
|
||||
|
||||
async function withDb<T>(fallback: T, callback: (db: IDBDatabase) => Promise<T>): Promise<T> {
|
||||
const db = await openDB();
|
||||
if (!db) return fallback;
|
||||
|
||||
try {
|
||||
return await callback(db);
|
||||
} catch (error) {
|
||||
handleDbFailure(error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
return withDb<{ bytes: number; entries: number }>({ bytes: 0, entries: 0 }, db => new Promise<{ bytes: number; entries: number }>((resolve, reject) => {
|
||||
const tx = db.transaction(META_STORE, "readonly");
|
||||
const store = tx.objectStore(META_STORE);
|
||||
|
||||
|
|
@ -268,7 +298,7 @@ export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; ent
|
|||
cursor.continue();
|
||||
};
|
||||
cursorReq.onerror = () => reject(cursorReq.error);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export async function clearTtsCache(): Promise<void> {
|
||||
|
|
@ -279,19 +309,17 @@ export async function clearTtsCache(): Promise<void> {
|
|||
}
|
||||
ttsCache.clear();
|
||||
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
await withDb<void>(void 0, db => new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
|
||||
tx.objectStore(VOICES_STORE).clear();
|
||||
tx.objectStore(META_STORE).clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX_BYTES): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
await withDb<void>(void 0, db => new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
|
||||
const voicesStore = tx.objectStore(VOICES_STORE);
|
||||
const metaStore = tx.objectStore(META_STORE);
|
||||
|
|
@ -344,12 +372,11 @@ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX
|
|||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getCachedVoiceFromDB(cacheKey: string): Promise<Blob | null> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
return withDb<Blob | null>(null, db => new Promise<Blob | null>((resolve, reject) => {
|
||||
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
|
||||
const voicesStore = tx.objectStore(VOICES_STORE);
|
||||
const metaStore = tx.objectStore(META_STORE);
|
||||
|
|
@ -373,12 +400,11 @@ export async function getCachedVoiceFromDB(cacheKey: string): Promise<Blob | nul
|
|||
resolve(blob);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise<void> {
|
||||
const db = await openDB();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
await withDb<void>(void 0, db => new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
|
||||
const voicesStore = tx.objectStore(VOICES_STORE);
|
||||
const metaStore = tx.objectStore(META_STORE);
|
||||
|
|
@ -392,7 +418,7 @@ export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise<
|
|||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}));
|
||||
|
||||
await trimPersistentCacheToMaxBytes();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue