Skip to content

Commit e19270e

Browse files
committed
fix(sync): stop the per-cycle resync after another device deleted attachments the phone still listed (#1136)
1 parent 0bf5784 commit e19270e

5 files changed

Lines changed: 128 additions & 4 deletions

File tree

docs/release-notes/unreleased.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ Changes collected after `v1.2.7` and before the next version tag.
55
## Full Change List
66

77
- Sync: leaving the app mid-sync (switching to another app on a phone, or a background sync hitting its deadline) could leave the shared sync lock behind on Dropbox and WebDAV, and every device then reported "Remote sync is temporarily reserved by mindwtr-mobile" and waited up to five minutes before syncing again. The abort cancelled the request that removes the lock. Lock requests on desktop and mobile now finish independently of the abort, so an interrupted cycle still releases its lock. The "Sync follow-up scheduled" log line also now reports the delay that actually applies instead of only the pacing delay. (from a v1.2.7 device log)
8+
9+
- Sync: a phone could keep syncing every second or two with no changes, warning "syncConflictDiscarded" for the same attachments on every cycle, after another device had deleted attachments the phone still listed. Two causes are fixed. The app kept its own older copy of a task whenever only its attachments had changed, and then wrote that copy back over what sync had just stored. And two devices that hold the same records in a different order were treated as different, so every cycle uploaded again and a self-hosted server answered with a merge each time. The order of records no longer counts as a change, and attachment-only changes now replace the in-memory task. (#1136)

packages/core/src/store-helpers.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
reuseSettingsIfEquivalent,
2121
selectFocusedCount,
2222
} from './store-helpers';
23-
import type { Project, Section, Task } from './types';
23+
import type { Attachment, Project, Section, Task } from './types';
2424
import type { SaveBaseState } from './store-types';
2525

2626
const createTask = (
@@ -1021,3 +1021,36 @@ describe('persist', () => {
10211021
expect(debouncedSave).not.toHaveBeenCalled();
10221022
});
10231023
});
1024+
1025+
describe('reconcileEntityCollection attachments (#1136)', () => {
1026+
const attachment = (overrides: Partial<Attachment> = {}): Attachment => ({
1027+
id: 'att-1',
1028+
kind: 'file' as const,
1029+
title: 'scan.pdf',
1030+
uri: 'file:///attachments/att-1.pdf',
1031+
createdAt: '2026-01-01T00:00:00.000Z',
1032+
updatedAt: '2026-01-01T00:00:00.000Z',
1033+
...overrides,
1034+
});
1035+
1036+
it('replaces an owner whose attachments changed under an unchanged revision tuple', () => {
1037+
const existing = createTask('t1', 'project-1', 0, { attachments: [attachment()] });
1038+
const incoming = createTask('t1', 'project-1', 0, {
1039+
attachments: [attachment({ deletedAt: '2026-01-01T00:00:00.000Z' })],
1040+
});
1041+
1042+
expect(hasSameEntityIdentity(existing, incoming)).toBe(false);
1043+
const result = reconcileEntityCollection([existing], buildEntityMap([existing]), [incoming]);
1044+
expect(result.items[0]).toBe(incoming);
1045+
});
1046+
1047+
it('still reuses an owner whose attachments are equal by content', () => {
1048+
const existing = createTask('t1', 'project-1', 0, { attachments: [attachment()] });
1049+
const incoming = createTask('t1', 'project-1', 0, { attachments: [attachment()] });
1050+
1051+
expect(hasSameEntityIdentity(existing, incoming)).toBe(true);
1052+
const result = reconcileEntityCollection([existing], buildEntityMap([existing]), [incoming]);
1053+
expect(result.items).toEqual([existing]);
1054+
expect(result.items[0]).toBe(existing);
1055+
});
1056+
});

packages/core/src/store-helpers.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { isTaskActionable, isTaskFinished } from './task-status';
1010
import { safeParseDate } from './date';
1111
import { filterNotDeleted } from './sync-helpers';
1212
import { nextRevision, normalizeRevision } from './sync-revision';
13-
import type { AiSettings, AppData, Area, Person, Project, Section, Task, TaskStatus } from './types';
13+
import type { AiSettings, AppData, Area, Attachment, Person, Project, Section, Task, TaskStatus } from './types';
1414
import { generateUUID as uuidv4 } from './uuid';
1515
import type { DerivedState, SaveBaseState, TaskStore } from './store-types';
1616

@@ -511,12 +511,33 @@ export const reuseArrayIfShallowEqual = <T>(previous: T[], next: T[]): T[] => (
511511
: next
512512
);
513513

514+
// Attachments carry their own per-record LWW (deletedAt, cloudKey, localStatus,
515+
// contentRev) and a merge can change them WITHOUT touching the owner's revision
516+
// tuple. Reusing the existing owner object on that tuple alone kept a task's
517+
// pre-merge attachments alive in the store; the post-load persist then wrote
518+
// them back over what the sync cycle had just stored, every cycle (#1136).
519+
const haveSameAttachments = (left?: Attachment[], right?: Attachment[]): boolean => {
520+
if (left === right) return true;
521+
const leftItems = left ?? [];
522+
const rightItems = right ?? [];
523+
if (leftItems.length !== rightItems.length) return false;
524+
for (let index = 0; index < leftItems.length; index += 1) {
525+
if (leftItems[index] === rightItems[index]) continue;
526+
if (JSON.stringify(leftItems[index]) !== JSON.stringify(rightItems[index])) return false;
527+
}
528+
return true;
529+
};
530+
514531
export const hasSameEntityIdentity = <T extends EntityWithRevision>(existing: T, incoming: T): boolean => (
515532
existing.updatedAt === incoming.updatedAt
516533
&& normalizeRevision(existing.rev) === normalizeRevision(incoming.rev)
517534
&& existing.revBy === incoming.revBy
518535
&& existing.deletedAt === incoming.deletedAt
519536
&& existing.purgedAt === incoming.purgedAt
537+
&& haveSameAttachments(
538+
(existing as { attachments?: Attachment[] }).attachments,
539+
(incoming as { attachments?: Attachment[] }).attachments,
540+
)
520541
);
521542

