fix(mobile-history): drop history cache writes that outlive the auth scope

This commit is contained in:
Yun Chan 2026-09-28 00:54:01 +09:00
parent 2576f4af7b
commit e243e5282c
5 changed files with 431 additions and 32 deletions

View file

@ -0,0 +1,71 @@
import type { HistoryListEntry } from './history-service'
import { clearHistoryCache, saveHistoryCache } from './history-cache'
/**
* Storage port for the per-user history cache. The default adapter is the
* Keychain-backed cache; tests and alternative stores inject their own.
*/
export interface HistoryCacheStore {
save: (userId: string, entries: HistoryListEntry[]) => Promise<unknown>
clear: (userId: string) => Promise<void>
}
export const keychainHistoryCacheStore: HistoryCacheStore = {
save: saveHistoryCache,
clear: clearHistoryCache,
}
/**
* Reports whether the auth scope that started an asynchronous history
* operation is still the active one. It must turn false as soon as the target
* user signs out, is replaced, or its owning component unmounts.
*/
export type HistoryCacheScopeCheck = () => boolean
export type HistoryCacheWriteOutcome = 'written' | 'dropped'
/**
* Writes the history cache only while the originating auth scope is current.
*
* The logout privacy purge enumerates the cache services once and deletes
* them, so a write that was already in flight can land after the enumeration
* and silently survive the purge. The scope is therefore checked twice: before
* the write (skip it) and after the write (compensate by deleting the entry
* that was just stored). Either way nothing for a stale scope stays on disk.
*/
export async function saveHistoryCacheInScope(
userId: string,
entries: HistoryListEntry[],
isCurrent: HistoryCacheScopeCheck,
store: HistoryCacheStore = keychainHistoryCacheStore,
): Promise<HistoryCacheWriteOutcome> {
if (!isCurrent()) return 'dropped'
await store.save(userId, entries)
if (isCurrent()) return 'written'
await store.clear(userId)
return 'dropped'
}
/**
* Monotonic generation counter for an auth scope. `capture` returns a check
* bound to the current generation; `invalidate` makes every earlier check
* report stale. React owners call `invalidate` from effect cleanup on user
* change and on unmount.
*/
export interface HistoryCacheScope {
capture: () => HistoryCacheScopeCheck
invalidate: () => void
}
export function createHistoryCacheScope(): HistoryCacheScope {
let generation = 0
return {
capture: () => {
const captured = generation
return () => captured === generation
},
invalidate: () => {
generation += 1
},
}
}

View file

