Skip to content

fix(cloud-sync): prevent stale snapshots from overwriting newer account state#561

Open
Ridanshi wants to merge 1 commit into
Shruti070107:mainfrom
Ridanshi:fix/issue-543-cloud-sync-stale-overwrite
Open

fix(cloud-sync): prevent stale snapshots from overwriting newer account state#561
Ridanshi wants to merge 1 commit into
Shruti070107:mainfrom
Ridanshi:fix/issue-543-cloud-sync-stale-overwrite

Conversation

@Ridanshi

@Ridanshi Ridanshi commented Jun 1, 2026

Copy link
Copy Markdown

Closes #543

Problem

hydrateFromCloud merged accounts as { ...local, ...cloud }, letting the Appwrite snapshot overwrite every local field unconditionally. A device that earned tokens while offline and reconnected before the offline queue was flushed would silently lose those balances — stale cloud state won regardless of which copy was more recent.

Root cause

No freshness metadata existed on local or remote account records, so there was no way to determine which side of a merge was authoritative. The merge always applied cloud data on top of local, making the scenario in #543 reproducible any time:

  1. Device B goes offline.
  2. Tokens are earned on Device A and pushed to Appwrite.
  3. Device B comes back online; hydrateFromCloud runs.
  4. Device B's local state (possibly with its own offline edits) is overwritten by the Appwrite snapshot, which may be older than what Device B had locally.

Fix

Timestamp field (_syncTs)

DB.set now stamps every local account write with _syncTs: Date.now(). sanitizeAccount includes _syncTs in every Appwrite push so both sides carry the same timestamp field over time.

Freshness-aware merge in hydrateFromCloud

localTs > cloudTs  → local wins; Appwrite is re-pushed to bring it up to date.
cloudTs >= localTs → cloud wins (standard cloud-wins merge, same as before).

This covers:

  • Offline device recovery — local edits made while offline are not discarded on reconnect.
  • Normal sync — cloud state is applied when it is current.
  • Multi-device divergence — the side with the higher timestamp wins.

Safety net for legacy records

Accounts written before this fix carry no _syncTs. When both sides have _syncTs === 0, Math.max is applied to tokens and staked so an offline-earned balance is never silently discarded even on legacy data.

Files changed

File Change
src/app.js DB.set stamps _syncTs on every account write; passes stamped object to pushAccount and offline queue
src/cloud-sync.js sanitizeAccount persists _syncTs; hydrateFromCloud replaced with timestamp-aware merge + re-push + legacy safety net
scripts/test-cloud-sync-merge.mjs 17 test cases, 22 assertions covering all merge paths

Test results

Results: 22 passed, 0 failed

Covered scenarios:

  1. Regression — original buggy merge overwrites local tokens (documented, shows baseline)
  2. Local newer → local tokens preserved
  3. Cloud newer → cloud tokens applied
  4. Same timestamp → cloud wins
    5–6. Legacy records — Math.max safety net for tokens and staked
  5. Legacy — cloud-only fields still populated
    8–9. Re-push triggered only when local is newer
    10–11. Non-financial fields follow the winning side
  6. Local newer — cloud-only fields still populated into result
  7. Empty local — cloud wins entirely
  8. No cloud account — local preserved unchanged
  9. _syncTs preserved in merged result
  10. Regression — offline-earned tokens survive reconnect hydration
  11. Multi-device divergence resolved by timestamp

…nt state

hydrateFromCloud previously merged accounts as { ...local, ...cloud },
letting the Appwrite snapshot unconditionally overwrite every local field.
A device that earned tokens while offline and reconnected before the
offline queue was flushed would silently lose those balances.

Fix: DB.set now stamps every local account write with _syncTs (epoch ms).
sanitizeAccount propagates _syncTs into Appwrite so both sides carry the
same timestamp field. hydrateFromCloud reads both timestamps and picks the
authoritative side before merging:

  localTs > cloudTs  → local wins; Appwrite is re-pushed to catch up.
  cloudTs >= localTs → cloud wins (standard cloud-wins merge).

Safety net for legacy records without _syncTs: Math.max is applied to
tokens and staked so an offline-earned balance is never silently discarded.

Covers: offline device recovery, reconnect, multi-device divergence.

Tests: scripts/test-cloud-sync-merge.mjs — 22 assertions, all passing.

Closes Shruti070107#543
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CloudSync can overwrite newer account balances with stale remote data, causing irreversible token and state loss

1 participant