fix: race condition hardening across auth, counters, and data fetching (#102)
* fix: prevent race conditions in token operations and auth flows Backend: - Refresh token rotation: use atomic UPDATE...WHERE revoked_at IS NULL to prevent concurrent refresh requests from both succeeding - Account invite codes: SELECT FOR UPDATE to prevent double-spend - Platform invite codes: SELECT FOR UPDATE to prevent double-spend - Password reset tokens: SELECT FOR UPDATE to prevent double-use - Email verification tokens: SELECT FOR UPDATE to prevent double-use Frontend: - Token refresh subscriber arrays: swap before iterating so a throwing callback doesn't leave the queue in a dirty state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: atomic counters, plan limit re-check, and double-submit guard Backend: - Tree usage_count: use SQL-level UPDATE (Tree.usage_count + 1) instead of Python-level increment to prevent lost updates under concurrency - Tag usage_count: same SQL-level atomic increment/decrement in both create_tree and update_tree (delete_tree already used this pattern) - Plan tree limit: re-check count after db.flush() to close the TOCTOU window where two concurrent creates could both pass the pre-check Frontend: - TreeEditorPage: add isSaving early-return guard inside handleSaveDraft and handlePublish callbacks so Ctrl+S can't bypass the button disabled prop and fire duplicate save requests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent stale API responses from overwriting newer data - SessionHistoryPage: move loadSessions into effect with cancelled flag so rapid filter/tab changes discard outdated responses - TreeLibraryPage: add request ID ref to loadTrees so stale responses from previous filter selections are discarded - QuickStartPage: add request ID ref to debounced search so out-of-order responses don't overwrite newer search results Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add flexible intake design — deferred variables + prepared sessions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #102.
This commit is contained in:
@@ -60,7 +60,59 @@ export function SessionHistoryPage() {
|
||||
|
||||
// Load sessions when filters change
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadSessions = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params: Record<string, string | boolean> = {}
|
||||
|
||||
// Tab filter (all/active/completed)
|
||||
if (filter !== 'all') {
|
||||
params.completed = filter === 'completed'
|
||||
}
|
||||
|
||||
// Search/filter params
|
||||
if (filters.ticketNumber) {
|
||||
params.ticket_number = filters.ticketNumber
|
||||
}
|
||||
if (filters.clientName) {
|
||||
params.client_name = filters.clientName
|
||||
}
|
||||
if (filters.treeName) {
|
||||
params.tree_name = filters.treeName
|
||||
}
|
||||
|
||||
// Date range params
|
||||
if (filters.dateRange?.from) {
|
||||
const fromDate = filters.dateRange.from
|
||||
const toDate = filters.dateRange.to || filters.dateRange.from
|
||||
|
||||
if (filters.dateType === 'started') {
|
||||
params.started_after = fromDate.toISOString()
|
||||
params.started_before = toDate.toISOString()
|
||||
} else {
|
||||
params.completed_after = fromDate.toISOString()
|
||||
params.completed_before = toDate.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
|
||||
if (cancelled) return
|
||||
const truncated = sessionsData.length > 50
|
||||
setHasMore(truncated)
|
||||
setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
toast.error('Failed to load sessions')
|
||||
console.error(err)
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadSessions()
|
||||
return () => { cancelled = true }
|
||||
}, [filter, filters])
|
||||
|
||||
// Update URL params when filters change
|
||||
@@ -79,53 +131,6 @@ export function SessionHistoryPage() {
|
||||
setSearchParams(params, { replace: true })
|
||||
}, [filters, setSearchParams])
|
||||
|
||||
const loadSessions = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params: Record<string, string | boolean> = {}
|
||||
|
||||
// Tab filter (all/active/completed)
|
||||
if (filter !== 'all') {
|
||||
params.completed = filter === 'completed'
|
||||
}
|
||||
|
||||
// Search/filter params
|
||||
if (filters.ticketNumber) {
|
||||
params.ticket_number = filters.ticketNumber
|
||||
}
|
||||
if (filters.clientName) {
|
||||
params.client_name = filters.clientName
|
||||
}
|
||||
if (filters.treeName) {
|
||||
params.tree_name = filters.treeName
|
||||
}
|
||||
|
||||
// Date range params
|
||||
if (filters.dateRange?.from) {
|
||||
const fromDate = filters.dateRange.from
|
||||
const toDate = filters.dateRange.to || filters.dateRange.from
|
||||
|
||||
if (filters.dateType === 'started') {
|
||||
params.started_after = fromDate.toISOString()
|
||||
params.started_before = toDate.toISOString()
|
||||
} else {
|
||||
params.completed_after = fromDate.toISOString()
|
||||
params.completed_before = toDate.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsData = await sessionsApi.list({ ...params, size: 51 })
|
||||
const truncated = sessionsData.length > 50
|
||||
setHasMore(truncated)
|
||||
setSessions(truncated ? sessionsData.slice(0, 50) : sessionsData)
|
||||
} catch (err) {
|
||||
toast.error('Failed to load sessions')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterChange = (newFilters: SessionFilterState) => {
|
||||
setFilters(newFilters)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user