From e154c572fcf25e7d6643a9cccc9bca169f396cfc Mon Sep 17 00:00:00 2001 From: Aaron Shafovaloff Date: Sat, 16 Nov 2024 09:56:30 -0700 Subject: [PATCH] Implement count and clear --- README.md | 4 + packages/idb-cache-app/src/App.tsx | 120 +++++++++++++++++++-- packages/idb-cache-app/src/utils.ts | 10 -- packages/idb-cache/src/index.ts | 120 +++++++++++++++++---- packages/idb-cache/tests/idb-cache.spec.ts | 62 +++++++++++ 5 files changed, 279 insertions(+), 37 deletions(-) create mode 100644 packages/idb-cache/tests/idb-cache.spec.ts diff --git a/README.md b/README.md index fd17350..a463a4a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ interface AsyncStorage { getItem: (key: string) => Promise; setItem: (key: string, value: string) => Promise; removeItem: (key: string) => Promise; + clear: () => Promise; } ``` @@ -47,6 +48,9 @@ console.log(token); // Outputs: 'value' // Remove an item await cache.removeItem('key'); +// Clears all items from cache +cache.clear(); + // Destroy the cache instance cache.destroy(); ``` diff --git a/packages/idb-cache-app/src/App.tsx b/packages/idb-cache-app/src/App.tsx index f465db1..218f17c 100644 --- a/packages/idb-cache-app/src/App.tsx +++ b/packages/idb-cache-app/src/App.tsx @@ -1,7 +1,7 @@ import "./App.css"; import { IDBCache } from "@instructure/idb-cache"; import { useCallback, useState } from "react"; -import { uuid, deterministicHash, generateTextOfSize } from "./utils"; +import { deterministicHash, generateTextOfSize } from "./utils"; import { Button } from "@instructure/ui-buttons"; import { Metric } from "@instructure/ui-metric"; import { View } from "@instructure/ui-view"; @@ -13,13 +13,13 @@ import { NumberInput } from "@instructure/ui-number-input"; // Do *not* store cacheKey to localStorage in production. let cacheKey: string = localStorage.cacheKey; if (!cacheKey) { - cacheKey = uuid(); + cacheKey = crypto.randomUUID(); localStorage.cacheKey = cacheKey; } let cacheBuster: string = localStorage.cacheBuster; if (!cacheBuster) { - cacheBuster = uuid(); + cacheBuster = crypto.randomUUID(); localStorage.cacheBuster = cacheBuster; } @@ -54,12 +54,20 @@ const App = () => { const [timeToGenerate, setTimeToGenerate] = useState(null); const [setTime, setSetTime] = useState(null); const [getTime, setGetTime] = useState(null); + const [countTime, setCountTime] = useState(null); + const [clearTime, setClearTime] = useState(null); const [itemSize, setItemSize] = useState(initialItemSize); + const [itemCount, setItemCount] = useState(null); + const [randomKey, generateRandomKey] = useState(() => + crypto.randomUUID(), + ); const encryptAndStore = useCallback(async () => { + const key = crypto.randomUUID(); + generateRandomKey(key); const start1 = performance.now(); const paragraphs = Array.from({ length: DEFAULT_NUM_ITEMS }, (_, index) => - generateTextOfSize(itemSize, `${cacheBuster}-${index}`), + generateTextOfSize(itemSize, `${cacheBuster}-${key}-${index}`), ); const end1 = performance.now(); setTimeToGenerate(end1 - start1); @@ -67,7 +75,7 @@ const App = () => { const start2 = performance.now(); for (let i = 0; i < DEFAULT_NUM_ITEMS; i++) { - await cache.setItem(`item-${i}`, paragraphs[i]); + await cache.setItem(`item-${key}-${i}`, paragraphs[i]); } const end2 = performance.now(); @@ -81,13 +89,32 @@ const App = () => { const start = performance.now(); for (let i = 0; i < DEFAULT_NUM_ITEMS; i++) { - const result = await cache.getItem(`item-${i}`); + const result = await cache.getItem(`item-${randomKey}-${i}`); results.push(result); } const end = performance.now(); setGetTime(end - start); - setHash2(results.length > 0 ? deterministicHash(results.join("")) : null); + setHash2( + results.filter((x) => x).length > 0 + ? deterministicHash(results.join("")) + : null, + ); + }, [randomKey]); + + const count = useCallback(async () => { + const start = performance.now(); + const count = await cache.count(); + const end = performance.now(); + setCountTime(end - start); + setItemCount(count); + }, []); + + const clear = useCallback(async () => { + const start = performance.now(); + await cache.clear(); + const end = performance.now(); + setClearTime(end - start); }, []); return ( @@ -302,6 +329,85 @@ const App = () => { + + + + + + + +   + + + ) + } + /> + + + + ) + } + /> + + + + + + + + + + + + +   + + + ) + } + /> + +   + + + + diff --git a/packages/idb-cache-app/src/utils.ts b/packages/idb-cache-app/src/utils.ts index c8ac41c..7235318 100644 --- a/packages/idb-cache-app/src/utils.ts +++ b/packages/idb-cache-app/src/utils.ts @@ -44,13 +44,3 @@ export function generateTextOfSize( textCache[cacheKey] = result; return result; } - -export function uuid(): string { - return `${[1e7]}-1e3-4e3-8e3-1e11`.replace(/[018]/g, (c) => - ( - Number.parseInt(c, 10) ^ - ((crypto.getRandomValues(new Uint8Array(1))[0] & 15) >> - (Number.parseInt(c, 10) / 4)) - ).toString(16) - ); -} diff --git a/packages/idb-cache/src/index.ts b/packages/idb-cache/src/index.ts index 72a8a21..8c8212b 100644 --- a/packages/idb-cache/src/index.ts +++ b/packages/idb-cache/src/index.ts @@ -48,10 +48,11 @@ interface IDBCacheConfig { gcTime?: number; } -interface AsyncStorage { +export interface AsyncStorage { getItem: (key: string) => Promise; setItem: (key: string, value: string) => Promise; removeItem: (key: string) => Promise; + clear: () => Promise; } export class IDBCache implements AsyncStorage { @@ -546,31 +547,110 @@ export class IDBCache implements AsyncStorage { } /** - * Destroys the IDBCache instance by clearing intervals, terminating the worker, and rejecting all pending requests. + * Counts the total number of encrypted chunks stored in the cache. + * @returns The total number of entries (chunks) in the cache. + * @throws {DatabaseError} If there is an issue accessing the database. */ - public destroy() { - if (this.cleanupIntervalId !== undefined) { - clearInterval(this.cleanupIntervalId); - } + async count(): Promise { + try { + const db = await this.dbReadyPromise; + const transaction = db.transaction(this.storeName, "readonly"); + const store = transaction.store; - this.pendingRequests.forEach((pending, requestId) => { - pending.reject( - new IDBCacheError("IDBCache instance is being destroyed.") - ); - this.pendingRequests.delete(requestId); - }); + const totalCount = await store.count(); + + await transaction.done; + + if (this.debug) { + console.debug(`Total entries in cache: ${totalCount}`); + } - if (this.port) { - this.port.postMessage({ type: "destroy" }); - this.port.close(); - this.port = null; + return totalCount; + } catch (error) { + console.error("Error in count():", error); + if (error instanceof DatabaseError) { + throw error; + } + throw new DatabaseError("Failed to count items in the cache."); } + } - if (this.worker) { - this.worker.terminate(); - this.worker = null; + /** + * Clears all items from the cache without affecting the worker or pending requests. + * @throws {DatabaseError} If there is an issue accessing the database. + */ + async clear(): Promise { + try { + const db = await this.dbReadyPromise; + const transaction = db.transaction(this.storeName, "readwrite"); + const store = transaction.store; + + await store.clear(); + + await transaction.done; + + if (this.debug) { + console.debug("All items have been cleared from the cache."); + } + } catch (error) { + console.error("Error in clear:", error); + if (error instanceof DatabaseError) { + throw error; + } + if (error instanceof IDBCacheError) { + throw error; + } + throw new DatabaseError("Failed to clear the cache."); } + } + + /** + * Destroys the IDBCache instance by clearing data (optional), releasing resources, and terminating the worker. + * @param options - Configuration options for destruction. + * @param options.clearData - Whether to clear all cached data before destruction. + * @throws {DatabaseError} If there is an issue accessing the database during data clearing. + */ + public async destroy(options?: { clearData?: boolean }): Promise { + const { clearData = false } = options || {}; + + try { + if (clearData) { + await this.clear(); + } + + if (this.cleanupIntervalId !== undefined) { + clearInterval(this.cleanupIntervalId); + } - this.workerReadyPromise = null; + this.pendingRequests.forEach((pending, requestId) => { + pending.reject( + new IDBCacheError("IDBCache instance is being destroyed.") + ); + this.pendingRequests.delete(requestId); + }); + + if (this.port) { + this.port.postMessage({ type: "destroy" }); + this.port.close(); + this.port = null; + } + + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + + this.workerReadyPromise = null; + + if (this.debug) { + console.debug("IDBCache instance has been destroyed."); + } + } catch (error) { + console.error("Error in destroy:", error); + if (error instanceof IDBCacheError) { + throw error; + } + throw new IDBCacheError("Failed to destroy the cache instance."); + } } } diff --git a/packages/idb-cache/tests/idb-cache.spec.ts b/packages/idb-cache/tests/idb-cache.spec.ts new file mode 100644 index 0000000..67e3c69 --- /dev/null +++ b/packages/idb-cache/tests/idb-cache.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "@playwright/test"; +import { IDBCache } from "../src/index"; // Adjust the import path as necessary + +test.describe("IDBCache .clear() Method", () => { + let cache: IDBCache; + + test.beforeEach(async () => { + cache = new IDBCache({ + cacheBuster: "testCacheBuster", + cacheKey: "testCacheKey", + debug: true, + dbName: "test-idb-cache", + gcTime: 1000 * 60 * 60 * 24, // 1 day + }); + + // Add some items to the cache + await cache.setItem("key1", "value1"); + await cache.setItem("key2", "value2"); + await cache.setItem("key3", "value3"); + }); + + test.afterEach(async () => { + // Clean up + cache.destroy(); + }); + + test("should clear all items from the cache", async () => { + // Ensure items are set + expect(await cache.getItem("key1")).toBe("value1"); + expect(await cache.getItem("key2")).toBe("value2"); + expect(await cache.getItem("key3")).toBe("value3"); + + // Clear the cache + await cache.clear(); + + // Verify all items are removed + expect(await cache.getItem("key1")).toBeNull(); + expect(await cache.getItem("key2")).toBeNull(); + expect(await cache.getItem("key3")).toBeNull(); + }); + + test("should handle clearing an already empty cache gracefully", async () => { + // Clear the cache first + await cache.clear(); + + // Attempt to clear again + await expect(cache.clear()).resolves.toBeUndefined(); + + // Verify cache is still empty + expect(await cache.getItem("key1")).toBeNull(); + expect(await cache.getItem("key2")).toBeNull(); + expect(await cache.getItem("key3")).toBeNull(); + }); + + test("should throw DatabaseError when clearing fails", async () => { + // Simulate a failure by destroying the database connection + cache.dbReadyPromise = Promise.reject(new Error("Simulated DB failure")); + + // Attempt to clear the cache + await expect(cache.clear()).rejects.toThrow("Failed to clear the cache."); + }); +});