diff --git a/src/lib/db/client-amq.ts b/src/lib/db/client-amq.ts new file mode 100644 index 0000000..98bb745 --- /dev/null +++ b/src/lib/db/client-amq.ts @@ -0,0 +1,194 @@ +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 access. + * + * - Seeds SQLocal's managed database file from `static/data/amq.sqlite` + * - Uses a version key so you control when clients refresh their local snapshot + * - Exposes a small query helper to fetch a short anime list for display + * + * Notes: + * - This module MUST only be used in the browser. Guard with `browser`, or call + * the exported functions from `onMount`. + * - `overwriteDatabaseFile()` resets connections, so only call it when needed. + */ + +/** + * Bump this when you ship a new `static/data/amq.sqlite`. + * This is intentionally manual so you control when clients refresh. + */ +export const AMQ_DB_SEED_VERSION = 1; + +/** + * The file name SQLocal uses internally (you can change this; it does not need + * to match the static seed file name). + */ +const SQLOCAL_DB_FILE_NAME = "amq.sqlite3"; + +/** + * Where the shipped, static database lives. + * `static/data/amq.sqlite` -> served at `/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 (SSR is not supported).", + ); + } +} + +/** + * 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` (and optionally other columns). + * This is deliberately small and safe for client-side browsing. + */ +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))); + + // Simple LIKE search. If you want better search later, consider FTS5 in the shipped snapshot. + // Note: we don't escape %/_ here; if you need literal matching, we'll switch to a raw SQL + // fragment with an explicit ESCAPE clause. + const pattern = `%${q}%`; + + const rows = await 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); + + return rows; +} + +// Re-export for convenience in components that need to ensure seeding first. +export { db, overwriteDatabaseFile, SQLOCAL_DB_FILE_NAME }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cc88df0..38fe25a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,2 +1,117 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + +

AMQ Browser

+ +{#if status === "loading"} +

Loading client database…

+{:else if status === "error"} +

+ Error: {error} +

+{:else if status === "ready"} +

+ Showing {anime.length} anime +

+ + +{/if}