diff --git a/CHANGELOG.md b/CHANGELOG.md index 313fd19..2d39772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Preserve the first explicit fetch across the initial matching sign-in reconciliation of a nil-state engine while retaining lifecycle invalidation for every later account transition. + All notable changes to CloudSaveKit are documented here. ## 0.2.3 — 2026-09-14 diff --git a/README.md b/README.md index cfaec00..5034662 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Call `start()` successfully before any explicit synchronization. `fetchNow()` an Opaque CKSyncEngine checkpoint writes are serialized with host-failure lifecycle invalidation. A host callback failure blocks new work and advances the lifecycle immediately, while explicit `start()` recovery waits for every earlier checkpoint write and the failed engine's teardown. A replacement engine therefore cannot start from a checkpoint that an older engine later regresses through actor reentrancy. -Known iCloud account transitions advance the engine lifecycle and block new synchronization before the host switches account-scoped persistence. The gate remains closed until the new account's durable pending ledger is restored, invalidating every pre-transition snapshot and explicit operation without allowing previous-account work to enter the new account. Owned configurations also restore their zone save, while shared configurations restore only record changes because a participant must never create the owner's zone. Transitions clear operation and recovery state scoped to the previous account. Sign-out immediately publishes the cleared current status instead of leaving the previous account's pending or failed projection buffered. +Known iCloud account transitions advance the engine lifecycle and block new synchronization before the host switches account-scoped persistence. The gate remains closed until the new account's durable pending ledger is restored, invalidating every pre-transition snapshot and explicit operation without allowing previous-account work to enter the new account. The sole exception is the first matching sign-in reported by a newly initialized nil-state engine: that event establishes the engine's initial account and may finish reconciliation without invalidating the explicit fetch that triggered it. The host still validates the account, and every later sign-in, sign-out, or switch retains full invalidation. Owned configurations also restore their zone save, while shared configurations restore only record changes because a participant must never create the owner's zone. Transitions clear operation and recovery state scoped to the previous account. Sign-out immediately publishes the cleared current status instead of leaving the previous account's pending or failed projection buffered. `statusUpdates` is a current-state projection, not an event history. It begins with `.idle` and retains only the latest unconsumed status so an absent or slow observer cannot accumulate an unbounded buffer. diff --git a/Sources/CloudSaveKit/CloudSaveAccountChange.swift b/Sources/CloudSaveKit/CloudSaveAccountChange.swift index 8662d3b..0cd63fd 100644 --- a/Sources/CloudSaveKit/CloudSaveAccountChange.swift +++ b/Sources/CloudSaveKit/CloudSaveAccountChange.swift @@ -11,3 +11,13 @@ public enum CloudSaveAccountChange: Equatable, Sendable { /// The device switched directly between two iCloud accounts. case switched(previousAccountID: String, currentAccountID: String) } + +extension CloudSaveAccountChange { + /// Whether the transition establishes a signed-in account. + var isSignedIn: Bool { + if case .signedIn = self { + return true + } + return false + } +} diff --git a/Sources/CloudSaveKit/CloudSaveAccountTransitionClassifier.swift b/Sources/CloudSaveKit/CloudSaveAccountTransitionClassifier.swift new file mode 100644 index 0000000..c75c411 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveAccountTransitionClassifier.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Classifies the one account-establishment event that belongs to a nil-state engine bootstrap. +struct CloudSaveAccountTransitionClassifier { + private let wasInitializedWithState: Bool + private var hasObservedAccountChange: Bool + + /// Creates lifecycle classification for one CKSyncEngine instance. + init(wasInitializedWithState: Bool) { + self.wasInitializedWithState = wasInitializedWithState + hasObservedAccountChange = wasInitializedWithState + } + + /// Returns whether this account event must invalidate in-flight explicit operations. + mutating func shouldInvalidate(for accountChange: CloudSaveAccountChange) -> Bool { + let isInitialSignIn = + !wasInitializedWithState + && !hasObservedAccountChange + && accountChange.isSignedIn + hasObservedAccountChange = true + return !isInitialSignIn + } +} diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 99f14c0..dc24092 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -14,6 +14,7 @@ public final actor CloudSaveEngine { private let ledgerPersistenceLock = CloudSaveAsyncLock() private let statePersistenceLock = CloudSaveAsyncLock() private let statusContinuation: AsyncStream.Continuation + private var accountTransitionClassifier: CloudSaveAccountTransitionClassifier private var isAccountTransitionPending = false private var isHostFailureInvalidationPending = false private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization? @@ -35,6 +36,9 @@ public final actor CloudSaveEngine { statusContinuation = statusChannel.continuation self.client = client self.configuration = configuration + accountTransitionClassifier = CloudSaveAccountTransitionClassifier( + wasInitializedWithState: configuration.stateSerialization != nil + ) lastPersistedStateSerialization = configuration.stateSerialization } @@ -1097,10 +1101,16 @@ extension CloudSaveEngine { return } + let shouldInvalidate = accountTransitionClassifier.shouldInvalidate(for: accountChange) + isAccountTransitionPending = true needsAccountTransitionLedgerRefresh = false - lifecycleGeneration &+= 1 - await fetchCoordinator.invalidate() + if shouldInvalidate { + lifecycleGeneration &+= 1 + await fetchCoordinator.invalidate() + } else { + CloudSaveLogging.log("account change | phase=initial-sign-in") + } let accountLifecycleGeneration = lifecycleGeneration try await commitPendingChangesMutation { [client] in try await client.handle(accountChange: accountChange) diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 87ba564..fbd0c6a 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -8,7 +8,7 @@ CloudSaveKit wraps Apple's `CKSyncEngine` lifecycle and delegate surface without Create the engine early in application launch, call ``CloudSaveEngine/start()``, and enqueue changes only after their corresponding local transactions succeed. Observe ``CloudSaveEngine/statusUpdates`` to project synchronization state into the host architecture. The current-state stream begins with ``CloudSaveStatus/idle`` and retains only its latest unconsumed value rather than preserving an event history. -Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetchNow()``, ``CloudSaveEngine/sendNow()``, or ``CloudSaveEngine/syncNow()`` only at user-visible checkpoints where immediate work is useful. Explicit operations are serialized per engine. An explicit fetch waits for pre-request fetch work to drain and requires a post-request fetch generation to complete, including every related host apply, before returning successfully. Explicit synchronization requires a successful ``CloudSaveEngine/start()`` and raises ``CloudSaveEngineError`` when the engine has not started, host recovery is required, or CKSyncEngine returns without the required fresh generation. +Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetchNow()``, ``CloudSaveEngine/sendNow()``, or ``CloudSaveEngine/syncNow()`` only at user-visible checkpoints where immediate work is useful. Explicit operations are serialized per engine. An explicit fetch waits for pre-request fetch work to drain and requires a post-request fetch generation to complete, including every related host apply, before returning successfully. A newly initialized nil-state engine may reconcile its first matching sign-in while that first explicit fetch is in flight without invalidating the fetch; every later account transition still invalidates current work. Explicit synchronization requires a successful ``CloudSaveEngine/start()`` and raises ``CloudSaveEngineError`` when the engine has not started, host recovery is required, or CKSyncEngine returns without the required fresh generation. CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. Owned zones may be created and recovered. Shared zones retain their exact owner-qualified identifier and are never recreated by a participant; initial sign-in and account transitions restore the participant's durable record changes without scheduling a zone save, while lost access requires host reconfiguration. If the host cannot persist a sync-engine checkpoint or apply a CloudKit result, the engine cancels the current work and waits for the host to call ``CloudSaveEngine/start()`` after local recovery. Host callback failures are reported as ``CloudSaveFailure/localPersistence``. diff --git a/Tests/CloudSaveKitTests/CloudSaveAccountChangeTests.swift b/Tests/CloudSaveKitTests/CloudSaveAccountChangeTests.swift new file mode 100644 index 0000000..7fd2728 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSaveAccountChangeTests.swift @@ -0,0 +1,46 @@ +import Testing + +@testable import CloudSaveKit + +struct CloudSaveAccountChangeTests { + @Test func onlySignedInTransitionEstablishesAccount() { + #expect(CloudSaveAccountChange.signedIn(currentAccountID: "current").isSignedIn) + #expect(!CloudSaveAccountChange.signedOut(previousAccountID: "previous").isSignedIn) + #expect( + !CloudSaveAccountChange.switched( + previousAccountID: "previous", + currentAccountID: "current" + ).isSignedIn + ) + } + + @Test func nilStateInitialSignInPreservesOnlyItsCurrentLifecycle() { + var classifier = CloudSaveAccountTransitionClassifier(wasInitializedWithState: false) + + let initial = classifier.shouldInvalidate(for: .signedIn(currentAccountID: "current")) + let repeated = classifier.shouldInvalidate(for: .signedIn(currentAccountID: "current")) + let switched = classifier.shouldInvalidate( + for: .switched(previousAccountID: "current", currentAccountID: "next") + ) + + #expect(!initial) + #expect(repeated) + #expect(switched) + } + + @Test func nilStateUnexpectedFirstEventAndRestoredEnginesAlwaysInvalidate() { + var signedOutFirst = CloudSaveAccountTransitionClassifier(wasInitializedWithState: false) + var switchedFirst = CloudSaveAccountTransitionClassifier(wasInitializedWithState: false) + var restored = CloudSaveAccountTransitionClassifier(wasInitializedWithState: true) + + let signedOut = signedOutFirst.shouldInvalidate(for: .signedOut(previousAccountID: "previous")) + let switched = switchedFirst.shouldInvalidate( + for: .switched(previousAccountID: "previous", currentAccountID: "current") + ) + let restoredSignIn = restored.shouldInvalidate(for: .signedIn(currentAccountID: "current")) + + #expect(signedOut) + #expect(switched) + #expect(restoredSignIn) + } +}