| author | |
| committer | |
| log | 7839140e5e93d3f401d7eb45b86cf1a234eb3609 |
| tree | 40c4972ff05a62b0c9f4c0a4fcc7ace46414168a |
| parent | c7c1dfe4df99edf65a49d8a31b39ef1361f37f44 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 240 insertions(+), 82 deletions(-)
apps/mobile/src/state/thread-outbox-manager.ts+7-11| ... | ... | @@ -276,17 +276,13 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 276 | 276 | const revisionsAtRequest = new Map(revisions); |
| 277 | 277 | return serialize(async () => { |
| 278 | 278 | const persisted = await options.storage.load().catch((cause) => { |
| 279 | warn( | |
| 280 | "[thread-outbox] failed to load messages while clearing environment", | |
| 281 | new ThreadOutboxManagerError({ | |
| 282 | operation: "clear-environment-load", | |
| 283 | environmentId, | |
| 284 | threadId: null, | |
| 285 | messageId: null, | |
| 286 | cause, | |
| 287 | }), | |
| 288 | ); | |
| 289 | return []; | |
| 279 | throw new ThreadOutboxManagerError({ | |
| 280 | operation: "clear-environment-load", | |
| 281 | environmentId, | |
| 282 | threadId: null, | |
| 283 | messageId: null, | |
| 284 | cause, | |
| 285 | }); | |
| 290 | 286 | }); |
| 291 | 287 | const allMessages = flattenQueuedThreadMessages( |
| 292 | 288 | groupQueuedThreadMessages([...persisted, ...currentMessages()]), |
apps/mobile/src/state/thread-outbox-storage.ts+11-11| ... | ... | @@ -80,20 +80,20 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 80 | 80 | try { |
| 81 | 81 | messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); |
| 82 | 82 | } catch (cause) { |
| 83 | console.warn( | |
| 84 | "[thread-outbox] ignored invalid persisted message", | |
| 85 | new ThreadOutboxStorageError({ | |
| 86 | operation: "read-message", | |
| 87 | environmentId: null, | |
| 88 | threadId: null, | |
| 89 | messageId: null, | |
| 90 | fileName: entry.name, | |
| 91 | cause, | |
| 92 | }), | |
| 93 | ); | |
| 83 | // A partial queue hides attachment owners from cleanup. Keep all | |
| 84 | // records untouched until every persisted message can be read. | |
| 85 | throw new ThreadOutboxStorageError({ | |
| 86 | operation: "read-message", | |
| 87 | environmentId: null, | |
| 88 | threadId: null, | |
| 89 | messageId: null, | |
| 90 | fileName: entry.name, | |
| 91 | cause, | |
| 92 | }); | |
| 94 | 93 | } |
| 95 | 94 | } |
| 96 | 95 | } catch (cause) { |
| 96 | if (cause instanceof ThreadOutboxStorageError) throw cause; | |
| 97 | 97 | throw new ThreadOutboxStorageError({ |
| 98 | 98 | operation: "load", |
| 99 | 99 | environmentId: null, |
apps/mobile/src/state/thread-outbox.test.ts+95-1| ... | ... | @@ -8,6 +8,34 @@ import { |
| 8 | 8 | ThreadId, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 11 | import { onTestFinished, vi } from "vite-plus/test"; | |
| 12 | ||
| 13 | const outboxFiles = vi.hoisted(() => new Map<string, string | Error>()); | |
| 14 | ||
| 15 | vi.mock("expo-file-system", () => { | |
| 16 | class File { | |
| 17 | constructor(readonly name: string) {} | |
| 18 | ||
| 19 | async text(): Promise<string> { | |
| 20 | const contents = outboxFiles.get(this.name); | |
| 21 | if (contents instanceof Error) throw contents; | |
| 22 | if (contents === undefined) throw new Error("Missing file"); | |
| 23 | return contents; | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | return { | |
| 28 | File, | |
| 29 | Directory: class { | |
| 30 | create() {} | |
| 31 | ||
| 32 | list() { | |
| 33 | return Array.from(outboxFiles.keys(), (name) => new File(name)); | |
| 34 | } | |
| 35 | }, | |
| 36 | Paths: { document: "/documents" }, | |
| 37 | }; | |
| 38 | }); | |
| 11 | 39 | |
| 12 | 40 | import { |
| 13 | 41 | decodeQueuedThreadMessage, |
| ... | ... | @@ -24,7 +52,7 @@ import { |
| 24 | 52 | type QueuedThreadMessage, |
| 25 | 53 | } from "./thread-outbox-model"; |
| 26 | 54 | import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager"; |
| 27 | import type { ThreadOutboxStorage } from "./thread-outbox-storage"; | |
| 55 | import { expoThreadOutboxStorage, type ThreadOutboxStorage } from "./thread-outbox-storage"; | |
| 28 | 56 | |
| 29 | 57 | function queuedMessage(input: { |
| 30 | 58 | readonly environmentId?: string; |
| ... | ... | @@ -44,6 +72,72 @@ function queuedMessage(input: { |
| 44 | 72 | } |
| 45 | 73 | |
| 46 | 74 | describe("thread outbox", () => { |
| 75 | it.each(["read", "json", "schema"] as const)( | |
| 76 | "does not load a partial outbox after a record %s failure", | |
| 77 | async (failure) => { | |
| 78 | onTestFinished(() => outboxFiles.clear()); | |
| 79 | const first = queuedMessage({ | |
| 80 | messageId: "message-1", | |
| 81 | createdAt: "2026-06-08T10:00:01.000Z", | |
| 82 | }); | |
| 83 | const second = queuedMessage({ | |
| 84 | messageId: "message-2", | |
| 85 | createdAt: "2026-06-08T10:00:02.000Z", | |
| 86 | }); | |
| 87 | outboxFiles.set("message-1.json", JSON.stringify(encodeQueuedThreadMessage(first))); | |
| 88 | outboxFiles.set( | |
| 89 | "message-2.json", | |
| 90 | failure === "read" | |
| 91 | ? new Error("storage unavailable") | |
| 92 | : failure === "json" | |
| 93 | ? "{" | |
| 94 | : JSON.stringify({ ...second, schemaVersion: 999 }), | |
| 95 | ); | |
| 96 | ||
| 97 | await expect(expoThreadOutboxStorage.load()).rejects.toMatchObject({ | |
| 98 | operation: "read-message", | |
| 99 | fileName: "message-2.json", | |
| 100 | }); | |
| 101 | ||
| 102 | outboxFiles.set("message-2.json", JSON.stringify(encodeQueuedThreadMessage(second))); | |
| 103 | await expect(expoThreadOutboxStorage.load()).resolves.toEqual([first, second]); | |
| 104 | }, | |
| 105 | ); | |
| 106 | ||
| 107 | it("preserves queued messages when environment cleanup cannot read the outbox", async () => { | |
| 108 | const registry = AtomRegistry.make(); | |
| 109 | onTestFinished(() => registry.dispose()); | |
| 110 | const message = queuedMessage({ | |
| 111 | messageId: "message-1", | |
| 112 | createdAt: "2026-06-08T10:00:01.000Z", | |
| 113 | }); | |
| 114 | const stored = new Map<MessageId, QueuedThreadMessage>(); | |
| 115 | const manager = createThreadOutboxManager({ | |
| 116 | registry, | |
| 117 | warn: () => {}, | |
| 118 | storage: { | |
| 119 | load: async () => { | |
| 120 | throw new Error("storage unavailable"); | |
| 121 | }, | |
| 122 | write: async (entry) => { | |
| 123 | stored.set(entry.messageId, entry); | |
| 124 | }, | |
| 125 | remove: async (entry) => { | |
| 126 | stored.delete(entry.messageId); | |
| 127 | }, | |
| 128 | }, | |
| 129 | }); | |
| 130 | await manager.enqueue(message); | |
| 131 | ||
| 132 | await expect(manager.clearEnvironment(message.environmentId)).rejects.toMatchObject({ | |
| 133 | operation: "clear-environment-load", | |
| 134 | }); | |
| 135 | expect([...stored.values()]).toEqual([message]); | |
| 136 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ | |
| 137 | "environment-1:thread-1": [message], | |
| 138 | }); | |
| 139 | }); | |
| 140 | ||
| 47 | 141 | it("groups messages by scoped thread and preserves creation order", () => { |
| 48 | 142 | const later = queuedMessage({ |
| 49 | 143 | messageId: "message-2", |
apps/mobile/src/state/use-composer-drafts.test.ts+66-3| ... | ... | @@ -9,7 +9,8 @@ import { |
| 9 | 9 | import { onTestFinished, vi } from "vite-plus/test"; |
| 10 | 10 | |
| 11 | 11 | const composerDraftFileMocks = vi.hoisted(() => { |
| 12 | let document = ""; | |
| 12 | let document = JSON.stringify({ schemaVersion: 1, drafts: {} }); | |
| 13 | let readError: Error | null = null; | |
| 13 | 14 | let writeError: Error | null = null; |
| 14 | 15 | let releaseRead: (() => void) | null = null; |
| 15 | 16 | let readBarrier = Promise.resolve(); |
| ... | ... | @@ -33,6 +34,9 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 33 | 34 | setDocument(value: unknown) { |
| 34 | 35 | document = JSON.stringify(value); |
| 35 | 36 | }, |
| 37 | setReadError(error: Error | null) { | |
| 38 | readError = error; | |
| 39 | }, | |
| 36 | 40 | setWriteError(error: Error | null) { |
| 37 | 41 | writeError = error; |
| 38 | 42 | }, |
| ... | ... | @@ -50,6 +54,10 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 50 | 54 | }, |
| 51 | 55 | Directory: class { |
| 52 | 56 | create() {} |
| 57 | ||
| 58 | list() { | |
| 59 | return []; | |
| 60 | } | |
| 53 | 61 | }, |
| 54 | 62 | File: class { |
| 55 | 63 | exists = true; |
| ... | ... | @@ -61,6 +69,7 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 61 | 69 | |
| 62 | 70 | async text() { |
| 63 | 71 | await readBarrier; |
| 72 | if (readError) throw readError; | |
| 64 | 73 | return document; |
| 65 | 74 | } |
| 66 | 75 | |
| ... | ... | @@ -156,7 +165,8 @@ const DRAFT: ComposerDraft = { |
| 156 | 165 | afterEach(() => { |
| 157 | 166 | vi.useRealTimers(); |
| 158 | 167 | resetComposerDraftsLoadState(); |
| 159 | composerDraftFileMocks.setDocument(""); | |
| 168 | composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); | |
| 169 | composerDraftFileMocks.setReadError(null); | |
| 160 | 170 | composerDraftFileMocks.setWriteError(null); |
| 161 | 171 | composerDraftFileMocks.setNextWriteBarrier(null); |
| 162 | 172 | composerDraftFileMocks.setOnWrite(null); |
| ... | ... | @@ -1043,9 +1053,62 @@ describe("mobile composer drafts", () => { |
| 1043 | 1053 | }); |
| 1044 | 1054 | }); |
| 1045 | 1055 | |
| 1056 | it.each(["read", "decode"] as const)( | |
| 1057 | "preserves saved drafts and attachment files when the draft %s fails", | |
| 1058 | async (failure) => { | |
| 1059 | vi.useFakeTimers(); | |
| 1060 | const file = { | |
| 1061 | id: "saved-file", | |
| 1062 | type: "file" as const, | |
| 1063 | name: "report.pdf", | |
| 1064 | mimeType: "application/pdf", | |
| 1065 | sizeBytes: 42, | |
| 1066 | fileUri: "file:///documents/t3-composer-attachments/report.pdf", | |
| 1067 | }; | |
| 1068 | composerDraftFileMocks.setDocument({ | |
| 1069 | schemaVersion: failure === "decode" ? 999 : 1, | |
| 1070 | drafts: { "environment-1:saved": { text: "Saved draft", attachments: [file] } }, | |
| 1071 | }); | |
| 1072 | const original = composerDraftFileMocks.getDocument(); | |
| 1073 | if (failure === "read") { | |
| 1074 | composerDraftFileMocks.setReadError(new Error("storage unavailable")); | |
| 1075 | } | |
| 1076 | ||
| 1077 | await expect(releaseUnusedComposerAttachmentFiles([file])).rejects.toMatchObject({ | |
| 1078 | operation: failure, | |
| 1079 | }); | |
| 1080 | setComposerDraftText("environment-1:new", "Keep my new edits too"); | |
| 1081 | await expect(flushComposerDrafts()).rejects.toMatchObject({ operation: failure }); | |
| 1082 | ||
| 1083 | expect(composerDraftFileMocks.getDocument()).toBe(original); | |
| 1084 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 1085 | }, | |
| 1086 | ); | |
| 1087 | ||
| 1088 | it("retries a failed debounced read on final flush without dropping saved drafts or new edits", async () => { | |
| 1089 | vi.useFakeTimers(); | |
| 1090 | composerDraftFileMocks.setDocument({ | |
| 1091 | schemaVersion: 1, | |
| 1092 | drafts: { "environment-1:saved": DRAFT }, | |
| 1093 | }); | |
| 1094 | const original = composerDraftFileMocks.getDocument(); | |
| 1095 | composerDraftFileMocks.setReadError(new Error("storage unavailable")); | |
| 1096 | setComposerDraftText("environment-1:new", "New edits"); | |
| 1097 | await vi.advanceTimersByTimeAsync(200); | |
| 1098 | expect(composerDraftFileMocks.getDocument()).toBe(original); | |
| 1099 | ||
| 1100 | composerDraftFileMocks.setReadError(null); | |
| 1101 | await flushComposerDrafts(); | |
| 1102 | ||
| 1103 | expect(JSON.parse(composerDraftFileMocks.getDocument()).drafts).toEqual({ | |
| 1104 | "environment-1:saved": DRAFT, | |
| 1105 | "environment-1:new": { text: "New edits", attachments: [] }, | |
| 1106 | }); | |
| 1107 | }); | |
| 1108 | ||
| 1046 | 1109 | it("serializes environment cleanup after an older queued write", async () => { |
| 1047 | 1110 | vi.useFakeTimers(); |
| 1048 | composerDraftFileMocks.setDocument(JSON.stringify({ schemaVersion: 1, drafts: {} })); | |
| 1111 | composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); | |
| 1049 | 1112 | composerDraftFileMocks.resetWrites(); |
| 1050 | 1113 | let releaseFirstWrite!: () => void; |
| 1051 | 1114 | const firstWriteBarrier = new Promise<void>((resolve) => { |
apps/mobile/src/state/use-composer-drafts.ts+61-56| ... | ... | @@ -148,11 +148,13 @@ export const composerCloudDraftsAtom = Atom.make<ComposerCloudDraftState>({ |
| 148 | 148 | |
| 149 | 149 | let loadPromise: Promise<void> | null = null; |
| 150 | 150 | let persistTimer: ReturnType<typeof setTimeout> | null = null; |
| 151 | let persistRetryNeeded = false; | |
| 151 | 152 | const persistenceQueue = new SerializedAsyncQueue(); |
| 152 | 153 | |
| 153 | 154 | /** Resets module-level state between test runs. */ |
| 154 | 155 | export function resetComposerDraftsLoadState(): void { |
| 155 | 156 | loadPromise = null; |
| 157 | persistRetryNeeded = false; | |
| 156 | 158 | } |
| 157 | 159 | |
| 158 | 160 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { |
| ... | ... | @@ -265,20 +267,12 @@ async function loadPersistedComposerState(): Promise< |
| 265 | 267 | operation = "decode"; |
| 266 | 268 | return decodePersistedComposerState(JSON.parse(raw) as unknown); |
| 267 | 269 | } catch (cause) { |
| 268 | console.warn( | |
| 269 | "[composer-drafts] ignored persisted draft failure", | |
| 270 | new ComposerDraftPersistenceError({ | |
| 271 | operation, | |
| 272 | directory: COMPOSER_DRAFTS_DIRECTORY, | |
| 273 | fileName: COMPOSER_DRAFTS_FILE, | |
| 274 | cause, | |
| 275 | }), | |
| 276 | ); | |
| 277 | return { | |
| 278 | drafts: {}, | |
| 279 | stickyModelSelection: null, | |
| 280 | cloudDrafts: { accountId: null, signedOut: {} }, | |
| 281 | }; | |
| 270 | throw new ComposerDraftPersistenceError({ | |
| 271 | operation, | |
| 272 | directory: COMPOSER_DRAFTS_DIRECTORY, | |
| 273 | fileName: COMPOSER_DRAFTS_FILE, | |
| 274 | cause, | |
| 275 | }); | |
| 282 | 276 | } |
| 283 | 277 | } |
| 284 | 278 | |
| ... | ... | @@ -341,20 +335,26 @@ export async function flushComposerDrafts(): Promise<void> { |
| 341 | 335 | // An edit during an awaited write schedules another debounced write, so |
| 342 | 336 | // keep landing snapshots until no debounce is pending after a queue drain. |
| 343 | 337 | do { |
| 344 | while (persistTimer !== null) { | |
| 345 | clearTimeout(persistTimer); | |
| 338 | while (persistTimer !== null || persistRetryNeeded) { | |
| 339 | if (persistTimer !== null) clearTimeout(persistTimer); | |
| 346 | 340 | persistTimer = null; |
| 347 | await persistenceQueue.run(() => | |
| 348 | writePersistedComposerState( | |
| 349 | appAtomRegistry.get(composerDraftsAtom), | |
| 350 | appAtomRegistry.get(stickyComposerModelSelectionAtom), | |
| 351 | ), | |
| 352 | ); | |
| 341 | persistRetryNeeded = false; | |
| 342 | try { | |
| 343 | await persistenceQueue.run(() => | |
| 344 | writePersistedComposerState( | |
| 345 | appAtomRegistry.get(composerDraftsAtom), | |
| 346 | appAtomRegistry.get(stickyComposerModelSelectionAtom), | |
| 347 | ), | |
| 348 | ); | |
| 349 | } catch (error) { | |
| 350 | persistRetryNeeded = true; | |
| 351 | throw error; | |
| 352 | } | |
| 353 | 353 | } |
| 354 | 354 | // Draining also waits for an already-fired debounce whose write is still |
| 355 | 355 | // gated behind its own hydration await inside the queue. |
| 356 | 356 | await persistenceQueue.run(() => Promise.resolve()); |
| 357 | } while (persistTimer !== null); | |
| 357 | } while (persistTimer !== null || persistRetryNeeded); | |
| 358 | 358 | } |
| 359 | 359 | |
| 360 | 360 | function signedOutAttachmentOwners() { |
| ... | ... | @@ -534,19 +534,20 @@ function schedulePersistComposerState(): void { |
| 534 | 534 | } |
| 535 | 535 | persistTimer = setTimeout(() => { |
| 536 | 536 | persistTimer = null; |
| 537 | ensureComposerDraftsLoaded(); | |
| 538 | 537 | // The write enters the serialization queue before waiting on hydration, |
| 539 | 538 | // so flushComposerDrafts' queue drain cannot resolve ahead of it. |
| 540 | 539 | void persistenceQueue.run(async () => { |
| 541 | if (loadPromise !== null) { | |
| 542 | await loadPromise; | |
| 543 | } | |
| 544 | 540 | try { |
| 541 | await waitForComposerDraftsLoaded(); | |
| 545 | 542 | await writePersistedComposerState( |
| 546 | 543 | appAtomRegistry.get(composerDraftsAtom), |
| 547 | 544 | appAtomRegistry.get(stickyComposerModelSelectionAtom), |
| 548 | 545 | ); |
| 546 | persistRetryNeeded = false; | |
| 549 | 547 | } catch (error) { |
| 548 | // A failed debounce has no timer left. A later final flush must retry | |
| 549 | // these edits after persisted ownership can be read safely. | |
| 550 | persistRetryNeeded = true; | |
| 550 | 551 | console.warn("[composer-drafts] failed to persist drafts", error); |
| 551 | 552 | // Draft persistence is best-effort; in-memory drafts still keep working. |
| 552 | 553 | } |
| ... | ... | @@ -558,35 +559,39 @@ export function ensureComposerDraftsLoaded(): void { |
| 558 | 559 | if (loadPromise !== null) { |
| 559 | 560 | return; |
| 560 | 561 | } |
| 561 | loadPromise = loadPersistedComposerState() | |
| 562 | .then((persisted) => { | |
| 563 | appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); | |
| 564 | if (Object.keys(persisted.drafts).length > 0) { | |
| 565 | const current = appAtomRegistry.get(composerDraftsAtom); | |
| 566 | appAtomRegistry.set(composerDraftsAtom, { | |
| 567 | ...persisted.drafts, | |
| 568 | ...current, | |
| 569 | }); | |
| 570 | } | |
| 571 | if ( | |
| 572 | persisted.stickyModelSelection !== null && | |
| 573 | appAtomRegistry.get(stickyComposerModelSelectionAtom) === null | |
| 574 | ) { | |
| 575 | appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); | |
| 576 | } | |
| 577 | }) | |
| 578 | .catch((cause) => { | |
| 579 | console.warn( | |
| 580 | "[composer-drafts] failed to hydrate drafts", | |
| 581 | new ComposerDraftPersistenceError({ | |
| 582 | operation: "hydrate", | |
| 583 | directory: COMPOSER_DRAFTS_DIRECTORY, | |
| 584 | fileName: COMPOSER_DRAFTS_FILE, | |
| 585 | cause, | |
| 586 | }), | |
| 587 | ); | |
| 588 | // Draft loading is best-effort; in-memory drafts still keep working. | |
| 589 | }); | |
| 562 | const loading = loadPersistedComposerState().then((persisted) => { | |
| 563 | appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); | |
| 564 | if (Object.keys(persisted.drafts).length > 0) { | |
| 565 | const current = appAtomRegistry.get(composerDraftsAtom); | |
| 566 | appAtomRegistry.set(composerDraftsAtom, { | |
| 567 | ...persisted.drafts, | |
| 568 | ...current, | |
| 569 | }); | |
| 570 | } | |
| 571 | if ( | |
| 572 | persisted.stickyModelSelection !== null && | |
| 573 | appAtomRegistry.get(stickyComposerModelSelectionAtom) === null | |
| 574 | ) { | |
| 575 | appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); | |
| 576 | } | |
| 577 | }); | |
| 578 | loadPromise = loading; | |
| 579 | // Handle fire-and-forget hook loads without swallowing failures from the | |
| 580 | // write and cleanup callers that await this same promise. A later call retries. | |
| 581 | void loading.catch((cause) => { | |
| 582 | if (loadPromise === loading) loadPromise = null; | |
| 583 | console.warn( | |
| 584 | "[composer-drafts] failed to hydrate drafts", | |
| 585 | cause instanceof ComposerDraftPersistenceError | |
| 586 | ? cause | |
| 587 | : new ComposerDraftPersistenceError({ | |
| 588 | operation: "hydrate", | |
| 589 | directory: COMPOSER_DRAFTS_DIRECTORY, | |
| 590 | fileName: COMPOSER_DRAFTS_FILE, | |
| 591 | cause, | |
| 592 | }), | |
| 593 | ); | |
| 594 | }); | |
| 590 | 595 | } |
| 591 | 596 | |
| 592 | 597 | /** Wait until persisted drafts have been merged into the in-memory composer state. */ |