This commit is contained in:
thororen1234 2026-04-12 20:05:45 -04:00
parent eba9ee446a
commit 5e4a310c4e
No known key found for this signature in database
4 changed files with 267 additions and 364 deletions

View file

@ -18,97 +18,31 @@
* limitations under the License.
*/
import { Logger } from "@utils/Logger";
const logger = new Logger("DataStore");
let loggedFailure = false;
const memoryDatabases = new Map<string, Map<string, Map<IDBValidKey, unknown>>>();
interface MemoryStore {
__memoryStore: true;
get<T>(key: IDBValidKey): T | undefined;
put(value: unknown, key: IDBValidKey): void;
delete(key: IDBValidKey): void;
clear(): void;
getAllKeys<KeyType extends IDBValidKey>(): KeyType[];
getAll<T>(): T[];
entries<KeyType extends IDBValidKey, ValueType>(): [KeyType, ValueType][];
}
type StoreLike = IDBObjectStore | MemoryStore;
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;
});
}
function getMemoryStore(dbName: string, storeName: string): MemoryStore {
let database = memoryDatabases.get(dbName);
if (!database) {
database = new Map();
memoryDatabases.set(dbName, database);
}
let data = database.get(storeName);
if (!data) {
data = new Map<IDBValidKey, unknown>();
database.set(storeName, data);
}
return {
__memoryStore: true,
get: <T>(key: IDBValidKey) => data.get(key) as T | undefined,
put: (value, key) => { data.set(key, value); },
delete: key => { data.delete(key); },
clear: () => { data.clear(); },
getAllKeys: <KeyType extends IDBValidKey>() => Array.from(data.keys()) as KeyType[],
getAll: <T>() => Array.from(data.values()) as T[],
entries: <KeyType extends IDBValidKey, ValueType>() => Array.from(data.entries()) as [KeyType, ValueType][]
};
}
function isMemoryStore(store: StoreLike): store is MemoryStore {
return "__memoryStore" in store;
}
export function promisifyRequest<T = undefined>(
request: IDBRequest<T> | IDBTransaction,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const target = request as IDBRequest<T> & IDBTransaction;
target.oncomplete = target.onsuccess = () => resolve(target.result);
target.onabort = target.onerror = () => reject(target.error);
// @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);
});
}
export function createStore(dbName: string, storeName: string): UseStore {
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
const dbp = promisifyRequest(request).catch(error => {
handleFailure(error);
return null;
});
const memoryStore = getMemoryStore(dbName, storeName);
const dbp = promisifyRequest(request);
return async (txMode, callback) => {
const db = await dbp;
if (!db) return callback(memoryStore);
return callback(db.transaction(storeName, txMode).objectStore(storeName));
};
return (txMode, callback) =>
dbp.then(db =>
callback(db.transaction(storeName, txMode).objectStore(storeName)),
);
}
export type UseStore = <T>(
txMode: IDBTransactionMode,
callback: (store: StoreLike) => T | PromiseLike<T>,
callback: (store: IDBObjectStore) => T | PromiseLike<T>,
) => Promise<T>;
let defaultGetStoreFunc: UseStore | undefined;
@ -120,68 +54,88 @@ 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 withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.get(key) as T | undefined;
return promisifyRequest(store.get(key));
}), undefined);
return customStore("readonly", store => promisifyRequest(store.get(key)));
}
/**
* 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 withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
store.put(value, key);
if (isMemoryStore(store)) return;
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 withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
entries.forEach(entry => store.put(entry[1], entry[0]));
if (isMemoryStore(store)) return;
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 withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) {
return keys.map(key => store.get(key) as T | undefined);
}
return Promise.all(keys.map(key => promisifyRequest(store.get(key))));
}), []);
return 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 withFailureFallback(customStore(
return customStore(
"readwrite",
store => {
if (isMemoryStore(store)) {
store.put(updater(store.get(key) as T | undefined), key);
return;
}
return new Promise((resolve, reject) => {
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 {
store.put(updater(this.result), key);
@ -190,42 +144,52 @@ export function update<T = any>(
reject(err);
}
};
});
},
), undefined);
}),
);
}
/**
* 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 withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
store.delete(key);
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
}), undefined);
});
}
/**
* 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 withFailureFallback(customStore("readwrite", store => {
keys.forEach(key => store.delete(key));
if (isMemoryStore(store)) return;
return customStore("readwrite", (store: IDBObjectStore) => {
keys.forEach((key: IDBValidKey) => store.delete(key));
return promisifyRequest(store.transaction);
}), undefined);
});
}
/**
* 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 withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
store.clear();
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
}), undefined);
});
}
function eachCursor(
@ -240,12 +204,16 @@ 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 withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAllKeys<KeyType>();
return customStore("readonly", store => {
// Fast path for modern browsers
if (store.getAllKeys) {
return promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
@ -253,45 +221,59 @@ export function keys<KeyType extends IDBValidKey>(
}
const items: KeyType[] = [];
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 withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAll<T>();
return customStore("readonly", store => {
// Fast path for modern browsers
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,
);
}), [] 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 withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.entries<KeyType, ValueType>();
return customStore("readonly", store => {
// Fast path for modern browsers
// (although, hopefully we'll get a simpler path some day)
if (store.getAll && store.getAllKeys) {
return Promise.all([
promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
),
promisifyRequest(store.getAll() as IDBRequest<ValueType[]>),
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]] as [KeyType, ValueType]));
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]]));
}
const items: [KeyType, ValueType][] = [];
return eachCursor(store, cursor =>
items.push([cursor.key as KeyType, cursor.value]),
).then(() => items);
}), [] as [KeyType, ValueType][]);
return customStore("readonly", store =>
eachCursor(store, cursor =>
items.push([cursor.key as KeyType, cursor.value]),
).then(() => items),
);
});
}

View file

@ -17,7 +17,7 @@
*/
import { Flogger, settings } from ".";
import { addMessageIDB, countMessagesIDB, DBMessageStatus, deleteMessagesBulkIDB, getOlderThanTimestampForGuildsIDB, getOldestMessagesIDB } from "./db";
import { addMessageIDB, db, 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 countMessagesIDB();
const currentMessageCount = await db.count("messages");
if (currentMessageCount > settings.store.messageLimit) {
const messagesToDelete = currentMessageCount - settings.store.messageLimit;
if (messagesToDelete <= 0 || messagesToDelete >= settings.store.messageLimit) return;

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { ChannelStore, Toasts } from "@webpack/common";
import { DBSchema, IDBPDatabase, openDB } from "idb";
@ -38,63 +37,17 @@ export interface MLIDB extends DBSchema {
by_timestamp_and_message_id: [string, string];
};
};
}
const logger = new Logger("MessageLoggerEnhanced");
let db: IDBPDatabase<MLIDB> | null = null;
let dbPromise: Promise<IDBPDatabase<MLIDB> | null> | null = null;
let loggedFailure = false;
export let db: IDBPDatabase<MLIDB>;
export const cachedMessages = new Map<string, LoggedMessageJSON>();
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[], cacheAttachmentBlobs = true) {
// this is probably not the best way to do this
async function cacheRecords(records: DBMessageRecord[]) {
for (const r of records) {
cacheRecord(r);
if (!cacheAttachmentBlobs) continue;
for (const att of r.message.attachments) {
const blobUrl = await getAttachmentBlobUrl(att);
if (blobUrl) {
@ -103,7 +56,6 @@ async function cacheRecords(records: DBMessageRecord[], cacheAttachmentBlobs = t
}
}
}
return records;
}
@ -116,56 +68,56 @@ async function cacheRecord(record?: DBMessageRecord | null) {
}
export async function initIDB() {
await ensureDb();
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"]);
}
});
}
void initIDB();
initIDB();
export async function hasMessageIDB(message_id: string) {
if (cachedMessages.has(message_id)) return true;
return withDb(false, database => database.count("messages", message_id).then(count => count > 0));
return cachedMessages.has(message_id) || (await db.count("messages", message_id)) > 0;
}
export async function countMessagesIDB() {
if (!db) return cachedMessages.size;
return db.count("messages");
}
export async function countMessagesByStatusIDB(status: DBMessageStatus) {
return withDb(0, database => database.countFromIndex("messages", "by_status", status));
return db.countFromIndex("messages", "by_status", status);
}
export async function getAllMessagesIDB() {
return withDb([], async database => cacheRecords(await database.getAll("messages")));
return cacheRecords(await db.getAll("messages"));
}
export async function getMessagesForChannelIDB(channel_id: string) {
return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_channel_id", channel_id)));
return cacheRecords(await db.getAllFromIndex("messages", "by_channel_id", channel_id));
}
export async function getMessageIDB(message_id: string) {
return withDb<DBMessageRecord | null>(null, async database => {
const record = await database.get("messages", message_id);
await cacheRecord(record);
return record ?? null;
});
return cacheRecord(await db.get("messages", message_id));
}
export async function getMessagesByStatusIDB(status: DBMessageStatus) {
return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_status", status)));
return cacheRecords(await db.getAllFromIndex("messages", "by_status", status));
}
export async function getOldestMessagesIDB(limit: number) {
return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_timestamp", undefined, limit)));
return cacheRecords(await db.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[] = [];
const tx = db!.transaction("messages");
// new transaction for each batch to avoid timeouts during yield
const tx = db.transaction("messages");
const range = lastId ? IDBKeyRange.lowerBound(lastId, true) : undefined;
let cursor = await tx.store.openCursor(range);
@ -177,6 +129,7 @@ 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;
@ -184,20 +137,22 @@ export async function* iterateAllMessagesIDB(batchSize = 100) {
}
export async function getOlderThanTimestampIDB(timestamp: string) {
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 tx = db.transaction("messages", "readonly");
const { store } = tx;
const index = store.index("by_timestamp");
if (!cursor) return [];
const cursor = await index.openCursor(IDBKeyRange.upperBound(timestamp));
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
}
if (!cursor) {
return [];
}
return cacheRecords(messages, false);
});
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
}
return cacheRecords(messages);
}
export async function getOlderThanTimestampForGuildsIDB(timestamp: string, currentChannelId?: string, preserveCurrentChannel?: boolean) {
@ -212,104 +167,96 @@ export async function getOlderThanTimestampForGuildsIDB(timestamp: string, curre
}
export async function getDateStortedMessagesByStatusIDB(newest: boolean, limit: number, status: DBMessageStatus) {
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 tx = db.transaction("messages", "readonly");
const { store } = tx;
const index = store.index("by_status");
if (!cursor) {
return [];
}
const direction = newest ? "prev" : "next";
const cursor = await index.openCursor(IDBKeyRange.only(status), direction);
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
if (messages.length >= limit) break;
}
if (!cursor) {
console.log("No messages found");
return [];
}
return cacheRecords(messages);
});
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
if (messages.length >= limit) break;
}
return cacheRecords(messages);
}
export async function getMessagesByChannelAndAfterTimestampIDB(channel_id: string, start: string) {
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 tx = db.transaction("messages", "readonly");
const { store } = tx;
const index = store.index("by_timestamp_and_message_id");
if (!cursor) {
return [];
}
const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"]));
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
}
if (!cursor) {
console.log("No messages found in range");
return [];
}
return cacheRecords(messages, false);
});
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
}
return cacheRecords(messages);
}
export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessageStatus) {
stripTransientRenderState(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,
});
await db.put("messages", {
channel_id: message.channel_id,
message_id: message.id,
status,
message,
});
cachedMessages.set(message.id, 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) {
cachedMessages.delete(message_id);
await db.delete("messages", message_id);
return withDb(void 0, database => database.delete("messages", message_id));
cachedMessages.delete(message_id);
}
export async function deleteMessagesBulkIDB(message_ids: string[]) {
message_ids.forEach(id => cachedMessages.delete(id));
const tx = db.transaction("messages", "readwrite");
const { store } = tx;
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
]);
});
await Promise.all([...message_ids.map(id => store.delete(id)), tx.done]);
message_ids.forEach(id => cachedMessages.delete(id));
}
export async function clearMessagesIDB(showToast = true) {
export async function clearMessagesIDB() {
await db.clear("messages");
cachedMessages.clear();
await withDb(void 0, database => database.clear("messages"));
if (!showToast) return;
Toasts.show({
type: Toasts.Type.MESSAGE,
message: "Cleared message log database and cache.",

View file

@ -198,9 +198,6 @@ 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 = {
@ -209,76 +206,49 @@ type VoiceMeta = {
lastAccess: number;
};
let openDbPromise: Promise<IDBDatabase | null> | undefined;
let loggedFailure = false;
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");
}
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;
}
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);
});
}
export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> {
return withDb<{ bytes: number; entries: number; }>({ bytes: 0, entries: 0 }, db => new Promise<{ bytes: number; entries: number; }>((resolve, reject) => {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(META_STORE, "readonly");
const store = tx.objectStore(META_STORE);
@ -298,7 +268,7 @@ export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; ent
cursor.continue();
};
cursorReq.onerror = () => reject(cursorReq.error);
}));
});
}
export async function clearTtsCache(): Promise<void> {
@ -309,17 +279,19 @@ export async function clearTtsCache(): Promise<void> {
}
ttsCache.clear();
await withDb<void>(void 0, db => new Promise<void>((resolve, reject) => {
const db = await openDB();
return new Promise((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> {
await withDb<void>(void 0, db => new Promise<void>((resolve, reject) => {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
@ -372,11 +344,12 @@ 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> {
return withDb<Blob | null>(null, db => new Promise<Blob | null>((resolve, reject) => {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
@ -400,11 +373,12 @@ 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> {
await withDb<void>(void 0, db => new Promise<void>((resolve, reject) => {
const db = await openDB();
await 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);
@ -418,7 +392,7 @@ export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise<
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
}));
});
await trimPersistentCacheToMaxBytes();
}