@ -16,8 +16,12 @@ import {
import {
filterCachedHistory,
loadHistoryCache,
saveHistoryCache,
} from './history-cache'
import {
createHistoryCacheScope,
saveHistoryCacheInScope,
type HistoryCacheScopeCheck,
} from './history-cache-writer'
export interface HistorySyncError {
code: HistoryServiceErrorCode
@ -76,6 +80,10 @@ export function useHistorySync(): HistorySyncModel {
const mutatingIdsRef = useRef<Set<string>>(new Set())
const requestId = useRef(0)
const entriesRef = useRef(entries)
// Auth scope for cache writes. Invalidated on every user change and on
// unmount so a request started for user A can never persist A's history
// after the logout privacy purge (see saveHistoryCacheInScope).
const [cacheScope] = useState(createHistoryCacheScope)
useEffect(() => {
entriesRef.current = entries
@ -86,21 +94,30 @@ export function useHistorySync(): HistorySyncModel {
return () => clearTimeout(timeout)
}, [search])
const writeCache = useCallback(async (nextEntries: HistoryListEntry[]): Promise<void> => {
useEffect(() => () => {
cacheScope.invalidate()
requestId.current += 1
}, [cacheScope, userId])
const writeCache = useCallback(async (
nextEntries: HistoryListEntry[],
isCurrent: HistoryCacheScopeCheck,
): Promise<void> => {
if (userId === null) return
try {
await saveHistoryCache(userId, nextEntries)
setCacheWarning(false)
const outcome = await saveHistoryCacheInScope(userId, nextEntries, isCurrent)
if (outcome === 'written') setCacheWarning(false)
} catch {
setCacheWarning(true)
if (isCurrent()) setCacheWarning(true)
}
}, [userId])
const patchCachedEntry = useCallback(async (
entryId: string,
replacement: HistoryListEntry | null,
isCurrent: HistoryCacheScopeCheck,
): Promise<void> => {
if (userId === null) return
if (userId === null || !isCurrent()) return
try {
const snapshot = await loadHistoryCache(userId)
if (snapshot === null) return
@ -110,12 +127,11 @@ export function useHistorySync(): HistorySyncModel {
snapshot.entries.filter((entry) => entry.id !== entryId),
[replacement],
)
await saveHistoryCache(userId, next)
setCacheWarning(false)
await writeCache(next, isCurrent)
} catch {
setCacheWarning(true)
if (isCurrent()) setCacheWarning(true)
}
}, [userId])
}, [userId, writeCache])
const performRefresh = useCallback(async (showRefresh: boolean): Promise<void> => {
if (userId === null) {
@ -127,6 +143,7 @@ export function useHistorySync(): HistorySyncModel {
}
const currentRequest = ++requestId.current
const isCurrentScope = cacheScope.capture()
if (showRefresh) setRefreshing(true)
if (entriesRef.current.length === 0) setLoading(true)
@ -145,7 +162,7 @@ export function useHistorySync(): HistorySyncModel {
setError(null)
if (filter === 'all' && debouncedSearch.length === 0) {
await writeCache(page.entries)
await writeCache(page.entries, isCurrentScope)
}
} catch (requestError) {
if (currentRequest !== requestId.current) return
@ -183,7 +200,7 @@ export function useHistorySync(): HistorySyncModel {
setRefreshing(false)
}
}
}, [debouncedSearch, filter, userId, writeCache])
}, [cacheScope, debouncedSearch, filter, userId, writeCache])
useEffect(() => {
setEntries([])
@ -255,6 +272,7 @@ export function useHistorySync(): HistorySyncModel {
const loadMore = useCallback(async (): Promise<void> => {
if (userId === null || nextCursor === null || loadingMore || offline) return
const isCurrentScope = cacheScope.capture()
setLoadingMore(true)
try {
@ -264,21 +282,34 @@ export function useHistorySync(): HistorySyncModel {
search: debouncedSearch,
cursor: nextCursor,
})
if (!isCurrentScope()) return
setEntries((current) => {
const merged = mergeHistoryEntries(current, page.entries)
if (filter === 'all' && debouncedSearch.length === 0) void writeCache(merged)
if (filter === 'all' && debouncedSearch.length === 0) {
void writeCache(merged, isCurrentScope)
}
return merged
})
setNextCursor(page.nextCursor)
setError(null)
} catch (requestError) {
if (!isCurrentScope()) return
const normalized = syncError(requestError)
setError(normalized)
if (normalized.code === 'network') setOffline(true)
} finally {
setLoadingMore(false)
}
}, [debouncedSearch, filter, loadingMore, nextCursor, offline, userId, writeCache])
}, [
cacheScope,
debouncedSearch,
filter,
loadingMore,
nextCursor,
offline,
userId,
writeCache,
])
const beginMutation = useCallback((entryId: string): boolean => {
if (mutatingIdsRef.current.has(entryId)) return false
@ -298,6 +329,7 @@ export function useHistorySync(): HistorySyncModel {
const toggleFavorite = useCallback(async (entry: HistoryListEntry): Promise<void> => {
if (userId === null) throw new HistoryServiceError('auth', 'Login is required')
if (!beginMutation(entry.id)) return
const isCurrentScope = cacheScope.capture()
const optimisticFavorite = !entry.is_favorite
setEntries((current) => current.map((item) => (
@ -318,7 +350,7 @@ export function useHistorySync(): HistorySyncModel {
}
return current.map((item) => item.id === entry.id ? replacement : item)
})
await patchCachedEntry(entry.id, replacement)
await patchCachedEntry(entry.id, replacement, isCurrentScope)
} catch (mutationError) {
setEntries((current) => current.map((item) => (
item.id === entry.id ? entry : item
@ -327,23 +359,24 @@ export function useHistorySync(): HistorySyncModel {
} finally {
endMutation(entry.id)
}
}, [beginMutation, endMutation, filter, patchCachedEntry, userId])
}, [beginMutation, cacheScope, endMutation, filter, patchCachedEntry, userId])
const deleteEntry = useCallback(async (entry: HistoryListEntry): Promise<void> => {
if (userId === null) throw new HistoryServiceError('auth', 'Login is required')
if (!beginMutation(entry.id)) return
const isCurrentScope = cacheScope.capture()
setEntries((current) => current.filter((item) => item.id !== entry.id))
try {
await deleteHistoryRevisionSafe(userId, entry.id, entry.revision)
await patchCachedEntry(entry.id, null)
await patchCachedEntry(entry.id, null, isCurrentScope)
} catch (mutationError) {
setEntries((current) => mergeHistoryEntries(current, [entry]))
throw mutationError
} finally {
endMutation(entry.id)
}
}, [beginMutation, endMutation, patchCachedEntry, userId])
}, [beginMutation, cacheScope, endMutation, patchCachedEntry, userId])
const isMutating = useCallback((entryId: string): boolean => {
return mutatingIds.has(entryId)

View file

@ -0,0 +1,53 @@
import type { HistoryListEntry } from '../history/history-service'
import {
saveHistoryCacheInScope,
type HistoryCacheScopeCheck,
type HistoryCacheStore,
} from '../history/history-cache-writer'
/**
* Ports the missed-notification full sync depends on. App wires the real
* services; tests inject fakes. Keeping the orchestration free of React and
* Supabase lets the auth-scope rules be verified in isolation.
*/
export interface MissedNotificationSyncPorts {
listRecentHistory: (userId: string) => Promise<{ entries: HistoryListEntry[] }>
listTeams: (userId: string) => Promise<unknown>
fetchEntitlement: (userId: string) => Promise<unknown>
refreshEntitlement: () => Promise<unknown>
historyCacheStore?: HistoryCacheStore
}
/**
* Re-pulls the data a missed push could have announced and refreshes the
* offline history cache. The cache write and the entitlement refresh only run
* while the auth scope that started the sync is still current, so a logout
* purge that happens during the network round trip cannot be undone by this
* sync writing the signed-out account's history back to secure storage.
*
* Returns true only when the sync completed for the still-current user.
*/
export async function synchronizeAfterMissedNotifications(
userId: string,
isCurrent: HistoryCacheScopeCheck,
ports: MissedNotificationSyncPorts,
): Promise<boolean> {
try {
const [history] = await Promise.all([
ports.listRecentHistory(userId),
ports.listTeams(userId),
ports.fetchEntitlement(userId),
])
const outcome = await saveHistoryCacheInScope(
userId,
history.entries,
isCurrent,
ports.historyCacheStore,
)
if (outcome === 'dropped') return false
await ports.refreshEntitlement()
return true
} catch {
return false
}
}