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
|
|
@ -61,7 +61,8 @@ import type { NotificationNavigationTarget } from './src/features/notifications/
|
||||||
import { loadPendingInviteToken } from './src/features/teams/pending-invite'
|
import { loadPendingInviteToken } from './src/features/teams/pending-invite'
|
||||||
import { listTeams } from './src/features/teams/team-service'
|
import { listTeams } from './src/features/teams/team-service'
|
||||||
import { listHistoryPage } from './src/features/history/history-service'
|
import { listHistoryPage } from './src/features/history/history-service'
|
||||||
import { saveHistoryCache } from './src/features/history/history-cache'
|
import { createHistoryCacheScope } from './src/features/history/history-cache-writer'
|
||||||
|
import { synchronizeAfterMissedNotifications } from './src/features/notifications/missed-notification-sync'
|
||||||
import { useRecordingProcessingQueue } from './src/features/recording/use-recording-processing-queue'
|
import { useRecordingProcessingQueue } from './src/features/recording/use-recording-processing-queue'
|
||||||
import {
|
import {
|
||||||
getPendingIncomingMediaId,
|
getPendingIncomingMediaId,
|
||||||
|
|
@ -246,22 +247,22 @@ function RootNavigator(): React.ReactElement {
|
||||||
void routeIncomingMedia()
|
void routeIncomingMedia()
|
||||||
}, [routeIncomingMedia])
|
}, [routeIncomingMedia])
|
||||||
|
|
||||||
|
// Invalidated whenever the signed-in user changes (including the null that
|
||||||
|
// the logout privacy purge publishes) so an in-flight full sync cannot write
|
||||||
|
// the previous account's history cache back after the purge.
|
||||||
|
const [historyCacheScope] = useState(createHistoryCacheScope)
|
||||||
|
useEffect(() => () => historyCacheScope.invalidate(), [historyCacheScope, user?.id])
|
||||||
|
|
||||||
const synchronizeMissedNotifications = useCallback(async (): Promise<boolean> => {
|
const synchronizeMissedNotifications = useCallback(async (): Promise<boolean> => {
|
||||||
const userId = user?.id
|
const userId = user?.id
|
||||||
if (!userId) return false
|
if (!userId) return false
|
||||||
try {
|
return synchronizeAfterMissedNotifications(userId, historyCacheScope.capture(), {
|
||||||
const [history] = await Promise.all([
|
listRecentHistory: (id) => listHistoryPage({ userId: id, filter: 'all', pageSize: 50 }),
|
||||||
listHistoryPage({ userId, filter: 'all', pageSize: 50 }),
|
listTeams,
|
||||||
listTeams(userId),
|
fetchEntitlement: fetchEntitlementSnapshot,
|
||||||
fetchEntitlementSnapshot(userId),
|
refreshEntitlement: () => entitlement.refresh(),
|
||||||
])
|
})
|
||||||
await saveHistoryCache(userId, history.entries)
|
}, [entitlement, historyCacheScope, user?.id])
|
||||||
await entitlement.refresh()
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}, [entitlement, user?.id])
|
|
||||||
|
|
||||||
useNotificationRuntime({
|
useNotificationRuntime({
|
||||||
navigate: navigateFromNotification,
|
navigate: navigateFromNotification,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||||
|
import * as Keychain from 'react-native-keychain'
|
||||||
|
import type { HistoryListEntry, HistoryPageResult } from '../src/features/history/history-service'
|
||||||
|
|
||||||
|
const USER_A = '11111111-1111-4111-8111-111111111111'
|
||||||
|
const HISTORY_CACHE_PREFIX = 'com.d3ro.voice.history-cache.v1.'
|
||||||
|
|
||||||
|
let mockAuthUser: { id: string } | null = null
|
||||||
|
const mockListHistoryPage = jest.fn<Promise<HistoryPageResult>, [unknown]>()
|
||||||
|
|
||||||
|
jest.mock('../src/lib/supabase', () => ({
|
||||||
|
supabase: {
|
||||||
|
channel: jest.fn(),
|
||||||
|
removeChannel: jest.fn(async () => 'ok'),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
jest.mock('../src/lib/auth-context', () => ({
|
||||||
|
useAuth: () => ({ user: mockAuthUser }),
|
||||||
|
}))
|
||||||
|
jest.mock('../src/features/history/history-service', () => ({
|
||||||
|
...jest.requireActual('../src/features/history/history-service'),
|
||||||
|
listHistoryPage: (options: unknown) => mockListHistoryPage(options),
|
||||||
|
subscribeToHistory: () => ({ unsubscribe: async () => undefined }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { clearAllHistoryCaches } from '../src/features/history/history-cache'
|
||||||
|
import {
|
||||||
|
createHistoryCacheScope,
|
||||||
|
saveHistoryCacheInScope,
|
||||||
|
type HistoryCacheStore,
|
||||||
|
} from '../src/features/history/history-cache-writer'
|
||||||
|
import { useHistorySync } from '../src/features/history/use-history-sync'
|
||||||
|
import { synchronizeAfterMissedNotifications } from '../src/features/notifications/missed-notification-sync'
|
||||||
|
|
||||||
|
const keychainMock = Keychain as unknown as typeof Keychain & { __reset: () => void }
|
||||||
|
|
||||||
|
function entry(id: string): HistoryListEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
user_id: USER_A,
|
||||||
|
title: null,
|
||||||
|
original_text: `original ${id}`,
|
||||||
|
polished_text: null,
|
||||||
|
focused_app: null,
|
||||||
|
focused_app_name: null,
|
||||||
|
focused_app_window_title: null,
|
||||||
|
mode: 'dictation',
|
||||||
|
status: 'completed',
|
||||||
|
error_code: null,
|
||||||
|
audio_storage_key: null,
|
||||||
|
duration: 2,
|
||||||
|
detected_language: 'ko',
|
||||||
|
mic_device: null,
|
||||||
|
word_count: 2,
|
||||||
|
stt_model: 'whisper',
|
||||||
|
llm_model: null,
|
||||||
|
stt_latency_ms: null,
|
||||||
|
llm_latency_ms: null,
|
||||||
|
app_version: '1.0.0',
|
||||||
|
summary_text: null,
|
||||||
|
is_favorite: false,
|
||||||
|
revision: 1,
|
||||||
|
created_at: '2026-08-21T00:00:00.000Z',
|
||||||
|
updated_at: '2026-08-21T00:00:00.000Z',
|
||||||
|
activeJob: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>
|
||||||
|
resolve: (value: T) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): Deferred<T> {
|
||||||
|
let resolve: (value: T) => void = () => undefined
|
||||||
|
const promise = new Promise<T>((settle) => { resolve = settle })
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storedHistoryServices(): Promise<string[]> {
|
||||||
|
const services = await Keychain.getAllGenericPasswordServices()
|
||||||
|
return services.filter((service) => service.startsWith(HISTORY_CACHE_PREFIX))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let index = 0; index < 10; index += 1) await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness(): null {
|
||||||
|
useHistorySync()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): HistoryCacheStore & { save: jest.Mock; clear: jest.Mock } {
|
||||||
|
return {
|
||||||
|
save: jest.fn(async () => undefined),
|
||||||
|
clear: jest.fn(async () => undefined),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('history cache auth-scope guard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
keychainMock.__reset()
|
||||||
|
mockAuthUser = null
|
||||||
|
mockListHistoryPage.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drops a write whose scope went stale before it started', async () => {
|
||||||
|
const store = fakeStore()
|
||||||
|
const outcome = await saveHistoryCacheInScope(USER_A, [entry('a')], () => false, store)
|
||||||
|
expect(outcome).toBe('dropped')
|
||||||
|
expect(store.save).not.toHaveBeenCalled()
|
||||||
|
expect(store.clear).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes the entry it just stored when the scope went stale during the write', async () => {
|
||||||
|
const store = fakeStore()
|
||||||
|
let current = true
|
||||||
|
store.save.mockImplementation(async () => { current = false })
|
||||||
|
const outcome = await saveHistoryCacheInScope(USER_A, [entry('a')], () => current, store)
|
||||||
|
expect(outcome).toBe('dropped')
|
||||||
|
expect(store.clear).toHaveBeenCalledWith(USER_A)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('writes normally while the scope stays current', async () => {
|
||||||
|
const store = fakeStore()
|
||||||
|
const outcome = await saveHistoryCacheInScope(USER_A, [entry('a')], () => true, store)
|
||||||
|
expect(outcome).toBe('written')
|
||||||
|
expect(store.save).toHaveBeenCalledWith(USER_A, [entry('a')])
|
||||||
|
expect(store.clear).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scope checks captured before invalidate report stale, later captures are current', () => {
|
||||||
|
const scope = createHistoryCacheScope()
|
||||||
|
const before = scope.capture()
|
||||||
|
expect(before()).toBe(true)
|
||||||
|
scope.invalidate()
|
||||||
|
expect(before()).toBe(false)
|
||||||
|
expect(scope.capture()()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missed-notification sync does not persist history after the auth scope changed', async () => {
|
||||||
|
const store = fakeStore()
|
||||||
|
const scope = createHistoryCacheScope()
|
||||||
|
const history = deferred<{ entries: HistoryListEntry[] }>()
|
||||||
|
const refreshEntitlement = jest.fn(async () => null)
|
||||||
|
|
||||||
|
const pending = synchronizeAfterMissedNotifications(USER_A, scope.capture(), {
|
||||||
|
listRecentHistory: () => history.promise,
|
||||||
|
listTeams: async () => [],
|
||||||
|
fetchEntitlement: async () => null,
|
||||||
|
refreshEntitlement,
|
||||||
|
historyCacheStore: store,
|
||||||
|
})
|
||||||
|
scope.invalidate()
|
||||||
|
history.resolve({ entries: [entry('a')] })
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe(false)
|
||||||
|
expect(store.save).not.toHaveBeenCalled()
|
||||||
|
expect(refreshEntitlement).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missed-notification sync still caches and refreshes for the current user', async () => {
|
||||||
|
const store = fakeStore()
|
||||||
|
const refreshEntitlement = jest.fn(async () => null)
|
||||||
|
const ok = await synchronizeAfterMissedNotifications(USER_A, () => true, {
|
||||||
|
listRecentHistory: async () => ({ entries: [entry('a')] }),
|
||||||
|
listTeams: async () => [],
|
||||||
|
fetchEntitlement: async () => null,
|
||||||
|
refreshEntitlement,
|
||||||
|
historyCacheStore: store,
|
||||||
|
})
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(store.save).toHaveBeenCalledWith(USER_A, [entry('a')])
|
||||||
|
expect(refreshEntitlement).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an in-flight refresh cannot write the history cache back after the logout purge', async () => {
|
||||||
|
const page = deferred<HistoryPageResult>()
|
||||||
|
mockListHistoryPage.mockReturnValueOnce(page.promise)
|
||||||
|
mockAuthUser = { id: USER_A }
|
||||||
|
|
||||||
|
let renderer: ReactTestRenderer | null = null
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(<Harness />)
|
||||||
|
await flush()
|
||||||
|
})
|
||||||
|
expect(mockListHistoryPage).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Logout: the purge clears every cache and the history screen unmounts
|
||||||
|
// while user A's request is still in flight with a valid JWT.
|
||||||
|
mockAuthUser = null
|
||||||
|
await clearAllHistoryCaches()
|
||||||
|
await act(async () => {
|
||||||
|
renderer?.unmount()
|
||||||
|
await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
page.resolve({ entries: [entry('a')], nextCursor: null })
|
||||||
|
await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await storedHistoryServices()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a write that lands after the purge enumerated the caches is compensated', async () => {
|
||||||
|
mockListHistoryPage.mockResolvedValueOnce({ entries: [entry('a')], nextCursor: null })
|
||||||
|
const write = deferred<false | { service: string; storage: string }>()
|
||||||
|
const setGenericPassword = Keychain.setGenericPassword as jest.Mock
|
||||||
|
const realSet = setGenericPassword.getMockImplementation()
|
||||||
|
setGenericPassword.mockImplementationOnce(async (...args: unknown[]) => {
|
||||||
|
await write.promise
|
||||||
|
return realSet?.(...args)
|
||||||
|
})
|
||||||
|
mockAuthUser = { id: USER_A }
|
||||||
|
|
||||||
|
let renderer: ReactTestRenderer | null = null
|
||||||
|
await act(async () => {
|
||||||
|
renderer = create(<Harness />)
|
||||||
|
await flush()
|
||||||
|
})
|
||||||
|
// The cache write was issued while A was still current but has not landed
|
||||||
|
// yet. The purge enumerates now (nothing to clear), then the screen
|
||||||
|
// unmounts, and only afterwards does the native write complete.
|
||||||
|
mockAuthUser = null
|
||||||
|
await clearAllHistoryCaches()
|
||||||
|
await act(async () => {
|
||||||
|
renderer?.unmount()
|
||||||
|
await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
write.resolve(false)
|
||||||
|
await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await storedHistoryServices()).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
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 {
|
import {
|
||||||
filterCachedHistory,
|
filterCachedHistory,
|
||||||
loadHistoryCache,
|
loadHistoryCache,
|
||||||
saveHistoryCache,
|
|
||||||
} from './history-cache'
|
} from './history-cache'
|
||||||
|
import {
|
||||||
|
createHistoryCacheScope,
|
||||||
|
saveHistoryCacheInScope,
|
||||||
|
type HistoryCacheScopeCheck,
|
||||||
|
} from './history-cache-writer'
|
||||||
|
|
||||||
export interface HistorySyncError {
|
export interface HistorySyncError {
|
||||||
code: HistoryServiceErrorCode
|
code: HistoryServiceErrorCode
|
||||||
|
|
@ -76,6 +80,10 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
const mutatingIdsRef = useRef<Set<string>>(new Set())
|
const mutatingIdsRef = useRef<Set<string>>(new Set())
|
||||||
const requestId = useRef(0)
|
const requestId = useRef(0)
|
||||||
const entriesRef = useRef(entries)
|
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(() => {
|
useEffect(() => {
|
||||||
entriesRef.current = entries
|
entriesRef.current = entries
|
||||||
|
|
@ -86,21 +94,30 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [search])
|
}, [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
|
if (userId === null) return
|
||||||
try {
|
try {
|
||||||
await saveHistoryCache(userId, nextEntries)
|
const outcome = await saveHistoryCacheInScope(userId, nextEntries, isCurrent)
|
||||||
setCacheWarning(false)
|
if (outcome === 'written') setCacheWarning(false)
|
||||||
} catch {
|
} catch {
|
||||||
setCacheWarning(true)
|
if (isCurrent()) setCacheWarning(true)
|
||||||
}
|
}
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
const patchCachedEntry = useCallback(async (
|
const patchCachedEntry = useCallback(async (
|
||||||
entryId: string,
|
entryId: string,
|
||||||
replacement: HistoryListEntry | null,
|
replacement: HistoryListEntry | null,
|
||||||
|
isCurrent: HistoryCacheScopeCheck,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (userId === null) return
|
if (userId === null || !isCurrent()) return
|
||||||
try {
|
try {
|
||||||
const snapshot = await loadHistoryCache(userId)
|
const snapshot = await loadHistoryCache(userId)
|
||||||
if (snapshot === null) return
|
if (snapshot === null) return
|
||||||
|
|
@ -110,12 +127,11 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
snapshot.entries.filter((entry) => entry.id !== entryId),
|
snapshot.entries.filter((entry) => entry.id !== entryId),
|
||||||
[replacement],
|
[replacement],
|
||||||
)
|
)
|
||||||
await saveHistoryCache(userId, next)
|
await writeCache(next, isCurrent)
|
||||||
setCacheWarning(false)
|
|
||||||
} catch {
|
} catch {
|
||||||
setCacheWarning(true)
|
if (isCurrent()) setCacheWarning(true)
|
||||||
}
|
}
|
||||||
}, [userId])
|
}, [userId, writeCache])
|
||||||
|
|
||||||
const performRefresh = useCallback(async (showRefresh: boolean): Promise<void> => {
|
const performRefresh = useCallback(async (showRefresh: boolean): Promise<void> => {
|
||||||
if (userId === null) {
|
if (userId === null) {
|
||||||
|
|
@ -127,6 +143,7 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentRequest = ++requestId.current
|
const currentRequest = ++requestId.current
|
||||||
|
const isCurrentScope = cacheScope.capture()
|
||||||
if (showRefresh) setRefreshing(true)
|
if (showRefresh) setRefreshing(true)
|
||||||
if (entriesRef.current.length === 0) setLoading(true)
|
if (entriesRef.current.length === 0) setLoading(true)
|
||||||
|
|
||||||
|
|
@ -145,7 +162,7 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
if (filter === 'all' && debouncedSearch.length === 0) {
|
if (filter === 'all' && debouncedSearch.length === 0) {
|
||||||
await writeCache(page.entries)
|
await writeCache(page.entries, isCurrentScope)
|
||||||
}
|
}
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
if (currentRequest !== requestId.current) return
|
if (currentRequest !== requestId.current) return
|
||||||
|
|
@ -183,7 +200,7 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [debouncedSearch, filter, userId, writeCache])
|
}, [cacheScope, debouncedSearch, filter, userId, writeCache])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEntries([])
|
setEntries([])
|
||||||
|
|
@ -255,6 +272,7 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
|
|
||||||
const loadMore = useCallback(async (): Promise<void> => {
|
const loadMore = useCallback(async (): Promise<void> => {
|
||||||
if (userId === null || nextCursor === null || loadingMore || offline) return
|
if (userId === null || nextCursor === null || loadingMore || offline) return
|
||||||
|
const isCurrentScope = cacheScope.capture()
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -264,21 +282,34 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
search: debouncedSearch,
|
search: debouncedSearch,
|
||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
})
|
})
|
||||||
|
if (!isCurrentScope()) return
|
||||||
setEntries((current) => {
|
setEntries((current) => {
|
||||||
const merged = mergeHistoryEntries(current, page.entries)
|
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
|
return merged
|
||||||
})
|
})
|
||||||
setNextCursor(page.nextCursor)
|
setNextCursor(page.nextCursor)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
|
if (!isCurrentScope()) return
|
||||||
const normalized = syncError(requestError)
|
const normalized = syncError(requestError)
|
||||||
setError(normalized)
|
setError(normalized)
|
||||||
if (normalized.code === 'network') setOffline(true)
|
if (normalized.code === 'network') setOffline(true)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMore(false)
|
setLoadingMore(false)
|
||||||
}
|
}
|
||||||
}, [debouncedSearch, filter, loadingMore, nextCursor, offline, userId, writeCache])
|
}, [
|
||||||
|
cacheScope,
|
||||||
|
debouncedSearch,
|
||||||
|
filter,
|
||||||
|
loadingMore,
|
||||||
|
nextCursor,
|
||||||
|
offline,
|
||||||
|
userId,
|
||||||
|
writeCache,
|
||||||
|
])
|
||||||
|
|
||||||
const beginMutation = useCallback((entryId: string): boolean => {
|
const beginMutation = useCallback((entryId: string): boolean => {
|
||||||
if (mutatingIdsRef.current.has(entryId)) return false
|
if (mutatingIdsRef.current.has(entryId)) return false
|
||||||
|
|
@ -298,6 +329,7 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
const toggleFavorite = useCallback(async (entry: HistoryListEntry): Promise<void> => {
|
const toggleFavorite = useCallback(async (entry: HistoryListEntry): Promise<void> => {
|
||||||
if (userId === null) throw new HistoryServiceError('auth', 'Login is required')
|
if (userId === null) throw new HistoryServiceError('auth', 'Login is required')
|
||||||
if (!beginMutation(entry.id)) return
|
if (!beginMutation(entry.id)) return
|
||||||
|
const isCurrentScope = cacheScope.capture()
|
||||||
|
|
||||||
const optimisticFavorite = !entry.is_favorite
|
const optimisticFavorite = !entry.is_favorite
|
||||||
setEntries((current) => current.map((item) => (
|
setEntries((current) => current.map((item) => (
|
||||||
|
|
@ -318,7 +350,7 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
}
|
}
|
||||||
return current.map((item) => item.id === entry.id ? replacement : item)
|
return current.map((item) => item.id === entry.id ? replacement : item)
|
||||||
})
|
})
|
||||||
await patchCachedEntry(entry.id, replacement)
|
await patchCachedEntry(entry.id, replacement, isCurrentScope)
|
||||||
} catch (mutationError) {
|
} catch (mutationError) {
|
||||||
setEntries((current) => current.map((item) => (
|
setEntries((current) => current.map((item) => (
|
||||||
item.id === entry.id ? entry : item
|
item.id === entry.id ? entry : item
|
||||||
|
|
@ -327,23 +359,24 @@ export function useHistorySync(): HistorySyncModel {
|
||||||
} finally {
|
} finally {
|
||||||
endMutation(entry.id)
|
endMutation(entry.id)
|
||||||
}
|
}
|
||||||
}, [beginMutation, endMutation, filter, patchCachedEntry, userId])
|
}, [beginMutation, cacheScope, endMutation, filter, patchCachedEntry, userId])
|
||||||
|
|
||||||
const deleteEntry = useCallback(async (entry: HistoryListEntry): Promise<void> => {
|
const deleteEntry = useCallback(async (entry: HistoryListEntry): Promise<void> => {
|
||||||
if (userId === null) throw new HistoryServiceError('auth', 'Login is required')
|
if (userId === null) throw new HistoryServiceError('auth', 'Login is required')
|
||||||
if (!beginMutation(entry.id)) return
|
if (!beginMutation(entry.id)) return
|
||||||
|
const isCurrentScope = cacheScope.capture()
|
||||||
|
|
||||||
setEntries((current) => current.filter((item) => item.id !== entry.id))
|
setEntries((current) => current.filter((item) => item.id !== entry.id))
|
||||||
try {
|
try {
|
||||||
await deleteHistoryRevisionSafe(userId, entry.id, entry.revision)
|
await deleteHistoryRevisionSafe(userId, entry.id, entry.revision)
|
||||||
await patchCachedEntry(entry.id, null)
|
await patchCachedEntry(entry.id, null, isCurrentScope)
|
||||||
} catch (mutationError) {
|
} catch (mutationError) {
|
||||||
setEntries((current) => mergeHistoryEntries(current, [entry]))
|
setEntries((current) => mergeHistoryEntries(current, [entry]))
|
||||||
throw mutationError
|
throw mutationError
|
||||||
} finally {
|
} finally {
|
||||||
endMutation(entry.id)
|
endMutation(entry.id)
|
||||||
}
|
}
|
||||||
}, [beginMutation, endMutation, patchCachedEntry, userId])
|
}, [beginMutation, cacheScope, endMutation, patchCachedEntry, userId])
|
||||||
|
|
||||||
const isMutating = useCallback((entryId: string): boolean => {
|
const isMutating = useCallback((entryId: string): boolean => {
|
||||||
return mutatingIds.has(entryId)
|
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