Files
bitvid/js/services/syncConflict.js
T
thePR0M3TH3ANandClaude Opus 4.8 dec5cf8fe1 feat(sync): warn before overwriting a newer remote copy (conflict guard)
Saves push silently as before, but if the relay copy is NEWER than the one this
device last pushed (another device changed it), the user is now asked to confirm
before overwriting — preventing a silent multi-device clobber. No nag on normal
saves.

- js/services/settingsSyncFlags.js: track per-pubkey+kind last-pushed created_at
  (getSyncPushedAt/setSyncPushedAt).
- js/services/syncConflict.js: pushWithConflictCheck — peek remote created_at
  (list-only, no decrypt), compare to last push, call confirmOverwrite only on a
  real conflict, then push strictly-newer than remote so the replace wins.
- storage/walletSyncService push()/enable() route through it and accept a
  confirmOverwrite callback.
- ProfileStorage/WalletController supply confirmOverwrite (window.confirm) and
  surface a "kept the newer copy" status when the user declines.

tests/sync-conflict.test.mjs: no-remote→silent, newer→prompt(+decline blocks
push), confirm→strictly-newer push, older→no prompt. Mutation-verified
(ignoring the decline fails). Service-test twins gained exists().

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 21:21:54 -04:00

51 lines
1.6 KiB
JavaScript

// Conflict-aware push for encrypted settings sync (todo #15 follow-up).
//
// On save we normally push silently. But if the relay copy is NEWER than the one
// we last pushed, another device changed it — overwriting would lose that. So we
// peek the remote created_at (list-only, no decrypt), compare it to our last
// push, and only when the remote is newer do we ask the caller's confirmOverwrite
// callback. On confirm (or no conflict) we push, forcing created_at strictly
// newer than the remote so the replace wins, and record the new push timestamp.
import { getSyncPushedAt, setSyncPushedAt } from "./settingsSyncFlags.js";
export async function pushWithConflictCheck({
encryptedSync,
dTag,
kind,
pubkey,
payload,
confirmOverwrite,
} = {}) {
let remoteCreatedAt = 0;
if (encryptedSync && typeof encryptedSync.exists === "function") {
try {
const peek = await encryptedSync.exists(dTag);
remoteCreatedAt = Number(peek?.createdAt) || 0;
} catch (error) {
remoteCreatedAt = 0;
}
}
const lastPushed = getSyncPushedAt(pubkey, kind);
if (remoteCreatedAt && remoteCreatedAt > lastPushed) {
const confirmed =
typeof confirmOverwrite === "function"
? await confirmOverwrite({ remoteCreatedAt, lastPushed })
: true;
if (!confirmed) {
return { ok: false, skipped: true, conflict: true, remoteCreatedAt };
}
}
const result = await encryptedSync.push(dTag, payload, {
afterCreatedAt: remoteCreatedAt,
});
if (result?.ok && Number.isFinite(result.createdAt)) {
setSyncPushedAt(pubkey, kind, result.createdAt);
}
return result;
}
export default pushWithConflictCheck;