Fallbacks for IndexedDB cascading errors (#957)

* Harden IndexedDB failures and ChannelTabs sizing

* Remove README.MD check feature.
This commit is contained in:
Jake 2026-04-08 15:59:00 +01:00 • committed by GitHub
parent aa2054d323
commit 0e0a398623
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 294 additions and 391 deletions

View file

@ -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][]);
}

View file

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

View file

@ -123,8 +123,7 @@
}
.vc-channeltabs-tab-scroller-following {
width: fit-content;
flex: unset;
max-width: 100%;
}
.vc-channeltabs-tab-scroller::-webkit-scrollbar {

View file

@ -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;
}

View file

@ -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;
}
});

View file

@ -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
}
};
}

View file

@ -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;

View file

@ -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()
});
});
}

View file

@ -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();
}