mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-07-12 18:43:45 +00:00
feat(#56): Address PR comments
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type { CatchRecord, CatchRecordDB } from '$lib/models/CatchRecord';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
|
||||
class CatchRecordRepository {
|
||||
constructor(
|
||||
private supabase: SupabaseClient,
|
||||
@@ -50,12 +52,43 @@ class CatchRecordRepository {
|
||||
* 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)
|
||||
}));
|
||||
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')
|
||||
|
||||
@@ -71,7 +71,7 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue
|
||||
getPendingCount: () => number;
|
||||
clearError: () => void;
|
||||
} {
|
||||
const { endpointUrl, fetchFn, batchSize = 100, concurrency = 2 } = options;
|
||||
const { endpointUrl, fetchFn, batchSize = 100, concurrency = 1 } = options;
|
||||
|
||||
const items = new Map<string, QueueItem>();
|
||||
let scheduled: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -88,12 +88,29 @@ export const POST = async (event: RequestEvent) => {
|
||||
try {
|
||||
const userId = await requireAuth(event);
|
||||
const { id: pokedexId } = event.params;
|
||||
const records: Partial<CatchRecord>[] = 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<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();
|
||||
if (session) {
|
||||
await event.locals.supabase.auth.setSession(session);
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
|
||||
async function readApiErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const data = await response.json();
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
let catchWriteQueue: ReturnType<typeof createCatchRecordWriteQueue> | null = null;
|
||||
let catchWriteQueueKey: string | null = null;
|
||||
let catchWriteQueueUnsubscribe: (() => void) | null = null;
|
||||
let catchWriteStatus: CatchRecordWriteQueueStatus = {
|
||||
pending: 0,
|
||||
inFlight: 0,
|
||||
@@ -57,6 +58,10 @@
|
||||
localUser = value;
|
||||
});
|
||||
onDestroy(unsubscribe);
|
||||
onDestroy(() => {
|
||||
catchWriteQueueUnsubscribe?.();
|
||||
catchWriteQueueUnsubscribe = null;
|
||||
});
|
||||
|
||||
function openPokemonModal(pokemon: CombinedData) {
|
||||
selectedPokemon = pokemon;
|
||||
@@ -75,15 +80,19 @@
|
||||
const desiredKey = `${localUser.id}:${pokedexId}`;
|
||||
if (catchWriteQueue && catchWriteQueueKey === desiredKey) return;
|
||||
|
||||
// Unsubscribe from the previous queue's status store before replacing the queue.
|
||||
catchWriteQueueUnsubscribe?.();
|
||||
catchWriteQueueUnsubscribe = null;
|
||||
|
||||
catchWriteQueue = createCatchRecordWriteQueue({
|
||||
endpointUrl: `/api/pokedexes/${pokedexId}/catch-records`,
|
||||
fetchFn: fetch,
|
||||
batchSize: 200,
|
||||
concurrency: 2
|
||||
concurrency: 1
|
||||
});
|
||||
catchWriteQueueKey = desiredKey;
|
||||
|
||||
catchWriteQueue.getStatus.subscribe((s) => {
|
||||
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
|
||||
catchWriteStatus = s;
|
||||
});
|
||||
}
|
||||
@@ -332,9 +341,11 @@
|
||||
if (document.visibilityState === 'hidden') flushKeepalive();
|
||||
};
|
||||
|
||||
const onOnline = () => void catchWriteQueue?.flushNow();
|
||||
|
||||
window.addEventListener('pagehide', flushKeepalive);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
window.addEventListener('online', () => void catchWriteQueue?.flushNow());
|
||||
window.addEventListener('online', onOnline);
|
||||
|
||||
// Periodic reconciliation to guard against any missed state (e.g. aborted tab-close flush).
|
||||
const reconcileInterval = window.setInterval(() => {
|
||||
@@ -347,6 +358,7 @@
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', flushKeepalive);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
window.removeEventListener('online', onOnline);
|
||||
window.clearInterval(reconcileInterval);
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user