Skip to content

Commit 29a0f6e

Browse files
heavygeecursoragent
andcommitted
fix(scratchlist): address HAPI Bot Major findings on PR tiann#896
Two real data-correctness paths the bot caught on the initial review. 1. Migration partial-failure data loss The migration loop swallowed each failed POST and still wrote the `migrated` flag, while the offline-cache effect mirrored the (partial) hub state back into `hapi.scratchlist.v1.<sessionId>` - so a transient error or cap rejection could leave entries neither on the hub nor in localStorage. Fix: - Track failed entries during migration and persist them back to localStorage; do NOT advance the flag if any entry failed, so a future mount retries. - Gate the offline-cache effect on the migration flag. Pre- migration, localStorage holds the v1 entries the migration reads; mirroring an empty hub fetch over them was the wipe. - Drop the "skip migration when hub is non-empty" gate. Combined with the duplicate-idempotent POST short-circuit (below), a retry against a session that another device already populated is a safe union. 2. Duplicate POST returned 409 at cap The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking the store whether the supplied `entryId` already existed, so an idempotent migration retry against a 200-row session returned 409 instead of 200. Fix: check duplicate first via a new `SyncEngine.getScratchlistEntry`, return the existing row with 200, and only run the cap check for genuinely new ids. Tests added: - hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap + new entryId still 409. - web/hook: partial-failure persists the failed entries back to localStorage and leaves the flag unset; offline-cache effect does not wipe pre-migration localStorage. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 604d70d commit 29a0f6e

5 files changed

Lines changed: 228 additions & 15 deletions

File tree

hub/src/sync/syncEngine.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,27 @@ export class SyncEngine {
353353
return this.store.scratchlist.count(sessionId)
354354
}
355355

