mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 19:02:28 +00:00
sync/zaps: enabled devices auto-pull newer wallet/storage notes at login; zap errors name the failing address (#15 follow-up)
Two fixes from live testing: - "NWC doesn't sync unless I click Restore": devices with sync ENABLED only ever pushed-on-save — edits from another device sat unseen until the manual Restore click. encryptedSyncItem.autoPullIfNewer pulls the remote note at login when its created_at is newer than this device's last-synced marker (set on push AND now after a successful pull, so the same note is never re-pulled and the next conflict check stays honest). Wired into the login restore flow ahead of the one-time offer; disabled items are never touched. - "Could not get user information" on a known-good creator address: that reason string comes from the LNURL SERVER, and the failing fetch was the PLATFORM-FEE address, not the creator's. prepareLightningContext now wraps both metadata fetches so the toast names which address failed (e.g. "Platform fee Lightning address (…): Could not get user information") instead of blaming the creator. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
86d7db4b1a
commit
e2d4251035
4 files changed
+206
-3
No files matched your search
@@ -16,7 +16,12 @@
|
||||
// logLabel short label for warnings
|
||||
|
||||
import { encryptedSync as defaultEncryptedSync } from "../nostr/encryptedSyncFacade.js";
|
||||
import { isSyncEnabled, setSyncEnabled } from "./settingsSyncFlags.js";
|
||||
import {
|
||||
isSyncEnabled,
|
||||
setSyncEnabled,
|
||||
getSyncPushedAt,
|
||||
setSyncPushedAt,
|
||||
} from "./settingsSyncFlags.js";
|
||||
import { pushWithConflictCheck } from "./syncConflict.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
|
||||
@@ -100,6 +105,36 @@ export function createEncryptedSyncItem({
|
||||
return { ...result, imported: true };
|
||||
}
|
||||
|
||||
// #15 follow-up ("doesn't sync unless I click Restore"): a device with sync
|
||||
// ENABLED auto-pulls a NEWER remote note at login. Previously enabled devices
|
||||
// only pushed-on-save — edits from another device sat unseen until the manual
|
||||
// Restore click. The per-device freshness marker doubles as "local state
|
||||
// matches note X": it's recorded on push AND after a successful pull, so the
|
||||
// next conflict check stays honest and the same note is never re-pulled.
|
||||
async function autoPullIfNewer(pubkey) {
|
||||
const key = normalizePubkey(pubkey);
|
||||
if (!key || !isAvailable() || !isEnabled(key)) {
|
||||
return { pulled: false, reason: "not-enabled" };
|
||||
}
|
||||
let remoteCreatedAt = 0;
|
||||
try {
|
||||
const peek = await encryptedSync.exists(dtag);
|
||||
remoteCreatedAt = Number(peek?.createdAt) || 0;
|
||||
} catch (error) {
|
||||
return { pulled: false, reason: "peek-failed", cause: error };
|
||||
}
|
||||
const lastSynced = getSyncPushedAt(key, id);
|
||||
if (!remoteCreatedAt || remoteCreatedAt <= lastSynced) {
|
||||
return { pulled: false, reason: "up-to-date" };
|
||||
}
|
||||
const result = await pull(key);
|
||||
if (result?.imported) {
|
||||
setSyncPushedAt(key, id, remoteCreatedAt);
|
||||
return { pulled: true, remoteCreatedAt };
|
||||
}
|
||||
return { pulled: false, reason: result?.error || "pull-failed" };
|
||||
}
|
||||
|
||||
async function enable(pubkey, options = {}) {
|
||||
setEnabledFlag(pubkey, true);
|
||||
return push(pubkey, options);
|
||||
@@ -118,6 +153,7 @@ export function createEncryptedSyncItem({
|
||||
hasRemote,
|
||||
push,
|
||||
pull,
|
||||
autoPullIfNewer,
|
||||
enable,
|
||||
disable,
|
||||
};
|
||||
|
||||
@@ -102,6 +102,32 @@ export function createSettingsRestorePrompt({
|
||||
return { offered: false, reason: "no-pubkey" };
|
||||
}
|
||||
|
||||
// Items ALREADY enabled on this device sync silently: pull the remote note
|
||||
// when it's newer than what this device last pushed/pulled (i.e. another
|
||||
// device changed it). No prompt — the user opted in when enabling sync.
|
||||
// Without this, enabled devices only ever pushed, and edits from another
|
||||
// device sat unseen until the manual Restore click ("doesn't sync").
|
||||
const autoPulled = [];
|
||||
for (const [kind, service] of [
|
||||
["storage", storageSync],
|
||||
["wallet", walletSync],
|
||||
]) {
|
||||
if (typeof service?.autoPullIfNewer !== "function") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await service.autoPullIfNewer(key);
|
||||
if (result?.pulled) {
|
||||
autoPulled.push(kind);
|
||||
}
|
||||
} catch (error) {
|
||||
logger?.warn?.(`[settingsRestore] ${kind} auto-pull failed`, error);
|
||||
}
|
||||
}
|
||||
if (autoPulled.length && typeof onRestored === "function") {
|
||||
onRestored(autoPulled);
|
||||
}
|
||||
|
||||
const candidates = await collectCandidates(key);
|
||||
if (!candidates.length) {
|
||||
// Don't mark offered — a note may be published later from another device;
|
||||
|
||||
+22
-2
@@ -790,7 +790,19 @@ export default class ZapController {
|
||||
throw new Error("Enter a zap amount greater than zero.");
|
||||
}
|
||||
|
||||
const creatorEntry = await fetchLightningMetadata(lightningAddress);
|
||||
// Name WHICH address failed: LNURL servers return opaque reasons like
|
||||
// "Could not get user information", and without the address the creator's
|
||||
// (fine) address gets blamed for a broken platform-fee address.
|
||||
let creatorEntry;
|
||||
try {
|
||||
creatorEntry = await fetchLightningMetadata(lightningAddress);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error?.message || "Unable to load this creator's Lightning info.";
|
||||
throw new Error(
|
||||
`Creator Lightning address (${lightningAddress}): ${detail}`,
|
||||
);
|
||||
}
|
||||
if (shares.creatorShare > 0) {
|
||||
try {
|
||||
validateInvoiceAmount(creatorEntry.metadata, shares.creatorShare);
|
||||
@@ -814,7 +826,15 @@ export default class ZapController {
|
||||
throw new Error("Platform Lightning address is unavailable.");
|
||||
}
|
||||
|
||||
platformEntry = await fetchLightningMetadata(platformAddress);
|
||||
try {
|
||||
platformEntry = await fetchLightningMetadata(platformAddress);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error?.message || "Unable to load the platform's Lightning info.";
|
||||
throw new Error(
|
||||
`Platform fee Lightning address (${platformAddress}): ${detail}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
validateInvoiceAmount(platformEntry.metadata, shares.platformShare);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// #15 follow-up ("NWC doesn't sync unless I click Restore"): a device with
|
||||
// sync ENABLED must auto-pull a NEWER remote note at login. Previously enabled
|
||||
// devices only pushed-on-save, so wallet/storage changes made on another
|
||||
// device sat unseen until the manual Restore click. Covers the shared
|
||||
// encryptedSyncItem.autoPullIfNewer and its wiring in the login restore flow.
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-enabled-device-auto-pull
|
||||
// given: "sync enabled locally; a remote note newer/older than the device's last-synced marker"
|
||||
// when: "autoPullIfNewer runs at login (via settingsRestorePrompt.maybeOffer)"
|
||||
// then: "newer remote -> pulled + applied + marker advanced (no prompt); older/equal -> untouched; disabled -> untouched"
|
||||
// observable_outcomes:
|
||||
// - "applyPayload called exactly when the remote is newer"
|
||||
// - "second login does not re-pull the same note"
|
||||
// - "disabled items still go through the one-time offer, not auto-pull"
|
||||
// determinism_controls:
|
||||
// - "in-memory localStorage; scripted encryptedSync double; no timers"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import test, { beforeEach } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { createEncryptedSyncItem } from "../js/services/encryptedSyncItem.js";
|
||||
import { createSettingsRestorePrompt } from "../js/services/settingsRestorePrompt.js";
|
||||
import { setSyncEnabled, setSyncPushedAt } from "../js/services/settingsSyncFlags.js";
|
||||
|
||||
const PUB = "a".repeat(64);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function makeItem({
|
||||
remoteCreatedAt,
|
||||
id = "wallet",
|
||||
payload = { nwcUri: "nostr+walletconnect://x" },
|
||||
}) {
|
||||
const applied = [];
|
||||
const item = createEncryptedSyncItem({
|
||||
id,
|
||||
dtag: "bitvid:nwc",
|
||||
encryptedSync: {
|
||||
isAvailable: () => true,
|
||||
exists: async () => ({ exists: remoteCreatedAt > 0, createdAt: remoteCreatedAt }),
|
||||
pull: async () => ({ found: true, payload }),
|
||||
push: async () => ({ ok: true, createdAt: remoteCreatedAt + 1 }),
|
||||
clear: async () => ({ ok: true }),
|
||||
},
|
||||
buildPayload: () => payload,
|
||||
applyPayload: async (pubkey, incoming) => {
|
||||
applied.push({ pubkey, incoming });
|
||||
},
|
||||
});
|
||||
return { item, applied };
|
||||
}
|
||||
|
||||
test("enabled + newer remote → pulled, applied, marker advanced (no re-pull)", async () => {
|
||||
const { item, applied } = makeItem({ remoteCreatedAt: 500 });
|
||||
setSyncEnabled(PUB, "wallet", true);
|
||||
setSyncPushedAt(PUB, "wallet", 100); // this device last synced an older note
|
||||
|
||||
const first = await item.autoPullIfNewer(PUB);
|
||||
assert.equal(first.pulled, true);
|
||||
assert.equal(applied.length, 1, "remote payload applied");
|
||||
assert.equal(applied[0].incoming.nwcUri, "nostr+walletconnect://x");
|
||||
|
||||
const second = await item.autoPullIfNewer(PUB);
|
||||
assert.equal(second.pulled, false);
|
||||
assert.equal(second.reason, "up-to-date", "marker advanced — same note not re-pulled");
|
||||
assert.equal(applied.length, 1);
|
||||
});
|
||||
|
||||
test("enabled + remote not newer → untouched; disabled → untouched", async () => {
|
||||
const { item, applied } = makeItem({ remoteCreatedAt: 100 });
|
||||
setSyncEnabled(PUB, "wallet", true);
|
||||
setSyncPushedAt(PUB, "wallet", 100); // this device pushed that very note
|
||||
|
||||
assert.equal((await item.autoPullIfNewer(PUB)).pulled, false);
|
||||
assert.equal(applied.length, 0, "own note never re-applied");
|
||||
|
||||
// A DIFFERENT sync kind whose flag was never enabled on this device (the
|
||||
// wallet flag set above is per pubkey+kind and must not bleed over).
|
||||
const { item: disabledItem, applied: disabledApplied } = makeItem({
|
||||
remoteCreatedAt: 9999,
|
||||
id: "storage",
|
||||
});
|
||||
const result = await disabledItem.autoPullIfNewer(PUB);
|
||||
assert.equal(result.pulled, false);
|
||||
assert.equal(result.reason, "not-enabled");
|
||||
assert.equal(disabledApplied.length, 0);
|
||||
});
|
||||
|
||||
test("login flow: enabled wallet auto-pulls silently; no prompt is shown for it", async () => {
|
||||
const { item, applied } = makeItem({ remoteCreatedAt: 700 });
|
||||
setSyncEnabled(PUB, "wallet", true);
|
||||
setSyncPushedAt(PUB, "wallet", 100);
|
||||
|
||||
let confirmCalls = 0;
|
||||
const restoredBatches = [];
|
||||
const prompt = createSettingsRestorePrompt({
|
||||
storageSync: { isAvailable: () => false, isEnabled: () => false },
|
||||
walletSync: item,
|
||||
confirm: async () => {
|
||||
confirmCalls += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await prompt.maybeOffer(PUB, {
|
||||
onRestored: (items) => restoredBatches.push(items),
|
||||
});
|
||||
|
||||
assert.equal(applied.length, 1, "newer note pulled during login");
|
||||
assert.deepEqual(restoredBatches, [["wallet"]], "user notified of the silent sync");
|
||||
assert.equal(confirmCalls, 0, "no prompt for an already-enabled item");
|
||||
});
|
||||
Reference in new issue
Block a user