Skip to content

Detect upstream changes before saving - #4

Open
ironprogrammer wants to merge 2 commits into
mainfrom
feature/upstream-change-detection
Open

Detect upstream changes before saving#4
ironprogrammer wants to merge 2 commits into
mainfrom
feature/upstream-change-detection

Conversation

@ironprogrammer

Copy link
Copy Markdown
Owner

Troche is often open on more than one machine. Saving was already per song and diff-based, so idle tabs never wrote and songs created elsewhere were never trashed — but a song edited in two places was overwritten by whichever tab saved last, silently, with wp-admin revisions as the only trace.

Approach

Each song gets a version token: a hash of its stored JSON, so an identical re-save doesn't move it (post_modified_gmt was the obvious alternative but its one-second resolution makes two saves in the same second indistinguishable). Tokens are computed from the post as actually stored, after WordPress's save filters, so a filtered save can't mint a token that instantly reads as a conflict. Clients never compute one, only compare, so the hash is free to change shape later.

A new GET /library/state serves those tokens and nothing else — no song content, so the payload stays flat as the library grows. The app checks it when a tab becomes visible (the moment a laptop is most likely stale) and again before each save, which closes the debounce window. No background polling.

Per-song granularity means most upstream activity isn't a conflict and doesn't need the user:

Situation What happens
Changed elsewhere, untouched here Adopted silently
Added elsewhere Pulled in
Trashed elsewhere, untouched here Dropped
Edited in both places Notice + Keep mine / Use theirs
Edited here, trashed there Notice + Keep mine / Delete here too

Only the last two surface, and they surface per song naming the song — a library-wide "reload" would discard local edits. Flagged songs are held back from writing until resolved; the rest of the library keeps autosaving, so a conflict on one song doesn't stall the others.

Also fixes an update to a song trashed elsewhere reporting itself as "Offline — changes kept locally". The 404 is now distinguished from a network failure and routed into the same reconcile.

Scope

No merging. "Keep mine" still overwrites the other machine's copy — the change is that it's a decision made with the song named, rather than something that happens silently, and the losing version stays in that song's revisions.

Testing

97 passing (npm test). New tests/sync.mjs (29 tests, wired in as test:sync) drives storage.js against a fake in-process WordPress and covers each row above plus the cases most likely to break: a quiet server costs exactly one token request and no content; a held song is neither written nor trashed by the diff; a song created this session isn't duplicated by a sync landing before the app adopts its handle; a kept orphan is re-created rather than PUT to a dead post. Plugin harness 35 → 46.

Also exercised by hand in Playground, staging a real two-machine scenario:

  • Song changed elsewhere, untouched here → adopted silently, pill stays "Saved"
  • Same song edited in both places → flagged, pill "Changed on another device"
  • 12s past the autosave debounce while conflicted → server unchanged, the local rename was never pushed
  • Use theirs → local becomes the server's copy, no write issued
  • Keep mine → local copy pushed
  • Edited here + deleted there → orphan notice; "Keep mine" re-creates it as a new song instead of 404-ing

No console errors.

Troche is often open on more than one machine. Saving was already per song
and diff-based, so idle tabs never wrote and songs created elsewhere were
never trashed — but a song edited in two places was overwritten by whichever
tab saved last, silently, with wp-admin revisions as the only trace.

