Skip to content

fix(persistence): never let a device that started before sync wipe the synced library - #398

Merged
chhoumann merged 12 commits into
masterfrom
fix/fresh-device-sync
Oct 11, 2026
Merged

chhoumann merged 12 commits into
masterfrom
fix/fresh-device-sync

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Summary

A device that starts PodNotes before its sync client delivers data.json no longer wipes the library on the user's other devices. This covers a new device, and one whose file was missing, empty or unreadable.

Since #388, a device with no unsaved edits takes a changed data.json whole. A fresh install writes a defaults file as soon as PodNotes loads. When that file reached a device that held the synced library, the merge read every difference as an edit from another device, and every device lost its feeds, progress, playlists and preferences. In the other race order, the new device lost what its user had added. Obsidian Sync produces both orders (its conflict rules).

The fix gives data.json an optional root libraryId.

  • A new install gets an id that sorts by when its library started. A file without one belongs to the library that the devices already share. It reads as "", which sorts first.
  • A file whose libraryId differs from the base is another library. mergeDataFile merges the two from the defaults document, so every entry of both survives. The result keeps the id of the library that started first, so that library's other devices still merge three ways.
  • Every saved file carries the id, so an Obsidian Sync key merge cannot put one device's id on the other device's content.

This PR also settles the two open Macroscope threads on #388. Thread (b) is real and fixed. Interrogate also found a related #388 bug, also fixed: a failed write that was already settled got settled again and brought back a removed entry. Thread (a) cannot happen, because no release among the 96 tags assigns or clears a playlist's currentEpisode.

Verification

Obsidian 1.13.7 ran on headless Linux in the isolated worktree instance, with two vaults. Vault A held a library: 2 feeds, 2 progress records, a playlist, a queue, favorites, playback rate 1.5 and a custom template. Vault B was a fresh install that added http://127.0.0.1.nip.io:20784/feed-b.xml in Settings. A script copied data.json between the vaults the way a sync client does.

Scenario master this PR
B's file reaches A Both devices keep only B's podcast. A loses everything else. Both devices hold A's library and B's podcast after 1 delivery.
A's file reaches B B loses the podcast it added. Both devices hold everything after 1 delivery.
B's install-time file reaches A, no user action A is wiped to defaults. Both devices hold A's library.

Device A on master after B's file arrives: Queue 0, Favorites 0, Played 0, one podcast Device A on this PR after B's file arrives: Queue 1, Favorites 1, Played 1, Road Trip 1, the library's podcasts

Release impact

The storage change is additive. Every saved data.json gets a root libraryId string, and a file without one reads as "". schemaVersion stays 2, and no migration runs. Released 2.21.1 keeps the field when it saves.

Known limits:

Note

Prevent pre-sync devices from wiping the synced library via library identifiers

  • Adds a libraryId to every persisted data document. New installs get a timestamp+UUID identity; legacy files decode with an empty pre-identifier marker in podNotesData.ts
  • mergeDataFile in merge.ts handles two documents with different library identifiers by merging both libraries and picking the lexicographically earlier identifier. Same-library merges use the ordinary three-way merge
  • DataFileWriter.persist and applyExternalChange in writer.ts now use mergeDataFile. Failed writes are settled when their exact document is later observed on disk
  • Fixes threeWayMergeSettings to recurse keys when the base object is absent, instead of taking the local value wholesale
  • Extensive unit and e2e tests cover two-device file exchanges, Obsidian Sync key merges, unreadable startup files, and cross-library podcast renames
  • Behavioral Change: merged documents now retain entries from both libraries instead of overwriting one with the other; every encoded v2 document carries a libraryId (empty string for legacy)

Macroscope summarized baee866.

…ed library

A device that starts with no data.json writes a defaults file at once and
saves its own changes on top. When that file reaches a device holding the
synced library, the library device adopts it whole, because its memory equals
its base and the merge takes disk. In the other race order the new device
adopts the library and drops what its user added.

Also reproduces a rollback undone when an external change reports the file a
failed write reached.
…eal Obsidian

PodNotes started with no data.json writes the file a new device holds before
the sync client delivers the library. Delivering it over a library loses the
library; delivering the library over it loses the new device's change.
… file a failed write reached

A save settles a failed write whose content the file shows, so memory and
base match what is on disk before it merges. A change reported elsewhere
merged that file against the old base instead, so an edit made since the
failed write, such as a failed import's rollback, was undone.
…re sync meets the synced data.json

A device that starts with no data.json writes a library of its own. When that
file reached a device holding the synced library, the merge read every
difference from its base as an edit made elsewhere and took the file whole, so
the library was gone on every device. In the other race order the new device
dropped what its user had added.

data.json now carries an optional root libraryId. A new install gets one that
sorts by when the library started; a file without one belongs to the library
every device shared before this change (""). A file whose libraryId differs
from the base is another library: the two merge from the empty library, so
every entry of both survives, and the merged file keeps the libraryId of the
library started first, so its other devices go on merging three ways. Every
decoded file carries a libraryId, so each save writes one and an Obsidian Sync
key merge cannot pair one device's id with the other's content.

An object both sides added, such as a playlist both libraries created under one
name, now merges per key instead of taking one side whole. A device that could
not read its file at startup starts a library of its own for the same reason.
The test-only DataFileWriter.hasBase getter is gone.
…was already settled

A failed write that reached disk is settled when the file shows it, but stays
recorded, so the next save settles the same snapshot again and brings back an
entry removed since.
…d disk

Settling a failed write whose content the file shows now also drops the
record, in a save and in a change reported elsewhere alike. Kept, the record
was settled again by the next save against memory from before the failure,
which brought back an entry removed since.
The codec now writes the libraryId wherever it encodes a document, "" when
the root fields have none, instead of relying on every caller to decode one
first. A document built without them stays in its library, and the type says
so. newLibraryId names the id a new library gets.

The two-device tests assert that a device writes nothing once both agree, and
the e2e helper checks that it produced a new device's file.
The codec cases build the file a released version wrote, without a key or
with one that is not text, instead of letting the encoder fill it in first.
The crossed-delivery test drops a delivery of a device's own file. The
encoder notes why libraryId is not a known key.
…it relies on

mergeDataFile names the empty library it merges two libraries from and keeps
the library that started first; the library from before ids is a named
constant. The test that memory, not base, decides which library a merge keeps
stands on its own.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: baee866
Status: ✅  Deploy successful!
Preview URL: https://5340416e.podnotes.pages.dev
Branch Preview URL: https://fix-fresh-device-sync.podnotes.pages.dev

View logs

…ferent titles

The library saved a podcast under an earlier title and a new device saved its
current title, both with the same feed URL. After the two libraries merge,
both tiles render, each opens the feed's episodes, Settings opens, and no
error is thrown.
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