success pt. 3
This commit is contained in:
@@ -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<void> | 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<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`.
|
|
||||||
* (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);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export * from "./queries";
|
||||||
|
export * from "./seed";
|
||||||
|
|
||||||
import { drizzle } from "drizzle-orm/sqlite-proxy";
|
import { drizzle } from "drizzle-orm/sqlite-proxy";
|
||||||
import { SQLocalDrizzle } from "sqlocal/drizzle";
|
import { SQLocalDrizzle } from "sqlocal/drizzle";
|
||||||
|
|
||||||
98
src/lib/db/client-db/queries.ts
Normal file
98
src/lib/db/client-db/queries.ts
Normal file
@@ -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<AnimeListItem[]> {
|
||||||
|
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<AnimeListItem[]> {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
94
src/lib/db/client-db/seed.ts
Normal file
94
src/lib/db/client-db/seed.ts
Normal file
@@ -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<void> | 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<void> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { getAnimeList } from "$lib/db/client-amq";
|
import { db, ensureSeeded, getAnimeList } from "$lib/db/client-db";
|
||||||
|
|
||||||
type AnimeListItem = {
|
|
||||||
annId: number;
|
|
||||||
mainName: string;
|
|
||||||
year: number;
|
|
||||||
seasonId: number;
|
|
||||||
malId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = $state<"idle" | "loading" | "ready" | "error">("idle");
|
let status = $state<"idle" | "loading" | "ready" | "error">("idle");
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let anime = $state<AnimeListItem[]>([]);
|
|
||||||
|
type AnimeItem = Awaited<ReturnType<typeof getAnimeList>>[number];
|
||||||
|
let anime = $state<AnimeItem[]>([]);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -20,7 +14,8 @@
|
|||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
anime = await getAnimeList(20);
|
await ensureSeeded();
|
||||||
|
anime = await getAnimeList(db, 20);
|
||||||
status = "ready";
|
status = "ready";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = e instanceof Error ? e.message : String(e);
|
||||||
@@ -30,7 +25,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function seasonName(seasonId: number) {
|
function seasonName(seasonId: number) {
|
||||||
// matches your Season enum mapping (0..3)
|
|
||||||
switch (seasonId) {
|
switch (seasonId) {
|
||||||
case 0:
|
case 0:
|
||||||
return "Winter";
|
return "Winter";
|
||||||
@@ -51,13 +45,9 @@
|
|||||||
{#if status === "loading"}
|
{#if status === "loading"}
|
||||||
<p class="mt-3 text-sm text-muted-foreground">Loading client database…</p>
|
<p class="mt-3 text-sm text-muted-foreground">Loading client database…</p>
|
||||||
{:else if status === "error"}
|
{:else if status === "error"}
|
||||||
<p class="mt-3 text-sm text-red-600">
|
<p class="mt-3 text-sm text-red-600">Error: {error}</p>
|
||||||
Error: {error}
|
|
||||||
</p>
|
|
||||||
{:else if status === "ready"}
|
{:else if status === "ready"}
|
||||||
<p class="mt-3 text-sm text-muted-foreground">
|
<p class="mt-3 text-sm text-muted-foreground">Showing {anime.length} anime</p>
|
||||||
Showing {anime.length} anime
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul class="mt-4 space-y-2">
|
<ul class="mt-4 space-y-2">
|
||||||
{#each anime as a (a.annId)}
|
{#each anime as a (a.annId)}
|
||||||
|
|||||||
Reference in New Issue
Block a user