Give the server a per-song version token (a hash of the stored JSON, so an
identical re-save doesn't move it) and expose it from a new
GET /library/state, which carries tokens only and no content. Clients never
compute a token, only compare, so the hash is free to change shape later.

The app checks that endpoint when a tab becomes visible — the moment a laptop
is most likely stale — and again before each save, closing the debounce
window. No background polling.

Per-song granularity means most upstream activity isn't a conflict and
doesn't need the user: a song changed elsewhere that this tab hasn't touched
is adopted, one added elsewhere is pulled in, one trashed elsewhere is
dropped. Only a song edited in both places, or edited here and trashed there,
surfaces — as a notice naming the song with a choice, since a library-wide
"reload" would discard local edits. Those songs are held back from saving
until resolved; the rest of the library keeps autosaving.

Also fixes an update to a song trashed elsewhere reporting itself as
"Offline — changes kept locally". The 404 is now distinguished from a network
failure and routed into the same reconcile.
@ironprogrammer

Copy link
Copy Markdown
Owner Author

Read the diff, ran test:unit (9), test:sync (29) and the Playground harness (46) — all green — and probed the reconcile paths directly. Design is right: content-derived tokens minted from the post as stored, per-song holds, and 404 → reconcile all hold up. Three things to address before this merges.

1. A re-conflicted song keeps a stale theirs, and "Use theirs" then clobbers the server. (src/App.jsx:363-378) storage.js correctly re-reports the conflict with the newer server copy — probed: second sync returns theirs.bpm = 175 after the other machine saves again. The reducer drops it: seen is built from kept, which still holds the old flag, so the fresh one is filtered out. "Use theirs" then writes the stale copy into local state, which no longer matches serverSnapshot, so the next autosave PUTs it over the other machine's newer version — silently. Reached through the button labelled "take their version". Fix is to let incoming always win, which gets orphan-supersedes-conflict for free.

2. Resolving a conflict leaves the offline buffer holding the discarded copy. (src/App.jsx:400-432) "Use theirs" and "Delete here too" correctly don't set dirty — there's nothing to write — but the buffer is only written on save, so it keeps the version the user just rejected. Reload while offline resurrects it.

3. The core race is still open. The server accepts any PUT unconditionally, so two tabs saving inside the same window still clobber each other silently; the sync-before-save narrows the window but doesn't close it. Worth closing here since the client plumbing already exists: send the expected token with the PUT, 409 on mismatch, and route the 409 into the same reconcile path the 404 already uses.

Tests. tests/sync.mjs is well aimed — held song neither written nor trashed, kept orphan re-created rather than PUT to a dead post, one-token quiet path are the right things to pin. Gaps worth closing alongside the above:

  • { diverged: true } has no test, despite being a headline fix in the description. It works (probed), but nothing stops a refactor regressing it to offline.
  • The re-conflict case is untested at both layers — which is how (1) shipped.
  • The flag reducer has no coverage at all. It's pure; lifting it out of the component makes it directly testable.
  • PHP: nothing asserts a trashed song drops out of /library/state (only out of /library), and the client's whole trash detection rides on that. The unparseable-post skip in get_state() is also uncovered.

Minor: runSave captures the library before the sync round-trip and setLibrary(merged) after, so keystrokes inside that window are dropped (narrow — only when something actually moved upstream); ensureNonEmpty runs after writeBuffer(reconciled) in doSync, and the blank song it mints has no wpId, so the next save POSTs it to the server; setPullFlash timeout isn't cleaned up on unmount.

PHP side otherwise I'd take as-is. Addressing 1–3 plus the test gaps on this branch.

Review follow-up on three points.

Syncing before a save narrows the clobber window but can't close it: the
check and the write aren't one operation, so another machine can land a save
in between. PUT now carries the token the client last saw in an
X-Troche-Expect-Token header, and the server refuses with 409 if the song has
moved since — checked immediately before the write. A refusal is routed into
the same reconcile the 404 already used, so a lost race surfaces as the same
per-song question rather than an overwrite. A custom header rather than
If-Match on purpose: an If-Match a proxy decides to evaluate itself would
fail the save outright, whereas a stripped custom header degrades to the
unconditional write this endpoint has always done.

Fix a re-flagged song keeping the copy it was first flagged with. If the
other machine edited again while the notice sat unresolved, the reducer kept
the original entry and dropped the newer one, so "Use theirs" adopted a stale
copy — which then no longer matched the snapshot and was pushed straight back
over their newer version on the next save, silently. Incoming entries now
win, keeping their position in the strip; unmentioned flags still survive, so
a song's save hold is never stranded. Lifted out of the component as
mergeFlags() so it's directly testable.

Fix resolving a conflict in the server's favour leaving the offline buffer
holding the discarded copy. "Use theirs" and "Delete here too" correctly
don't dirty the library — local already matches the server — but the buffer
is otherwise only written by a save, so an offline reload resurrected the
rejected version.

Tests 97 -> 134. Covers the conditional write end to end (refused, accepted,
and absent-header back-compat), a re-conflict carrying the newer copy, the
diverged result for both 404 and 409 — a headline fix of the parent commit
that had no test — the flag reducer's four rules, offline and expired probes,
and that /library/state agrees with /library about trashed and unparseable
posts, which is what makes deletions propagate at all.
@ironprogrammer

Copy link
Copy Markdown
Owner Author

Addressed in b94d514.

Conditional writes. PUT now sends X-Troche-Expect-Token with the token the client last saw, and save_song() compares it immediately before the write — 409 on mismatch, routed into the same reconcile the 404 already used. The race is closed, not just narrowed. Went with a custom header rather than If-Match: an If-Match a proxy or cache decides to evaluate itself would fail the save outright, whereas a stripped custom header degrades to the unconditional write this endpoint has always done.

Stale theirs. Incoming flag entries now win, keeping their position in the strip; flags the sync didn't mention still survive, so a save hold is never stranded. Lifted out of the component as mergeFlags() in utils.js so the four rules are directly testable.

Buffer on resolve. useTheirs / discardDeleted mirror to the buffer via a new cacheLibrary() — they correctly leave the library un-dirty, so nothing was writing it.

Tests 97 → 134. The conditional write end to end (refused / accepted / absent-header back-compat), a re-conflict carrying the newer copy, diverged for both 404 and 409, the reducer's four rules, offline and expired probes, and that /library/state agrees with /library on trashed and unparseable posts.

One thing I did not change, worth knowing about. The pagehide flush can now be refused where it previously landed: close a tab inside the debounce window while another machine has just edited the same song, and that edit stays in the buffer but the next load discards it, because loadLibrary() replaces app state with the server's copy and consults the buffer only for activeId. Better than the silent clobber it replaces, and the hide→return path is fine (the visibility sync flags it as a conflict), but genuinely closing the tab at that exact moment loses the edit quietly. That's a pre-existing gap — it already applied to any flush that failed, e.g. going offline at close — and fixing it means reconciling the buffer against the server on load rather than replacing, which is its own feature. Happy to open an issue.

Left alone as noted: the runSave capture-then-setLibrary window, ensureNonEmpty running after writeBuffer in doSync, the uncleaned setPullFlash timeout.

@ironprogrammer

Copy link
Copy Markdown
Owner Author

Follow-ups opened for what's left, so this PR doesn't have to grow: #5 (buffer edits discarded on load — the one the refused-flush case exposes), #6 (sync overwriting edits made during its own round-trip), #7 (placeholder-song propagation, buffer write ordering, stray timeout).

@ironprogrammer

Copy link
Copy Markdown
Owner Author

Second pass, against the head including b94d514 — so the three items from the earlier review are in. Confirmed all three land: mergeFlags() re-reports with the newer theirs, cacheLibrary() closes the buffer-on-resolve gap, and the conditional PUT routes a 409 into the same reconcile as the 404. test:unit (17), test:sync (46) and the plugin harness (58) all green on Node 20. Everything below is new or unresolved; nothing already fixed is repeated.

The design holds up. The gap is that it's a three-way merge for content and a two-way merge for existenceprevSnapshot is consulted at every branch that asks "was this edited?", and at none of the branches that ask "was this deleted?". Three bugs fall out of that, and two of them destroy work.

1. A locally deleted song is resurrected by any unrelated upstream change

src/storage.js:480-483 assumes anything on the server but absent locally was added on the other machine:

for (const server of unclaimed.values()) {
  merged.push(server); // added on the other machine
  pulled++;
}

It can't have been — we knew about that song at the last sync, or it wouldn't be in prevSnapshot. The other loop asks; this one doesn't. So a delete sitting in the 10s debounce is undone by the sync runSave() performs immediately before it. Repro, in the existing harness:

const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta"), 3: song("c", "Gamma") });
const storage = await freshStorage();
const lib = await storage.loadLibrary();

// This tab deletes Gamma; still inside the autosave debounce.
const afterDelete = { ...lib, songs: lib.songs.filter((s) => s.id !== "c") };

// Meanwhile the other laptop edits an entirely unrelated song.
server.edit(1, { bpm: 200 });

const result = await storage.syncUpstream(afterDelete);
// pulled: 2, library: [Alpha, Beta, Gamma]

Gamma is back, counted as a silent pull ("Updated 2 songs from another device"), and the delete never reaches the server. The 6s undo toast is long gone. It doesn't need the other machine to have touched that song — any upstream movement at all trips it, because that's what gates the full fetch.

Shape of a fix. Guard the loop on prevSnapshot.has(wpId). A wpId that was in the base and is now absent locally is a pending local delete, not a remote add: leave it out of merged and let the save's trash diff do its job. A wpId in the base, absent locally, and changed upstream is the row the table in the description is missing — deleted here, edited there — and deserves a flag rather than a silent win either way.

2. A held song is trashed anyway if it's deleted locally

heldWpIds is checked in the write branch (src/storage.js:258) and not in the trash diff (src/storage.js:297), which is built from serverSnapshot.keys() minus seenWpIds — and a song removed from the library never reaches seenWpIds. So the hold protects a conflicted song right up until the user deletes it, at which point it's trashed with no prompt:

// Beta flagged as a conflict and held, then deleted locally:
const saved = await storage.saveLibrary(afterDelete);
// calls: ['DELETE /songs/2']  → the other laptop's edit is trashed

DELETE carries no token, so the server can't refuse it either. Either skip held wpIds in the trash diff, or make trash conditional the way PUT now is — the second is better, see the disagreement below.

3. Flags outlive their songs

deleteSong() (src/App.jsx:293) touches neither flags nor heldWpIds. The notice goes on naming a song that no longer exists, the pill stays on "Changed on another device" indefinitely, and useTheirs (src/App.jsx:395) maps over songs, matches nothing, and dismisses the flag — a button labelled as a decision that silently does nothing. restoreSong() has the mirror problem: it brings the song back without re-holding it.

Worth noting 2 and 3 are both the same underlying thing: the hold is a set floating beside the library rather than a property of a song, so every path that removes a song has to remember to maintain it, and none of them do. Making it a field on the song, or a map that the trash diff is also required to consult, makes both unrepresentable.

4. An unreadable PUT response silently disables the guarantee

New in b94d514. readJson() returning null writes a null token (src/storage.js:272), and wpFetch omits the header when the token is falsy — so the next write for that song goes out unconditional, which is exactly the state the commit set out to eliminate. It self-heals at the next sync, but that sync also reports a pull that didn't happen:

// PUT succeeds; response body can't be parsed (keepalive teardown, proxy, etc.)
const saved = await storage.saveLibrary(edited);   // ok: true, server has the edit
const result = await storage.syncUpstream(edited); // pulled: 1, conflicts: 0
// ...with nothing changed on any other machine

upstreamMoved() sees null !== token, pays for a full /library fetch, and untouchedHere then adopts a copy identical to the one already held. Keeping the previous token instead of writing null fixes both halves.

5. Disagreement: "the race is closed, not just narrowed"

Closed at the protocol level — a client can't overwrite a version it hasn't seen — and that's the part that matters. Two caveats on the stronger reading, and on the comment at wp-plugin/includes/class-store.php:238-240:

It's a read-compare-write, not a compare-and-set. save_song() reads $existing->post_content, compares, then calls wp_update_post() with nothing held in between. Two concurrent requests can both pass. At band scale the practical difference is nil and I wouldn't hold the merge on it — but "makes 'your edit never disappears' true rather than merely likely" claims atomicity the code doesn't have, and that comment will be read as a guarantee by whoever touches this next.

The conditional covers PUT only. DELETE is still unconditional, so the invariant holds for overwrites and not for trashes — which is the mechanism behind item 2 above, and the reason I'd rather see trash carry a token than see the trash diff special-case the hold set. It also means #5's premise ("before conditional writes A's edit would have landed and silently destroyed B's") is only true of the update path.

Minor

  • Read-only users get conflict prompts that do nothing. runSync() ignores canEdit, so a viewer with local edits gets flagged songs and a Keep mine / Use theirs choice; "Keep mine" sets dirty, the save returns readOnly: true, and the pill reports "Saved". Gating the notice on canEdit would be honest.
  • The pulled line is hidden whenever any flag exists (src/components/SyncNotice.jsx:31), so during a conflict you're never told other songs were adopted. Deliberate-looking, but it withholds the information at the moment it's least expected.

On the open follow-ups

Independently re-derived #5 and the setPullFlash half of #7 before reading them, and agree with how all three are scoped — none of them need to grow this PR. One interaction worth recording: the merge-at-commit-time fix sketched in #6 and the prevSnapshot.has() fix for item 1 above touch the same few lines in doSync, so whichever lands second should expect a small conflict.

Items 1 and 2 undo user work silently; I'd fix those here. 3 is a few lines in the same neighbourhood, and 4 is a one-line change to a regression this branch introduced. The delete-path cases have no coverage in tests/sync.mjs at all — sections 6 and 7 cover trashed upstream thoroughly and never deleted locally, which is how all three shipped.

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