diff --git a/mobile/src/session/mobile-structured-send-operation-journal.test.ts b/mobile/src/session/mobile-structured-send-operation-journal.test.ts index 65a2b61b8b9..cee58bded95 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.test.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.test.ts @@ -18,8 +18,11 @@ import { mobileStructuredSendOperationKey, resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +import { readMirroredStorage } from '../storage/mirrored-storage-keys' const NOW = 1_900_000_000_000 +/** The key the journal persists under, which the hybrid shell mirrors into every `init`. */ +const JOURNAL_KEY = 'orca:mobileStructuredSendOperations:v1' const OPERATION_KEY = 'a'.repeat(64) const CALLER_IDENTITY = 'mobile-device-a' @@ -80,6 +83,47 @@ describe('mobile structured send operation journal', () => { expect(createAfterRemount).not.toHaveBeenCalled() }) + /** + * A mirror the page reads is not allowed to run ahead of the store (round 4, CodeRabbit). + * + * The hybrid shell builds `init` from the mirror synchronously, so the page is handed whatever + * was noted here. Noting the write before it is persisted is what keeps an `init` in the same + * turn current; keeping the note after the persist was refused publishes a journal that does + * not exist, and the page resumes operations the device never wrote down. + */ + it('rolls the mirror back when persisting an added entry fails', async () => { + await getOrCreateMobileStructuredSendOperation({ + operationKey: OPERATION_KEY, + createOperationId: () => operationIdAt(NOW, '8'), + now: NOW + }) + const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY] + asyncStorage.setItem.mockRejectedValueOnce(new Error('the store is full')) + await expect( + getOrCreateMobileStructuredSendOperation({ + operationKey: 'c'.repeat(64), + createOperationId: () => operationIdAt(NOW, '9'), + now: NOW + }) + ).rejects.toThrow('the store is full') + expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held) + }) + + it('rolls the mirror back when persisting the last clear fails', async () => { + const firstId = operationIdAt(NOW, 'a') + await getOrCreateMobileStructuredSendOperation({ + operationKey: OPERATION_KEY, + createOperationId: () => firstId, + now: NOW + }) + const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY] + asyncStorage.removeItem.mockRejectedValueOnce(new Error('the store is full')) + await expect( + clearMobileStructuredSendOperation({ operationKey: OPERATION_KEY, operationId: firstId }) + ).rejects.toThrow('the store is full') + expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held) + }) + it('clears only the exact settled operation', async () => { const firstId = operationIdAt(NOW, '3') await getOrCreateMobileStructuredSendOperation({ diff --git a/mobile/src/session/mobile-structured-send-operation-journal.ts b/mobile/src/session/mobile-structured-send-operation-journal.ts index 2db4694e2fe..d8c1c2ad48b 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.ts @@ -1,6 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { z } from 'zod' -import { noteMirroredWrite } from '../storage/mirrored-storage-keys' +import { noteMirroredWrite, readMirroredStorage } from '../storage/mirrored-storage-keys' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, @@ -87,16 +87,21 @@ function parseJournal(raw: string | null): OperationJournal { } async function writeEntries(entries: OperationEntry[]): Promise { - if (entries.length === 0) { - // Noted before it is persisted: the hybrid shell hands this key to the page on every `init`, - // built synchronously, so a write that only reached the store would be one `init` behind. - noteMirroredWrite(STORAGE_KEY, null) - await AsyncStorage.removeItem(STORAGE_KEY) - return - } - const value = JSON.stringify({ v: 1, entries }) + const value = entries.length === 0 ? null : JSON.stringify({ v: 1, entries }) + // Noted before it is persisted: the hybrid shell hands this key to the page on every `init`, + // built synchronously, so a write that only reached the store would be one `init` behind. Put + // back when the store refuses it, because the other direction is worse — a page told about a + // journal the device never wrote resumes operations nothing is holding. + const held = readMirroredStorage([STORAGE_KEY])[STORAGE_KEY] ?? null noteMirroredWrite(STORAGE_KEY, value) - await AsyncStorage.setItem(STORAGE_KEY, value) + try { + await (value === null + ? AsyncStorage.removeItem(STORAGE_KEY) + : AsyncStorage.setItem(STORAGE_KEY, value)) + } catch (error) { + noteMirroredWrite(STORAGE_KEY, held) + throw error + } } async function serialize(action: () => Promise): Promise {