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}
-
+ updateCatchRecord('toggle')} + /> +
- {/if} + + {/if}

diff --git a/src/lib/repositories/CatchRecordRepository.ts b/src/lib/repositories/CatchRecordRepository.ts index 564d4c0..f9d25e0 100644 --- a/src/lib/repositories/CatchRecordRepository.ts +++ b/src/lib/repositories/CatchRecordRepository.ts @@ -1,6 +1,8 @@ import type { CatchRecord, CatchRecordDB } from '$lib/models/CatchRecord'; import type { SupabaseClient } from '@supabase/supabase-js'; +type PartialExcept = Partial & Pick; + class CatchRecordRepository { constructor( private supabase: SupabaseClient, @@ -43,6 +45,66 @@ 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: Array> + ): Promise { + if (records.length === 0) return []; + + const dbRows: Partial[] = records.map((r, index) => { + if (r.pokedexEntryId === undefined || r.pokedexEntryId === null || r.pokedexEntryId === '') { + throw new Error( + `CatchRecordRepository.bulkUpsert: record at index ${index} is missing required field "pokedexEntryId"` + ); + } + + // Preserve repository scoping; if a caller supplies ids, they must match the repo scope. + if (r.userId !== undefined && r.userId !== this.userId) { + throw new Error( + `CatchRecordRepository.bulkUpsert: record at index ${index} has userId "${r.userId}" but repository is scoped to "${this.userId}"` + ); + } + if (r.pokedexId !== undefined && r.pokedexId !== this.pokedexId) { + throw new Error( + `CatchRecordRepository.bulkUpsert: record at index ${index} has pokedexId "${r.pokedexId}" but repository is scoped to "${this.pokedexId}"` + ); + } + + const mapped = this.transformToDatabase(r); + // Enforce repo scope after mapping so per-record input cannot override it. + mapped.userId = this.userId; + mapped.pokedexId = this.pokedexId; + + if (mapped.pokedexEntryId === undefined || mapped.pokedexEntryId === null) { + throw new Error( + `CatchRecordRepository.bulkUpsert: record at index ${index} produced no pokedexEntryId after transform` + ); + } + + return mapped; + }); + + 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 +200,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 +214,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..5bc89e1 --- /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 = 1 } = 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..f8a113b 100644 --- a/src/routes/api/pokedexes/[id]/catch-records/+server.ts +++ b/src/routes/api/pokedexes/[id]/catch-records/+server.ts @@ -88,12 +88,29 @@ export const POST = async (event: RequestEvent) => { try { const userId = await requireAuth(event); const { id: pokedexId } = event.params; - const records: Partial[] = await event.request.json(); + const body: unknown = await event.request.json(); if (!pokedexId) { return json({ error: 'Pokedex ID is required' }, { status: 400 }); } + if (!Array.isArray(body)) { + return json({ error: 'Request body must be an array of catch records' }, { status: 400 }); + } + const invalidIndex = body.findIndex((r) => { + if (!r || typeof r !== 'object') return true; + const entryId = (r as Partial).pokedexEntryId; + return entryId === undefined || entryId === null || entryId === ''; + }); + if (invalidIndex !== -1) { + return json( + { error: `Record at index ${invalidIndex} is missing required field "pokedexEntryId"` }, + { status: 400 } + ); + } + + const records = body as Array & Pick>; + const { session } = await event.locals.safeGetSession(); if (session) { await event.locals.supabase.auth.setSession(session); @@ -109,16 +126,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..9677d5f 100644 --- a/src/routes/my-pokedexes/+page.svelte +++ b/src/routes/my-pokedexes/+page.svelte @@ -38,6 +38,23 @@ } } + async function readApiErrorMessage(response: Response): Promise { + try { + // Use a clone so we can still fall back to reading the original body as text. + const data = await response.clone().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 +92,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 +111,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..fe44eeb 100644 --- a/src/routes/pokedex/[id]/+page.svelte +++ b/src/routes/pokedex/[id]/+page.svelte @@ -1,5 +1,5 @@ @@ -426,6 +495,15 @@
+ {#if catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0} +
+ Saving… ({catchWriteStatus.pending} queued) +
+ {:else if catchWriteStatus.lastError} +
+ Save failed (will retry) +
+ {/if} 0 THEN + RAISE EXCEPTION + 'USAFE MIGRATION: found % user(s) with % catch_records row(s) where "pokedexId" is NULL but the user has no pokedexes. Sample userId(s): %', + orphan_user_count, + orphan_row_count, + COALESCE(orphan_user_samples, '(none)'); + END IF; + + -- Guard B: Would assigning NULL pokedexId rows to each user''s oldest pokedex create duplicates? + WITH primary_pokedex AS ( + SELECT DISTINCT ON ("userId") id AS pokedex_id, "userId" + FROM public.pokedexes + ORDER BY "userId", "createdAt" ASC + ), + projected AS ( + SELECT + cr."userId", + COALESCE(cr."pokedexId", pp.pokedex_id) AS projected_pokedex_id, + cr."pokedexEntryId" + FROM public.catch_records cr + LEFT JOIN primary_pokedex pp + ON pp."userId" = cr."userId" + ), + dup_groups AS ( + SELECT + "userId", + projected_pokedex_id, + "pokedexEntryId", + COUNT(*)::bigint AS row_count + FROM projected + WHERE projected_pokedex_id IS NOT NULL + GROUP BY "userId", projected_pokedex_id, "pokedexEntryId" + HAVING COUNT(*) > 1 + ) + SELECT + COUNT(*)::bigint, + ( + SELECT string_agg( + format( + '(userId=%s, pokedexId=%s, pokedexEntryId=%s, rows=%s)', + "userId"::text, + projected_pokedex_id::text, + "pokedexEntryId"::text, + row_count::text + ), + E'\n' + ORDER BY row_count DESC, "userId"::text + ) + FROM ( + SELECT * FROM dup_groups + ORDER BY row_count DESC, "userId"::text + LIMIT 10 + ) s + ) + INTO dup_group_count, dup_group_samples + FROM dup_groups; + + IF dup_group_count > 0 THEN + RAISE EXCEPTION + 'USAFE MIGRATION: backfill would create % duplicate (userId, pokedexId, pokedexEntryId) group(s) after projecting NULL "pokedexId" to each user''s oldest pokedex. Sample group(s):\n%', + dup_group_count, + COALESCE(dup_group_samples, '(none)'); + END IF; +END $$; + +-- 2) Best-effort backfill for any NULL pokedexId rows. +-- Strategy: assign to the user's oldest pokedex. +WITH primary_pokedex AS ( + SELECT DISTINCT ON ("userId") id AS pokedex_id, "userId" + FROM public.pokedexes + ORDER BY "userId", "createdAt" ASC +) +UPDATE public.catch_records cr +SET "pokedexId" = pp.pokedex_id +FROM primary_pokedex pp +WHERE cr."userId" = pp."userId" + AND cr."pokedexId" IS NULL; + +-- 3) Enforce NOT NULL + FK +ALTER TABLE public.catch_records + ALTER COLUMN "pokedexId" SET NOT NULL; + +-- Drop/recreate FK to avoid "already exists" name collisions. +ALTER TABLE public.catch_records + DROP CONSTRAINT IF EXISTS "catch_records_pokedexId_fkey"; + +ALTER TABLE public.catch_records + ADD CONSTRAINT "catch_records_pokedexId_fkey" + FOREIGN KEY ("pokedexId") REFERENCES public.pokedexes(id) ON DELETE CASCADE; + +-- 4) Replace uniqueness constraint +ALTER TABLE public.catch_records + DROP CONSTRAINT IF EXISTS "catch_records_userId_pokedexEntryId_key"; + +ALTER TABLE public.catch_records + DROP CONSTRAINT IF EXISTS "catch_records_user_pokemon_pokedex_unique"; + +ALTER TABLE public.catch_records + ADD CONSTRAINT "catch_records_user_pokemon_pokedex_unique" + UNIQUE ("userId", "pokedexId", "pokedexEntryId"); + +-- 5) Helpful index +CREATE INDEX IF NOT EXISTS idx_catch_records_pokedex_id ON public.catch_records("pokedexId"); + +-- 6) RLS policies (recreate safely) +ALTER TABLE public.catch_records ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users can view own catch records" ON public.catch_records; +DROP POLICY IF EXISTS "Users can insert own catch records" ON public.catch_records; +DROP POLICY IF EXISTS "Users can update own catch records" ON public.catch_records; +DROP POLICY IF EXISTS "Users can delete own catch records" ON public.catch_records; + +CREATE POLICY "Users can view own catch records" ON public.catch_records + FOR SELECT USING ( + auth.uid() = "userId" AND + EXISTS ( + SELECT 1 FROM public.pokedexes + WHERE public.pokedexes.id = public.catch_records."pokedexId" + AND public.pokedexes."userId" = auth.uid() + ) + ); + +CREATE POLICY "Users can insert own catch records" ON public.catch_records + FOR INSERT WITH CHECK ( + auth.uid() = "userId" AND + EXISTS ( + SELECT 1 FROM public.pokedexes + WHERE public.pokedexes.id = public.catch_records."pokedexId" + AND public.pokedexes."userId" = auth.uid() + ) + ); + +CREATE POLICY "Users can update own catch records" ON public.catch_records + FOR UPDATE USING ( + auth.uid() = "userId" AND + EXISTS ( + SELECT 1 FROM public.pokedexes + WHERE public.pokedexes.id = public.catch_records."pokedexId" + AND public.pokedexes."userId" = auth.uid() + ) + ) + WITH CHECK ( + auth.uid() = new."userId" AND + EXISTS ( + SELECT 1 FROM public.pokedexes + WHERE public.pokedexes.id = new."pokedexId" + AND public.pokedexes."userId" = auth.uid() + ) + ); + +CREATE POLICY "Users can delete own catch records" ON public.catch_records + FOR DELETE USING ( + auth.uid() = "userId" AND + EXISTS ( + SELECT 1 FROM public.pokedexes + WHERE public.pokedexes.id = public.catch_records."pokedexId" + AND public.pokedexes."userId" = auth.uid() + ) + ); diff --git a/test/catchRecordWriteQueue.test.ts b/test/catchRecordWriteQueue.test.ts new file mode 100644 index 0000000..bf72a1e --- /dev/null +++ b/test/catchRecordWriteQueue.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createCatchRecordWriteQueue } from '../src/lib/utils/catchRecordWriteQueue'; +import type { CatchRecord } from '../src/lib/models/CatchRecord'; + +function mkRecord(overrides: Partial = {}): CatchRecord { + return { + _id: '', + userId: 'u1', + pokedexId: 'p1', + pokedexEntryId: '25', + haveToEvolve: false, + caught: false, + inHome: false, + hasGigantamaxed: false, + personalNotes: '', + ...overrides + }; +} + +describe('createCatchRecordWriteQueue()', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.restoreAllMocks(); + }); + + it('coalesces multiple updates for the same key and flushes only the latest state', async () => { + const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + return new Response(init?.body as string, { status: 200 }); + }); + + const queue = createCatchRecordWriteQueue({ + endpointUrl: '/api/pokedexes/p1/catch-records', + fetchFn, + batchSize: 50, + concurrency: 1 + }); + + queue.enqueue(mkRecord({ caught: true })); + queue.enqueue(mkRecord({ caught: false, haveToEvolve: true })); + + await queue.flushNow(); + + expect(fetchFn).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body)); + expect(body).toHaveLength(1); + expect(body[0].haveToEvolve).toBe(true); + expect(body[0].caught).toBe(false); + }); + + it('debounces notes updates before flushing', async () => { + const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + return new Response(init?.body as string, { status: 200 }); + }); + + const queue = createCatchRecordWriteQueue({ + endpointUrl: '/api/pokedexes/p1/catch-records', + fetchFn, + batchSize: 50, + concurrency: 1 + }); + + queue.enqueue(mkRecord({ personalNotes: 'a' }), { debounceMs: 500 }); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(499); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(1); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +});