Skip to content

Commit 48dd067

Browse files
Programcodeclaude
andcommitted
Clear all keeps queued work if the clear fails; case arguments keyed by position
From the Codex review of deb28ba: - Clear all dropped queued notes and changes before the server cleared, so a clear that failed lost an unsynced note for good. The queue now holds its replay while the clear runs and drops the debate's writes only once it succeeds; a failed clear keeps them and the queue resumes (tested). - The v2 case document keyed arguments by the model's id, so two arguments with the same id opened and closed together; they are keyed by position again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent deb28ba commit 48dd067

4 files changed

Lines changed: 31 additions & 15 deletions

File tree

‎apps/mobile/src/app/(tabs)/(library)/library.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { confirmDestructive, promptText } from '@/lib/confirm';
2626
import { useDebates } from '@/lib/debate-context';
2727
import { haptics } from '@/lib/haptics';
2828
import { LIBRARY_ROOMY_WIDTH, LIST_WIDTH, useContentWidth, useLayoutClass } from '@/lib/layout';
29-
import { currentSourceId, discardQueuedForDebate, waitingSourceIds, withQueuedWrites } from '@/lib/offline-queue';
29+
import { clearQueuedDebate, currentSourceId, waitingSourceIds, withQueuedWrites } from '@/lib/offline-queue';
3030
import { openRowId } from '@/lib/open-pane';
3131
import { pickSourceFile } from '@/lib/pick-source-file';
3232
import { useSourceActions } from '@/lib/source-actions';
@@ -103,9 +103,8 @@ function LibraryView({ debateId, layout }: { debateId: string | null; layout: Re
103103
const confirmed = await confirmDestructive({ title: t('libraryPage.clearAll'), message: t('libraryPage.clearConfirm'), confirm: t('libraryPage.clearAll'), cancel: t('common.cancel') });
104104
if (!confirmed) return;
105105
try {
106-
// Notes and changes still waiting to sync would come back after the clear; they go first.
107-
await discardQueuedForDebate(userId, debateId);
108-
await clearLibrary(userId, debateId);
106+
// Notes and changes still waiting to sync would come back after the clear; they go once it succeeds.
107+
await clearQueuedDebate(userId, debateId, () => clearLibrary(userId, debateId));
109108
setSelected(null);
110109
library.setSources([]);
111110
toast({ tone: 'success', title: t('common.deleted'), message: t('libraryPage.clearSuccess') });

‎apps/mobile/src/components/case-document.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ export function CaseDocument({
8585
const chips = chipsByArgument[index];
8686
const pieces = chips.length || (argument.evidence ? 1 : 0);
8787
return {
88-
key: argument.id || `${index}`,
88+
// By position: a model can repeat an id, and two arguments must never open and close together.
89+
key: `${index}`,
8990
title: argument.title ?? argument.point,
9091
summary: pieces > 0
9192
? t('mobile.results.evidenceCount', { count: pieces })

‎apps/mobile/src/lib/offline-queue.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,18 @@ describe('offline queue', () => {
152152
expect(queue.getQueueSnapshot().items).toEqual([]);
153153
});
154154

155-
it('clears every write queued for a debate before Clear all, so none comes back', async () => {
155+
it('drops the writes queued for a debate once Clear all succeeds, and keeps them when it fails', async () => {
156156
const queue = await load();
157157
copies.set('sources:d1', [row('s1', { title: 'Kept', content: 'Text', debateId: 'd1' })]);
158158
net.offline = true;
159159
const note = queue.queueAddSource('user-a', { title: 'Waiting note', content: 'Text', type: 'text', debateId: 'd1' });
160160
await note;
161161
await queue.queueUpdateSource('user-a', 'd2', 's9', { title: 'Other debate' });
162-
await queue.discardQueuedForDebate('user-a', 'd1');
162+
// A clear the server refuses keeps everything queued.
163+
await expect(queue.clearQueuedDebate('user-a', 'd1', async () => { throw new Error('clear failed'); })).rejects.toThrow('clear failed');
164+
expect(queue.getQueueSnapshot().items).toHaveLength(2);
165+
net.offline = true;
166+
await queue.clearQueuedDebate('user-a', 'd1', async () => undefined);
163167
await cache.copiesWritten();
164168
expect(queue.getQueueSnapshot().items.map((item) => item.kind === 'add' ? item.input.debateId : item.debateId)).toEqual(['d2']);
165169
expect((copies.get('sources:d1') as Source[]).map((item) => item.id)).toEqual(['s1']);

‎apps/mobile/src/lib/offline-queue.ts‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ let waiters = new Map<string, Waiter[]>();
123123
let inFlight: string | null = null;
124124
let snapshot: QueueSnapshot = { owner, items, synced };
125125
let running: Promise<void> | null = null;
126+
// While Clear all asks the server, nothing replays: a queued note must not land in a Library being cleared.
127+
let paused = 0;
126128
let again = false;
127129
let keys = 0;
128130

@@ -487,14 +489,24 @@ export async function queueSetSourcesActive(userId: string, debateId: string, so
487489
}
488490

489491
/**
490-
* Clear all for a debate: lets a replay that is running finish, then drops every write still
491-
* queued for that debate (its waiting notes, edits and switches), so none comes back after the
492-
* server clears the Library. Call it before clearLibrary.
492+
* Clear all for a debate: lets a replay that is running finish and holds the queue while `clear`
493+
* asks the server; only once it succeeds are the debate's queued writes (its waiting notes, edits
494+
* and switches) dropped, so none comes back. A clear that fails keeps them, and the queue resumes.
493495
*/
494-
export async function discardQueuedForDebate(userId: string, debateId: string): Promise<void> {
496+
export async function clearQueuedDebate(userId: string, debateId: string, clear: () => Promise<void>): Promise<void> {
495497
await adopt(userId);
496-
if (running) await running.catch(() => undefined);
497-
if (owner !== userId) return;
498+
paused += 1;
499+
try {
500+
if (running) await running.catch(() => undefined);
501+
await clear();
502+
if (owner === userId) dropDebateWrites(userId, debateId);
503+
} finally {
504+
paused -= 1;
505+
void replayOfflineQueue();
506+
}
507+
}
508+
509+
function dropDebateWrites(userId: string, debateId: string) {
498510
const debateOf = (item: QueuedWrite) => (item.kind === 'add' ? item.input.debateId : item.debateId);
499511
const dropped = items.filter((item) => debateOf(item) === debateId);
500512
if (dropped.length === 0) return;
@@ -626,9 +638,9 @@ function describe(item: QueuedWrite): QueueReport['dropped'][number] {
626638
async function drain() {
627639
const userId = owner;
628640
const gen = generation;
629-
if (!userId) return;
641+
if (!userId || paused > 0) return;
630642
await ready;
631-
if (gen !== generation || items.length === 0) return;
643+
if (gen !== generation || items.length === 0 || paused > 0) return;
632644
if (isKnownOffline() || !(await signedInAs(userId))) {
633645
if (gen === generation) parkAll();
634646
return;

0 commit comments

Comments
 (0)