mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-07-13 11:03:44 +00:00
refactor(#56): Improve how the dex updating is handled and the error messaging around it
This commit is contained in:
@@ -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');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -124,30 +126,30 @@
|
||||
<div class="form-control">
|
||||
<label class="cursor-pointer label">
|
||||
<span class="block font-bold mr-2">In Home:</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.inHome}
|
||||
class="checkbox checkbox-primary border-black"
|
||||
on:change={updateCatchRecord}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.inHome}
|
||||
class="checkbox checkbox-primary border-black"
|
||||
on:change={() => updateCatchRecord('toggle')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{#if pokedexEntry.canGigantamax && showForms}
|
||||
<div class="flex items-center">
|
||||
<div class="form-control">
|
||||
<label class="cursor-pointer label">
|
||||
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.hasGigantamaxed}
|
||||
class="checkbox checkbox-primary border-black"
|
||||
on:change={updateCatchRecord}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={catchRecord.hasGigantamaxed}
|
||||
class="checkbox checkbox-primary border-black"
|
||||
on:change={() => updateCatchRecord('toggle')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<p>
|
||||
<label
|
||||
class="block font-bold mb-1"
|
||||
@@ -158,7 +160,8 @@
|
||||
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
|
||||
class="form-textarea w-full p-2 border rounded"
|
||||
style="min-height: 120px;"
|
||||
on:change={updateCatchRecord}
|
||||
on:input={() => updateCatchRecord('notes')}
|
||||
on:change={() => updateCatchRecord('notes-blur')}
|
||||
></textarea>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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<CatchRecord>[]): Promise<CatchRecord[]> {
|
||||
const dbRows: Partial<CatchRecordDB>[] = 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<CatchRecord | null> {
|
||||
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<CatchRecord | null> {
|
||||
async findByUserAndPokemon(
|
||||
userId: string,
|
||||
pokedexEntryId: string,
|
||||
pokedexId: string
|
||||
): Promise<CatchRecord | null> {
|
||||
const numericId = Number(pokedexEntryId);
|
||||
if (isNaN(numericId)) {
|
||||
return null;
|
||||
|
||||
@@ -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<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) {
|
||||
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 = 2 } = 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user