Skip to content

Keep Margin highlights in sync: edits and deletes on Margin reach Skyreader - #706

Open
disnet wants to merge 6 commits into
mainfrom
claude/project-thread-u0p64a
Open

disnet wants to merge 6 commits into
mainfrom
claude/project-thread-u0p64a

Conversation

@disnet

@disnet disnet commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Requested by Tim · project thread

What

Skyreader already had a one-way Margin highlight import ("Bring in highlights from Margin"). It only ever added, so a note edited or a highlight deleted on Margin never reached Skyreader. This adds the sync half, and shows the reader that it is happening.

Each poll now also reconciles every highlight linked to an at.margin.note, whether it was imported or published from here:

  • Note edited on Margin: the highlight takes Margin's note. The exception is a note the reader also edited in Skyreader and that hasn't reached Margin yet. That edit wins and is sent again.
  • Note deleted on Margin: an imported highlight is removed. A highlight made in Skyreader is only unpublished: the link is cleared and the highlight stays, private. An imported highlight with an unsent edit is also kept, unpublished.

Sync direction. Margin to Skyreader is a full reconcile (add, edit, delete), the same diff-against-what-we-hold idea as linked Shelf collections. Skyreader to Margin stays per-highlight "Publish to Margin". Edits and deletes of published highlights already propagate, and the poll now re-sends an edit that never got there. Auto-publishing every highlight would make private ones public, so this PR deliberately doesn't do it.

When it runs. The poll is still interval-gated (15 min) and shared by callers. AppShell now also triggers it app-wide, on load and when the tab becomes visible, not only on /highlights and the review deck.

Showing the sync to the reader

Tim asked how a reader knows the sync is happening. Before this, the poll ran silently.

  • Top of /highlights: a "Sync with Margin" switch, with the status beside it: "Syncing…", "Synced 5m ago", "Couldn't reach Margin just now", or "Off." It also notes a partial fetch. It renders straight away, with the switch disabled until the permission check lands, so the list doesn't shift.
  • Settings → Reading → Highlights: the same control (MarginSyncControl.svelte, shared by both places), with the same status line.
  • Removals: a toast says when a sync removed or unpublished highlights deleted on Margin, and stays up for 6s.
  • Lapsed grant: the sync line says "Syncing stopped: Margin access lapsed." and offers "Allow Margin access". The app's own permission banner covers other pages.
  • marginSync.svelte.ts (new) holds syncing, failed, partial and lapsed state, and the last-synced time per account in localStorage.

How conflicts are decided

Each linked highlight carries marginNote: the note Margin's record was last known to hold. It's set when Skyreader publishes or updates the record, on import, and when the sync takes Margin's version. A local note that differs from it is an edit made here that hasn't reached Margin yet, on whatever device it was made. The sync never reads Margin's older note over it, and sends it again when nothing of it is still queued or in flight.

Highlights linked before this field existed get it recorded on the first poll where both sides agree. That's one write per item. Until then, Margin wins for them, guarded as below.

Safety rails

A deletion is only read from a listing that can prove it:

  • The backend now returns rkeys (every note record listed, parseable or not), did, and a server-clock listedAt. A record that still exists but no longer parses is left alone, and an older backend without rkeys never triggers deletions.
  • Never from a truncated listing.
  • Only for TID rkeys older than a 10-minute grace window before the server's listedAt.
  • Never for a note in another repo.

The sync also skips highlights that changed while the listing was out:

  • a Margin write sitting in the sync queue (checked before and after the listing);
  • a note PUT or DELETE in flight, or started within a minute of the listing (tracked by rkey in marginWrites.ts);
  • any other change since the listing was requested.

It stops before applying anything if the import was switched off, or the account changed, mid-poll.

A note update on a record that is gone now returns 404 note_gone instead of recreating it. Recreating would undo the deletion, and the next poll would import the revived note as a second highlight. The client unpublishes on that 404, whether the update was sent directly or from the queue. note_gone only fires when the PDS says RecordNotFound. A refused token or similar is not a deletion and fails as before.

Changes

  • backend/src/routes/integrations.ts: GET /api/integrations/margin/highlights also returns rkeys, did, listedAt. PUT /api/integrations/margin/notes/:rkey returns 404 note_gone for a deleted note.
  • frontend/src/lib/types: Highlight.marginNote.
  • frontend/src/lib/utils/highlightAliases.ts: marginSynced mutation (rkey-guarded); the margin mutation records and clears marginNote.
  • frontend/src/lib/utils/marginHighlightImport.ts: planMarginHighlightSync (pure). Change kinds: note, synced, push, remove, unlink.
  • frontend/src/lib/services/marginHighlightImport.ts: applies the plan after import and rekey, batching note bookkeeping per item. The outcome gains updated, removed, unpublished. It reports to marginSyncStore and toasts removals.
  • frontend/src/lib/services/marginHighlights.ts and sync-queue.ts: record marginNote on publish and update. On note_gone, unlink, only while the highlight still links to that record. pendingMarginNoteHighlightIds().
  • frontend/src/lib/services/marginWrites.ts (new), wired into api.updateMarginNote and deleteMarginNote.
  • frontend/src/lib/stores/marginSync.svelte.ts (new), components/settings/MarginSyncControl.svelte (new, extracted from HighlightSettings.svelte), HighlightsPage.svelte (the bar), toast.svelte.ts (lingerMs).
  • AppShell.svelte: the app-wide poll. highlightReview.ts: redeal an untouched deck after removals too.
  • Docs: guide/saving-and-highlights.md.

