From 757be5a1a2c3eacae3092156a27bc35f74908fb7 Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Sat, 22 Aug 2026 08:25:48 +0800 Subject: [PATCH] Wheeler cloud sync: union-merge instead of pull-replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authInit previously replaced the entire local trade array with the cloud copy whenever the remote savedAt was newer. A trade entered locally while signed out (or before its push landed) never bumps wheeler_cloud_ts, so a later boot pull would silently clobber it — how an IBIT CSP went missing. Reconcile by union-merging local + remote by trade id (mergeTradesById): a trade on only one side is always kept; the newer side wins shared-id conflicts. Converge whichever store is behind. Deletions still don't propagate (accepted tradeoff vs. silent loss). Adds test/unit/wheeler-merge.test.js incl. the local-only-survives-pull regression. All 190 tests pass; both builds clean. --- CLAUDE.md | 2 +- src/js/tradfi/12-cloud-sync.js | 53 +++++++++++++++++++++++++++------ test/unit/wheeler-merge.test.js | 48 +++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 test/unit/wheeler-merge.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 450194a..1fe7c6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ Tests mirror this: `test/helpers/assemble.js` does the fragment substitution and | File | Lines | Key exports / purpose | |------|------:|-----------------------| | `12a-persistence.js` | ~28 | **Persistence seam** (tradfi impl): local-first under `wheeler_trades`, `currentUserKey() → 'local'` (#84). Same basename as crypto's so it fills the same order slot. `persist()` also calls `scheduleCloudPush()` when signed in (#110) | -| `12-cloud-sync.js` | ~95 | **Wheeler cloud sync** (#110, ADR 0007, Design B): `authInit` (boot: GET `/api/wheeler-sync` → set gate UI, pull-if-newer or back-up-local), `cloudPush`/`scheduleCloudPush` (debounced POST of the FULL trade array), `wheelerSignIn`/`wheelerSignOut`, `_setAuthUI`, `_setCloudStatus`. Auth state via session cookie; only pushes when signed in | +| `12-cloud-sync.js` | ~95 | **Wheeler cloud sync** (#110, ADR 0007, Design B): `authInit` (boot: GET `/api/wheeler-sync` → set gate UI, then **union-merge** local+remote via `mergeTradesById` and converge whichever store is behind — never wholesale-replaces, so a pull can't drop a local-only trade), `mergeTradesById(local, remote, preferRemote)` (pure, dual-exported: union by `id`, newer side wins shared ids; deletes don't propagate), `cloudPush`/`scheduleCloudPush` (debounced POST of the FULL trade array), `wheelerSignIn`/`wheelerSignOut`, `_setAuthUI`, `_setCloudStatus`. Auth state via session cookie; only pushes when signed in | | `17-boot.js` | ~23 | **Wheeler boot**: no wallet popup / chain. `trades = await loadTrades()`, `sAsset = ''` (free-text ticker), `setType(sType)`, `render()`, `fetchExpiryPrices()`, `authInit()` (fire-and-forget). Empty trades → rTable's "add your first trade" prompt | | `19-tradfi-form.js` | ~50 | `setTicker(v)` (mirrors free-text ticker → `sAsset`, uppercased) and `wheelerAddTrade()` — manual PUT/CALL/HOLDING entry (crypto's core `addTrade()` is HOLDING-only), routed through `save()`/`render()`. Trades carry `platform: 'MANUAL'`. Supports the CLOSED (buy-to-close) outcome: reads `f-closecost` and stores `closeCost` (netted off premium by the core cash-flow lens). **Options are entered in contracts and stored as shares** via `contractsToShares` (holdings stay raw shares); `setSizeDisplay(unit)` flips the header contracts↔shares toggle and re-renders. Display of `size` routes through `fmtSize` in `06-render-table.js` (#91) | diff --git a/src/js/tradfi/12-cloud-sync.js b/src/js/tradfi/12-cloud-sync.js index c2bec40..91b07d5 100644 --- a/src/js/tradfi/12-cloud-sync.js +++ b/src/js/tradfi/12-cloud-sync.js @@ -35,8 +35,28 @@ function _setAuthUI(authed, email) { function wheelerSignIn() { window.location = '/api/auth/google'; } function wheelerSignOut() { window.location = '/api/auth/google?action=logout'; } -// Boot-time: learn auth state, then either pull newer remote data or back up -// local trades (first-login migration) if signed in. +// Union-merge local + remote trade arrays by `id`. A trade present on only one +// side is always kept, so a cloud pull can never silently drop a locally-entered +// trade. On a shared id, the side with the newer savedAt wins, preserving +// edit last-write-wins. NOTE: deletions do not propagate — an accepted tradeoff +// vs. silent data loss (see ADR 0007). Pure; dual-exported for Node tests. +function mergeTradesById(local, remote, preferRemote) { + const byId = new Map(); + const winner = preferRemote ? remote : local; + const loser = preferRemote ? local : remote; + for (const t of loser) byId.set(t.id, t); + for (const t of winner) byId.set(t.id, t); // winner overwrites on shared id + return [...byId.values()]; +} + +// Stable content fingerprint (id-sorted) so we only save/push when something +// actually changed, avoiding needless writes and cross-device push loops. +function _tradesFingerprint(arr) { + return JSON.stringify([...arr].sort((a, b) => (a.id || 0) - (b.id || 0))); +} + +// Boot-time: learn auth state, then reconcile local and remote by merging both +// (never replacing) so no trade is lost, and converge whichever store is behind. async function authInit() { try { const r = await fetch('/api/wheeler-sync'); @@ -45,20 +65,31 @@ async function authInit() { if (!data.authed) { _setAuthUI(false); return; } _setAuthUI(true, data.email); + const remote = Array.isArray(data.trades) ? data.trades : []; const remoteTs = data.savedAt || 0; const localTs = parseInt(localStorage.getItem(WHEELER_CLOUD_TS) || '0'); - if (Array.isArray(data.trades) && data.trades.length > 0 && remoteTs > localTs) { + const merged = mergeTradesById(trades, remote, remoteTs > localTs); + + const localIds = new Set(trades.map(t => t.id)); + const gained = merged.filter(t => !localIds.has(t.id)).length; + const mergedFp = _tradesFingerprint(merged); + + if (mergedFp !== _tradesFingerprint(trades)) { _suppressPush = true; - trades = data.trades; - localStorage.setItem(WHEELER_CLOUD_TS, String(remoteTs)); + trades = merged; save(); render(); _suppressPush = false; - if (typeof toast === 'function') { - toast('Pulled ' + data.trades.length + ' trade' + (data.trades.length === 1 ? '' : 's') + ' from cloud', 'info'); - } + } + + if (mergedFp !== _tradesFingerprint(remote)) { + scheduleCloudPush(); // push local-only trades up; cloudPush sets the ts } else { - scheduleCloudPush(); // back up local trades to the freshly-signed-in account + localStorage.setItem(WHEELER_CLOUD_TS, String(Math.max(remoteTs, localTs))); + } + + if (gained > 0 && typeof toast === 'function') { + toast('Pulled ' + gained + ' trade' + (gained === 1 ? '' : 's') + ' from cloud', 'info'); } _setCloudStatus('ok'); } catch { @@ -90,3 +121,7 @@ function scheduleCloudPush() { clearTimeout(_pushTimer); _pushTimer = setTimeout(cloudPush, 300); } + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { mergeTradesById }; +} diff --git a/test/unit/wheeler-merge.test.js b/test/unit/wheeler-merge.test.js new file mode 100644 index 0000000..f16d355 --- /dev/null +++ b/test/unit/wheeler-merge.test.js @@ -0,0 +1,48 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { mergeTradesById } = require('../../src/js/tradfi/12-cloud-sync.js'); + +const ids = arr => arr.map(t => t.id).sort((a, b) => a - b); + +// ── The reported bug: a local-only trade must survive a cloud pull ──────────── + +test('mergeTradesById: local-only trade is never dropped by a newer remote', () => { + const local = [{ id: 1, asset: 'PURR' }, { id: 2, asset: 'IBIT' }]; // IBIT only local + const remote = [{ id: 1, asset: 'PURR' }, { id: 3, asset: 'HOOD' }]; // HOOD only remote + const merged = mergeTradesById(local, remote, /* preferRemote */ true); + assert.deepStrictEqual(ids(merged), [1, 2, 3]); +}); + +test('mergeTradesById: remote-only trade is pulled in', () => { + const local = [{ id: 1 }]; + const remote = [{ id: 1 }, { id: 2 }]; + assert.deepStrictEqual(ids(mergeTradesById(local, remote, false)), [1, 2]); +}); + +// ── Shared-id conflicts resolve by the preferRemote flag ────────────────────── + +test('mergeTradesById: preferRemote=true → remote version wins on shared id', () => { + const local = [{ id: 1, outcome: 'OPEN' }]; + const remote = [{ id: 1, outcome: 'CLOSED' }]; + const merged = mergeTradesById(local, remote, true); + assert.strictEqual(merged.find(t => t.id === 1).outcome, 'CLOSED'); +}); + +test('mergeTradesById: preferRemote=false → local version wins on shared id', () => { + const local = [{ id: 1, outcome: 'OPEN' }]; + const remote = [{ id: 1, outcome: 'CLOSED' }]; + const merged = mergeTradesById(local, remote, false); + assert.strictEqual(merged.find(t => t.id === 1).outcome, 'OPEN'); +}); + +// ── Convergence: merging equal sets is a no-op ──────────────────────────────── + +test('mergeTradesById: identical arrays merge to the same set', () => { + const a = [{ id: 1 }, { id: 2 }]; + assert.deepStrictEqual(ids(mergeTradesById(a, a.slice(), true)), [1, 2]); +}); + +test('mergeTradesById: empty remote keeps all local trades', () => { + const local = [{ id: 1 }, { id: 2 }]; + assert.deepStrictEqual(ids(mergeTradesById(local, [], true)), [1, 2]); +});