commit d75d58af4999746df989f90000beef2e389f6218 truex <truex@equwal.com> 2026-09-26 13:27:35 -0700 Upgrade the page cache database and make the cache optional Earlier builds of the app made version 1 of the "scan-subread" database with the stores "dictionaries" and "terms". An install over such a build keeps the WebView data, so the upgrade did not run and each PDF open failed with NotFoundError. The cache now opens version 2. The upgrade makes each store that is missing and deletes the two old stores. When IndexedDB fails, a cache read now finds nothing and a cache write does nothing. One warning goes to the console. The reader continues without the cache. The tests use fake-indexeddb, an in-memory IndexedDB for Node. Node has no IndexedDB.
package-lock.json | 11 +++++++ package.json | 1 + src/token-cache.ts | 58 ++++++++++++++++++++++++++++-------- test/token-cache.test.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 12 deletions(-)
diff --git a/package-lock.json b/package-lock.json index 16c75ca..9276eb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@capacitor/cli": "^8.5.2", "@pdf-lib/fontkit": "^1.1.1", "@types/node": "^26.6.2", + "fake-indexeddb": "^6.2.5", "fast-check": "^4.10.2", "pdf-lib": "^1.17.1", "prettier": "^3.9.9", @@ -2819,6 +2820,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-check": { "version": "4.10.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", diff --git a/package.json b/package.json index 660a4bd..8b911f8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@capacitor/cli": "^8.5.2", "@pdf-lib/fontkit": "^1.1.1", "@types/node": "^26.6.2", + "fake-indexeddb": "^6.2.5", "fast-check": "^4.10.2", "pdf-lib": "^1.17.1", "prettier": "^3.9.9", diff --git a/src/token-cache.ts b/src/token-cache.ts index 73a6d58..5a92872 100644 --- a/src/token-cache.ts +++ b/src/token-cache.ts @@ -4,8 +4,11 @@ // OCR is slow, so a page is read once. The key names the file, the page, // the OCR language and the source (text layer or OCR). The subtitles that // SubRead made are kept under the key of the PDF. +// +// The reader does not need the cache. When IndexedDB fails, a read finds +// nothing, a write does nothing, and one warning goes to the console. -import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; +import { openDB, unwrap, type DBSchema, type IDBPDatabase } from 'idb'; import type { OcrToken } from './align'; export type TextSource = 'text' | 'ocr'; @@ -23,18 +26,49 @@ interface Schema extends DBSchema { srt: { key: string; value: string }; } +const DB_NAME = 'scan-subread'; + +/** + * Earlier builds of the app made version 1 of this database with the + * stores of their dictionary. Version 2 makes sure that the stores of + * this schema are there, and deletes the old stores. + */ +const DB_VERSION = 2; +const STORES = ['pages', 'srt'] as const; +const OLD_STORES = ['dictionaries', 'terms']; + let dbPromise: Promise<IDBPDatabase<Schema>> | undefined; +let warned = false; function db(): Promise<IDBPDatabase<Schema>> { - dbPromise ??= openDB<Schema>('scan-subread', 1, { - upgrade(d) { - d.createObjectStore('pages'); - d.createObjectStore('srt'); + dbPromise ??= openDB<Schema>(DB_NAME, DB_VERSION, { + upgrade(database) { + // The typed database knows only the stores of the schema. + const raw = unwrap(database); + for (const name of OLD_STORES) { + if (raw.objectStoreNames.contains(name)) raw.deleteObjectStore(name); + } + for (const name of STORES) { + if (!raw.objectStoreNames.contains(name)) raw.createObjectStore(name); + } }, }); return dbPromise; } +/** Runs `op` on the database. Gives undefined when IndexedDB fails, and warns once. */ +async function tryDb<T>(op: (d: IDBPDatabase<Schema>) => Promise<T>): Promise<T | undefined> { + try { + return await op(await db()); + } catch (err) { + if (!warned) { + warned = true; + console.warn('The page cache is off: IndexedDB failed.', err); + } + return undefined; + } +} + type FileId = Pick<File, 'name' | 'size'>; /** The key of a book: the file name and size. */ @@ -46,22 +80,22 @@ export function pageKey(file: FileId, page: number, lang: string, source: TextSo return `${bookKey(file)}|${page}|${lang}|${source}`; } -export async function getPage(key: string): Promise<PageEntry | undefined> { - return (await db()).get('pages', key); +export function getPage(key: string): Promise<PageEntry | undefined> { + return tryDb((d) => d.get('pages', key)); } export async function putPage(key: string, entry: PageEntry): Promise<void> { - await (await db()).put('pages', entry, key); + await tryDb((d) => d.put('pages', entry, key)); } export async function clearPages(): Promise<void> { - await (await db()).clear('pages'); + await tryDb((d) => d.clear('pages')); } -export async function getSrt(key: string): Promise<string | undefined> { - return (await db()).get('srt', key); +export function getSrt(key: string): Promise<string | undefined> { + return tryDb((d) => d.get('srt', key)); } export async function putSrt(key: string, srt: string): Promise<void> { - await (await db()).put('srt', srt, key); + await tryDb((d) => d.put('srt', srt, key)); } diff --git a/test/token-cache.test.ts b/test/token-cache.test.ts new file mode 100644 index 0000000..1bb572b --- /dev/null +++ b/test/token-cache.test.ts @@ -0,0 +1,76 @@ +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { openDB } from 'idb'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PageEntry } from '../src/token-cache'; + +const entry: PageEntry = { + tokens: [{ text: '猫', page: 0, line: 0, word: 0, bbox: { x0: 1, y0: 2, x1: 3, y1: 4 } }], + width: 1600, + height: 2263, + source: 'ocr', +}; + +const srt = '1\n00:00:00,000 --> 00:00:02,500\n吾輩は猫である。\n'; + +/** A new copy of the module. Its database connection is not open yet. */ +async function freshCache() { + vi.resetModules(); + return import('../src/token-cache'); +} + +describe('token cache', () => { + beforeEach(() => { + // Each test gets an empty IndexedDB. + globalThis.indexedDB = new IDBFactory(); + }); + afterEach(() => vi.restoreAllMocks()); + + it('upgrades the database of an earlier build that has other stores', async () => { + // Earlier builds made version 1 with the stores of the dictionary. + const old = await openDB('scan-subread', 1, { + upgrade(d) { + d.createObjectStore('dictionaries'); + d.createObjectStore('terms'); + }, + }); + await old.put('terms', { term: '猫' }, 'k'); + old.close(); + + const cache = await freshCache(); + await cache.putPage('book.pdf|10|0|jpn|ocr', entry); + expect(await cache.getPage('book.pdf|10|0|jpn|ocr')).toEqual(entry); + await cache.putSrt('book.pdf|10', srt); + expect(await cache.getSrt('book.pdf|10')).toBe(srt); + + const after = await openDB('scan-subread'); + expect(after.version).toBe(2); + expect([...after.objectStoreNames].sort()).toEqual(['pages', 'srt']); + after.close(); + }); + + it('makes the stores in a new database', async () => { + const cache = await freshCache(); + expect(await cache.getPage('missing')).toBeUndefined(); + await cache.putPage('p', entry); + expect(await cache.getPage('p')).toEqual(entry); + await cache.clearPages(); + expect(await cache.getPage('p')).toBeUndefined(); + await cache.putSrt('b', srt); + expect(await cache.getSrt('b')).toBe(srt); + }); + + it('works without the cache when IndexedDB fails, and warns once', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(indexedDB, 'open').mockImplementation(() => { + throw new DOMException('IndexedDB is off.', 'SecurityError'); + }); + const cache = await freshCache(); + await expect(cache.getPage('p')).resolves.toBeUndefined(); + await expect(cache.putPage('p', entry)).resolves.toBeUndefined(); + await expect(cache.clearPages()).resolves.toBeUndefined(); + await expect(cache.getSrt('b')).resolves.toBeUndefined(); + await expect(cache.putSrt('b', srt)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + }); +});