Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
53 changes: 44 additions & 9 deletions src/js/tradfi/12-cloud-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 {
Expand Down Expand Up @@ -90,3 +121,7 @@ function scheduleCloudPush() {
clearTimeout(_pushTimer);
_pushTimer = setTimeout(cloudPush, 300);
}

if (typeof module !== 'undefined' && module.exports) {
module.exports = { mergeTradesById };
}
48 changes: 48 additions & 0 deletions test/unit/wheeler-merge.test.js
Original file line number Diff line number Diff line change
@@ -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]);
});
Loading