Commit Graph

6 Commits

Author SHA1 Message Date
Arkadiusz Fal
9467ac4dba Migrate CloudKit sync to state-driven CKSyncEngine pattern
Register pending changes with the engine state instead of keeping
app-managed record arrays, and materialize records from local data at
send time in nextRecordZoneChangeBatch. This fixes several data-loss
paths:

- Pending changes now persist inside the engine state serialization,
  so saves queued during the debounce window (or while offline) survive
  app termination. Legacy record-name keys are migrated once.
- Transient CloudKit errors (zoneBusy, serviceUnavailable, rate limits,
  network failures, limitExceeded, batchRequestFailed) re-register the
  change for engine-scheduled retry instead of silently dropping it.
- Conflicts are resolved against the exact record that was sent; the
  resolved record (carrying the server change tag) is cached and takes
  precedence at the next send, and any newer local edit evicts it.
- Queue methods are now synchronous, removing a task-reordering race
  where a fast delete/re-add could end up deleting the re-added entity.
- Records are built fresh at send time, so queued changes no longer
  upload stale field snapshots, and batch size limits are handled by
  the framework.
2026-07-13 23:25:00 +02:00
Arkadiusz Fal
da6c949f51 Resume CloudKit sync from persisted state instead of full re-fetch
Previously CKSyncEngine was created with nil state serialization on
every launch, replaying the entire zone change history (hundreds of
merge/delete log lines and re-downloads each start). Now the saved
state is loaded so launches only fetch changes since the last session.

To make incremental sync safe:
- Clear persisted state once when the record schema version increases,
  so records previously skipped as unsupported get re-delivered after
  an app update.
- Handle remote zone deletion in fetchedDatabaseChanges by recreating
  the zone and re-uploading local data, instead of leaving sync dead.

Account change and manual Refresh Sync still clear the state and force
a full fetch as before.
2026-06-23 23:54:40 +02:00
Arkadiusz Fal
ba33823048 Run CloudKit record mapping on the main actor to fix SwiftData race
CloudKitRecordMapper was a standalone actor, so 'await recordMapper.toCKRecord'
from the @MainActor sync engine hopped off the main actor and read live
SwiftData @Model properties (e.g. LocalPlaylistItem.authorName) from the wrong
executor. SwiftData models are bound to the main ModelContext and are not
thread-safe, so this raced the backing store and crashed with EXC_BAD_ACCESS.

Make the mapper @MainActor (it must touch main-isolated models regardless) and
drop the now-redundant awaits. Fixes the crash for all synced model types.

Fixes an EXC_BAD_ACCESS seen in TestFlight build 261.
2026-05-13 20:56:03 +02:00
Arkadiusz Fal
d49591eaf4 Skip local-folder watches from iCloud sync 2026-05-09 15:53:54 +02:00
Arkadiusz Fal
07003a36d7 Fix deleted playlists resurrecting from iCloud after app restart
Pending deletes were lost across app restarts because
recoverPersistedPendingChanges() never reconstructed CKRecord.ID
objects from persisted record names. Additionally, incoming iCloud
records for deleted playlists were blindly applied, and orphaned
playlist items in CloudKit would recreate placeholder playlists.

- Rebuild pendingDeletes array from UserDefaults on recovery
- Guard applyRemoteRecord against records pending local deletion
- Skip deferred items whose parent playlist is pending deletion
- Queue all playlist item deletions when deleting a playlist
- Clean up placeholder playlists for pending-delete playlists
2026-04-18 20:37:35 +02:00
Arkadiusz Fal
100df744d9 Yattee v2 rewrite 2026-04-18 20:37:24 +02:00