Skip to content

fix(sync): re-register clips an applied remote change leaves dirty - #209

Merged
johnny4young merged 2 commits into
mainfrom
fix/sync-requeue-dirty-clips
Oct 9, 2026
Merged

johnny4young merged 2 commits into
mainfrom
fix/sync-requeue-dirty-clips

Conversation

@johnny4young

Copy link
Copy Markdown
Owner

Summary

The pending-board-deletion fix re-flags a clip for upload when an applied remote copy still names a board whose local deletion has not propagated yet. Two adapter paths applied such a copy but never told CKSyncEngine about the new save, so the corrected clip waited for the next explicit start() (panel open or a forced sync). Until then, the server copy kept naming the deleted board.

  1. Server-wins conflict. SyncOutboundWork.resolveConflict returned true only when the local copy won. A server copy that applied and then had a board dropped from its membership came back dirty, but nothing registered it with the engine again.
  2. Push-fed fetch. handleFetchedRecordZoneChanges applied the page and stopped there. The poll path is covered because start() and receive recovery re-enqueue all pending work, but a push-fed fetch has no such follow-up.

Changes

  • resolveConflict now returns "needs sending again" for a verified local win, and also for an applied server copy whose row is still dirty. To avoid scanning the upload queue for every conflict (for example, a resync after an identity reset), it reads the queue only when the post-apply membership differs from the board IDs the record carried. If that follow-up read fails after the apply has committed, the clip is sent again; batch preparation skips a row that turns out to be clean.
  • After a push-fed fetch, the adapter registers the fetched clips that the apply left dirty. This also happens when only part of the page applied, and the receive failure is still reported as before. It does not run while uploads are paused or while an identity reset is pending.
  • The still-dirty lookup now lives in SyncOutboundWork.stillDirty and is shared by the acknowledgement, conflict and fetch paths. It matches zones by name and returns this device's own zone IDs, which is the form prepare accepts.
  • requeueStillDirty treats an engine that was replaced while it ran as superseded work, not as a sync failure. The next start re-enqueues every dirty row anyway.
  • Updated docs/BOARD_DELETION_SYNC.md and the SyncLocalStore.setBoardMembership contract. Added a Fixed entry to the not-yet-tagged 0.10.0 section of the CHANGELOG and extended the sync line in docs/releases/v0.10.0.md.

Follow-ups (not in this PR)

  • Same-revision replay after the board tombstone is acknowledged. A re-flagged clip keeps its received updatedAt. If the board deletion is acknowledged before the clip upload goes out and the stale server copy is then replayed, the equal-timestamp remote wins again. That clears the dirty flag and can seed a placeholder board. While the tombstone is still pending, a replay just re-flags the clip, so this PR is not affected. Fixing it means changing either the tie rule or the re-queue revision, which needs its own change. The existing doc already says cross-zone ordering after acknowledgement is not promised.
  • Board counting in the default applyRemoteChanges. The protocol-extension default counts every board upsert as applied, while the GRDB store counts rejected board metadata as skippedAsStale. The protocol's applyRemoteBoardUpsert returns Void, so aligning the two means changing the protocol. Only test doubles use the default, and the difference affects counts in diagnostics only.
  • Report re-flagged clips from the store. RemoteApplySummary could report which clips the membership rebuild re-flagged. The fetch path could then skip its one queue read per page.

Testing

New regression tests in SyncOutboundWorkTests, run against the real GRDB store:

  • serverWinThatStaysDirty: a newer server copy that names a pending-deleted board makes resolveConflict return true. The clip stays queued, and the rebuilt record no longer carries the board. Without the fix this returns false.
  • fetchedClipsLeftDirty: after applyFetched with one clip naming a pending-deleted board and one clean clip, only the stale clip is reported for registration. A clean board in the same request is not reported.
  • The existing conflicts test still pins that a plain server win needs no resend.

The engine registration itself (CKSyncEngine.state.add) cannot be unit-tested, because a CKSyncEngine cannot be built without CloudKit entitlements. The decision logic it uses is the code covered above. Nothing was compiled locally (Linux authoring host), so CI is the compile and test authority. swift-format lint --strict was run on the touched files, and check-version-sync, check-product-truth and check-site pass.

Checklist

  • make format and make lint pass (swift-format strict on touched files; full lint in CI)
  • make test passes (CI)
  • Conventional Commit message (no AI co-authorship / "generated-by" trailers)
  • No real clipboard content in logs, tests, or fixtures; test data is synthetic
  • Docs / CHANGELOG updated if behavior changed

Generated by Claude Code

claude added 2 commits October 9, 2026 19:48
- After a server-wins conflict, send the clip again when its membership rebuild dropped a board with a pending local deletion; the conflict reply now reports any row that still needs sending.
- After a push-fed fetch, register the fetched clips the apply left dirty with the engine instead of leaving them for the next explicit start.
- Move the still-dirty lookup into SyncOutboundWork so acknowledgement, conflict and fetch paths share it, matching zones by name.
- Cover both paths with regression tests and document the behavior.
- Register dirty fetched clips even when only part of a push-fed page applied, then report the receive failure as before.
- Treat an engine replaced mid-requeue as superseded work rather than a sync failure.
- Return still-dirty saves with this device's own zone IDs, the form batch preparation accepts.
- Read the upload queue after a server-wins conflict only when the membership rebuild dropped a board, and re-send if that follow-up read fails after the apply committed.
@johnny4young
johnny4young merged commit 70dd203 into main Oct 9, 2026
6 checks passed
@johnny4young
johnny4young deleted the fix/sync-requeue-dirty-clips branch October 9, 2026 20:53
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