From 19abb34693bbffe435f59d52585d74cc6a5529a1 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:48:34 +0100 Subject: [PATCH] fix(sync): keep queued catch-record writes from crossing an account change The queue held whole records and flushed whatever was in it, so edits made before a sign-out could be written against the account that signed in next. Queue patches and merge them per key, so two edits to one entry combine instead of one replacing the other; drop everything when the owning account is no longer the current one; and stop scheduling flushes while offline, where they could only fail and back off. --- src/lib/utils/catchRecordWriteQueue.ts | 27 ++++++++++++------ tests/unit/catchRecordWriteQueue.test.ts | 36 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/lib/utils/catchRecordWriteQueue.ts b/src/lib/utils/catchRecordWriteQueue.ts index 6b6da6c..413cfaa 100644 --- a/src/lib/utils/catchRecordWriteQueue.ts +++ b/src/lib/utils/catchRecordWriteQueue.ts @@ -1,5 +1,5 @@ import { writable, type Readable } from 'svelte/store'; -import type { CatchRecord } from '$lib/models/CatchRecord'; +import type { CatchRecordPatch } from '$lib/models/PokedexGridRow'; export type CatchRecordWriteQueueStatus = { pending: number; @@ -10,7 +10,7 @@ export type CatchRecordWriteQueueStatus = { }; type QueueItem = { - record: CatchRecord; + record: CatchRecordPatch; attempts: number; notBefore: number; // unix ms debounceTimer: ReturnType | null; @@ -31,6 +31,8 @@ export type CreateCatchRecordWriteQueueOptions = { batchSize?: number; /** Max number of concurrent in-flight requests. */ concurrency?: number; + /** Prevent queued work from crossing an account change. */ + isCurrentUser?: () => boolean; }; export type EnqueueCatchRecordWriteOptions = { @@ -53,7 +55,7 @@ export type FlushOptions = { limit?: number; }; -function keyFor(record: CatchRecord): string { +function keyFor(record: CatchRecordPatch): string { return `${record.userId}:${record.pokedexId}:${record.pokemonId}`; } @@ -66,11 +68,12 @@ function backoffMs(attempts: number): number { } export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueueOptions): { - enqueue: (record: CatchRecord, opts?: EnqueueCatchRecordWriteOptions) => void; + enqueue: (record: CatchRecordPatch, opts?: EnqueueCatchRecordWriteOptions) => void; flushNow: (opts?: FlushOptions) => Promise; getStatus: Readable; getPendingCount: () => number; clearError: () => void; + getPendingPatch: (pokemonId: string) => CatchRecordPatch | undefined; } { const { endpointUrl, fetchFn, batchSize = 100, concurrency = 1 } = options; @@ -109,7 +112,7 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue } function scheduleFlush() { - if (scheduled) return; + if (scheduled || (typeof navigator !== 'undefined' && navigator.onLine === false)) return; const next = computeNextWakeup(); if (next === null) return; const delay = Math.max(0, next - Date.now()); @@ -120,6 +123,12 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue } async function flushBatch(opts?: FlushOptions): Promise { + if (options.isCurrentUser && !options.isCurrentUser()) { + for (const item of items.values()) if (item.debounceTimer) clearTimeout(item.debounceTimer); + items.clear(); + updateStatus({ lastError: null }); + return; + } if (typeof navigator !== 'undefined' && navigator.onLine === false) { // Stay queued; caller can retry when online. return; @@ -206,7 +215,7 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue scheduleFlush(); } - function enqueue(record: CatchRecord, opts?: EnqueueCatchRecordWriteOptions) { + function enqueue(record: CatchRecordPatch, opts?: EnqueueCatchRecordWriteOptions) { const k = keyFor(record); const now = Date.now(); const existing = items.get(k); @@ -232,7 +241,7 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue } items.set(k, { - record, + record: { ...existing?.record, ...record }, attempts: existing?.attempts ?? 0, notBefore, debounceTimer, @@ -255,6 +264,8 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue flushNow, getStatus: { subscribe: statusStore.subscribe }, getPendingCount, - clearError + clearError, + getPendingPatch: (pokemonId: string) => + [...items.values()].find((item) => item.record.pokemonId === pokemonId)?.record }; } diff --git a/tests/unit/catchRecordWriteQueue.test.ts b/tests/unit/catchRecordWriteQueue.test.ts index c2944f9..1ad745e 100644 --- a/tests/unit/catchRecordWriteQueue.test.ts +++ b/tests/unit/catchRecordWriteQueue.test.ts @@ -28,6 +28,27 @@ describe('createCatchRecordWriteQueue()', () => { vi.unstubAllGlobals(); }); + it('merges partial notes/status patches without inventing omitted fields', async () => { + const fetchFn = vi.fn(async () => new Response('[]')); + const queue = createCatchRecordWriteQueue({ endpointUrl: '/api/catches', fetchFn }); + const identity = { userId: 'u', pokedexId: 'd', pokemonId: '1' }; + queue.enqueue({ ...identity, personalNotes: 'Keep' }, { flushSoon: false }); + queue.enqueue({ ...identity, inHome: true }, { flushSoon: false }); + expect(queue.getPendingPatch('1')).toEqual({ + ...identity, + personalNotes: 'Keep', + inHome: true + }); + await queue.flushNow(); + const payload = JSON.parse( + String((fetchFn.mock.calls[0] as unknown as [string, RequestInit])[1].body) + ); + expect(payload).toEqual([{ ...identity, personalNotes: 'Keep', inHome: true }]); + queue.enqueue({ ...identity, personalNotes: '' }, { flushSoon: false }); + expect(queue.getPendingPatch('1')?.personalNotes).toBe(''); + await queue.flushNow(); + }); + 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 }); @@ -141,6 +162,21 @@ describe('createCatchRecordWriteQueue()', () => { expect(queue.getPendingCount()).toBe(1); }); + it('discards queued edits after the owning account changes', async () => { + const fetchFn = vi.fn(); + let current = true; + const queue = createCatchRecordWriteQueue({ + endpointUrl: '/catch-records', + fetchFn, + isCurrentUser: () => current + }); + queue.enqueue(mkRecord(), { debounceMs: 100, flushSoon: false }); + current = false; + await queue.flushNow(); + expect(fetchFn).not.toHaveBeenCalled(); + expect(queue.getPendingCount()).toBe(0); + }); + it('does not discard a newer version enqueued during an in-flight request', async () => { let resolveFirst: ((response: Response) => void) | undefined; const firstResponse = new Promise((resolve) => (resolveFirst = resolve));