Found while building the iOS sync client against the real schemas. It affects the shipped desktop product, not just iOS.
The mismatch
POST /sync decides a conflict with (routes/sync.ts:164-168):
if (latestEntry && latestEntry.deviceId !== deviceId &&
change.localVersion !== undefined &&
latestEntry.version > change.localVersion) { /* conflict */ }
Those two numbers live in different spaces:
| Value |
Where it comes from |
Shape |
latestEntry.version |
COALESCE(MAX(version),0)+1 over syncLog filtered only by userId (sync.ts:140-145) |
global per-user counter, grows with every change to every note |
change.localVersion |
notes.local_version INTEGER DEFAULT 1, +1 per edit (storage-sqlite/src/migrations/011_sync_tracking.ts:16,41) |
per-note edit counter, small |
On any account with more than a handful of total changes, the global version exceeds a single note's edit count. So whenever the last writer was a different device, latestEntry.version > change.localVersion is true and the push is rejected as conflict — regardless of whether anything actually conflicts.
Why nobody has noticed
Push conflicts are not surfaced (SyncService.ts:554-560):
const pushConflicts = pushResult.results.filter(r => r.status === 'conflict');
if (pushConflicts.length > 0) {
console.warn(`Push conflicts detected for ${pushConflicts.length} notes:`, pushConflicts);
}
A conflicted note is not in successfulNoteIds, so markMultipleAsSynced skips it, it stays dirty, and it is retried on every cycle — forever, with no user-visible signal. The symptom is "my edit never showed up on the other machine", not an error.
Impact
Edit a note on device A, then edit it on device B: B's edit never uploads. This is exactly the P3 mobile goal — type on the phone, see it on the desktop — so it will block that too.
Not verified against a live account: I have no credentials. The above is read from the source in develop and I would rather report it than let iOS quietly inherit the same bug.
Options, all needing a call
- Stop sending
localVersion. The guard becomes inert and push is last-writer-wins — which is effectively today's behaviour anyway, since the conflicts are discarded. Smallest change, honest about what the system actually does.
- Send the server
version observed at pull time instead of the local edit counter. Makes the comparison meaningful. Requires clients to persist it per note; a protocol change touching desktop and iOS.
- Fix the comparison server-side to scope
MAX(version) per note, or compare against a per-note sequence.
Option 2 is the only one that gives real optimistic concurrency; 1 is the honest quick fix. Either way, push conflicts should stop being console.warn.
/cc — blocking decision for dripnex/ios sub-project C (dripnex/ios#11, open question 1).
Found while building the iOS sync client against the real schemas. It affects the shipped desktop product, not just iOS.
The mismatch
POST /syncdecides a conflict with (routes/sync.ts:164-168):Those two numbers live in different spaces:
latestEntry.versionCOALESCE(MAX(version),0)+1oversyncLogfiltered only byuserId(sync.ts:140-145)change.localVersionnotes.local_version INTEGER DEFAULT 1,+1per edit (storage-sqlite/src/migrations/011_sync_tracking.ts:16,41)On any account with more than a handful of total changes, the global version exceeds a single note's edit count. So whenever the last writer was a different device,
latestEntry.version > change.localVersionis true and the push is rejected asconflict— regardless of whether anything actually conflicts.Why nobody has noticed
Push conflicts are not surfaced (
SyncService.ts:554-560):A conflicted note is not in
successfulNoteIds, somarkMultipleAsSyncedskips it, it stays dirty, and it is retried on every cycle — forever, with no user-visible signal. The symptom is "my edit never showed up on the other machine", not an error.Impact
Edit a note on device A, then edit it on device B: B's edit never uploads. This is exactly the P3 mobile goal — type on the phone, see it on the desktop — so it will block that too.
Not verified against a live account: I have no credentials. The above is read from the source in
developand I would rather report it than let iOS quietly inherit the same bug.Options, all needing a call
localVersion. The guard becomes inert and push is last-writer-wins — which is effectively today's behaviour anyway, since the conflicts are discarded. Smallest change, honest about what the system actually does.versionobserved at pull time instead of the local edit counter. Makes the comparison meaningful. Requires clients to persist it per note; a protocol change touching desktop and iOS.MAX(version)per note, or compare against a per-note sequence.Option 2 is the only one that gives real optimistic concurrency; 1 is the honest quick fix. Either way, push conflicts should stop being
console.warn./cc — blocking decision for dripnex/ios sub-project C (dripnex/ios#11, open question 1).