5689f7f6a3
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.
- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
(immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
IndexedDB (best-effort, self-healing on a rejected blob) and reused across
sessions; a warm-up overlay covers a cold load, then the network preview is the
fallback; a bad-connection breaker stops warming after repeated misses; the move
preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
CI against the release dictionaries. A hidden debug readout lists the cached
dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
155 lines
5.0 KiB
TypeScript
155 lines
5.0 KiB
TypeScript
// Persistent cache for dictionary DAWG blobs, keyed by `${variant}@${version}`.
|
|
// It lives in its own IndexedDB database, separate from session.ts's 'scrabble'
|
|
// DB, so the multi-hundred-KB binary blobs never interfere with the session /
|
|
// prefs store or its schema versioning. Everything here is best-effort: any
|
|
// failure resolves to a miss or a no-op and the caller falls back to the network.
|
|
|
|
const DB_NAME = 'scrabble-dict';
|
|
const STORE = 'dawg';
|
|
|
|
let dbPromise: Promise<IDBDatabase> | null | undefined;
|
|
|
|
function openDb(): Promise<IDBDatabase> | null {
|
|
if (dbPromise !== undefined) return dbPromise;
|
|
if (typeof indexedDB === 'undefined') {
|
|
dbPromise = null;
|
|
return null;
|
|
}
|
|
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, 1);
|
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
}).catch(() => {
|
|
dbPromise = null;
|
|
throw new Error('indexedDB unavailable');
|
|
});
|
|
return dbPromise;
|
|
}
|
|
|
|
/** dictKey is the persistent cache key for a (variant, version) dictionary. */
|
|
export function dictKey(variant: string, version: string): string {
|
|
return `${variant}@${version}`;
|
|
}
|
|
|
|
/** idbGetDawg returns the cached blob for key, or null on a miss or any failure. */
|
|
export async function idbGetDawg(key: string): Promise<ArrayBuffer | null> {
|
|
const db = openDb();
|
|
if (!db) return null;
|
|
try {
|
|
const d = await db;
|
|
return await new Promise<ArrayBuffer | null>((resolve, reject) => {
|
|
const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key);
|
|
r.onsuccess = () => resolve((r.result ?? null) as ArrayBuffer | null);
|
|
r.onerror = () => reject(r.error);
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** idbPutDawg stores the blob under key, swallowing any failure (best-effort). */
|
|
export async function idbPutDawg(key: string, data: ArrayBuffer): Promise<void> {
|
|
const db = openDb();
|
|
if (!db) return;
|
|
try {
|
|
const d = await db;
|
|
await new Promise<void>((resolve, reject) => {
|
|
const tx = d.transaction(STORE, 'readwrite');
|
|
tx.objectStore(STORE).put(data, key);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
} catch {
|
|
/* best-effort: a failed persist just re-downloads next time */
|
|
}
|
|
}
|
|
|
|
/** idbDelDawg removes a cached blob — e.g. a stale or partial entry the reader rejected, so
|
|
* the next load re-fetches instead of failing on it forever. Best-effort. */
|
|
export async function idbDelDawg(key: string): Promise<void> {
|
|
const db = openDb();
|
|
if (!db) return;
|
|
try {
|
|
const d = await db;
|
|
await new Promise<void>((resolve) => {
|
|
const tx = d.transaction(STORE, 'readwrite');
|
|
tx.objectStore(STORE).delete(key);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => resolve();
|
|
});
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
let persistRequested = false;
|
|
|
|
/**
|
|
* requestPersist asks the browser (once) not to evict this origin's storage, so a
|
|
* cached dictionary survives storage pressure. The grant is honoured unevenly
|
|
* across platforms (see docs), so it is only a hint — the cache stays best-effort.
|
|
*/
|
|
export async function requestPersist(): Promise<void> {
|
|
if (persistRequested) return;
|
|
persistRequested = true;
|
|
try {
|
|
if (typeof navigator !== 'undefined' && navigator.storage?.persist) {
|
|
await navigator.storage.persist();
|
|
}
|
|
} catch {
|
|
/* ignore — persistence is only a hint */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* listCachedDicts returns the key (variant@version) and byte length of every cached
|
|
* dictionary blob, for the debug panel's local-dictionary readout. Best-effort: an
|
|
* empty list on any failure.
|
|
*/
|
|
export async function listCachedDicts(): Promise<Array<{ key: string; bytes: number }>> {
|
|
const db = openDb();
|
|
if (!db) return [];
|
|
try {
|
|
const d = await db;
|
|
return await new Promise((resolve) => {
|
|
const out: Array<{ key: string; bytes: number }> = [];
|
|
const req = d.transaction(STORE, 'readonly').objectStore(STORE).openCursor();
|
|
req.onsuccess = () => {
|
|
const cur = req.result;
|
|
if (!cur) {
|
|
resolve(out);
|
|
return;
|
|
}
|
|
const v = cur.value as ArrayBuffer;
|
|
out.push({ key: String(cur.key), bytes: v?.byteLength ?? 0 });
|
|
cur.continue();
|
|
};
|
|
req.onerror = () => resolve(out);
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* clearDictCache empties the dictionary blob store (backs the DebugPanel reset). The
|
|
* store is cleared rather than the database deleted, to avoid a delete blocked on an
|
|
* open handle. Best-effort: a failure just leaves the cache to be reused.
|
|
*/
|
|
export async function clearDictCache(): Promise<void> {
|
|
const db = openDb();
|
|
if (!db) return;
|
|
try {
|
|
const d = await db;
|
|
await new Promise<void>((resolve, reject) => {
|
|
const tx = d.transaction(STORE, 'readwrite');
|
|
tx.objectStore(STORE).clear();
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|