522543
export const reconcileEntityCollection = <T extends EntityWithRevision>(

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
assertNoPendingAttachmentUploads,
55
computeCoveredSettingsFingerprint,
66
computeSyncChangeFingerprint,
7+
computeStableValueFingerprint,
78
computeSyncPayloadFingerprint,
89
findPendingAttachmentUploads,
910
hasPendingSyncSideEffects,
@@ -1042,3 +1043,55 @@ describe('sync-helpers computeSyncPayloadFingerprint', () => {
10421043
expect(computeSyncPayloadFingerprint(data)).toBe(computeSyncPayloadFingerprint(data));
10431044
});
10441045
});
1046+
1047+
describe('sync comparison ignores the order of id-keyed lists (#1136)', () => {
1048+
const attachment = (id: string): Attachment => ({
1049+
id,
1050+
kind: 'file',
1051+
title: `${id}.pdf`,
1052+
uri: '',
1053+
createdAt: '2026-01-01T00:00:00.000Z',
1054+
updatedAt: '2026-01-01T00:00:00.000Z',
1055+
});
1056+
const task = (id: string, attachments: Attachment[]) => ({
1057+
id,
1058+
title: id,
1059+
status: 'inbox' as const,
1060+
tags: [],
1061+
contexts: [],
1062+
createdAt: '2026-01-01T00:00:00.000Z',
1063+
updatedAt: '2026-01-01T00:00:00.000Z',
1064+
attachments,
1065+
});
1066+
const doc = (tasks: ReturnType<typeof task>[]): AppData => ({
1067+
tasks: tasks as unknown as AppData['tasks'],
1068+
projects: [],
1069+
sections: [],
1070+
areas: [],
1071+
people: [],
1072+
settings: {},
1073+
});
1074+
1075+
it('treats a merge that listed the same records local-first on each device as equal', () => {
1076+
const phone = doc([task('t2', [attachment('a2'), attachment('a1')]), task('t1', [])]);
1077+
const server = doc([task('t1', []), task('t2', [attachment('a1'), attachment('a2')])]);
1078+
1079+
expect(areSyncPayloadsEqual(phone, server)).toBe(true);
1080+
expect(computeStableValueFingerprint(phone)).toBe(computeStableValueFingerprint(server));
1081+
expect(computeSyncPayloadFingerprint(phone)).toBe(computeSyncPayloadFingerprint(server));
1082+
});
1083+
1084+
it('still sees a content difference behind a reorder', () => {
1085+
const phone = doc([task('t2', [attachment('a2'), attachment('a1')])]);
1086+
const server = doc([task('t2', [attachment('a1'), { ...attachment('a2'), deletedAt: '2026-01-02T00:00:00.000Z' }])]);
1087+
1088+
expect(areSyncPayloadsEqual(phone, server)).toBe(false);
1089+
});
1090+
1091+
it('keeps primitive lists positional', () => {
1092+
expect(areSyncPayloadsEqual(
1093+
{ ...doc([]), settings: { contexts: ['a', 'b'] } as AppData['settings'] },
1094+
{ ...doc([]), settings: { contexts: ['b', 'a'] } as AppData['settings'] },
1095+
)).toBe(false);
1096+
});
1097+
});

packages/core/src/sync-helpers.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,24 @@ export const sanitizeAppDataForRemote = (data: AppData): AppData => {
315315
};
316316
};
317317

318+
const isIdKeyed = (item: unknown): item is { id: string } => (
319+
!!item && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string'
320+
);
321+
322+
// Lists of id-keyed records (entities, attachments, checklist items) compare
323+
// by content, not position. A merge emits the local side's order first, so two
324+
// devices that added records concurrently hold the same set in different
325+
// orders forever; comparing positionally made every fingerprint differ, every
326+
// cycle upload, and the self-hosted server report a merge each time (#1136).
327+
// Position carries no meaning for these lists: ordering lives in explicit
328+
// order fields, and every reorder bumps the owner's updatedAt anyway.
318329
const normalizeForSyncComparison = (value: unknown): unknown => {
319330
if (Array.isArray(value)) {
320-
return value.map((item) => normalizeForSyncComparison(item));
331+
const items = value.map((item) => normalizeForSyncComparison(item));
332+
if (items.length > 1 && items.every(isIdKeyed)) {
333+
items.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
334+
}
335+
return items;
321336
}
322337
if (value && typeof value === 'object') {
323338
const record = value as Record<string, unknown>;
@@ -352,7 +367,7 @@ const hashStableSyncJson = (value: string): string => {
352367

353368
export const computeStableValueFingerprint = (value: unknown): string => {
354369
const json = toStableSyncJson(value);
355-
return `stable-v1:${json.length}:${hashStableSyncJson(json)}`;
370+
return `stable-v2:${json.length}:${hashStableSyncJson(json)}`;
356371
};
357372

358373
export const computeSyncPayloadFingerprint = (data: AppData): string =>

0 commit comments

Comments
 (0)