Skip to content

fix(sync): put the notes-holding device in the approver role so the approval prompt appears#9

Merged
khokonm merged 1 commit into
mainfrom
fix/sync-new-device-role-classification
Jun 25, 2026
Merged

fix(sync): put the notes-holding device in the approver role so the approval prompt appears#9
khokonm merged 1 commit into
mainfrom
fix/sync-new-device-role-classification

Conversation

@khokonm

@khokonm khokonm commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Problem

Even after #8, the device-transfer approve / decline prompt still appears on no device when adding a new device to a sync chain.

Root cause — role misclassification

The server decided whether a joining device "needs a transfer" with (server/src/index.ts):

const isNewDevice = device.cursor === 0 && otherDevices.length > 0

cursor only advances when a device pushes or acks server ops. But notes are not pushed as ops to new devices — a new device receives them through the peer transfer flow. So the device that generated the sync code and owns all the notes normally has cursor === 0 (it hasn't pushed any ops yet).

Consequently, as soon as a second device joins:

  • The notes-holding origin device is misclassified as isNewDevice = true → its UI shows "Choose a source device" and it asks another device for a transfer. It is also skipped by the pending-transfer replay added in fix(sync): show device-approval prompt after reconnect (replay pending transfers) #8 (which is gated on !isNewDevice).
  • The joining device is also cursor === 0 with others present → also isNewDevice = true.

Both devices sit in the "pick a source" picker waiting for each other. The device that should be the approver is never placed in the approver role, so the approve/decline prompt is never produced — it appears nowhere. This is exactly the reported symptom.

The server can't inspect which device holds notes because the data is end-to-end encrypted. It needs the client to tell it.

Fix

The client now reports whether it already holds local notes via a hasData flag on the join message, and the server only treats a device as new when it has no data:

const hasData = msg.hasData === true
const isNewDevice = !hasData && device.cursor === 0 && otherDevices.length > 0

Effect:

  • A device with local notes is always a source/approver, never a transfer requester. The origin device now connects as a normal synced device and (a) no longer shows the wrong picker, and (b) is eligible for the fix(sync): show device-approval prompt after reconnect (replay pending transfers) #8 pending-transfer replay, so it reliably receives the approval prompt.
  • A fresh device with no notes is correctly the requester — it picks a source, and that source shows the approve/decline prompt.

This breaks the both-devices-are-new deadlock.

Files changed

  • src/utils/sync.ts — add buildJoinPayload() that includes hasData: (local notes > 0); use it at both join send sites (initial connect + token-refresh re-join).
  • server/src/index.ts — read msg.hasData and factor it into the isNewDevice decision; update protocol doc comment.

Where the prompt appears (for the user)

On the device that already has your notes (the one you generated the sync code on / your existing device), the approve/decline prompt shows in two places:

  • A full-screen modal ("Allow sync to ?" with Deny / Approve) — global, appears on any screen.
  • A "Transfer Request" card inside Settings → Sync (Approve / Deny).

Both devices must be open at the same time for the initial copy (the relay is ephemeral and only bridges two live devices).

Verification

  • Traced the full flow: client join (hasData) → server isNewDevice → new device picker (needsTransfer) → request-transfer-from → server transfer-requested → client case 'transfer-requested' sets pendingTransfer → global modal in App.tsx + card in SettingsPanel.tsx. The notes-holder is now correctly the approver.
  • Not run locally: tsc / build / lint — frontend deps aren't installed in the sandbox and package installs are blocked (registry 403); the server has no installed toolchain either. Changes are additive and use only existing in-scope symbols (callbacks.getNotes, msg.hasData).

Manual test plan

  1. Device A: enable sync, generate a code (A holds notes). Don't edit anything afterwards (so A's cursor stays 0 — the failing case).
  2. Device B (fresh, no notes): enter the code. B should show "Choose a source device" and list A.
  3. With both apps open, on B tap A. Confirm A shows the approve/decline prompt (full-screen modal, and the card under Settings → Sync).
  4. Approve on A → B receives the notes; deny → B sees "Transfer denied".
  5. Confirm A itself no longer shows "Choose a source device" (it has data → not a new device).

…l prompt appears

The server inferred 'new device needing a transfer' from cursor === 0, but the
device that generates the sync code owns all the notes yet has cursor 0 (it
pushes no ops; new devices get data via transfer). So the notes-holder was
misclassified as a requester and skipped by the #8 replay, while the joiner was
also flagged new — both sat on 'Choose a source device' and the approve/decline
prompt appeared nowhere.

Clients now send hasData (true when local notes exist) on join; the server only
treats a device as new when !hasData && cursor === 0 && otherDevices exist. The
notes-holder is now always the source/approver and reliably receives the
approval prompt.
@khokonm khokonm merged commit 0f7c7e1 into main Jun 25, 2026
3 checks passed
@khokonm khokonm deleted the fix/sync-new-device-role-classification branch June 25, 2026 08:42
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.

1 participant