From f4cfca5538631de3c5953b3110cf1cd95bf9bb0e Mon Sep 17 00:00:00 2001 From: Yuri Tatishchev Date: Thu, 5 Feb 2026 03:41:09 -0800 Subject: [PATCH] success pt. 3 --- src/lib/db/client-amq.ts | 170 ------------------ .../db/{client-db.ts => client-db/index.ts} | 3 + src/lib/db/client-db/queries.ts | 98 ++++++++++ src/lib/db/client-db/seed.ts | 94 ++++++++++ src/routes/+page.svelte | 26 +-- 5 files changed, 203 insertions(+), 188 deletions(-) delete mode 100644 src/lib/db/client-amq.ts rename src/lib/db/{client-db.ts => client-db/index.ts} (84%) create mode 100644 src/lib/db/client-db/queries.ts create mode 100644 src/lib/db/client-db/seed.ts diff --git a/src/lib/db/client-amq.ts b/src/lib/db/client-amq.ts deleted file mode 100644 index 7d9f69f..0000000 --- a/src/lib/db/client-amq.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { desc, like } from "drizzle-orm"; -import { browser } from "$app/environment"; -import { asset } from "$app/paths"; -import { animeTable } from "$lib/db/schema"; - -import { db, overwriteDatabaseFile } from "./client-db"; - -/** - * Client-side, read-only AMQ database helper. - * - * Responsibilities: - * - Seed SQLocal's managed DB file from `static/data/amq.sqlite` (served at `/data/amq.sqlite`) - * - Gate the overwrite behind a manual version key (so you decide when clients refresh) - * - Provide minimal query helpers for browsing - * - * IMPORTANT: - * - Call these functions only in the browser (e.g. from `onMount`) - * - `overwriteDatabaseFile()` resets connections; don't call it repeatedly - */ - -/** Bump when you ship a new `static/data/amq.sqlite`. */ -export const AMQ_DB_SEED_VERSION = 1; - -/** Static seed location. `static/data/amq.sqlite` -> `/data/amq.sqlite` */ -const SEED_ASSET_PATH = "/data/amq.sqlite"; - -const seededStorageKey = (version: number) => `amq.sqlocal.seeded.v${version}`; - -let initPromise: Promise | null = null; - -function ensureBrowser() { - if (!browser) - throw new Error("Client AMQ DB can only be used in the browser."); -} - -/** - * Ensure the SQLocal DB file is seeded with the shipped snapshot. - * No-ops once seeded for the current seed version. - */ -export async function ensureAmqDbSeeded( - opts: { version?: number; force?: boolean } = {}, -): Promise { - ensureBrowser(); - - const version = opts.version ?? AMQ_DB_SEED_VERSION; - - // Serialize seeding so multiple callers don't race. - if (initPromise) return initPromise; - - initPromise = (async () => { - const key = seededStorageKey(version); - const alreadySeeded = localStorage.getItem(key) === "1"; - - if (!opts.force && alreadySeeded) return; - - // Prefer streaming when available (better for large DB files). - // Use `asset(...)` so this works under `paths.assets`/`base` setups. - const url = asset(SEED_ASSET_PATH); - - const res = await fetch(url, { cache: "no-cache" }); - if (!res.ok) { - throw new Error( - `Failed to fetch AMQ seed DB from ${url}: ${res.status} ${res.statusText}`, - ); - } - - const stream = res.body; - if (stream) { - await overwriteDatabaseFile(stream, async () => { - // Read-only browsing: nothing required here. - // If you later need to run PRAGMAs or migrations, do it here. - }); - } else { - const blob = await res.blob(); - await overwriteDatabaseFile(blob, async () => {}); - } - - // Mark this version as seeded. (Optionally clear older keys, but not required.) - localStorage.setItem(key, "1"); - })(); - - try { - await initPromise; - } finally { - // Allow future seed attempts if this one threw. - // If it succeeded, callers will no-op due to localStorage key. - if ( - localStorage.getItem( - seededStorageKey(opts.version ?? AMQ_DB_SEED_VERSION), - ) !== "1" - ) { - initPromise = null; - } - } -} - -/** - * Helper: fetch a short list of anime for a "browse" page. - * - * Uses Drizzle against the SQLocal-backed database (sqlite-proxy driver). - */ -export async function getAnimeList(limit = 25): Promise< - Array<{ - annId: number; - mainName: string; - year: number; - seasonId: number; - malId: number; - }> -> { - ensureBrowser(); - await ensureAmqDbSeeded(); - - // Clamp to something reasonable to avoid accidental huge renders. - const safeLimit = Math.max(1, Math.min(200, Math.floor(limit))); - - const rows = await db - .select({ - annId: animeTable.annId, - mainName: animeTable.mainName, - year: animeTable.year, - seasonId: animeTable.seasonId, - malId: animeTable.malId, - }) - .from(animeTable) - .orderBy( - desc(animeTable.year), - desc(animeTable.seasonId), - desc(animeTable.annId), - ) - .limit(safeLimit); - - return rows; -} - -/** - * Helper: basic text search by `mainName`. - * (Good enough for POC browsing; consider FTS5 later for better UX/perf.) - */ -export async function searchAnimeByName( - query: string, - limit = 25, -): Promise< - Array<{ annId: number; mainName: string; year: number; seasonId: number }> -> { - ensureBrowser(); - await ensureAmqDbSeeded(); - - const q = query.trim(); - if (!q) return []; - - const safeLimit = Math.max(1, Math.min(200, Math.floor(limit))); - const pattern = `%${q}%`; - - return db - .select({ - annId: animeTable.annId, - mainName: animeTable.mainName, - year: animeTable.year, - seasonId: animeTable.seasonId, - }) - .from(animeTable) - .where(like(animeTable.mainName, pattern)) - .orderBy( - desc(animeTable.year), - desc(animeTable.seasonId), - desc(animeTable.annId), - ) - .limit(safeLimit); -} diff --git a/src/lib/db/client-db.ts b/src/lib/db/client-db/index.ts similarity index 84% rename from src/lib/db/client-db.ts rename to src/lib/db/client-db/index.ts index 973f302..d3f0601 100644 --- a/src/lib/db/client-db.ts +++ b/src/lib/db/client-db/index.ts @@ -1,3 +1,6 @@ +export * from "./queries"; +export * from "./seed"; + import { drizzle } from "drizzle-orm/sqlite-proxy"; import { SQLocalDrizzle } from "sqlocal/drizzle"; diff --git a/src/lib/db/client-db/queries.ts b/src/lib/db/client-db/queries.ts new file mode 100644 index 0000000..0a0f2c1 --- /dev/null +++ b/src/lib/db/client-db/queries.ts @@ -0,0 +1,98 @@ +import { desc, like } from "drizzle-orm"; +import { animeTable } from "$lib/db/schema"; + +/** + * Client-side DB query helpers (read-only). + * + * These functions assume the SQLocal-backed Drizzle `db` is already wired up. + * Seeding/overwriting the database file should be handled separately (see `seed.ts`). + * + * Keep these as pure query operations so pages/components can: + * 1) ensure seeded + * 2) query + */ + +export const DEFAULT_LIST_LIMIT = 20; +export const MAX_LIST_LIMIT = 200; + +export type AnimeListItem = { + annId: number; + mainName: string; + year: number; + seasonId: number; + malId: number; +}; + +/** + * Get a short list of anime entries for browsing. + * + * Sorted by (year desc, seasonId desc, annId desc). + */ +export async function getAnimeList( + db: { select: (...args: any[]) => any }, + limit = DEFAULT_LIST_LIMIT, +): Promise { + const safeLimit = clampLimit(limit); + + // NOTE: using explicit selection keeps payload small and stable + const rows = await (db as any) + .select({ + annId: animeTable.annId, + mainName: animeTable.mainName, + year: animeTable.year, + seasonId: animeTable.seasonId, + malId: animeTable.malId, + }) + .from(animeTable) + .orderBy( + desc(animeTable.year), + desc(animeTable.seasonId), + desc(animeTable.annId), + ) + .limit(safeLimit); + + return rows as AnimeListItem[]; +} + +/** + * Simple `LIKE` search over `anime.main_name`. + * + * If you want fast/quality search later, consider shipping an FTS5 virtual table + * in the snapshot DB, and querying that instead. + */ +export async function searchAnimeByName( + db: { select: (...args: any[]) => any }, + query: string, + limit = DEFAULT_LIST_LIMIT, +): Promise { + const q = query.trim(); + if (!q) return []; + + const safeLimit = clampLimit(limit); + const pattern = `%${q}%`; + + const rows = await (db as any) + .select({ + annId: animeTable.annId, + mainName: animeTable.mainName, + year: animeTable.year, + seasonId: animeTable.seasonId, + malId: animeTable.malId, + }) + .from(animeTable) + .where(like(animeTable.mainName, pattern)) + .orderBy( + desc(animeTable.year), + desc(animeTable.seasonId), + desc(animeTable.annId), + ) + .limit(safeLimit); + + return rows as AnimeListItem[]; +} + +function clampLimit(limit: number) { + const n = Number(limit); + if (!Number.isFinite(n)) return DEFAULT_LIST_LIMIT; + return Math.max(1, Math.min(MAX_LIST_LIMIT, Math.floor(n))); +} diff --git a/src/lib/db/client-db/seed.ts b/src/lib/db/client-db/seed.ts new file mode 100644 index 0000000..7499e8e --- /dev/null +++ b/src/lib/db/client-db/seed.ts @@ -0,0 +1,94 @@ +import { browser } from "$app/environment"; +import { asset } from "$app/paths"; +import { overwriteDatabaseFile } from "$lib/db/client-db"; + +/** + * Version-keyed seeding for the client-side SQLocal database. + * + * Your shipped snapshot lives at: + * - `static/data/amq.sqlite` -> `/data/amq.sqlite` + * + * SQLocal stores its own managed DB file (named in `client-db.ts`), and we + * overwrite its contents from the shipped snapshot when needed. + * + * This is intended for READ-ONLY browsing. Bump the version when you ship a new + * snapshot so clients refresh. + */ +export const AMQ_DB_SEED_VERSION = 1; + +const SEED_ASSET_PATH = "/data/amq.sqlite"; +const seededStorageKey = (version: number) => `amq.sqlocal.seeded.v${version}`; + +let seedPromise: Promise | null = null; + +function ensureBrowser() { + if (!browser) { + throw new Error("Client DB seeding can only run in the browser."); + } +} + +/** + * Ensure the client DB has been seeded for the given version. + * + * - Uses `localStorage` to skip reseeding once done. + * - Uses a shared promise to avoid races across multiple callers. + * - Uses streaming (ReadableStream) when available for large DB files. + * + * @param opts.version Bump to force users to refresh from the shipped snapshot + * @param opts.force If true, reseed even if already seeded for this version + */ +export async function ensureSeeded( + opts: { version?: number; force?: boolean } = {}, +): Promise { + ensureBrowser(); + + const version = opts.version ?? AMQ_DB_SEED_VERSION; + const key = seededStorageKey(version); + + if (!opts.force && localStorage.getItem(key) === "1") return; + + // Serialize seeding work so multiple callers don't overwrite concurrently. + if (seedPromise) return seedPromise; + + seedPromise = (async () => { + // Re-check inside the serialized section in case another caller finished first. + if (!opts.force && localStorage.getItem(key) === "1") return; + + const url = asset(SEED_ASSET_PATH); + + const res = await fetch(url, { cache: "no-cache" }); + if (!res.ok) { + throw new Error( + `Failed to fetch seed DB from ${url}: ${res.status} ${res.statusText}`, + ); + } + + // Prefer streaming when possible. + if (res.body) { + await overwriteDatabaseFile(res.body); + } else { + const blob = await res.blob(); + await overwriteDatabaseFile(blob); + } + + localStorage.setItem(key, "1"); + })(); + + try { + await seedPromise; + } finally { + // If seeding failed, allow future attempts (and let the error surface). + if (localStorage.getItem(key) !== "1") { + seedPromise = null; + } + } +} + +/** + * Utility for debugging / support: clears the seeded marker for a given version. + * This does NOT delete SQLocal's stored DB file; it only forces reseed on next call. + */ +export function clearSeedMarker(version = AMQ_DB_SEED_VERSION) { + ensureBrowser(); + localStorage.removeItem(seededStorageKey(version)); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3cc8510..af5ae9a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,18 +1,12 @@