Tests

  • Frontend: all 1670 vitest tests pass; svelte-check reports 0 errors. New tests cover the planner (conflicts, resend, legacy baseline, deletion rails), the marginSynced mutation, marginWrites, pendingMarginNoteHighlightIds, the 404 handling in updateHighlightNoteOnMargin, redeal-after-removal, marginSyncStore, and a mounted test of MarginSyncControl.
  • Backend: margin-highlights.spec.ts passes. It covers the listing fields, the 404 on a deleted note, no 404 on a refused read, and the merge onto a note that still exists.

Review

Each round ran the code-review skill at high effort and an independent breaker subagent.

Round 1 (sync), fixed

  • A poll could revert an online note edit whose PUT was still landing.
  • A queued write could drain while the listing was out and lose its protection.
  • Client clock skew could defeat the deletion grace window.
  • Switching the import off mid-poll still applied removals.
  • A note edit with no source URL was silently unsendable.
  • The docs overstated what syncs.

Round 2 (cross-device fixes Tim asked to fold in), fixed

  • The 404 originally fired on any definitive read failure, so an expired token would have unpublished. It is now RecordNotFound only, and the client requires the note_gone code.
  • An edit that never reached Margin would have frozen Margin's side for good. The poll now re-sends it.
  • Older highlights never got a baseline. They now get one on the first poll where both sides agree.
  • An unlink or bookkeeping write could land on a highlight republished as a new record. Both are now rkey-guarded.
  • Undo carried a stale marginNote.

Round 3 (sync visibility UI), fixed

  • The toggle's result note never cleared, so it hid every later status, including "Offline…" after the reader came back. A sync that starts now replaces it.
  • A failed sync looked like success. It now says it couldn't reach Margin.
  • The bar popped in after the permission check and pushed /highlights down. It now renders at once.
  • "Couldn't check your Margin permissions… once you're back online" showed with the sync off, and to readers who were online. It now shows only while the sync is on, with wording for offline vs. failure.
  • Removals were reported twice, by the toast and the toggle note. Now only the toast reports them, and it ends with a period.
  • Test gap: a new component test for the control, plus store tests for the failed state.

Left alone

  • An edit made while a publish is still queued isn't folded into the queued create. The next poll's re-send heals it within one interval, and folding it in would touch every note-edit caller.
  • On /highlights, the grant prompt still shows to readers without Margin scopes. It's the only explanation for the disabled switch.
  • After a re-login, the single localStorage slot forgets the other account's time. That costs one "Not synced yet." until the next poll.
  • Round 1's code review also raised findings in shelf-link.ts, saveBacking, QueueBacking and SaveBackingPicker. Those came from Keep a linked collection's new saves out of the Queue's inbox #703's diff, so they're out of scope here.

🤖 Generated with Claude Code

https://claude.ai/code/session_014mh1a5TEH18exKJYBvhJoC

…kyreader

The Margin highlight import only ever added. Each poll now also reconciles every
highlight linked to a Margin note: a note edited on Margin updates it, and one
deleted there removes an imported highlight or unpublishes one made here.
Absence is only trusted from a complete listing of the repo, for records older
than a grace window, and never over a queued or in-flight local write. The poll
also runs app-wide (open and tab return), not only on /highlights and review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mh1a5TEH18exKJYBvhJoC
@disnet disnet self-assigned this Oct 27, 2026
Track Margin note writes by rkey (in flight or started within a minute of the
listing) and skip them, read the sync queue before and after the listing, date
rkeys by the server's clock, stop before removals if the import was switched
off or the account changed mid-poll, fall back to the highlight's own source
URL for a note edit, and say in the docs what a Margin deletion takes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mh1a5TEH18exKJYBvhJoC
@disnet
disnet marked this pull request as ready for review October 27, 2026 22:54
…on't revive a deleted note

Each highlight now remembers the note Margin's record was last known to hold
(marginNote), set whenever Skyreader writes the record or takes Margin's
version. A local note that differs from it is an unsent edit, so the sync
leaves it alone on any device, rather than only where the write is queued.

A note update on a record deleted on Margin now 404s instead of recreating it
(which the next poll would import as a second highlight); the client
unpublishes its highlight on that 404, direct or from the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mh1a5TEH18exKJYBvhJoC
… real deletion

- note_gone only when the PDS says the record isn't there, not for any
  definitive read failure (a refused token is not a deletion), and the client
  only treats a 404 carrying that code as one
- an edit made here that never reached Margin (lost from a queue, cleared,
  made while a publish was queued) is sent again by the next poll instead of
  freezing Margin's side for good
- a highlight linked before marginNote existed gets it recorded on the first
  poll where both sides agree, one write per item
- unlink and sync bookkeeping act only while the highlight still links to the
  record the write was about; Undo drops marginNote with the link

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mh1a5TEH18exKJYBvhJoC
- one MarginSyncControl, shared by Settings, the review deck's gear and a new
  line at the top of /highlights: the switch, plus Syncing… / Synced 5m ago /
  partial / lapsed-with-Allow-Margin-access
- marginSync store: whether a poll is out, when it last finished (per device
  and account), whether it was partial, whether the grant lapsed
- a toast, up long enough to read, when a sync removes or unpublishes
  highlights deleted on Margin
- toggle renamed to Sync highlights with Margin; docs follow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mh1a5TEH18exKJYBvhJoC
- A later sync replaces what the toggle said, instead of the note sticking
- Say when a sync couldn't reach Margin
- Render the bar at once (switch disabled until the permission check lands), so it
  doesn't push /highlights down
- Only mention the permission check while the sync is on, and stop telling an
  online reader to come back online
- Removals are reported by the toast alone; the toast ends with a period
- Component test for the control; store tests for the failed state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mh1a5TEH18exKJYBvhJoC

This branch has not been deployed

No deployments
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.

2 participants