mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-07-13 02:53:45 +00:00
Merge pull request #76 from jcreek/56-improve-responsiveness-of-updates-to-the-dex
refactor(#56): Improve how the dex updating is handled and the error …
This commit is contained in:
@@ -29,8 +29,10 @@
|
|||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
function updateCatchRecord() {
|
type UpdateCatchSource = 'toggle' | 'notes' | 'notes-blur';
|
||||||
dispatch('updateCatch', { pokedexEntry, catchRecord });
|
|
||||||
|
function updateCatchRecord(source: UpdateCatchSource) {
|
||||||
|
dispatch('updateCatch', { pokedexEntry, catchRecord, source });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCaughtChange() {
|
function onCaughtChange() {
|
||||||
@@ -39,7 +41,7 @@
|
|||||||
if (catchRecord.caught) {
|
if (catchRecord.caught) {
|
||||||
catchRecord.haveToEvolve = false;
|
catchRecord.haveToEvolve = false;
|
||||||
}
|
}
|
||||||
updateCatchRecord();
|
updateCatchRecord('toggle');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onNeedsToEvolveChange() {
|
function onNeedsToEvolveChange() {
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
if (catchRecord.haveToEvolve) {
|
if (catchRecord.haveToEvolve) {
|
||||||
catchRecord.caught = false;
|
catchRecord.caught = false;
|
||||||
}
|
}
|
||||||
updateCatchRecord();
|
updateCatchRecord('toggle');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -124,30 +126,30 @@
|
|||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="cursor-pointer label">
|
<label class="cursor-pointer label">
|
||||||
<span class="block font-bold mr-2">In Home:</span>
|
<span class="block font-bold mr-2">In Home:</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={catchRecord.inHome}
|
bind:checked={catchRecord.inHome}
|
||||||
class="checkbox checkbox-primary border-black"
|
class="checkbox checkbox-primary border-black"
|
||||||
on:change={updateCatchRecord}
|
on:change={() => updateCatchRecord('toggle')}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{#if pokedexEntry.canGigantamax && showForms}
|
{#if pokedexEntry.canGigantamax && showForms}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="cursor-pointer label">
|
<label class="cursor-pointer label">
|
||||||
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
|
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={catchRecord.hasGigantamaxed}
|
bind:checked={catchRecord.hasGigantamaxed}
|
||||||
class="checkbox checkbox-primary border-black"
|
class="checkbox checkbox-primary border-black"
|
||||||
on:change={updateCatchRecord}
|
on:change={() => updateCatchRecord('toggle')}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
{/if}
|
||||||
<p>
|
<p>
|
||||||
<label
|
<label
|
||||||
class="block font-bold mb-1"
|
class="block font-bold mb-1"
|
||||||
@@ -158,7 +160,8 @@
|
|||||||
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
|
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
|
||||||
class="form-textarea w-full p-2 border rounded"
|
class="form-textarea w-full p-2 border rounded"
|
||||||
style="min-height: 120px;"
|
style="min-height: 120px;"
|
||||||
on:change={updateCatchRecord}
|
on:input={() => updateCatchRecord('notes')}
|
||||||
|
on:change={() => updateCatchRecord('notes-blur')}
|
||||||
></textarea>
|
></textarea>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { CatchRecord, CatchRecordDB } from '$lib/models/CatchRecord';
|
import type { CatchRecord, CatchRecordDB } from '$lib/models/CatchRecord';
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||||
|
|
||||||
class CatchRecordRepository {
|
class CatchRecordRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private supabase: SupabaseClient,
|
private supabase: SupabaseClient,
|
||||||
@@ -43,6 +45,66 @@ class CatchRecordRepository {
|
|||||||
return dbData;
|
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<PartialExcept<CatchRecord, 'pokedexEntryId'>>
|
||||||
|
): Promise<CatchRecord[]> {
|
||||||
|
if (records.length === 0) return [];
|
||||||
|
|
||||||
|
const dbRows: Partial<CatchRecordDB>[] = 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<CatchRecord | null> {
|
async findById(id: string): Promise<CatchRecord | null> {
|
||||||
const { data, error } = await this.supabase
|
const { data, error } = await this.supabase
|
||||||
.from('catch_records')
|
.from('catch_records')
|
||||||
@@ -138,7 +200,11 @@ class CatchRecordRepository {
|
|||||||
return this.update(data._id, data);
|
return this.update(data._id, data);
|
||||||
} else if (data.pokedexEntryId) {
|
} else if (data.pokedexEntryId) {
|
||||||
// Try to find existing record for this user, pokemon, and pokedex
|
// 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) {
|
if (existing) {
|
||||||
return this.update(existing._id, data);
|
return this.update(existing._id, data);
|
||||||
} else {
|
} else {
|
||||||
@@ -148,7 +214,11 @@ class CatchRecordRepository {
|
|||||||
return this.create(data);
|
return this.create(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserAndPokemon(userId: string, pokedexEntryId: string, pokedexId: string): Promise<CatchRecord | null> {
|
async findByUserAndPokemon(
|
||||||
|
userId: string,
|
||||||
|
pokedexEntryId: string,
|
||||||
|
pokedexId: string
|
||||||
|
): Promise<CatchRecord | null> {
|
||||||
const numericId = Number(pokedexEntryId);
|
const numericId = Number(pokedexEntryId);
|
||||||
if (isNaN(numericId)) {
|
if (isNaN(numericId)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ class PokedexRepository {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error creating pokedex:', 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) {
|
if (!result) {
|
||||||
@@ -91,12 +94,22 @@ class PokedexRepository {
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.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);
|
return this.transform(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
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) {
|
if (error) {
|
||||||
console.error('Error deleting pokedex:', error);
|
console.error('Error deleting pokedex:', error);
|
||||||
|
|||||||
@@ -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<typeof setTimeout> | 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<void>;
|
||||||
|
getStatus: Readable<CatchRecordWriteQueueStatus>;
|
||||||
|
getPendingCount: () => number;
|
||||||
|
clearError: () => void;
|
||||||
|
} {
|
||||||
|
const { endpointUrl, fetchFn, batchSize = 100, concurrency = 1 } = options;
|
||||||
|
|
||||||
|
const items = new Map<string, QueueItem>();
|
||||||
|
let scheduled: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let inFlight = 0;
|
||||||
|
|
||||||
|
const statusStore = writable<CatchRecordWriteQueueStatus>({
|
||||||
|
pending: 0,
|
||||||
|
inFlight: 0,
|
||||||
|
lastError: null,
|
||||||
|
lastFlushAttemptAt: null,
|
||||||
|
lastSuccessfulFlushAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateStatus(patch: Partial<CatchRecordWriteQueueStatus>) {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<typeof setTimeout> | 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,26 +22,38 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
|
|
||||||
// POST: Create new pokedex
|
// POST: Create new pokedex
|
||||||
export const POST = async (event: RequestEvent) => {
|
export const POST = async (event: RequestEvent) => {
|
||||||
|
let requestedName: string | undefined;
|
||||||
try {
|
try {
|
||||||
console.log('POST /api/pokedexes - Starting request');
|
|
||||||
const userId = await requireAuth(event);
|
const userId = await requireAuth(event);
|
||||||
console.log('POST /api/pokedexes - User authenticated:', userId);
|
|
||||||
|
|
||||||
const data: Partial<Pokedex> = await event.request.json();
|
const data: Partial<Pokedex> = await event.request.json();
|
||||||
console.log('POST /api/pokedexes - Request data:', data);
|
requestedName = typeof data.name === 'string' ? data.name : undefined;
|
||||||
|
|
||||||
data.userId = userId;
|
data.userId = userId;
|
||||||
|
|
||||||
const repo = new PokedexRepository(event.locals.supabase, userId);
|
const repo = new PokedexRepository(event.locals.supabase, userId);
|
||||||
const pokedex = await repo.create(data);
|
const pokedex = await repo.create(data);
|
||||||
console.log('POST /api/pokedexes - Pokédex created:', pokedex._id);
|
|
||||||
|
|
||||||
return json(pokedex);
|
return json(pokedex);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in POST /api/pokedexes:', 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 });
|
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
|
|
||||||
// PUT: Update pokedex
|
// PUT: Update pokedex
|
||||||
export const PUT = async (event: RequestEvent) => {
|
export const PUT = async (event: RequestEvent) => {
|
||||||
|
let requestedName: string | undefined;
|
||||||
try {
|
try {
|
||||||
const userId = await requireAuth(event);
|
const userId = await requireAuth(event);
|
||||||
const { id } = event.params;
|
const { id } = event.params;
|
||||||
const data: Partial<Pokedex> = await event.request.json();
|
const data: Partial<Pokedex> = await event.request.json();
|
||||||
|
requestedName = typeof data.name === 'string' ? data.name : undefined;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return json({ error: 'Pokedex ID is required' }, { status: 400 });
|
return json({ error: 'Pokedex ID is required' }, { status: 400 });
|
||||||
@@ -64,9 +66,25 @@ export const PUT = async (event: RequestEvent) => {
|
|||||||
return json(pokedex);
|
return json(pokedex);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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 });
|
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,12 +88,29 @@ export const POST = async (event: RequestEvent) => {
|
|||||||
try {
|
try {
|
||||||
const userId = await requireAuth(event);
|
const userId = await requireAuth(event);
|
||||||
const { id: pokedexId } = event.params;
|
const { id: pokedexId } = event.params;
|
||||||
const records: Partial<CatchRecord>[] = await event.request.json();
|
const body: unknown = await event.request.json();
|
||||||
|
|
||||||
if (!pokedexId) {
|
if (!pokedexId) {
|
||||||
return json({ error: 'Pokedex ID is required' }, { status: 400 });
|
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<CatchRecord>).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<Partial<CatchRecord> & Pick<CatchRecord, 'pokedexEntryId'>>;
|
||||||
|
|
||||||
const { session } = await event.locals.safeGetSession();
|
const { session } = await event.locals.safeGetSession();
|
||||||
if (session) {
|
if (session) {
|
||||||
await event.locals.supabase.auth.setSession(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 repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
|
||||||
|
|
||||||
const insertedRecords = [];
|
// Ensure the data is scoped to this pokédex and user
|
||||||
for (const record of records) {
|
const scoped = records.map((r) => ({
|
||||||
// Ensure the data is scoped to this pokédex and user
|
...r,
|
||||||
record.userId = userId;
|
userId,
|
||||||
record.pokedexId = pokedexId;
|
pokedexId
|
||||||
const upsertedRecord = await repo.upsert(record);
|
}));
|
||||||
insertedRecords.push(upsertedRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
if (err && typeof err === 'object' && 'status' in err) {
|
if (err && typeof err === 'object' && 'status' in err) {
|
||||||
|
|||||||
@@ -38,6 +38,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readApiErrorMessage(response: Response): Promise<string> {
|
||||||
|
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() {
|
function openCreateModal() {
|
||||||
editingPokedex = null;
|
editingPokedex = null;
|
||||||
formData = {
|
formData = {
|
||||||
@@ -75,9 +92,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await readApiErrorMessage(response);
|
||||||
console.error('Failed to update pokédex:', response.status, errorText);
|
console.error('Failed to update pokédex:', response.status, errorText);
|
||||||
alert(`Failed to update pokédex: ${errorText}`);
|
alert(errorText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +111,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await readApiErrorMessage(response);
|
||||||
console.error('Failed to create pokédex:', response.status, errorText);
|
console.error('Failed to create pokédex:', response.status, errorText);
|
||||||
alert(`Failed to create pokédex: ${errorText}`);
|
alert(errorText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const createdPokedex = (await response.json()) as Pokedex;
|
const createdPokedex = (await response.json()) as Pokedex;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { user } from '$lib/stores/user.js';
|
import { user } from '$lib/stores/user.js';
|
||||||
import { type User } from '@supabase/auth-js';
|
import { type User } from '@supabase/auth-js';
|
||||||
import { type CombinedData } from '$lib/models/CombinedData';
|
import { type CombinedData } from '$lib/models/CombinedData';
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
import type { CatchRecord } from '$lib/models/CatchRecord';
|
import type { CatchRecord } from '$lib/models/CatchRecord';
|
||||||
import type { PokedexEntry } from '$lib/models/PokedexEntry';
|
import type { PokedexEntry } from '$lib/models/PokedexEntry';
|
||||||
import { calculateBoxNumbers, calculateBoxPlacement } from '$lib/utils/boxPlacement';
|
import { calculateBoxNumbers, calculateBoxPlacement } from '$lib/utils/boxPlacement';
|
||||||
|
import {
|
||||||
|
createCatchRecordWriteQueue,
|
||||||
|
type CatchRecordWriteQueueStatus
|
||||||
|
} from '$lib/utils/catchRecordWriteQueue';
|
||||||
import PokedexViewBoxes from '$lib/components/pokedex/PokedexViewBoxes.svelte';
|
import PokedexViewBoxes from '$lib/components/pokedex/PokedexViewBoxes.svelte';
|
||||||
import PokedexModal from '$lib/components/pokedex/PokedexModal.svelte';
|
import PokedexModal from '$lib/components/pokedex/PokedexModal.svelte';
|
||||||
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
||||||
@@ -35,6 +39,17 @@
|
|||||||
let showModal = false;
|
let showModal = false;
|
||||||
let selectedPokemon: CombinedData | null = null;
|
let selectedPokemon: CombinedData | null = null;
|
||||||
|
|
||||||
|
let catchWriteQueue: ReturnType<typeof createCatchRecordWriteQueue> | null = null;
|
||||||
|
let catchWriteQueueKey: string | null = null;
|
||||||
|
let catchWriteQueueUnsubscribe: (() => void) | null = null;
|
||||||
|
let catchWriteStatus: CatchRecordWriteQueueStatus = {
|
||||||
|
pending: 0,
|
||||||
|
inFlight: 0,
|
||||||
|
lastError: null,
|
||||||
|
lastFlushAttemptAt: null,
|
||||||
|
lastSuccessfulFlushAt: null
|
||||||
|
};
|
||||||
|
|
||||||
// Derive from pokedex config
|
// Derive from pokedex config
|
||||||
$: showOrigins = !!pokedex?.isOriginDex;
|
$: showOrigins = !!pokedex?.isOriginDex;
|
||||||
$: showShiny = !!pokedex?.isShinyDex;
|
$: showShiny = !!pokedex?.isShinyDex;
|
||||||
@@ -43,6 +58,10 @@
|
|||||||
localUser = value;
|
localUser = value;
|
||||||
});
|
});
|
||||||
onDestroy(unsubscribe);
|
onDestroy(unsubscribe);
|
||||||
|
onDestroy(() => {
|
||||||
|
catchWriteQueueUnsubscribe?.();
|
||||||
|
catchWriteQueueUnsubscribe = null;
|
||||||
|
});
|
||||||
|
|
||||||
function openPokemonModal(pokemon: CombinedData) {
|
function openPokemonModal(pokemon: CombinedData) {
|
||||||
selectedPokemon = pokemon;
|
selectedPokemon = pokemon;
|
||||||
@@ -54,19 +73,54 @@
|
|||||||
selectedPokemon = null;
|
selectedPokemon = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModalCatchUpdate(event: any) {
|
function ensureCatchWriteQueue() {
|
||||||
await updateACatch(event); // Existing handler
|
if (!browser) return;
|
||||||
// Sync modal with refreshed data
|
if (!pokedexId) return;
|
||||||
if (selectedPokemon && combinedData) {
|
if (!localUser?.id) return;
|
||||||
const updated = combinedData.find(
|
const desiredKey = `${localUser.id}:${pokedexId}`;
|
||||||
(cd) => cd.pokedexEntry._id === selectedPokemon!.pokedexEntry._id
|
if (catchWriteQueue && catchWriteQueueKey === desiredKey) return;
|
||||||
);
|
|
||||||
if (updated) {
|
// Unsubscribe from the previous queue's status store before replacing the queue.
|
||||||
selectedPokemon = updated;
|
catchWriteQueueUnsubscribe?.();
|
||||||
|
catchWriteQueueUnsubscribe = null;
|
||||||
|
|
||||||
|
catchWriteQueue = createCatchRecordWriteQueue({
|
||||||
|
endpointUrl: `/api/pokedexes/${pokedexId}/catch-records`,
|
||||||
|
fetchFn: fetch,
|
||||||
|
batchSize: 200,
|
||||||
|
concurrency: 1
|
||||||
|
});
|
||||||
|
catchWriteQueueKey = desiredKey;
|
||||||
|
|
||||||
|
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
|
||||||
|
catchWriteStatus = s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
|
||||||
|
if (!combinedData) return;
|
||||||
|
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokedexEntryId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
// Replace the catchRecord entry with the updated version.
|
||||||
|
const current = combinedData[idx];
|
||||||
|
const patched: CombinedData = {
|
||||||
|
...current,
|
||||||
|
catchRecord: {
|
||||||
|
...(current.catchRecord ?? next),
|
||||||
|
...next
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
combinedData = [...combinedData.slice(0, idx), patched, ...combinedData.slice(idx + 1)];
|
||||||
|
|
||||||
|
if (selectedPokemon?.pokedexEntry._id === next.pokedexEntryId) {
|
||||||
|
selectedPokemon = patched;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleModalCatchUpdate(event: any) {
|
||||||
|
await updateACatch(event);
|
||||||
|
}
|
||||||
|
|
||||||
type GetDataOptions = {
|
type GetDataOptions = {
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
@@ -103,7 +157,11 @@
|
|||||||
|
|
||||||
async function updateACatch(event: any) {
|
async function updateACatch(event: any) {
|
||||||
if (!pokedexId) return;
|
if (!pokedexId) return;
|
||||||
const { catchRecord } = event.detail;
|
ensureCatchWriteQueue();
|
||||||
|
const { catchRecord, source } = event.detail as {
|
||||||
|
catchRecord: CatchRecord;
|
||||||
|
source: 'toggle' | 'notes' | 'notes-blur';
|
||||||
|
};
|
||||||
// Enforce mutual exclusivity (should be impossible to have both true).
|
// Enforce mutual exclusivity (should be impossible to have both true).
|
||||||
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
|
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
|
||||||
if (sanitizedCatchRecord.caught) {
|
if (sanitizedCatchRecord.caught) {
|
||||||
@@ -117,29 +175,18 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestOptions = {
|
// Optimistic UI: update local state immediately.
|
||||||
method: 'PUT',
|
applyOptimisticCatchRecordUpdate(sanitizedCatchRecord);
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(sanitizedCatchRecord),
|
|
||||||
credentials: 'include' as RequestCredentials
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
// Queue a background write with coalescing.
|
||||||
// Use new pokédex-scoped endpoint
|
const debounceMs = source === 'notes' ? 650 : 0;
|
||||||
const response = await fetch(`/api/pokedexes/${pokedexId}/catch-records`, requestOptions);
|
catchWriteQueue?.enqueue(sanitizedCatchRecord, {
|
||||||
if (!response.ok) {
|
debounceMs,
|
||||||
const errorText = await response.text();
|
flushSoon: true
|
||||||
console.error('Server response:', response.status, errorText);
|
});
|
||||||
alert('Failed to update catch record');
|
if (source === 'notes-blur') {
|
||||||
throw new Error('Failed to update catch record');
|
// Force a flush attempt when the user leaves the textarea.
|
||||||
}
|
await catchWriteQueue?.flushNow();
|
||||||
|
|
||||||
// Refresh the data to reflect changes from server
|
|
||||||
await getData({ page: currentPage, perPage: itemsPerPage, setCombinedDataToNull: false });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating catch record:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +198,7 @@
|
|||||||
) {
|
) {
|
||||||
if (!combinedData) return;
|
if (!combinedData) return;
|
||||||
if (!pokedexId) return;
|
if (!pokedexId) return;
|
||||||
|
ensureCatchWriteQueue();
|
||||||
|
|
||||||
const catchRecordsToUpdate: CatchRecord[] = combinedData
|
const catchRecordsToUpdate: CatchRecord[] = combinedData
|
||||||
.filter((_, index) => calculateBoxPlacement(index).box === boxNumber)
|
.filter((_, index) => calculateBoxPlacement(index).box === boxNumber)
|
||||||
@@ -183,31 +231,17 @@
|
|||||||
}
|
}
|
||||||
return updatedRecord;
|
return updatedRecord;
|
||||||
});
|
});
|
||||||
// Use new pokédex-scoped endpoint
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/pokedexes/${pokedexId}/catch-records`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(catchRecordsToUpdate)
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('Server response:', response.status, errorText);
|
|
||||||
alert(
|
|
||||||
`Failed to update catch records: ${response.status} ${response.statusText}\n${errorText}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await getData({ page: currentPage, perPage: itemsPerPage, setCombinedDataToNull: false });
|
// Optimistic patch: apply locally first.
|
||||||
} catch (error) {
|
for (const record of catchRecordsToUpdate) {
|
||||||
console.error('Error updating catch records:', error);
|
// Ensure mutual exclusivity locally.
|
||||||
alert(
|
if (record.caught) record.haveToEvolve = false;
|
||||||
`Network error updating catch records: ${error instanceof Error ? error.message : String(error)}`
|
if (record.haveToEvolve) record.caught = false;
|
||||||
);
|
applyOptimisticCatchRecordUpdate(record);
|
||||||
|
catchWriteQueue?.enqueue(record, { flushSoon: true });
|
||||||
}
|
}
|
||||||
|
// Kick a flush attempt (batching will occur inside the queue).
|
||||||
|
await catchWriteQueue?.flushNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markBoxAsNotCaught(boxNumber: number) {
|
async function markBoxAsNotCaught(boxNumber: number) {
|
||||||
@@ -293,6 +327,41 @@
|
|||||||
|
|
||||||
// Fetch data whenever pagination controls change (client-side only)
|
// Fetch data whenever pagination controls change (client-side only)
|
||||||
$: if (browser && pokedexId) getData({ page: currentPage, perPage: itemsPerPage });
|
$: if (browser && pokedexId) getData({ page: currentPage, perPage: itemsPerPage });
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const flushKeepalive = () => {
|
||||||
|
if (!catchWriteQueue) return;
|
||||||
|
// Best-effort: keepalive requests have body-size limits; flush a small batch.
|
||||||
|
void catchWriteQueue.flushNow({ keepalive: true, limit: 25 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') flushKeepalive();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOnline = () => void catchWriteQueue?.flushNow();
|
||||||
|
|
||||||
|
window.addEventListener('pagehide', flushKeepalive);
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
window.addEventListener('online', onOnline);
|
||||||
|
|
||||||
|
// Periodic reconciliation to guard against any missed state (e.g. aborted tab-close flush).
|
||||||
|
const reconcileInterval = window.setInterval(() => {
|
||||||
|
if (!pokedexId) return;
|
||||||
|
if (creatingRecords) return;
|
||||||
|
if (catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0) return;
|
||||||
|
void getData({ page: currentPage, perPage: itemsPerPage, setCombinedDataToNull: false });
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', flushKeepalive);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
window.removeEventListener('online', onOnline);
|
||||||
|
window.clearInterval(reconcileInterval);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -426,6 +495,15 @@
|
|||||||
|
|
||||||
<!-- Right side: Actions -->
|
<!-- Right side: Actions -->
|
||||||
<div class="flex flex-row lg:flex-col gap-2">
|
<div class="flex flex-row lg:flex-col gap-2">
|
||||||
|
{#if catchWriteStatus.pending > 0 || catchWriteStatus.inFlight > 0}
|
||||||
|
<div class="text-sm text-base-content/70">
|
||||||
|
Saving… ({catchWriteStatus.pending} queued)
|
||||||
|
</div>
|
||||||
|
{:else if catchWriteStatus.lastError}
|
||||||
|
<div class="text-sm text-error" title={catchWriteStatus.lastError}>
|
||||||
|
Save failed (will retry)
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<a href="/my-pokedexes" class="btn btn-outline btn-sm">
|
<a href="/my-pokedexes" class="btn btn-outline btn-sm">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
-- Fix catch_records uniqueness for multi-pokédex support.
|
||||||
|
--
|
||||||
|
-- Intended invariant:
|
||||||
|
-- one catch record per (userId, pokedexId, pokedexEntryId)
|
||||||
|
--
|
||||||
|
-- This migration is written to be safe to run even if parts of the schema already exist.
|
||||||
|
|
||||||
|
-- 1) Ensure pokedexId column exists
|
||||||
|
ALTER TABLE public.catch_records
|
||||||
|
ADD COLUMN IF NOT EXISTS "pokedexId" UUID;
|
||||||
|
|
||||||
|
-- 1.5) Guards: fail early with diagnostics if the backfill would be unsafe.
|
||||||
|
--
|
||||||
|
-- Why:
|
||||||
|
-- - The backfill below only updates rows for users that have at least one pokedex.
|
||||||
|
-- If any user has catch_records but no pokedexes, those rows would remain NULL
|
||||||
|
-- and the NOT NULL enforcement would fail.
|
||||||
|
-- - The backfill assigns all NULL pokedexId rows to each user's oldest pokedex.
|
||||||
|
-- That can create duplicate (userId, pokedexId, pokedexEntryId) combinations.
|
||||||
|
--
|
||||||
|
-- If either condition is detected, this migration aborts and prints sample IDs.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
orphan_user_count bigint;
|
||||||
|
orphan_row_count bigint;
|
||||||
|
orphan_user_samples text;
|
||||||
|
|
||||||
|
dup_group_count bigint;
|
||||||
|
dup_group_samples text;
|
||||||
|
BEGIN
|
||||||
|
-- Guard A: Any catch_records with NULL pokedexId for users who have no pokedexes?
|
||||||
|
WITH orphan_users AS (
|
||||||
|
SELECT cr."userId" AS user_id,
|
||||||
|
COUNT(*)::bigint AS null_row_count
|
||||||
|
FROM public.catch_records cr
|
||||||
|
WHERE cr."pokedexId" IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.pokedexes p
|
||||||
|
WHERE p."userId" = cr."userId"
|
||||||
|
)
|
||||||
|
GROUP BY cr."userId"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::bigint,
|
||||||
|
COALESCE(SUM(null_row_count), 0)::bigint,
|
||||||
|
(
|
||||||
|
SELECT string_agg(user_id::text, ', ' ORDER BY user_id::text)
|
||||||
|
FROM (
|
||||||
|
SELECT user_id, null_row_count
|
||||||
|
FROM orphan_users
|
||||||
|
ORDER BY null_row_count DESC, user_id
|
||||||
|
LIMIT 10
|
||||||
|
) s
|
||||||
|
)
|
||||||
|
INTO orphan_user_count, orphan_row_count, orphan_user_samples
|
||||||
|
FROM orphan_users;
|
||||||
|
|
||||||
|
IF orphan_user_count > 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()
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user