Skip to content

Commit 2cdd390

Browse files
committed
fix(sync): let backend activation complete when a referenced attachment blob is gone from the candidate (#1119)
1 parent 082b8e9 commit 2cdd390

3 files changed

Lines changed: 100 additions & 16 deletions

File tree

docs/release-notes/unreleased.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,4 @@ Changes collected after `v1.2.1` and before the next version tag.
179179
- Mobile: the quick "Add task" sheet's More panel now starts with a Note field, so a longer thought can be captured without typing the /note: token; a typed field and a /note: token merge instead of one overwriting the other. (#1118)
180180
- Deleting a section now says up front that its tasks are kept and move to No Section, instead of a bare "are you sure". Desktop and mobile. (#1011)
181181
- Desktop Timeline: redesigned around a fixed name column — every task's title now sits in a sticky left column with its project, bars no longer carry floating labels, single-dated tasks draw as small dots on their day, and the card ends after its last row instead of stretching a gridded empty canvas. Feedback welcome on #1111.
182+
- Switching to a sync location that references an attachment whose file is genuinely gone from that location no longer fails forever with "Candidate attachment proof incomplete": the missing attachment is marked unrecoverable, the same way an already-connected device handles it, and the switch completes. An attachment silently disappearing during the check still refuses the switch. (#1119)

packages/core/src/sync-run.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2294,3 +2294,71 @@ describe('normalizeRemoteWriteResult', () => {
22942294
expect(normalizeRemoteWriteResult('cloud', null)).toEqual({ fingerprint: null, serverMergedRemoteData: false });
22952295
});
22962296
});
2297+
2298+
describe('activation proof with unrecoverable attachments (#1119)', () => {
2299+
const fileAttachment = (id: string, title: string) => ({
2300+
id,
2301+
kind: 'file' as const,
2302+
title,
2303+
uri: '',
2304+
cloudKey: `attachments/${id}.txt`,
2305+
localStatus: 'missing' as const,
2306+
createdAt: STAMP,
2307+
updatedAt: STAMP,
2308+
});
2309+
2310+
it('accepts activation when the trial tombstones a blob that is gone from the candidate', async () => {
2311+
const remoteTask = createTask('t-two-attachments', 'Two attachments');
2312+
remoteTask.attachments = [fileAttachment('attachment-ok', 'Fine'), fileAttachment('attachment-404', 'Gone')];
2313+
const syncAttachments = vi.fn(async (data: AppData) => {
2314+
for (const attachment of data.tasks[0]?.attachments ?? []) {
2315+
if (attachment.id === 'attachment-ok') {
2316+
attachment.localStatus = 'available';
2317+
} else {
2318+
// What markAttachmentUnrecoverable does on a terminal 404.
2319+
attachment.cloudKey = undefined;
2320+
attachment.fileHash = undefined;
2321+
attachment.localStatus = 'missing';
2322+
attachment.deletedAt = '2026-07-02T00:00:00.000Z';
2323+
}
2324+
}
2325+
return data;
2326+
});
2327+
const { run } = createHarness({
2328+
local: createData(),
2329+
remote: createData([remoteTask]),
2330+
activationProbe: true,
2331+
io: { syncAttachments },
2332+
});
2333+
2334+
const result = await run();
2335+
2336+
expect(result.success).toBe(true);
2337+
});
2338+
2339+
it('still refuses activation when an expected attachment vanishes without a tombstone', async () => {
2340+
const remoteTask = createTask('t-two-attachments', 'Two attachments');
2341+
remoteTask.attachments = [fileAttachment('attachment-ok', 'Fine'), fileAttachment('attachment-dropped', 'Dropped')];
2342+
const syncAttachments = vi.fn(async (data: AppData) => {
2343+
const task = data.tasks[0]!;
2344+
for (const attachment of task.attachments ?? []) {
2345+
attachment.localStatus = 'available';
2346+
}
2347+
task.attachments = (task.attachments ?? []).filter((attachment) => attachment.id !== 'attachment-dropped');
2348+
return data;
2349+
});
2350+
const { run } = createHarness({
2351+
local: createData(),
2352+
remote: createData([remoteTask]),
2353+
activationProbe: true,
2354+
io: { syncAttachments },
2355+
});
2356+
2357+
const result = await run();
2358+
2359+
expect(result).toMatchObject({
2360+
success: false,
2361+
error: '[cloud] Candidate attachment proof incomplete: expected 2, proved 1',
2362+
});
2363+
});
2364+
});

packages/core/src/sync-run.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,17 @@ const mergedContentMustReplaceCandidateBlob = (
175175
const prepareActivationAttachmentSnapshot = (
176176
data: AppData,
177177
candidateRemoteData: AppData | null,
178-
): { data: AppData; count: number } => {
178+
): { data: AppData; count: number; expectedIds: Set<string> } => {
179179
const candidateAttachments = new Map<string, Attachment>();
180180
if (candidateRemoteData) {
181181
visitLiveFileAttachments(candidateRemoteData, (attachment) => {
182182
candidateAttachments.set(attachment.id, attachment);
183183
});
184184
}
185185
const candidate = cloneAppData(data);
186+
const expectedIds = new Set<string>();
186187
const count = visitLiveFileAttachments(candidate, (attachment) => {
188+
expectedIds.add(attachment.id);
187189
const candidateAttachment = candidateAttachments.get(attachment.id);
188190
const mustReplaceCandidateBlob = Boolean(
189191
candidateAttachment?.cloudKey
@@ -207,23 +209,36 @@ const prepareActivationAttachmentSnapshot = (
207209
}
208210
attachment.localStatus = 'missing';
209211
});
210-
return { data: candidate, count };
212+
return { data: candidate, count, expectedIds };
211213
};
212214

213-
const assertActivationAttachmentsProven = (data: AppData, expectedCount: number): void => {
214-
let provenCount = 0;
215-
visitLiveFileAttachments(data, (attachment) => {
216-
if (
217-
!attachment.cloudKey
218-
|| attachment.localStatus !== 'available'
219-
|| attachment.pendingContentUpload === true
220-
) {
221-
throw new Error(`Candidate attachment proof failed for ${attachment.id}`);
215+
const assertActivationAttachmentsProven = (data: AppData, expectedIds: ReadonlySet<string>): void => {
216+
const resolved = new Set<string>();
217+
for (const owner of [...data.tasks, ...data.projects]) {
218+
for (const attachment of owner.attachments ?? []) {
219+
if (attachment.kind !== 'file') continue;
220+
if (owner.deletedAt || attachment.deletedAt) {
221+
// Tombstoned during the trial itself (a 404'd blob is marked
222+
// unrecoverable, #1119): that is a PROVEN outcome - the same one an
223+
// established device converges to - not a gap in the proof. Only ids
224+
// the snapshot expected count; a pre-existing tombstone never does.
225+
if (expectedIds.has(attachment.id)) resolved.add(attachment.id);
226+
continue;
227+
}
228+
if (
229+
!attachment.cloudKey
230+
|| attachment.localStatus !== 'available'
231+
|| attachment.pendingContentUpload === true
232+
) {
233+
throw new Error(`Candidate attachment proof failed for ${attachment.id}`);
234+
}
235+
if (expectedIds.has(attachment.id)) resolved.add(attachment.id);
222236
}
223-
provenCount += 1;
224-
});
225-
if (provenCount !== expectedCount) {
226-
throw new Error(`Candidate attachment proof incomplete: expected ${expectedCount}, proved ${provenCount}`);
237+
}
238+
// An expected attachment that vanished without a tombstone is a silent drop
239+
// and still refuses the activation.
240+
if (resolved.size !== expectedIds.size) {
241+
throw new Error(`Candidate attachment proof incomplete: expected ${expectedIds.size}, proved ${resolved.size}`);
227242
}
228243
};
229244

@@ -874,7 +889,7 @@ class SharedSyncRunMachine {
874889
const provenData = result && typeof result === 'object'
875890
? result
876891
: activationSnapshot.data;
877-
assertActivationAttachmentsProven(provenData, activationSnapshot.count);
892+
assertActivationAttachmentsProven(provenData, activationSnapshot.expectedIds);
878893
this.ensureLocalSnapshotFresh();
879894
this.notifier.onDiagnostic?.({
880895
event: 'attachments-prepare-complete',

0 commit comments

Comments
 (0)