Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions Sources/CloudSaveKit/CloudSaveAccountChange.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
23 changes: 23 additions & 0 deletions Sources/CloudSaveKit/CloudSaveAccountTransitionClassifier.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
14 changes: 12 additions & 2 deletions Sources/CloudSaveKit/CloudSaveEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public final actor CloudSaveEngine {
private let ledgerPersistenceLock = CloudSaveAsyncLock()
private let statePersistenceLock = CloudSaveAsyncLock()
private let statusContinuation: AsyncStream<CloudSaveStatus>.Continuation
private var accountTransitionClassifier: CloudSaveAccountTransitionClassifier
private var isAccountTransitionPending = false
private var isHostFailureInvalidationPending = false
private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization?
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand Down
46 changes: 46 additions & 0 deletions Tests/CloudSaveKitTests/CloudSaveAccountChangeTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}