From b1b6e63b6f429568506617cf41757acc6300efb1 Mon Sep 17 00:00:00 2001
From: Josh Creek <8179928+jcreek@users.noreply.github.com>
Date: Sat, 10 Jan 2026 12:07:05 +0000
Subject: [PATCH 1/3] refactor(#56): Improve how the dex updating is handled
and the error messaging around it
---
.../pokedex/PokedexEntryCatchRecord.svelte | 47 ++--
src/lib/repositories/CatchRecordRepository.ts | 41 ++-
src/lib/repositories/PokedexRepository.ts | 19 +-
src/lib/utils/catchRecordWriteQueue.ts | 246 ++++++++++++++++++
src/routes/api/pokedexes/+server.ts | 30 ++-
src/routes/api/pokedexes/[id]/+server.ts | 22 +-
.../pokedexes/[id]/catch-records/+server.ts | 28 +-
src/routes/my-pokedexes/+page.svelte | 24 +-
src/routes/pokedex/[id]/+page.svelte | 178 +++++++++----
...00_fix_catch_records_unique_constraint.sql | 98 +++++++
test/catchRecordWriteQueue.test.ts | 74 ++++++
11 files changed, 700 insertions(+), 107 deletions(-)
create mode 100644 src/lib/utils/catchRecordWriteQueue.ts
create mode 100644 supabase/migrations/20260110114000_fix_catch_records_unique_constraint.sql
create mode 100644 test/catchRecordWriteQueue.test.ts
diff --git a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte
index fc2a44a..4644da5 100644
--- a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte
+++ b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte
@@ -29,8 +29,10 @@
const dispatch = createEventDispatcher();
- function updateCatchRecord() {
- dispatch('updateCatch', { pokedexEntry, catchRecord });
+ type UpdateCatchSource = 'toggle' | 'notes' | 'notes-blur';
+
+ function updateCatchRecord(source: UpdateCatchSource) {
+ dispatch('updateCatch', { pokedexEntry, catchRecord, source });
}
function onCaughtChange() {
@@ -39,7 +41,7 @@
if (catchRecord.caught) {
catchRecord.haveToEvolve = false;
}
- updateCatchRecord();
+ updateCatchRecord('toggle');
}
function onNeedsToEvolveChange() {
@@ -48,7 +50,7 @@
if (catchRecord.haveToEvolve) {
catchRecord.caught = false;
}
- updateCatchRecord();
+ updateCatchRecord('toggle');
}
@@ -124,30 +126,30 @@
-
+ updateCatchRecord('toggle')}
+ />
+
+
{#if pokedexEntry.canGigantamax && showForms}
- {/if}
+
+ {/if}
diff --git a/src/lib/repositories/CatchRecordRepository.ts b/src/lib/repositories/CatchRecordRepository.ts
index 564d4c0..ef67dad 100644
--- a/src/lib/repositories/CatchRecordRepository.ts
+++ b/src/lib/repositories/CatchRecordRepository.ts
@@ -43,6 +43,35 @@ class CatchRecordRepository {
return dbData;
}
+ /**
+ * Bulk upsert catch records in a single request when possible.
+ *
+ * Requires a unique constraint matching the `onConflict` columns.
+ * If the database is not configured with that constraint, the caller should
+ * catch the error and fall back to per-record upserts.
+ */
+ async bulkUpsert(records: Partial[]): Promise {
+ const dbRows: Partial[] = records.map((r) => ({
+ userId: this.userId,
+ pokedexId: this.pokedexId,
+ ...this.transformToDatabase(r)
+ }));
+
+ const { data: result, error } = await this.supabase
+ .from('catch_records')
+ .upsert(dbRows, {
+ onConflict: 'userId,pokedexId,pokedexEntryId'
+ })
+ .select();
+
+ if (error) {
+ console.error('Supabase error bulk upserting catch records:', error);
+ throw new Error(`Failed to bulk upsert catch records: ${error.message}`);
+ }
+
+ return (result ?? []).map((row) => this.transformCatchRecord(row));
+ }
+
async findById(id: string): Promise {
const { data, error } = await this.supabase
.from('catch_records')
@@ -138,7 +167,11 @@ class CatchRecordRepository {
return this.update(data._id, data);
} else if (data.pokedexEntryId) {
// Try to find existing record for this user, pokemon, and pokedex
- const existing = await this.findByUserAndPokemon(this.userId, data.pokedexEntryId, this.pokedexId);
+ const existing = await this.findByUserAndPokemon(
+ this.userId,
+ data.pokedexEntryId,
+ this.pokedexId
+ );
if (existing) {
return this.update(existing._id, data);
} else {
@@ -148,7 +181,11 @@ class CatchRecordRepository {
return this.create(data);
}
- async findByUserAndPokemon(userId: string, pokedexEntryId: string, pokedexId: string): Promise {
+ async findByUserAndPokemon(
+ userId: string,
+ pokedexEntryId: string,
+ pokedexId: string
+ ): Promise {
const numericId = Number(pokedexEntryId);
if (isNaN(numericId)) {
return null;
diff --git a/src/lib/repositories/PokedexRepository.ts b/src/lib/repositories/PokedexRepository.ts
index 0b1842e..4a48b39 100644
--- a/src/lib/repositories/PokedexRepository.ts
+++ b/src/lib/repositories/PokedexRepository.ts
@@ -70,7 +70,10 @@ class PokedexRepository {
if (error) {
console.error('Error creating pokedex:', error);
- throw new Error(`Failed to create pokedex: ${error.message}`);
+ // Preserve the Supabase error code so the API layer can map to a user-friendly response.
+ throw Object.assign(new Error(`Failed to create pokedex: ${error.message}`), {
+ code: error.code
+ });
}
if (!result) {
@@ -91,12 +94,22 @@ class PokedexRepository {
.select()
.single();
- if (error || !result) return null;
+ if (error) {
+ console.error('Error updating pokedex:', error);
+ throw Object.assign(new Error(`Failed to update pokedex: ${error.message}`), {
+ code: error.code
+ });
+ }
+ if (!result) return null;
return this.transform(result);
}
async delete(id: string): Promise {
- const { error } = await this.supabase.from('pokedexes').delete().eq('id', id).eq('userId', this.userId);
+ const { error } = await this.supabase
+ .from('pokedexes')
+ .delete()
+ .eq('id', id)
+ .eq('userId', this.userId);
if (error) {
console.error('Error deleting pokedex:', error);
diff --git a/src/lib/utils/catchRecordWriteQueue.ts b/src/lib/utils/catchRecordWriteQueue.ts
new file mode 100644
index 0000000..005cc4b
--- /dev/null
+++ b/src/lib/utils/catchRecordWriteQueue.ts
@@ -0,0 +1,246 @@
+import { writable, type Readable } from 'svelte/store';
+import type { CatchRecord } from '$lib/models/CatchRecord';
+
+export type CatchRecordWriteQueueStatus = {
+ pending: number;
+ inFlight: number;
+ lastError: string | null;
+ lastFlushAttemptAt: number | null;
+ lastSuccessfulFlushAt: number | null;
+};
+
+type QueueItem = {
+ record: CatchRecord;
+ attempts: number;
+ notBefore: number; // unix ms
+ debounceTimer: ReturnType | null;
+};
+
+export type CreateCatchRecordWriteQueueOptions = {
+ /**
+ * URL to the pokédex-scoped catch-records endpoint.
+ * Example: `/api/pokedexes/${pokedexId}/catch-records`
+ */
+ endpointUrl: string;
+ /**
+ * Fetch implementation to use (usually the `fetch` provided by SvelteKit load).
+ */
+ fetchFn: typeof fetch;
+ /** Max number of records sent per request. */
+ batchSize?: number;
+ /** Max number of concurrent in-flight requests. */
+ concurrency?: number;
+};
+
+export type EnqueueCatchRecordWriteOptions = {
+ /** Debounce before the record becomes eligible for flushing (ms). */
+ debounceMs?: number;
+ /** If true, schedule an immediate flush attempt after enqueue. */
+ flushSoon?: boolean;
+};
+
+export type FlushOptions = {
+ /**
+ * Use `keepalive` to improve best-effort delivery during `pagehide`.
+ * Note: browsers impose body-size limits on keepalive requests.
+ */
+ keepalive?: boolean;
+ /**
+ * Limit number of items flushed in this attempt.
+ * Useful for keepalive flushes on tab close.
+ */
+ limit?: number;
+};
+
+function keyFor(record: CatchRecord): string {
+ return `${record.userId}:${record.pokedexId}:${record.pokedexEntryId}`;
+}
+
+function backoffMs(attempts: number): number {
+ // Exponential backoff with jitter: 250ms, 500ms, 1s, 2s, 4s... capped.
+ const base = 250 * Math.pow(2, Math.max(0, attempts - 1));
+ const capped = Math.min(base, 10_000);
+ const jitter = Math.floor(Math.random() * 200);
+ return capped + jitter;
+}
+
+export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueueOptions): {
+ enqueue: (record: CatchRecord, opts?: EnqueueCatchRecordWriteOptions) => void;
+ flushNow: (opts?: FlushOptions) => Promise;
+ getStatus: Readable;
+ getPendingCount: () => number;
+ clearError: () => void;
+} {
+ const { endpointUrl, fetchFn, batchSize = 100, concurrency = 2 } = options;
+
+ const items = new Map();
+ let scheduled: ReturnType | null = null;
+ let inFlight = 0;
+
+ const statusStore = writable({
+ pending: 0,
+ inFlight: 0,
+ lastError: null,
+ lastFlushAttemptAt: null,
+ lastSuccessfulFlushAt: null
+ });
+
+ function updateStatus(patch: Partial) {
+ statusStore.update((s) => ({
+ ...s,
+ pending: items.size,
+ inFlight,
+ ...patch
+ }));
+ }
+
+ function computeNextWakeup(): number | null {
+ let next: number | null = null;
+ const now = Date.now();
+ for (const [, item] of items) {
+ if (item.debounceTimer) continue;
+ if (item.notBefore <= now) {
+ return now;
+ }
+ next = next === null ? item.notBefore : Math.min(next, item.notBefore);
+ }
+ return next;
+ }
+
+ function scheduleFlush() {
+ if (scheduled) return;
+ const next = computeNextWakeup();
+ if (next === null) return;
+ const delay = Math.max(0, next - Date.now());
+ scheduled = setTimeout(async () => {
+ scheduled = null;
+ await flushNow();
+ }, delay);
+ }
+
+ async function flushBatch(opts?: FlushOptions): Promise {
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
+ // Stay queued; caller can retry when online.
+ return;
+ }
+ if (inFlight >= concurrency) return;
+
+ const now = Date.now();
+ const eligible: Array<[string, QueueItem]> = [];
+ for (const entry of items.entries()) {
+ const [k, item] = entry;
+ if (item.debounceTimer) continue;
+ if (item.notBefore > now) continue;
+ eligible.push([k, item]);
+ if (eligible.length >= (opts?.limit ?? batchSize)) break;
+ }
+ if (eligible.length === 0) return;
+
+ inFlight++;
+ updateStatus({ lastFlushAttemptAt: now });
+ try {
+ const payload = eligible.map(([, item]) => item.record);
+ const response = await fetchFn(endpointUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ // keepalive improves odds during `pagehide`.
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ keepalive: !!opts?.keepalive,
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Failed to flush catch records: ${response.status} ${text}`);
+ }
+
+ // Success: drop the flushed keys.
+ for (const [k] of eligible) {
+ items.delete(k);
+ }
+ updateStatus({ lastError: null, lastSuccessfulFlushAt: Date.now() });
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ // Backoff all eligible items and keep them.
+ for (const [k, item] of eligible) {
+ const nextAttempts = item.attempts + 1;
+ items.set(k, {
+ ...item,
+ attempts: nextAttempts,
+ notBefore: Date.now() + backoffMs(nextAttempts)
+ });
+ }
+ updateStatus({ lastError: message });
+ } finally {
+ inFlight--;
+ updateStatus({});
+ }
+ }
+
+ async function flushNow(opts?: FlushOptions): Promise {
+ updateStatus({});
+ // Drain in waves while eligible work exists and we have capacity.
+ // We intentionally do not block indefinitely; this is a “best-effort background flush”.
+ for (let i = 0; i < 10; i++) {
+ if (items.size === 0) break;
+ await flushBatch(opts);
+ // If nothing is eligible yet, schedule the next wakeup and exit.
+ const next = computeNextWakeup();
+ if (next !== null && next > Date.now()) break;
+ if (inFlight >= concurrency) break;
+ }
+ scheduleFlush();
+ }
+
+ function enqueue(record: CatchRecord, opts?: EnqueueCatchRecordWriteOptions) {
+ const k = keyFor(record);
+ const now = Date.now();
+ const existing = items.get(k);
+ const debounceMs = opts?.debounceMs ?? 0;
+
+ // Clear any pending debounce timer: latest state always wins.
+ if (existing?.debounceTimer) {
+ clearTimeout(existing.debounceTimer);
+ }
+
+ let debounceTimer: ReturnType | null = null;
+ let notBefore = now;
+ if (debounceMs > 0) {
+ notBefore = now + debounceMs;
+ debounceTimer = setTimeout(() => {
+ const item = items.get(k);
+ if (!item) return;
+ items.set(k, { ...item, debounceTimer: null, notBefore: Date.now() });
+ updateStatus({});
+ scheduleFlush();
+ }, debounceMs);
+ }
+
+ items.set(k, {
+ record,
+ attempts: existing?.attempts ?? 0,
+ notBefore,
+ debounceTimer
+ });
+ updateStatus({});
+ if (opts?.flushSoon !== false) scheduleFlush();
+ }
+
+ function getPendingCount(): number {
+ return items.size;
+ }
+
+ function clearError() {
+ updateStatus({ lastError: null });
+ }
+
+ return {
+ enqueue,
+ flushNow,
+ getStatus: { subscribe: statusStore.subscribe },
+ getPendingCount,
+ clearError
+ };
+}
diff --git a/src/routes/api/pokedexes/+server.ts b/src/routes/api/pokedexes/+server.ts
index 729f4cf..9365ab8 100644
--- a/src/routes/api/pokedexes/+server.ts
+++ b/src/routes/api/pokedexes/+server.ts
@@ -22,26 +22,38 @@ export const GET = async (event: RequestEvent) => {
// POST: Create new pokedex
export const POST = async (event: RequestEvent) => {
+ let requestedName: string | undefined;
try {
- console.log('POST /api/pokedexes - Starting request');
const userId = await requireAuth(event);
- console.log('POST /api/pokedexes - User authenticated:', userId);
-
const data: Partial = await event.request.json();
- console.log('POST /api/pokedexes - Request data:', data);
-
+ requestedName = typeof data.name === 'string' ? data.name : undefined;
data.userId = userId;
const repo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await repo.create(data);
- console.log('POST /api/pokedexes - Pokédex created:', pokedex._id);
-
return json(pokedex);
} catch (err) {
console.error('Error in POST /api/pokedexes:', err);
- if (err && typeof err === 'object' && 'status' in err) {
- throw err;
+
+ // Unique constraint violation (duplicate pokédex name for this user)
+ if (
+ err &&
+ typeof err === 'object' &&
+ 'code' in err &&
+ // Postgres unique_violation
+ (err as { code?: unknown }).code === '23505'
+ ) {
+ return json(
+ {
+ error: requestedName
+ ? `You already have a Pokédex named "${requestedName}". Please choose a different name.`
+ : 'You already have a Pokédex with that name. Please choose a different name.'
+ },
+ { status: 409 }
+ );
}
+
+ if (err && typeof err === 'object' && 'status' in err) throw err;
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
diff --git a/src/routes/api/pokedexes/[id]/+server.ts b/src/routes/api/pokedexes/[id]/+server.ts
index 2fea2fd..c4b71e9 100644
--- a/src/routes/api/pokedexes/[id]/+server.ts
+++ b/src/routes/api/pokedexes/[id]/+server.ts
@@ -40,10 +40,12 @@ export const GET = async (event: RequestEvent) => {
// PUT: Update pokedex
export const PUT = async (event: RequestEvent) => {
+ let requestedName: string | undefined;
try {
const userId = await requireAuth(event);
const { id } = event.params;
const data: Partial = await event.request.json();
+ requestedName = typeof data.name === 'string' ? data.name : undefined;
if (!id) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
@@ -64,9 +66,25 @@ export const PUT = async (event: RequestEvent) => {
return json(pokedex);
} catch (err) {
console.error(err);
- if (err && typeof err === 'object' && 'status' in err) {
- throw err;
+
+ // Unique constraint violation (duplicate pokédex name for this user)
+ if (
+ err &&
+ typeof err === 'object' &&
+ 'code' in err &&
+ (err as { code?: unknown }).code === '23505'
+ ) {
+ return json(
+ {
+ error: requestedName
+ ? `You already have a Pokédex named "${requestedName}". Please choose a different name.`
+ : 'You already have a Pokédex with that name. Please choose a different name.'
+ },
+ { status: 409 }
+ );
}
+
+ if (err && typeof err === 'object' && 'status' in err) throw err;
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
diff --git a/src/routes/api/pokedexes/[id]/catch-records/+server.ts b/src/routes/api/pokedexes/[id]/catch-records/+server.ts
index 1eb4c70..7989757 100644
--- a/src/routes/api/pokedexes/[id]/catch-records/+server.ts
+++ b/src/routes/api/pokedexes/[id]/catch-records/+server.ts
@@ -109,16 +109,26 @@ export const POST = async (event: RequestEvent) => {
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
- const insertedRecords = [];
- for (const record of records) {
- // Ensure the data is scoped to this pokédex and user
- record.userId = userId;
- record.pokedexId = pokedexId;
- const upsertedRecord = await repo.upsert(record);
- insertedRecords.push(upsertedRecord);
- }
+ // Ensure the data is scoped to this pokédex and user
+ const scoped = records.map((r) => ({
+ ...r,
+ userId,
+ pokedexId
+ }));
- return json(insertedRecords);
+ // Prefer a single bulk upsert when the DB supports it; fall back to per-record upserts.
+ try {
+ const upserted = await repo.bulkUpsert(scoped);
+ return json(upserted);
+ } catch (bulkErr) {
+ console.warn('Bulk upsert failed; falling back to per-record upserts:', bulkErr);
+ const insertedRecords = [];
+ for (const record of scoped) {
+ const upsertedRecord = await repo.upsert(record);
+ insertedRecords.push(upsertedRecord);
+ }
+ return json(insertedRecords);
+ }
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
diff --git a/src/routes/my-pokedexes/+page.svelte b/src/routes/my-pokedexes/+page.svelte
index 78294ae..ba83c18 100644
--- a/src/routes/my-pokedexes/+page.svelte
+++ b/src/routes/my-pokedexes/+page.svelte
@@ -38,6 +38,22 @@
}
}
+ async function readApiErrorMessage(response: Response): Promise {
+ try {
+ const data = await response.json();
+ if (data && typeof data === 'object' && 'error' in data && typeof data.error === 'string') {
+ return data.error;
+ }
+ } catch {
+ // fall through
+ }
+ try {
+ return await response.text();
+ } catch {
+ return 'Request failed';
+ }
+ }
+
function openCreateModal() {
editingPokedex = null;
formData = {
@@ -75,9 +91,9 @@
});
if (!response.ok) {
- const errorText = await response.text();
+ const errorText = await readApiErrorMessage(response);
console.error('Failed to update pokédex:', response.status, errorText);
- alert(`Failed to update pokédex: ${errorText}`);
+ alert(errorText);
return;
}
@@ -94,9 +110,9 @@
});
if (!response.ok) {
- const errorText = await response.text();
+ const errorText = await readApiErrorMessage(response);
console.error('Failed to create pokédex:', response.status, errorText);
- alert(`Failed to create pokédex: ${errorText}`);
+ alert(errorText);
return;
}
const createdPokedex = (await response.json()) as Pokedex;
diff --git a/src/routes/pokedex/[id]/+page.svelte b/src/routes/pokedex/[id]/+page.svelte
index 5518707..77f021b 100644
--- a/src/routes/pokedex/[id]/+page.svelte
+++ b/src/routes/pokedex/[id]/+page.svelte
@@ -1,5 +1,5 @@
@@ -426,6 +483,15 @@
+ {#if catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0}
+
+ Saving… ({catchWriteStatus.pending} queued)
+
+ {:else if catchWriteStatus.lastError}
+
+ Save failed (will retry)
+
+ {/if}