356+
/**
357+
* Read a single entry by id. The route layer uses this to short-
358+
* circuit duplicate POSTs (migration retry) BEFORE running the
359+
* server-side cap check; otherwise an idempotent retry against a
360+
* session that has hit `SCRATCHLIST_MAX_ENTRIES` would 409 when it
361+
* should 200 with the existing row.
362+
*/
363+
getScratchlistEntry(
364+
sessionId: string,
365+
entryId: string
366+
): { entryId: string; text: string; createdAt: number; updatedAt: number } | null {
367+
const row = this.store.scratchlist.get(sessionId, entryId)
368+
if (!row) return null
369+
return {
370+
entryId: row.entryId,
371+
text: row.text,
372+
createdAt: row.createdAt,
373+
updatedAt: row.updatedAt
374+
}
375+
}
376+
356377
/**
357378
* Insert a scratchlist entry. Returns the canonical row on success
358379
* (so the route layer can serialise it without a follow-up read).

hub/src/web/routes/sessions-scratchlist.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ function createSession(overrides?: Partial<Session>): Session {
6161
type EngineOverrides = Partial<{
6262
listScratchlistEntries: SyncEngine['listScratchlistEntries']
6363
countScratchlistEntries: SyncEngine['countScratchlistEntries']
64+
getScratchlistEntry: SyncEngine['getScratchlistEntry']
6465
createScratchlistEntry: SyncEngine['createScratchlistEntry']
6566
updateScratchlistEntry: SyncEngine['updateScratchlistEntry']
6667
deleteScratchlistEntry: SyncEngine['deleteScratchlistEntry']
@@ -81,6 +82,7 @@ function createApp(session: Session, overrides: EngineOverrides = {}) {
8182
},
8283
listScratchlistEntries: overrides.listScratchlistEntries ?? (() => []),
8384
countScratchlistEntries: overrides.countScratchlistEntries ?? (() => 0),
85+
getScratchlistEntry: overrides.getScratchlistEntry ?? (() => null),
8486
createScratchlistEntry: overrides.createScratchlistEntry
8587
?? ((sessionId: string, text: string) => ({
8688
outcome: 'created' as const,
@@ -225,6 +227,65 @@ describe('POST /api/sessions/:id/scratchlist', () => {
225227
expect(body.code).toBe('scratchlist_at_cap')
226228
})
227229

230+
it('still returns 200 for a duplicate entryId even when the session is at the cap (HAPI Bot, PR #896)', async () => {
231+
// The cap check used to fire BEFORE the duplicate check, which
232+
// turned an idempotent migration retry into a hard 409 the
233+
// moment a session reached `SCRATCHLIST_MAX_ENTRIES`. The fix
234+
// short-circuits on getScratchlistEntry first; this test pins
235+
// that ordering.
236+
const session = createSession()
237+
const createCalls: number[] = []
238+
const app = createApp(session, {
239+
countScratchlistEntries: () => 200,
240+
getScratchlistEntry: (_sessionId, entryId) => {
241+
if (entryId === 'pre-existing') {
242+
return {
243+
entryId: 'pre-existing',
244+
text: 'already there',
245+
createdAt: 100,
246+
updatedAt: 100
247+
}
248+
}
249+
return null
250+
},
251+
createScratchlistEntry: () => {
252+
createCalls.push(1)
253+
return {
254+
outcome: 'created' as const,
255+
entry: { entryId: 'should-not-fire', text: 'noop', createdAt: 0, updatedAt: 0 }
256+
}
257+
}
258+
})
259+
const res = await app.request('/api/sessions/session-1/scratchlist', {
260+
method: 'POST',
261+
headers: { 'content-type': 'application/json' },
262+
body: JSON.stringify({ text: 'replay', entryId: 'pre-existing' })
263+
})
264+
expect(res.status).toBe(200)
265+
const body = await res.json() as { entry: { text: string; entryId: string } }
266+
expect(body.entry.entryId).toBe('pre-existing')
267+
expect(body.entry.text).toBe('already there')
268+
// The route must NOT have called createScratchlistEntry: the
269+
// duplicate short-circuit returns BEFORE reaching the engine.
270+
expect(createCalls).toHaveLength(0)
271+
})
272+
273+
it('still returns 409 for a NEW entryId at the cap', async () => {
274+
// Mirror of the test above for the not-duplicate case: a fresh
275+
// POST at cap stays a 409.
276+
const session = createSession()
277+
const app = createApp(session, {
278+
countScratchlistEntries: () => 200,
279+
getScratchlistEntry: () => null
280+
})
281+
const res = await app.request('/api/sessions/session-1/scratchlist', {
282+
method: 'POST',
283+
headers: { 'content-type': 'application/json' },
284+
body: JSON.stringify({ text: 'fresh', entryId: 'never-seen' })
285+
})
286+
expect(res.status).toBe(409)
287+
})
288+
228289
it('returns 404 when the engine reports session-not-found post-auth', async () => {
229290
// This path covers a race: auth said the session was visible
230291
// (resolveSessionAccess.ok), but by the time we INSERT the row the

hub/src/web/routes/sessions.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,23 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
652652
return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400)
653653
}
654654

655+
// Idempotent-retry short-circuit (HAPI Bot, PR #896 review):
656+
// when the caller supplies an explicit entryId AND that id
657+
// already exists, return the canonical row with 200 BEFORE the
658+
// cap check fires. Otherwise a session sitting at the
659+
// 200-entry cap would 409 a duplicate POST that should be a
660+
// no-op - which is exactly the path the localStorage migration
661+
// retry uses after a partial failure.
662+
if (parsed.data.entryId) {
663+
const existing = engine.getScratchlistEntry(
664+
sessionResult.sessionId,
665+
parsed.data.entryId
666+
)
667+
if (existing) {
668+
return c.json({ entry: existing }, 200)
669+
}
670+
}
671+
655672
// Server-side cap enforcement. Mirrors the web-side cap so a
656673
// malicious / runaway client can't drive the table without
657674
// bound. Bypassing the optimistic add path on the web client

web/src/lib/use-hub-scratchlist.test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,86 @@ describe('useHubScratchlist - localStorage migration', () => {
330330
expect(create).not.toHaveBeenCalled()
331331
expect(result.current.migrationStatus).toBe('idle')
332332
})
333+
334+
it('persists FAILED entries back to localStorage and leaves the flag unset (HAPI Bot, PR #896)', async () => {
335+
// Migration partial failure: 2 entries in localStorage, the
336+
// first POST succeeds and the second throws. Per the bot
337+
// review, the failed entry must be written back to
338+
// localStorage and the migration flag must NOT advance, so a
339+
// future mount can retry. The status drops back to 'idle'
340+
// (banner does not render).
341+
const sid = makeSid()
342+
seedV1Entries(sid)
343+
let postCall = 0
344+
const create = vi.fn(async (_s: string, body: { text: string; entryId?: string; createdAt?: number }) => {
345+
postCall += 1
346+
if (postCall === 1) {
347+
return {
348+
entry: {
349+
entryId: body.entryId ?? 'a',
350+
text: body.text,
351+
createdAt: body.createdAt ?? 0,
352+
updatedAt: 0
353+
}
354+
}
355+
}
356+
throw new Error('HTTP 500: hub flaked on entry 2')
357+
})
358+
const api = createMockApi({
359+
getScratchlist: async () => ({ entries: [] }),
360+
createScratchlistEntry: create
361+
})
362+
const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() })
363+
await waitFor(() => expect(create).toHaveBeenCalledTimes(2))
364+
await waitFor(() => expect(result.current.migrationStatus).toBe('idle'))
365+
366+
// Flag must NOT be set: a future mount must retry.
367+
expect(localStorage.getItem(`hapi.scratchlist.v2.migrated.${sid}`)).toBeNull()
368+
// The failed entry (the second one) must be back in localStorage.
369+
const persisted = localStorage.getItem(`hapi.scratchlist.v1.${sid}`)
370+
expect(persisted).not.toBeNull()
371+
const parsed = JSON.parse(persisted!) as Array<{ id: string; text: string }>
372+
expect(parsed.map((e) => e.id)).toEqual(['old-2'])
373+
expect(parsed[0]?.text).toBe('another')
374+
})
375+
376+
it('does NOT mirror an empty hub fetch into localStorage before migration runs (HAPI Bot, PR #896)', async () => {
377+
// Pre-fix the offline-cache effect would clobber the v1
378+
// entries with `[]` the moment the initial fetch returned an
379+
// empty list, racing the migration effect's localStorage
380+
// read on a future mount. The fix gates the cache mirror on
381+
// the migration flag; this test pins it.
382+
const sid = makeSid()
383+
seedV1Entries(sid)
384+
const apiCalls: number[] = []
385+
const api = createMockApi({
386+
// Block on first fetch so we can inspect localStorage
387+
// BEFORE the migration effect kicks off.
388+
getScratchlist: async () => {
389+
apiCalls.push(Date.now())
390+
if (apiCalls.length === 1) {
391+
await new Promise((r) => setTimeout(r, 25))
392+
return { entries: [] }
393+
}
394+
return {
395+
entries: [
396+
{ entryId: 'old-1', text: 'pre-v2 note', createdAt: 100, updatedAt: 100 },
397+
{ entryId: 'old-2', text: 'another', createdAt: 200, updatedAt: 200 }
398+
]
399+
}
400+
}
401+
})
402+
const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() })
403+
// Wait for migration to complete (flag set + status flips).
404+
await waitFor(() => expect(result.current.migrationStatus).toBe('completed'), { timeout: 2000 })
405+
// localStorage now mirrors hub state (post-migration). It must
406+
// contain the v1 entries that round-tripped through the hub
407+
// fetch, NOT an empty array.
408+
const persisted = localStorage.getItem(`hapi.scratchlist.v1.${sid}`)
409+
expect(persisted).not.toBeNull()
410+
const parsed = JSON.parse(persisted!) as Array<{ id: string }>
411+
expect(parsed.map((e) => e.id).sort()).toEqual(['old-1', 'old-2'])
412+
})
333413
})
334414

335415
describe('useHubScratchlist - reorder (local-only)', () => {

web/src/lib/use-hub-scratchlist.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ApiClient } from '@/api/client'
44
import { queryKeys } from '@/lib/query-keys'
55
import {
66
moveScratchlistEntry,
7+
persistScratchlist,
78
readScratchlist,
89
SCRATCHLIST_MAX_ENTRIES,
910
SCRATCHLIST_MAX_TEXT_LENGTH,
@@ -196,18 +197,21 @@ export function useHubScratchlist(
196197

197198
// Migration trigger: runs ONCE per session when:
198199
// - api is available
199-
// - hub returned an empty list
200200
// - migration flag is unset
201201
// - localStorage holds v1 entries
202-
// The actual POSTs are sequential to keep retry semantics simple
203-
// and to avoid bursts that could trip rate-limit guards. For the
204-
// typical case of "a handful of stale entries" this is fine.
202+
// Hub being non-empty does NOT block the migration: each POST uses
203+
// the entry's original id and the route returns 200 for an
204+
// already-existing id (idempotent). So a session that another
205+
// device already populated is safely treated as a union with this
206+
// device's local entries. The actual POSTs are sequential to keep
207+
// retry semantics simple and to avoid bursts that could trip
208+
// rate-limit guards. For the typical case of "a handful of stale
209+
// entries" this is fine.
205210
useEffect(() => {
206211
if (!api || !sessionId) return
207212
if (migrationAttemptedRef.current) return
208213
if (query.isLoading || query.isFetching) return
209214
if (!query.data) return
210-
if (query.data.entries.length > 0) return
211215
if (readMigrationFlag(sessionId)) return
212216

213217
const localEntries = readScratchlist(sessionId)
@@ -222,6 +226,14 @@ export function useHubScratchlist(
222226
setMigrationStatus('migrating')
223227

224228
void (async () => {
229+
// HAPI Bot review on PR #896 caught a data-loss path here:
230+
// swallowing per-entry POST failures and still writing the
231+
// migration flag would strand entries (the offline-cache
232+
// mirror would replace the original localStorage with the
233+
// partial hub state). Track failed entries and persist them
234+
// back so a future mount retries; do NOT set the flag until
235+
// every local entry is reconciled.
236+
const failedEntries: ScratchlistEntry[] = []
225237
try {
226238
// Preserve creation order by POSTing in the order
227239
// localStorage holds them. The hub orders by createdAt
@@ -240,21 +252,34 @@ export function useHubScratchlist(
240252
createdAt: entry.createdAt
241253
})
242254
} catch {
243-
// Per-entry failure is non-fatal: the hub returns
244-
// 200 with the canonical row for duplicates, and
245-
// any genuine rejection (e.g. cap reached) just
246-
// drops the migrated entry. Logging to console
247-
// would be noise; the user can re-add manually.
255+
// Genuine rejection (cap, network, 5xx...). The
256+
// hub-side route returns 200 for duplicate
257+
// entryId so an idempotent retry doesn't land
258+
// here; only "really did not stick" failures do.
259+
failedEntries.push(entry)
248260
}
249261
}
262+
if (failedEntries.length > 0) {
263+
// Write the unsynced subset back to localStorage so
264+
// a future mount can retry them; leave the flag
265+
// unset so the migration effect re-fires next time.
266+
persistScratchlist(sessionId, failedEntries)
267+
migrationAttemptedRef.current = false
268+
setMigrationStatus('idle')
269+
return
270+
}
250271
writeMigrationFlag(sessionId)
251272
await queryClient.invalidateQueries({ queryKey })
252273
setMigrationStatus('completed')
253274
} catch {
254-
// Whole-flow failure (network out, etc): leave the flag
255-
// unset so a future mount retries; clear the banner
256-
// status so we don't show "completed" for a half-done
257-
// migration.
275+
// Whole-flow failure (network out, etc): persist the
276+
// entries that hadn't been attempted yet plus any that
277+
// failed up to the throw, leave the flag unset, and
278+
// clear the banner status so we don't show "completed"
279+
// for a half-done migration.
280+
if (failedEntries.length > 0) {
281+
persistScratchlist(sessionId, failedEntries)
282+
}
258283
migrationAttemptedRef.current = false
259284
setMigrationStatus('idle')
260285
}
@@ -431,8 +456,17 @@ export function useHubScratchlist(
431456
// surface (e.g. the standalone `ScratchlistPanel` used by tests)
432457
// working when offline, and protects against losing freshly-added
433458
// entries if the hub goes away mid-session.
459+
//
460+
// CRITICAL: gate on the migration flag. Pre-migration, localStorage
461+
// holds the v1 entries that the migration effect needs to read; if
462+
// we mirrored an empty hub fetch into localStorage on first render
463+
// we'd wipe the very entries we're about to upload (HAPI Bot
464+
// review on PR #896 caught a closely-related data-loss path). The
465+
// flag also stays unset on partial-failure migrations, which keeps
466+
// the failed-entry localStorage write from being clobbered.
434467
useEffect(() => {
435468
if (!sessionId) return
469+
if (!readMigrationFlag(sessionId)) return
436470
const data = query.data
437471
if (!data) return
438472
try {
@@ -448,7 +482,7 @@ export function useHubScratchlist(
448482
} catch {
449483
// Non-fatal: storage quota / private mode.
450484
}
451-
}, [sessionId, query.data])
485+
}, [sessionId, query.data, migrationStatus])
452486

453487
const entries: ScratchlistEntry[] = (query.data?.entries ?? []).map(toLocalEntry)
454488

0 commit comments

Comments
 (0)