first success!
This commit is contained in:
194
src/lib/db/client-amq.ts
Normal file
194
src/lib/db/client-amq.ts
Normal file
@@ -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<void> | 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<void> {
|
||||||
|
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 };
|
||||||
@@ -1,2 +1,117 @@
|
|||||||
<h1>Welcome to SvelteKit</h1>
|
<script lang="ts">
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
import { desc } from "drizzle-orm";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { db, overwriteDatabaseFile } from "$lib/db/client-db";
|
||||||
|
import { animeTable } from "$lib/db/schema";
|
||||||
|
|
||||||
|
type AnimeListItem = {
|
||||||
|
annId: number;
|
||||||
|
mainName: string;
|
||||||
|
year: number;
|
||||||
|
seasonId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEED_URL = "/data/amq.sqlite";
|
||||||
|
const SEED_VERSION = "amq.sqlite.v1"; // bump when static/data/amq.sqlite changes
|
||||||
|
const SEED_KEY = "amq.seed.version";
|
||||||
|
|
||||||
|
let status = $state<"idle" | "loading" | "ready" | "error">("idle");
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let anime = $state<AnimeListItem[]>([]);
|
||||||
|
|
||||||
|
async function ensureSeeded() {
|
||||||
|
const current = localStorage.getItem(SEED_KEY);
|
||||||
|
if (current === SEED_VERSION) return;
|
||||||
|
|
||||||
|
const res = await fetch(SEED_URL, { cache: "no-cache" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch seed DB: ${res.status} ${res.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = res.body;
|
||||||
|
if (stream) {
|
||||||
|
await overwriteDatabaseFile(stream);
|
||||||
|
} else {
|
||||||
|
const blob = await res.blob();
|
||||||
|
await overwriteDatabaseFile(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(SEED_KEY, SEED_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
(async () => {
|
||||||
|
status = "loading";
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSeeded();
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
annId: animeTable.annId,
|
||||||
|
mainName: animeTable.mainName,
|
||||||
|
year: animeTable.year,
|
||||||
|
seasonId: animeTable.seasonId,
|
||||||
|
})
|
||||||
|
.from(animeTable)
|
||||||
|
.orderBy(
|
||||||
|
desc(animeTable.year),
|
||||||
|
desc(animeTable.seasonId),
|
||||||
|
desc(animeTable.annId),
|
||||||
|
)
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
anime = rows;
|
||||||
|
status = "ready";
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
status = "error";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
function seasonName(seasonId: number) {
|
||||||
|
// matches your Season enum mapping (0..3)
|
||||||
|
switch (seasonId) {
|
||||||
|
case 0:
|
||||||
|
return "Winter";
|
||||||
|
case 1:
|
||||||
|
return "Spring";
|
||||||
|
case 2:
|
||||||
|
return "Summer";
|
||||||
|
case 3:
|
||||||
|
return "Fall";
|
||||||
|
default:
|
||||||
|
return `Season ${seasonId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-semibold">AMQ Browser</h1>
|
||||||
|
|
||||||
|
{#if status === "loading"}
|
||||||
|
<p class="mt-3 text-sm text-muted-foreground">Loading client database…</p>
|
||||||
|
{:else if status === "error"}
|
||||||
|
<p class="mt-3 text-sm text-red-600">
|
||||||
|
Error: {error}
|
||||||
|
</p>
|
||||||
|
{:else if status === "ready"}
|
||||||
|
<p class="mt-3 text-sm text-muted-foreground">
|
||||||
|
Showing {anime.length} anime
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mt-4 space-y-2">
|
||||||
|
{#each anime as a (a.annId)}
|
||||||
|
<li class="rounded border px-3 py-2">
|
||||||
|
<div class="font-medium">{a.mainName}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{a.year}
|
||||||
|
{seasonName(a.seasonId)} • ANN {a.annId}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user