fix(mobile-history): drop history cache writes that outlive the auth scope
This commit is contained in:
parent
2576f4af7b
commit
e243e5282c
5 changed files with 431 additions and 32 deletions
71
apps/mobile-rn/src/features/history/history-cache-writer.ts
Normal file
71
apps/mobile-rn/src/features/history/history-cache-writer.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue