From 4b35923d09fbe8b0dcb316cc46bc08b4e5013942 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 15:55:51 +0200 Subject: [PATCH 01/18] Add CloudSaveKit initial release --- .editorconfig | 1 + .gitattributes | 2 + .github/workflows/ci.yml | 35 ++ .github/workflows/nightly.yml | 23 + .github/workflows/release.yml | 101 +++++ .gitignore | 11 + .swift-format | 1 + AGENTS.md | 47 ++ .../skills/agent-guidelines-audit/SKILL.md | 60 +++ .../agent-guidelines-audit/agents/openai.yaml | 4 + AgentGuidelines/.github/workflows/ci.yml | 23 + AgentGuidelines/.github/workflows/release.yml | 44 ++ AgentGuidelines/.gitignore | 3 + AgentGuidelines/AGENTS.md | 59 +++ AgentGuidelines/CHANGELOG.md | 141 ++++++ .../Configurations/Swift/.editorconfig | 10 + .../Configurations/Swift/.swift-format | 81 ++++ AgentGuidelines/Guidelines/AgentWorkflow.md | 49 +++ .../Guidelines/Architecture/Redux.md | 297 +++++++++++++ AgentGuidelines/Guidelines/CICD.md | 63 +++ AgentGuidelines/Guidelines/Development.md | 30 ++ AgentGuidelines/Guidelines/Documentation.md | 40 ++ .../Guidelines/Git/Repositories.md | 52 +++ .../Guidelines/GitHub/PullRequests.md | 124 ++++++ AgentGuidelines/Guidelines/Logging.md | 74 ++++ AgentGuidelines/Guidelines/Packages.md | 104 +++++ .../Guidelines/Swift/Localization.md | 43 ++ AgentGuidelines/Guidelines/Swift/Swift.md | 37 ++ .../Guidelines/Swift/SwiftFormat.md | 54 +++ .../Guidelines/Swift/SwiftStyle.md | 44 ++ AgentGuidelines/Guidelines/Swift/SwiftUI.md | 62 +++ .../Guidelines/Testing/UnitTesting.md | 52 +++ AgentGuidelines/Guidelines/Xcode/MCP.md | 65 +++ AgentGuidelines/Guidelines/Xcode/Security.md | 35 ++ AgentGuidelines/LICENSE | 21 + AgentGuidelines/README.md | 166 +++++++ AgentGuidelines/Scripts/swift_format.sh | 63 +++ .../Scripts/validate_guidelines.py | 312 +++++++++++++ AgentGuidelines/Templates/AGENTS.md | 58 +++ .../Templates/GlobalCodexInstructions.md | 9 + AgentGuidelines/Templates/Store.swift | 112 +++++ .../Tests/test_validate_guidelines.py | 128 ++++++ AgentGuidelines/VERSION | 1 + LICENSE | 21 + Package.swift | 39 ++ README.md | 89 ++++ .../CloudSaveKit/CloudSaveAccountChange.swift | 13 + Sources/CloudSaveKit/CloudSaveClient.swift | 43 ++ .../CloudSaveKit/CloudSaveConfiguration.swift | 35 ++ Sources/CloudSaveKit/CloudSaveConflict.swift | 24 + .../CloudSaveConflictResolution.swift | 13 + Sources/CloudSaveKit/CloudSaveEngine.swift | 410 ++++++++++++++++++ Sources/CloudSaveKit/CloudSaveFailure.swift | 60 +++ .../CloudSaveKit.docc/CloudSaveKit.md | 31 ++ Sources/CloudSaveKit/CloudSaveLogging.swift | 20 + .../CloudSaveKit/CloudSavePendingChange.swift | 10 + Sources/CloudSaveKit/CloudSaveStatus.swift | 19 + .../CloudSaveFailureTests.swift | 27 ++ 58 files changed, 3595 insertions(+) create mode 120000 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/nightly.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 120000 .swift-format create mode 100644 AGENTS.md create mode 100644 AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md create mode 100644 AgentGuidelines/.agents/skills/agent-guidelines-audit/agents/openai.yaml create mode 100644 AgentGuidelines/.github/workflows/ci.yml create mode 100644 AgentGuidelines/.github/workflows/release.yml create mode 100644 AgentGuidelines/.gitignore create mode 100644 AgentGuidelines/AGENTS.md create mode 100644 AgentGuidelines/CHANGELOG.md create mode 100644 AgentGuidelines/Configurations/Swift/.editorconfig create mode 100644 AgentGuidelines/Configurations/Swift/.swift-format create mode 100644 AgentGuidelines/Guidelines/AgentWorkflow.md create mode 100644 AgentGuidelines/Guidelines/Architecture/Redux.md create mode 100644 AgentGuidelines/Guidelines/CICD.md create mode 100644 AgentGuidelines/Guidelines/Development.md create mode 100644 AgentGuidelines/Guidelines/Documentation.md create mode 100644 AgentGuidelines/Guidelines/Git/Repositories.md create mode 100644 AgentGuidelines/Guidelines/GitHub/PullRequests.md create mode 100644 AgentGuidelines/Guidelines/Logging.md create mode 100644 AgentGuidelines/Guidelines/Packages.md create mode 100644 AgentGuidelines/Guidelines/Swift/Localization.md create mode 100644 AgentGuidelines/Guidelines/Swift/Swift.md create mode 100644 AgentGuidelines/Guidelines/Swift/SwiftFormat.md create mode 100644 AgentGuidelines/Guidelines/Swift/SwiftStyle.md create mode 100644 AgentGuidelines/Guidelines/Swift/SwiftUI.md create mode 100644 AgentGuidelines/Guidelines/Testing/UnitTesting.md create mode 100644 AgentGuidelines/Guidelines/Xcode/MCP.md create mode 100644 AgentGuidelines/Guidelines/Xcode/Security.md create mode 100644 AgentGuidelines/LICENSE create mode 100644 AgentGuidelines/README.md create mode 100755 AgentGuidelines/Scripts/swift_format.sh create mode 100644 AgentGuidelines/Scripts/validate_guidelines.py create mode 100644 AgentGuidelines/Templates/AGENTS.md create mode 100644 AgentGuidelines/Templates/GlobalCodexInstructions.md create mode 100644 AgentGuidelines/Templates/Store.swift create mode 100644 AgentGuidelines/Tests/test_validate_guidelines.py create mode 100644 AgentGuidelines/VERSION create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/CloudSaveKit/CloudSaveAccountChange.swift create mode 100644 Sources/CloudSaveKit/CloudSaveClient.swift create mode 100644 Sources/CloudSaveKit/CloudSaveConfiguration.swift create mode 100644 Sources/CloudSaveKit/CloudSaveConflict.swift create mode 100644 Sources/CloudSaveKit/CloudSaveConflictResolution.swift create mode 100644 Sources/CloudSaveKit/CloudSaveEngine.swift create mode 100644 Sources/CloudSaveKit/CloudSaveFailure.swift create mode 100644 Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md create mode 100644 Sources/CloudSaveKit/CloudSaveLogging.swift create mode 100644 Sources/CloudSaveKit/CloudSavePendingChange.swift create mode 100644 Sources/CloudSaveKit/CloudSaveStatus.swift create mode 100644 Tests/CloudSaveKitTests/CloudSaveFailureTests.swift diff --git a/.editorconfig b/.editorconfig new file mode 120000 index 0000000..1e825fd --- /dev/null +++ b/.editorconfig @@ -0,0 +1 @@ +AgentGuidelines/Configurations/Swift/.editorconfig \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..38ec4db --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Synced from thatfactory/agent-guidelines; keep tracked but collapse GitHub diffs. +AgentGuidelines/** linguist-generated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dba404c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +--- +name: CI + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + name: Swift Format + runs-on: [self-hosted, macOS] + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Lint Swift Sources + run: AgentGuidelines/Scripts/swift_format.sh lint-strict Sources Tests + + test: + name: Test + runs-on: [self-hosted, macOS] + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + clean: true + + - name: Run Tests + run: swift test -v diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..4fa795d --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,23 @@ +--- +name: Nightly Tests + +on: + schedule: + - cron: '0 4 * * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + nightly_tests: + name: Nightly Tests + runs-on: [self-hosted, macOS] + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + clean: true + + - name: Run Tests + run: swift test -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fa5bb7c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +--- +name: Release +run-name: Release ${{ github.event.release.tag_name }} + +on: + release: + types: + - published + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test Release + runs-on: [self-hosted, macOS] + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + clean: true + + - name: Run Tests + run: swift test -v + + build_docs: + name: Build DocC + runs-on: [self-hosted, macOS] + needs: test + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + with: + clean: true + + - name: Generate DocC + run: | + set -euo pipefail + swift package --allow-writing-to-directory ./public generate-documentation \ + --target CloudSaveKit \ + --disable-indexing \ + --output-path ./public \ + --transform-for-static-hosting \ + --hosting-base-path cloudsavekit + + cat > ./public/index.html <<'INDEX' + + + CloudSaveKit Documentation + INDEX + + - name: Upload Pages Artifact + uses: actions/upload-pages-artifact@v5 + with: + path: ./public + name: github-pages + + deploy_docs: + name: Deploy DocC + needs: build_docs + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 + + notify_package_collection: + name: Notify Package Collection + runs-on: ubuntu-latest + needs: deploy_docs + steps: + - name: Trigger Swift Package Collection Rebuild + env: + COLLECTION_REPO: thatfactory/swift-package-collection + WORKFLOW_FILE: publish.yml + REF: main + GH_TOKEN: ${{ secrets.COLLECTION_TRIGGER_TOKEN }} + SOURCE_REPO: ${{ github.repository }} + SOURCE_VERSION: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + + if [ -z "${GH_TOKEN:-}" ]; then + echo "Missing COLLECTION_TRIGGER_TOKEN secret" + exit 1 + fi + + curl -sS -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/$COLLECTION_REPO/actions/workflows/$WORKFLOW_FILE/dispatches" \ + -d "{\"ref\":\"$REF\",\"inputs\":{\"source_repo\":\"$SOURCE_REPO\",\"source_version\":\"$SOURCE_VERSION\"}}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f0ce17 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +*.xcworkspace +xcuserdata/ +Package.resolved +DerivedData/ +.swiftpm/configuration/registries.json +.netrc +/public-check diff --git a/.swift-format b/.swift-format new file mode 120000 index 0000000..06f3229 --- /dev/null +++ b/.swift-format @@ -0,0 +1 @@ +AgentGuidelines/Configurations/Swift/.swift-format \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..685e70a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# CloudSaveKit + +## Context + +CloudSaveKit is a pure Swift package that coordinates a host application's durable local store with a private CloudKit database through `CKSyncEngine`. Read [README.md](README.md) and the DocC catalog before changing public behavior. + +The package is persistence-, UI-, and application-architecture agnostic. Host applications own their local database, record schemas, merge semantics, and user-facing recovery policy. + +## Shared guidelines + +Read only the guides relevant to the task: + +- [Swift](AgentGuidelines/Guidelines/Swift/Swift.md) +- [Swift style](AgentGuidelines/Guidelines/Swift/SwiftStyle.md) +- [Swift format](AgentGuidelines/Guidelines/Swift/SwiftFormat.md) +- [Unit and integration testing](AgentGuidelines/Guidelines/Testing/UnitTesting.md) +- [Documentation](AgentGuidelines/Guidelines/Documentation.md) +- [Logging](AgentGuidelines/Guidelines/Logging.md) +- [Packages](AgentGuidelines/Guidelines/Packages.md) +- [CI/CD](AgentGuidelines/Guidelines/CICD.md) +- [Git repositories and SSH-first cloning](AgentGuidelines/Guidelines/Git/Repositories.md) +- [GitHub pull requests](AgentGuidelines/Guidelines/GitHub/PullRequests.md) +- [Xcode MCP](AgentGuidelines/Guidelines/Xcode/MCP.md) +- [Xcode security audits](AgentGuidelines/Guidelines/Xcode/Security.md) + +Redux, SwiftData, SwiftUI, and application-localization guidance do not apply to the package target. + +## Physical folder map + +| Role | Physical folder | +|---|---| +| Package sources | `Sources/CloudSaveKit/` | +| DocC catalog | `Sources/CloudSaveKit/CloudSaveKit.docc/` | +| Unit tests | `Tests/CloudSaveKitTests/` | + +## Package specialization + +- Use `CKSyncEngine` rather than duplicating its scheduling, batching, state tracking, or transient retry behavior. +- Do not add a local database, Redux, UI, product record schema, or game-specific conflict policy. +- Persist every engine state update through the host-provided client boundary. +- Keep status and errors privacy-safe; never log record contents or identifiers. +- Update tests, DocC, README examples, and release notes when public behavior changes. +- Use logging subsystem `com.thatfactory.cloudsavekit`, category `sync`, and canonical package emoji `☁️`. + +## Codex review scope + +For consumer pull requests, do not substantively review `AgentGuidelines/**` after exact tagged-tree provenance has been verified. Verify its `VERSION`, compare its tree with the matching central tag, and verify the required `.gitattributes` rule. If provenance does not match exactly, review the subtree contents and stop the merge. Report substantive guideline feedback against the central `agent-guidelines` pull request. diff --git a/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md b/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md new file mode 100644 index 0000000..1f33702 --- /dev/null +++ b/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md @@ -0,0 +1,60 @@ +--- +name: agent-guidelines-audit +description: Audit completed repository work against the consumer's applicable agent-guidelines, local AGENTS.md instructions, requested scope, and declared validation workflow. Use after implementing changes and before claiming completion, handing work to the user, preparing, opening, or updating a pull request, declaring merge readiness, or preparing a release. Do not use for simple answers, read-only exploration, or work that is still actively being implemented. +--- + +# Agent Guidelines Audit + +Perform a final, evidence-based compliance pass. Treat the applicable guidelines and local instructions as the source of truth; do not duplicate their full content in this skill. + +## Establish the audit scope + +1. Re-read the user request and list every requested outcome and explicit constraint. +2. Locate the repository root and every applicable `AGENTS.md` from the current directory to that root. +3. Read the shared guides referenced by those instructions that apply to the changed files and workflow. +4. Inspect `git status`, the complete diff, and relevant untracked files. Preserve unrelated user changes. +5. Check the consumer's `AgentGuidelines/VERSION` and provenance when the task changes or depends on the synchronized subtree. Do not update it implicitly. + +## Audit the implementation + +Review the actual change rather than only checking whether files exist: + +- Confirm every requested outcome is implemented and no material behavior was dropped. +- Confirm physical folders, familiar domain grouping, filenames, declaration order, type ownership, namespacing, documentation, and `MARK` organization follow the applicable guides. Distinguish values that describe data from tools that primarily execute algorithms or accumulate behavior. +- For Redux applications, trace actions, state, reducers, middleware, services, tools, presentation models, views, and side-effect results through the complete data flow. Confirm each Redux component folder contains only that component type. +- Check that framework objects, persistence, logging, and asynchronous work remain in their allowed boundaries. +- Check SwiftUI composition, narrow inputs, local versus durable state, localization, accessibility, and safe deterministic previews where applicable. +- Check tests for the required framework, mirrored paths, shared tags, Given/When/Then structure, deterministic seams, and coverage of changed behavior and failure paths. +- Check logging ownership, subsystem, categories, emoji, privacy, severity, metadata stability, and noise controls when logging changed. +- Check durable documentation, package configuration, CI/CD, Xcode project configuration, security-sensitive changes, and physical-device limitations when they are in scope. Compare documented Swift and concurrency settings with the effective application and test-target settings; flag both redundant isolation annotations and missing annotations at compiler-verified boundaries. +- Search for stale type names, superseded files, direct APIs forbidden by the new architecture, empty folders, and references to removed behavior. + +## Validate the evidence + +Run the repository's declared non-destructive checks in proportion to the change: + +- formatter and strict lint; +- focused tests, followed by the declared broader test plan when warranted; +- relevant builds or package validation; +- repository-specific validators; +- `git diff --check`. + +Use fresh successful evidence already produced in the same task instead of rerunning expensive checks without reason. Distinguish automated compilation and simulator evidence from hardware, signing, deployment, or manual validation that automation cannot prove. + +## Resolve findings + +- When the user authorized implementation, fix safe in-scope findings and rerun the affected checks. +- For review-only work, report findings without modifying code. +- Do not broaden the feature, rewrite unrelated files, edit a synchronized `AgentGuidelines/` subtree, or perform commits, pushes, pull requests, merges, tags, or releases without the required authority. +- Treat an unresolved required guideline violation or missing relevant validation as a blocker to claiming completion. + +## Hand off + +Summarize: + +- the instruction and guideline areas audited; +- findings fixed during the audit; +- validation commands and outcomes; +- any deliberate deviations, unavailable evidence, or remaining blockers. + +Do not say the work is done merely because the audit ran. Say it is ready only when the requested outcome is complete and the relevant evidence passes. diff --git a/AgentGuidelines/.agents/skills/agent-guidelines-audit/agents/openai.yaml b/AgentGuidelines/.agents/skills/agent-guidelines-audit/agents/openai.yaml new file mode 100644 index 0000000..dc4aab7 --- /dev/null +++ b/AgentGuidelines/.agents/skills/agent-guidelines-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Guidelines Audit" + short_description: "Audit completed work against shared guidelines" + default_prompt: "Use $agent-guidelines-audit to audit this completed change before handoff." diff --git a/AgentGuidelines/.github/workflows/ci.yml b/AgentGuidelines/.github/workflows/ci.yml new file mode 100644 index 0000000..5831117 --- /dev/null +++ b/AgentGuidelines/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + name: Validate guidelines + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Validate + run: | + python3 -m unittest discover -s Tests + python3 Scripts/validate_guidelines.py diff --git a/AgentGuidelines/.github/workflows/release.yml b/AgentGuidelines/.github/workflows/release.yml new file mode 100644 index 0000000..20b4c8f --- /dev/null +++ b/AgentGuidelines/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + push: + tags: + - "*.*.*" + +permissions: + contents: write + +jobs: + release: + name: Create GitHub release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Validate guidelines + run: python3 Scripts/validate_guidelines.py + + - name: Validate tag + run: | + version="$(tr -d '[:space:]' < VERSION)" + test "$GITHUB_REF_NAME" = "$version" + + - name: Prepare release notes + run: | + version="$(tr -d '[:space:]' < VERSION)" + awk -v version="$version" ' + index($0, "## [" version "]") == 1 { capture = 1; next } + capture && /^## \[/ { exit } + capture { print } + ' CHANGELOG.md > release-notes.md + test -s release-notes.md + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --verify-tag \ + --title "$GITHUB_REF_NAME" \ + --notes-file release-notes.md diff --git a/AgentGuidelines/.gitignore b/AgentGuidelines/.gitignore new file mode 100644 index 0000000..dff2f41 --- /dev/null +++ b/AgentGuidelines/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +__pycache__/ +*.py[cod] diff --git a/AgentGuidelines/AGENTS.md b/AgentGuidelines/AGENTS.md new file mode 100644 index 0000000..c6eef34 --- /dev/null +++ b/AgentGuidelines/AGENTS.md @@ -0,0 +1,59 @@ +# Agent Guidelines + +## Purpose + +This public repository is the versioned source of truth for reusable ThatFactory agent guidance. Keep it generic enough to apply to multiple applications and Swift packages. Product decisions, concrete project paths, and exceptions belong in each consumer repository. + +## Sources of truth + +- Use official Apple documentation for Apple APIs and Xcode behavior. +- Distill durable policy from Xcode-provided skills; do not copy exported Apple skills into this repository. +- Do not include private company information, credentials, personal absolute paths, or consumer-specific implementation details. +- When shared and consumer guidance differ, the consumer's nearest applicable `AGENTS.md` is the explicit specialization. +- Before changing this repository, verify that the consumer's checked-in guidelines version is current where applicable. + +## Documentation changes + +- Keep each rule in the narrowest relevant guide and link to it rather than duplicating it. +- Use physical folder terminology for Xcode projects. Do not call filesystem folders Xcode groups. +- Keep examples generic and concise. +- Use relative Markdown links inside this repository. +- Update `README.md` when adding, moving, or removing a guide. +- Keep the README guideline catalog sorted alphabetically by link label. +- Update `CHANGELOG.md` and `VERSION` for a release. +- When releasing a new version, update the version in both the README installation command and the README consumer-update command. Keep both commands aligned with the new release, for example: + + ```sh + git subtree add \ + --prefix=AgentGuidelines \ + https://github.com/thatfactory/agent-guidelines.git \ + \ + --squash + + git subtree pull \ + --prefix=AgentGuidelines \ + https://github.com/thatfactory/agent-guidelines.git \ + \ + --squash + ``` + +## Validation + +Run: + +```sh +python3 Scripts/validate_guidelines.py +``` + +Fix every validation failure before releasing a version. + +## Consumer pull-request review scope + +When reviewing a consumer pull request, do not review or comment on files under `AgentGuidelines/**` after exact tagged-tree provenance has been verified. That subtree is a tracked, synchronized copy marked `linguist-generated`; substantive guideline changes are reviewed in this repository. Verify the intended `AgentGuidelines/VERSION`, compare the subtree tree with the matching central tag (for example with `git subtree split --prefix=AgentGuidelines HEAD` and a tree comparison after fetching that tag), and verify the required `.gitattributes` rule. If provenance does not match exactly, review the subtree contents and stop the merge. Report substantive guideline feedback against the central `agent-guidelines` pull request instead. + +## Releases + +- Use semantic versioning. +- Create a Git tag and GitHub release matching `VERSION`. +- Consumer repositories adopt releases deliberately through Git subtree updates. +- Follow [the pull-request review workflow](Guidelines/GitHub/PullRequests.md) before merging any release change. diff --git a/AgentGuidelines/CHANGELOG.md b/AgentGuidelines/CHANGELOG.md new file mode 100644 index 0000000..ab64974 --- /dev/null +++ b/AgentGuidelines/CHANGELOG.md @@ -0,0 +1,141 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [0.0.15] - 2026-07-27 + +### Added + +- A shared agent-workflow guide for bounded grouping of independent repository inspections, with dependency, ordering, scope, and output-size safeguards. +- A versioned global Codex instruction template that bootstraps discovery of repository-local guidance without duplicating engineering policy. + +### Changed + +- Linked the workflow guide from the consumer template, documented the manual global Codex setup, and required alphabetical ordering of the README guideline catalog. +- Clarified that Codex review requests are automatic by default and must not be triggered manually without an explicit user request. + +## [0.0.14] - 2026-07-27 + +### Changed + +- Clarify Store/Middleware @MainActor usage. +- Removed workaround for a resolved Xcode issue. + +## [0.0.13] - 2026-07-26 + +### Added + +- A reusable `agent-guidelines-audit` skill and mandatory completion gate before handoff, pull requests, merge readiness, and releases. +- A canonical Redux Store template plus dependency-container and middleware-composition guidance. +- Consumer Stack guidance for recording toolchain, platform, strict-concurrency, and actor-isolation settings. + +### Changed + +- Clarified Redux folder ownership, familiar domain grouping, model-versus-tool classification, service-local helpers, presentation models, and one-component-per-file organization. +- Required documentation for new Swift declarations, meaningful `MARK` sections, one meaningful SwiftUI view per file, and deterministic previews where possible. +- Clarified when target isolation defaults replace explicit annotations and when compiler-verified boundaries still require them. +- Enabled conditional-import sorting and expanded validation for Swift templates, the audit skill, Stack guidance, and formatting policy. + +## [0.0.12] - 2026-07-25 + +### Added + +- Login-shell guidance for using explicitly authorized `gh` credentials exported by local shell startup configuration without exposing token values. + +## [0.0.11] - 2026-07-25 + +### Added + +- Pre-compilation Xcode build-phase guidance and a reusable `format-and-lint` command for human and agent workflows. +- An easy-to-find record of Xcode-aligned layout settings, enabled rule overrides, and deliberate non-adoptions. +- Pull-request guidance that prevents duplicate manual Codex requests when automatic review is enabled. + +### Changed + +- Enabled empty-array literals, force-try rejection, brace whitespace cleanup, `where` clauses in eligible loops, and documentation-comment validation. + +## [0.0.10] - 2026-07-24 + +### Added + +- Shared Xcode-aligned swift-format and EditorConfig configuration. +- Reusable format, warning-lint, and strict-lint commands for Swift consumers. + +### Changed + +- Replaced SwiftLint guidance with toolchain-native swift-format guidance. + +## [0.0.9] - 2026-07-23 + +### Added + +- Consumer pull-request review scope that excludes synchronized `AgentGuidelines/**` files from substantive Codex and human review outside the central repository. + +## [0.0.8] - 2026-07-23 + +### Added + +- Consumer guidance for keeping `AgentGuidelines/` tracked while collapsing synchronized files in GitHub pull-request diffs with `.gitattributes`. +- Pull-request conventions for isolated subtree commits, explicit version notes, central review links, and continued CI validation. + +## [0.0.7] - 2026-07-23 + +### Added + +- Shared logging ownership, subsystem, package emoji, message design, privacy, testing, and filtering guidance. +- Logging pointers for application development, Swift packages, and consumer instruction templates. + +## [0.0.6] - 2026-07-22 + +### Added + +- Generic Redux store contracts, state/action, service-boundary, projection, and middleware guidance. +- Generic GitHub Actions workflow, self-hosted runner, build strategy, and failure-investigation guidance. +- Shared documentation conventions and test-tag/mock guidance. + +## [0.0.5] - 2026-07-21 + +### Added + +- Default DocC documentation and GitHub Pages publishing guidance for Swift packages. + +## [0.0.4] - 2026-07-21 + +### Added + +- Development guidance for reusability-first design and checking the latest shared-guidelines version before project work. + +### Changed + +- Require an approved pull request before releasing `agent-guidelines` or any consumer package. + +## [0.0.3] - 2026-07-21 + +### Added + +- A Codex review-monitoring workflow covering paginated processing reactions and review threads, clean reviews, inline feedback, replies, thread resolution, and CI checks. + +## [0.0.2] - 2026-07-21 + +### Added + +- Standard README badge conventions for ThatFactory projects and packages. +- Git repository guidance that defaults push-capable clones to SSH remotes. +- GitHub pull-request review and merge-gate guidance. +- Updated and Revision badges to the repository README. + +### Changed + +- Updated GitHub workflows to `actions/checkout@v7` and documented using current stable action versions in new workflows. +- Clarified the Redux side-effect loop and the canonical view-projection test path. +- Expanded and tested semantic-version validation to support prerelease plus build metadata and reject invalid numeric identifiers. +- Removed the redundant README license section while retaining the MIT license badge and root license file. + +## [0.0.1] - 2026-07-21 + +### Added + +- Initial shared guidelines for Redux, Swift, SwiftUI, SwiftLint, localization, testing, documentation, package maintenance, CI/CD, Xcode MCP, and Xcode security audits. +- A consumer `AGENTS.md` template and Git subtree installation workflow. +- Structural validation for links, the documentation catalog, version metadata, subtree instructions, and public-repository safety. +- A tag-driven GitHub release workflow that validates the tag against `VERSION` and publishes changelog notes. diff --git a/AgentGuidelines/Configurations/Swift/.editorconfig b/AgentGuidelines/Configurations/Swift/.editorconfig new file mode 100644 index 0000000..f3faacc --- /dev/null +++ b/AgentGuidelines/Configurations/Swift/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*.swift] +indent_style = space +indent_size = 4 +tab_width = 4 +max_line_length = 120 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/AgentGuidelines/Configurations/Swift/.swift-format b/AgentGuidelines/Configurations/Swift/.swift-format new file mode 100644 index 0000000..ee5586f --- /dev/null +++ b/AgentGuidelines/Configurations/Swift/.swift-format @@ -0,0 +1,81 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentBlankLines" : false, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 4 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 120, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "multilineTrailingCommaBehavior" : "keptAsWritten", + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "orderedImports" : { + "includeConditionalImports" : true, + "shouldGroupImports" : true + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : "never", + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : true, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "tabWidth" : 4, + "version" : 1 +} diff --git a/AgentGuidelines/Guidelines/AgentWorkflow.md b/AgentGuidelines/Guidelines/AgentWorkflow.md new file mode 100644 index 0000000..ebf6f13 --- /dev/null +++ b/AgentGuidelines/Guidelines/AgentWorkflow.md @@ -0,0 +1,49 @@ +# Agent Workflow + +Use this guide for repository investigation and tool execution. It governs how work is explored and coordinated; language, architecture, testing, and development requirements remain in their respective guides. + +This guidance is motivated by high token consumption from unnecessary model and tool cycles during read-heavy investigation, as described in [openai/codex#35050](https://github.com/openai/codex/issues/35050). It aims to avoid unnecessary cycles while preserving coverage and correctness; it does not guarantee a particular reduction in token usage. + +## Bounded investigation + +Investigate in bounded stages based on the current task. + +Within a stage, group independent, already-known read-only operations when the available tools support doing so efficiently. Examples include targeted searches, reads of already-identified files, independent metadata checks, and inspection of separate tests or call sites. + +Use an appropriate supported mechanism for grouped or concurrent execution. A current implementation might use batched tool calls, concurrent shell operations, `Promise.allSettled`, or an equivalent approach, but no particular API is required. + +Inspect every result relevant to the conclusion. Account for failed, incomplete, and contradictory results rather than treating execution as successful merely because it was grouped. + +## Dependency and ordering + +Keep operations sequential when a result determines the next step or when ordering is observable. + +This includes: + +- adaptive investigation; +- approval-sensitive operations; +- related or conflicting mutations; +- edits followed by compilation or validation; +- diagnostics whose result determines the next change; +- stateful external operations; +- waits and resumptions. + +Architecture-specific ordering requirements remain authoritative. For example, follow the Redux guide for dispatch and side-effect ordering rather than inferring that investigation-level concurrency permits runtime concurrency. + +Do not group operations merely because concurrency is available. + +## Scope and output + +Keep each stage narrowly scoped to the request. + +Prefer targeted searches, relevant line ranges, focused diagnostics, and specific log sections over broad repository, file, or log dumps. + +Bound the combined output of grouped operations so that every result can be inspected reliably. When evidence is incomplete or truncated, retrieve only the missing portion rather than repeating the full investigation. + +Do not expand the investigation merely because additional operations can be executed concurrently. + +## Efficiency + +Avoid unnecessary repeated model and tool cycles when several independent operations are already known. + +Efficiency must not reduce required coverage, bypass validation, conceal failures, or introduce unrelated work. diff --git a/AgentGuidelines/Guidelines/Architecture/Redux.md b/AgentGuidelines/Guidelines/Architecture/Redux.md new file mode 100644 index 0000000..4a28847 --- /dev/null +++ b/AgentGuidelines/Guidelines/Architecture/Redux.md @@ -0,0 +1,297 @@ +# Redux Architecture + +Use this guide for applications that explicitly adopt the ThatFactory Redux architecture. Do not apply it to reusable packages or repositories whose local instructions choose another architecture. + +## Principles + +- Keep one application store as the source of truth for durable application state. +- Views read state and dispatch actions; they do not mutate application state directly. +- Actions describe events or intent, not implementation steps. +- Reducers are pure and synchronous. +- Middleware performs asynchronous work and other side effects. +- Services wrap external frameworks, packages, persistence, clocks, APIs, and system capabilities. +- Selectors derive shared domain information from state. +- Render-ready value models live under `Model/`; SwiftUI `View` types stay under `View/`. +- Every side-effect result returns to the store as an action before it changes state. + +## Data flow + +```text + await dispatch(Action) + +--------------+ --------------------------> +-----------+ + | SwiftUI view | | Store | + | | <-------------------------- | | + +--------------+ observable state | 1. Reducer| + | 2. Middle-| + | ware | + +-----+-----+ <---------------+ + | | + | side effect | + v | + +--------------+ | + | Service | | + | package/API | | + +------+-------+ | + | | + | Action? | + +-----------------------+ +``` + +The store reduces the original action first, then awaits middleware and sequentially dispatches returned follow-up actions. Keep ordering observable and deterministic. Do not start unstructured work inside reducers or hide state changes inside services. + +## Store + +Use one observable store as the source of truth and inject it at the application root. The canonical Store requires `Default Actor Isolation` set to `MainActor` and `nonisolated(nonsending) By Default` set to `Yes` in every application and test target that compiles or exercises it. New projects copy [the Store template](../../Templates/Store.swift) as is; do not add redundant isolation annotations or change its dispatch ordering, observation exclusions, or documentation. + +Dispatch is asynchronous and ordered: + +1. Reduce the original action. +2. Capture the resulting state. +3. Await each registered middleware with that state and action. +4. Collect returned actions. +5. Dispatch follow-up actions sequentially. + +Use only `await store.dispatch(_:)`. Do not add a fire-and-forget dispatch API. + +## Dependency composition + +Create one application-owned `DependencyContainer` that constructs and retains services, persistence, providers, and other side-effect dependencies. Create the container before the store, restore synchronous initial state through its dependencies, and pass the container to `makeMiddlewares(_:)`. + +```swift +@main +struct ExampleApp: App { + @State private var dependencies: DependencyContainer + @State private var store: AppStore + + init() { + let dependencies = DependencyContainer() + let store = AppStore( + initialState: dependencies.restoredAppState(), + middlewares: makeMiddlewares(dependencies), + reducer: appReducer + ) + _dependencies = State(initialValue: dependencies) + _store = State(initialValue: store) + } +} +``` + +Keep application bootstrap responsible for composition, not feature behavior. Do not construct individual services directly in the app after a dependency container exists. + +## Canonical physical folders + +These are filesystem folders, not Xcode groups. New single-application repositories use this structure by default: + +```text +/ +|-- App/ +|-- Model/ +|-- Redux/ +| |-- Action/ +| |-- Middleware/ +| |-- Reducer/ +| |-- Selector/ +| |-- State/ +| `-- Store.swift +|-- Services/ +|-- Tools/ +|-- View/ +`-- Resources/ + +Tests/ +|-- Mocks/ +|-- Model/ +|-- Redux/ +| |-- Action/ +| |-- Middleware/ +| |-- Reducer/ +| |-- Selector/ +| `-- State/ +|-- Services/ +|-- Tools/ +`-- View/ +``` + +A multi-target application may use a shared source root such as `Shared/Redux/` and target-specific roots such as `/View/`. Its root `AGENTS.md` must provide a concrete path map: + +```markdown +| Role | Physical folder | +|---|---| +| Redux | `Shared/Redux/` | +| Models | `Shared/Model/` | +| Services | `Shared/Services/` | +| Views | `/View/` | +| Unit tests | `Tests/` | +``` + +Once mapped, use the same component layout beneath those roots. Never guess a destination or create a parallel folder spelling such as `Views/` when the project declares `View/`. + +## Placement rules + +### App + +Put application bootstrap, app delegates, scene definitions, store construction, environment wiring, and root configuration in `App/`. Do not place feature logic there. + +### Model + +Put domain and presentation values in `Model/`. Models describe data, state, configuration, categories, or render-ready values; their primary responsibility is not executing an algorithm or coordinating side effects. Keep each important type in a focused file. Do not hide response models, payloads, logging categories, levels, or other values inside action or service folders merely because only one caller currently uses them. + +When several models are familiar parts of one domain, group them by that domain: + +```text +Model/ +|-- Camera/ +|-- Face/ +`-- Logging/ +``` + +Use names that help a reader reason about the domain. Keep `Model/` flat while a domain has only one file; do not create a folder for every type. + +### Action + +Put domain action enums in `Redux/Action/`. Use a root routing action that wraps focused feature actions: + +```swift +enum AppAction: Equatable { + case account(AccountAction) + case navigation(NavigationAction) +} +``` + +Name actions after what happened or what the user requested. Keep cases in the order required by the project's Swift style guide. + +Declare `AppAction` and each domain action in separate files. `AppAction.swift` contains the root routing action only; do not append logging models, categories, feature actions, or unrelated supporting declarations to it. + +Every production file under `Redux/Action/` must define an action. Values carried by actions, including categories, levels, payloads, and capability descriptions, belong in `Model/`. + +### State + +Put the root state and domain sub-states in `Redux/State/`. Prefer focused value types with compiler-synthesized conformances. Add a new sub-state for a durable domain instead of folding unrelated values into an existing feature. + +State stores durable facts. Avoid storing values that are cheap, deterministic derivations unless caching is an explicit measured requirement. + +Sub-states should conform to `Equatable` and `Codable`; add `Sendable` when their values and concurrency boundaries require it. Keep root state and root actions for genuine cross-domain behavior. Keep domain action cases descriptive of intent or outcomes and route them through the root action. + +Declare `AppState` and each domain sub-state in separate files. `AppState.swift` contains the root state only. + +### Reducer + +Put reducer functions in `Redux/Reducer/`. A reducer receives state and an action and returns new state. It must not: + +- perform asynchronous work; +- call services or packages; +- read the clock or generate random values; +- access files, databases, network clients, or system APIs; +- dispatch actions; +- trigger UI behavior directly. + +Use the smallest state and action inputs that correctly express the transition. Root reducers compose domain reducers. + +Declare the root reducer and each domain reducer in separate files. `AppReducer.swift` contains only root composition. Every production file under `Redux/Reducer/` must define a reducer; move events, capability values, policies, and other supporting domain types to `Model/` or their own appropriate component. + +### Middleware + +Put middleware in `Redux/Middleware/`. Middleware may call injected services and return a follow-up action. It must not mutate store state directly. + +Inject services, providers, managers, clocks, and identifier generators through parameters so middleware tests remain deterministic. Register middleware in one root composition file such as `AppMiddlewares.swift`. Reducers own every state mutation. + +Every production file under `Redux/Middleware/` must define or compose middleware. A helper, closure signature, or type alias used only by one middleware stays in that middleware file and should be private when its test seam and call sites allow it. Do not create a standalone middleware file for a declaration that is not middleware. + +Create a feature subfolder only when a domain has multiple middleware files: + +```text +Redux/Middleware/Account/ +|-- AccountMiddleware.swift +|-- LoadAccountMiddleware.swift +`-- UpdateAccountMiddleware.swift +``` + +### Selector + +Put pure, reusable domain extraction in `Redux/Selector/`. A selector may answer questions such as the current signed-in account, whether a capability is enabled, or which domain items are visible. + +Do not put SwiftUI types, colors, images, localized display strings, or render-ready screen state in selectors. + +### Services + +Put focused external-boundary abstractions in `Services/`. Services wrap APIs, persistence, packages, frameworks, sensors, system features, and other impure operations. Keep this folder flat while a capability has only one file; introduce a familiar capability folder such as `Services/FaceService/` or `Services/CalibrationService/` when that capability genuinely requires several related files. Middleware calls services; views and reducers do not. + +Prefer a protocol or otherwise injectable contract when a service must be replaced in tests. Keep transport-specific details behind the service boundary. + +Keep a supporting delegate, adapter, or helper beside its service when only that capability uses it. Local ownership is clearer than promoting a service-private framework bridge to a global `Tools/` folder. + +Views dispatch actions; middleware calls services. Views never call a service directly for Redux-owned behavior. + +### Tools + +Put specialized algorithms, accumulators, framework adapters, and genuinely cross-cutting implementation utilities in `Tools/`. This is not a miscellaneous folder. A type belongs here when its primary responsibility is performing computation or implementing technical behavior rather than describing values or owning an external capability. Feature-only helpers stay beside that feature. Keep `Tools/` flat until one familiar topic requires several files, then group them under a domain folder such as `Tools/Face/`. + +### View + +Put SwiftUI screens and components in `View/`. A new view belongs to the feature it renders, not in Redux. Reusable visual components may use `View/Generic/` or another explicitly declared shared-view folder. Keep `View/` flat while it has only a few files; introduce `View//` when a familiar feature genuinely has several views. + +Render-facing value types that do not conform to `View` are presentation models and live under `Model//`: + +```text +Model/Account/ +`-- AccountViewState.swift + +View/Account/ +`-- AccountView.swift +``` + +Keep a tiny private projection beside its consuming view only when it is an implementation detail rather than a named value type. + +### Resources + +Put catalogs, assets, preview assets, configuration resources, and test plans in `Resources/` or the concrete resource folders declared locally. Production targets must not depend on test fixtures. + +## File organization + +- Prefer one primary concern per file. +- When a feature has several files of one Redux component, introduce a feature subfolder under that component. +- Group several related models, services, or tools by a familiar domain or capability so readers can reason about them together. +- Keep root routing and composition at the component root; keep feature implementations below it. +- File names match their primary type or clearly describe their primary pure function. +- Do not introduce artificial enum namespaces solely to satisfy filename lint rules. +- Mirror production organization in tests so components are easy to locate. +- Do not keep empty component folders. Add `Selector/`, `Tools/`, feature folders, or mirrored test folders only when they contain a real implementation. + +## SwiftUI connection + +Create and inject the store at the application root. Views observe only the state they need and dispatch actions for application events. + +Keep view-local interaction state in private `@State` when it is not durable application state. Do not create an `@Observable` view model as a second source of truth for Redux-owned state. + +Prefer narrow view inputs or a focused view-state projection. This aligns SwiftUI invalidation with the smallest useful surface while Redux remains the durable source of truth. + +## Adding a feature + +| Step | Change | Default destination | +|---|---|---| +| 1 | Define domain models | `Model/` or `Model//` when several are familiar | +| 2 | Define feature state | `Redux/State/State.swift` | +| 3 | Add it to root state | `Redux/State/AppState.swift` | +| 4 | Define feature actions | `Redux/Action/Action.swift` | +| 5 | Route them through the root action | `Redux/Action/AppAction.swift` | +| 6 | Implement the reducer | `Redux/Reducer/Reducer.swift` | +| 7 | Compose the reducer | `Redux/Reducer/AppReducer.swift` | +| 8 | Add side effects if needed | `Redux/Middleware/` or a feature folder when several | +| 9 | Register middleware | `Redux/Middleware/AppMiddlewares.swift` | +| 10 | Add external boundaries if needed | `Services/` or `Services//` when several | +| 11 | Add shared domain selectors if needed | `Redux/Selector//` | +| 12 | Build the feature UI | `View/` or `View//` when several are familiar | +| 13 | Mirror tests | `Tests/` | + +Skip components that provide no value. A state-only transition needs no middleware; a screen-only projection does not need a Redux selector. + +## Testing responsibilities + +- Reducer tests provide state plus an action and assert the returned state. +- Selector tests provide state and assert the derived domain result. +- Middleware tests inject mocks, execute an action, and assert the returned follow-up action. +- Service tests exercise the external boundary without involving views. +- Presentation-model tests live under the matching `Tests/Model//` folder, or the consumer-mapped test root. +- Test mocks and fixture data live under the test target's `Mocks/` folder. + +Follow [Unit testing](../Testing/UnitTesting.md) for framework and concurrency conventions. diff --git a/AgentGuidelines/Guidelines/CICD.md b/AgentGuidelines/Guidelines/CICD.md new file mode 100644 index 0000000..c420331 --- /dev/null +++ b/AgentGuidelines/Guidelines/CICD.md @@ -0,0 +1,63 @@ +# CI/CD + +## Workflow principles + +- Keep CI deterministic, reproducible, and aligned with the repository's supported Xcode, Swift, and platform versions. +- Treat warnings introduced by a change as failures even when the compiler does not. +- Prefer the smallest permissions required by each workflow and job. +- For a new workflow, use the latest stable major version of every GitHub Action available at the time of creation. +- Do not copy an older major version into a fresh workflow unless a documented compatibility constraint requires it. +- For existing workflows, review action release notes and update deliberately rather than allowing runtime deprecation warnings to accumulate. +- Pin third-party actions to an intentional version and review updates. +- Do not place secrets in workflow files, logs, fixtures, or command arguments that may be echoed. +- Keep release workflows separate from pull-request validation when their permissions differ. + +## `ci-pr.yml` + +Projects using GitHub Actions should keep pull-request validation in `.github/workflows/ci-pr.yml`, triggered by `pull_request` events for `opened`, `synchronize`, and `reopened`. + +Use GitHub-hosted runners for jobs that can run on the hosted operating system and toolchain. When a job uses a self-hosted runner, document and select it through the repository's `Runner labels:` rather than hard-coding a machine name in shared guidance. + +### Runner labels: + +When a workflow uses self-hosted runners, document the labels required by each job in this section of the consumer's CI/CD guide. Always include `self-hosted` and add only stable capability or environment labels needed to select the runner, such as an operating system, architecture, toolchain, or signing capability. Keep machine names and changing fleet details out of shared guidance. + +A typical Swift package validates: + +- package resolution; +- build; +- Swift Testing tests; +- DocC generation when the package publishes documentation; +- repository-specific lint or validation scripts. + +An Xcode application validates its declared scheme and test plan. Use the same project/workspace, configuration, and platform assumptions documented for local development. + +Xcode projects and Swift packages must run on self-hosted macOS runners with the required Xcode, Swift toolchains, simulators, certificates, and signing environment. Do not use `macos-latest` for those jobs. For Xcode projects, test with `xcodebuild test` and explicit simulators, then validate compilation with `xcodebuild build CODE_SIGNING_ALLOWED=NO` across the supported platforms. For Swift packages, use Swift Package Manager commands such as `swift test` and `swift build`; packages do not require simulator selection, but may require the self-hosted signing environment for packaging or collection workflows. Generic jobs that do not require Apple tooling may use GitHub-hosted Linux or other suitable runners. CI validates tests and compile health, not app-store distribution. + +## `ci.yml` + +Validation of merges to `main` should live in `.github/workflows/ci.yml`, triggered by `push` on `main`. Use the same build, test, lint, and platform coverage as pull-request validation unless the repository documents a deliberate difference. + +## Failure investigation + +1. Use GitHub MCP connector tools to inspect check runs and logs for the failing commit or pull request. +2. Use `gh` for fast local triage when needed. +3. Reproduce locally with the exact build or test command shown in the failing job logs. + +Useful commands: + +```bash +gh run list --limit 10 +gh run view +gh run view --log +``` + +Distinguish compiler errors from lint violations, test failures from simulator or runtime infrastructure failures, and single-job failures from cross-platform matrix failures. Identify the first meaningful failing step, fix the narrowest root cause, and re-run affected validation. + +## Releases + +- A release tag and GitHub release must match the intended semantic version. +- Release notes summarize user- or integrator-relevant changes since the previous release. +- Use a notes file for multiline CLI release descriptions. +- Do not publish a release from an unverified or dirty worktree. +- Follow the consumer's local instructions for deployment, signing, notarization, App Store, or documentation publishing steps. diff --git a/AgentGuidelines/Guidelines/Development.md b/AgentGuidelines/Guidelines/Development.md new file mode 100644 index 0000000..8f93d90 --- /dev/null +++ b/AgentGuidelines/Guidelines/Development.md @@ -0,0 +1,30 @@ +# Development + +## Reusability first + +When developing a new feature or responding to a feature request, consider shared code first. If the code fits an existing package, suggest extending that package instead of adding the implementation directly to an application. Also consider whether the change belongs in a new Swift package, even when that package does not exist yet. Prefer reusable, focused package APIs when they can serve more than one consumer. + +## Guidelines version + +Before changing a project, verify that it uses the latest released version of `agent-guidelines`. Check the project's `AgentGuidelines/VERSION` against the latest release, update the subtree or equivalent when it is behind, and read the updated applicable guides before starting implementation. This check is manual and must be performed at the beginning of each project task. + +## Guidelines changes in pull requests + +Keep `AgentGuidelines/` tracked so consumers retain a reproducible, versioned copy for agents and CI. Do not add the subtree to `.gitignore`. Instead, add this rule to the consumer's tracked `.gitattributes` so GitHub collapses synchronized guideline files in pull-request diffs by default while reviewers can still expand them: + +```gitattributes +# Synced from thatfactory/agent-guidelines; keep tracked but collapse GitHub diffs. +AgentGuidelines/** linguist-generated +``` + +Keep each subtree update in its own commit. In the pull-request description, state the old and new guideline versions and link to the central release or pull request where the guideline changes were reviewed. Continue validating the checked-in subtree in CI. Because generated-file diffs are collapsed by default, never edit the subtree locally; make shared changes in the source repository and consume a tagged release. + +## Completion audit + +Before claiming implementation is complete, handing work to the user, preparing, opening, or updating a pull request, declaring merge readiness, or preparing a release, invoke `$agent-guidelines-audit`. + +If the skill is not discoverable in a subtree consumer, read and follow its [SKILL.md](../.agents/skills/agent-guidelines-audit/SKILL.md) directly. The audit is a final verification gate, not a substitute for reading and applying the relevant guidelines during implementation. Resolve in-scope findings and rerun affected checks before handoff. Do not broaden the requested scope merely to satisfy the audit. + +## Logging + +Applications own their orchestration, lifecycle, and product-domain diagnostics. Follow the shared [logging guide](Logging.md) and rely on each dependency to log its own implementation. Do not duplicate or reformat package-internal operations in the application log. diff --git a/AgentGuidelines/Guidelines/Documentation.md b/AgentGuidelines/Guidelines/Documentation.md new file mode 100644 index 0000000..4cc60c5 --- /dev/null +++ b/AgentGuidelines/Guidelines/Documentation.md @@ -0,0 +1,40 @@ +# Documentation + +- Use PascalCase Markdown filenames without spaces. +- Keep the folder flat until one topic genuinely requires several files. +- Prefer current implementation over speculative future design; label known gaps explicitly. + +## Code-level documentation + +- Document every new struct, class, enum, protocol, actor, and function with focused `///` DocC comments. +- Use `// MARK: -` pragmas to separate meaningful logical sections so source files remain easy to scan and navigate. +- Update documentation when changing a documented API, parameter, behavior, or invariant. +- End documentation sentences with periods. +- Explain intent, contracts, units, side effects, isolation, and non-obvious constraints; do not restate syntax. +- Add a short Swift example when it materially clarifies correct use. +- Keep documentation close to the declaration it describes. + +## Project-level documentation + +- Keep durable architecture and cross-cutting guides in the consumer's declared documentation folder. +- Update a guide when a change alters the documented architecture, data flow, public API, persistence, navigation, localization process, testing workflow, or delivery workflow. +- Do not update broad guides for minor implementation changes already explained by code and DocC. +- Remove or rewrite stale documentation when its feature or workflow is removed. +- Keep investigations, temporary plans, and one-time spike notes out of durable documentation unless they become lasting guidance. +- Prefer ASCII diagrams in fenced code blocks when universal rendering matters. + +## Review checklist + +When reviewing a change, ask: + +- Does it alter a documented public API or invariant? +- Does it introduce a reusable architectural pattern? +- Does it change data flow, ownership, persistence, localization, testing, or delivery? +- Does it remove or supersede an existing guide? +- Are code comments and project guides consistent with the implementation? + +Flag missing documentation only when the change affects durable knowledge. Avoid documentation churn for small fixes. + +## Shared versus local guidance + +This repository owns reusable policy. Consumer documentation owns its product domain, concrete paths, package relationships, feature registries, and explicit exceptions. Link across those layers instead of copying shared prose locally. diff --git a/AgentGuidelines/Guidelines/Git/Repositories.md b/AgentGuidelines/Guidelines/Git/Repositories.md new file mode 100644 index 0000000..04e66ce --- /dev/null +++ b/AgentGuidelines/Guidelines/Git/Repositories.md @@ -0,0 +1,52 @@ +# Git Repositories + +Use these rules when cloning repositories or configuring remotes. + +## SSH-first cloning + +Clone a repository over SSH when the working copy may be used to commit, push, or open a pull request: + +```sh +git clone git@github.com:/.git +``` + +- Prefer an SSH `origin` so command-line tools and Git clients such as Fork can reuse the machine's GitHub SSH authentication. +- Do not create a push-capable working copy with an HTTPS `origin` unless the user or environment explicitly requires HTTPS. +- After cloning for development, use `git remote -v` to confirm that fetch and push URLs are correct. +- If an existing development clone has an HTTPS `origin`, change it only when requested or when the task explicitly includes remote setup: + + ```sh + git remote set-url origin git@github.com:/.git + ``` + +HTTPS remains appropriate for deliberately read-only retrieval, ephemeral automation, or environments where SSH credentials are unavailable. A public Git subtree remote may also remain HTTPS because consumers fetch tagged content without pushing to the guideline repository. + +## GitHub CLI authentication recovery + +Treat a reported invalid `GITHUB_TOKEN` as potentially transient or environment-specific. Do not abandon the `gh` CLI or switch protocols solely because one Codex shell reports that token as invalid. + +When `gh` authentication appears inconsistent: + +1. Retry `gh auth status` in a fresh shell. +2. If the user can run commands locally, ask them to confirm `gh auth status` and share only the redacted result; never request or print the token itself. +3. Retry the original `gh` command after authentication is confirmed. Preserve the CLI workflow for repository inspection, Actions logs, and pull-request operations. +4. If an injected environment variable is shadowing the stored GitHub CLI credential, compare the credential-backed check without exposing secrets: + + ```sh + env -u GITHUB_TOKEN gh auth status + ``` + + If that succeeds, use the authenticated CLI session for the task or refresh it with `gh auth refresh` as appropriate. Do not copy a token into shell history, command arguments, files, or chat. +5. Use SSH for Git transport only when the CLI remains unavailable after retry and the operation is specifically a Git fetch, commit, or push. Continue using `gh` for GitHub API operations whenever it is working. + +An environment mismatch is not evidence that the user's GitHub account or token is invalid. Record the failed command and exact non-secret error, retry after the authentication check, and report the blocker only after repeated attempts fail. + +### Login-shell credentials + +Some developer environments export `GITHUB_TOKEN` from a shell startup file rather than from the non-interactive process that launched the agent. When the user has explicitly authorized using that local configuration, retry `gh` in a login shell that sources the user's startup configuration: + +```sh +zsh -lc 'source "$HOME/.zshrc"; gh auth status' +``` + +Run the required `gh` operation in that same shell after authentication succeeds. Never print, inspect, copy, or persist the token value; suppress unrelated startup output when practical, and do not source a startup file merely to bypass a credential or permission boundary without the user's authorization. diff --git a/AgentGuidelines/Guidelines/GitHub/PullRequests.md b/AgentGuidelines/Guidelines/GitHub/PullRequests.md new file mode 100644 index 0000000..3a2c349 --- /dev/null +++ b/AgentGuidelines/Guidelines/GitHub/PullRequests.md @@ -0,0 +1,124 @@ +# GitHub Pull Requests + +Use this guide whenever creating, reviewing, updating, or merging a GitHub pull request. + +## Before opening + +- Review the complete diff and exclude unrelated changes. +- Follow the repository's pull-request template and local contribution instructions. +- Run the relevant local validation and document anything that could not be run. +- Open the pull request without auto-merge and keep it unmerged while automated or agent review is pending. Use draft state only when configured reviewers also run on drafts. +- When automatic Codex review is enabled, opening the pull request schedules the review. Do not also post `@codex review` or make another manual request; duplicate reviews waste review capacity and tokens. Do not request a Codex review manually unless the user explicitly asks for one. + +## Consumer subtree review scope + +When reviewing a consumer pull request, do not review or comment on files under `AgentGuidelines/**` after exact tagged-tree provenance has been verified. The subtree is a tracked, synchronized copy marked `linguist-generated`; substantive guideline changes are reviewed in the central `thatfactory/agent-guidelines` pull request. Verify `AgentGuidelines/VERSION`, compare the subtree tree with the matching central tag (for example with `git subtree split --prefix=AgentGuidelines HEAD` and a tree comparison after fetching that tag), and verify the required `.gitattributes` rule. If provenance does not match exactly, review the subtree contents and stop the merge. Report substantive guideline feedback against the central pull request instead. + +## Review gate + +Opening a pull request starts review; it does not authorize merging it. + +1. Wait for the configured Codex review to finish. No review yet means pending, not approved. +2. Inspect all review summaries, inline threads, checks, and requested changes. +3. Assess each comment on its technical merits. +4. Implement valid feedback and rerun the affected validation. +5. If feedback should not be implemented, reply in the original thread with a concise technical reason. +6. Reply to implemented feedback with what changed and where. +7. Resolve a thread only after its concern has been addressed or explicitly declined. +8. After addressing review comments, update the pull-request description so it matches the current implementation, validation, and any remaining limitations. +9. Recheck the pull request immediately before merge for late comments and check-state changes. + +When replying with a commit reference, write the commit hash as raw text without backticks (for example, the hash 185c04f should remain 185c04f). GitHub then auto-links the hash to the commit. + +A thumbs-up or clean Codex review satisfies the agent-review step, but it does not replace any human approval required by the repository. Do not enable auto-merge before all review gates are satisfied. + +### Codex review monitoring + +Use GitHub review data, reactions, and checks together. An eyes reaction means Codex is processing the pull request; it is not an approval. A thumbs-up means the review completed without suggestions. A submitted review means its inline threads must be assessed individually. + +```text +PR opened + | + v +Codex adds eyes reaction + | + +--> thumbs-up ----------------> Clean review + | + `--> Review comments ----------> Assess each comment + | + fix or decline with reason + | + reply in original thread + | + resolve thread +``` + +When using the GitHub CLI, monitor all three surfaces: + +```sh +gh api --paginate repos///issues//reactions +gh pr view --repo / --json reviews,headRefOid +gh pr checks --repo / +``` + +Retrieve inline review threads and their resolution state through GraphQL; top-level pull-request comments do not include this information: + +```sh +gh api graphql --paginate \ + -f query='query($owner: String!, $repository: String!, $number: Int!, $endCursor: String) { + repository(owner: $owner, name: $repository) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $endCursor) { + nodes { id isResolved } + pageInfo { hasNextPage endCursor } + } + } + } + }' \ + -F owner= \ + -F repository= \ + -F number= +``` + +For every unresolved thread identifier returned above, retrieve its complete comment history with a second paginated query: + +```sh +gh api graphql --paginate \ + -f query='query($thread: ID!, $endCursor: String) { + node(id: $thread) { + ... on PullRequestReviewThread { + comments(first: 100, after: $endCursor) { + nodes { id author { login } body url } + pageInfo { hasNextPage endCursor } + } + } + } + }' \ + -F thread= +``` + +Continue polling while actively working on the pull request. Inspect every returned page for reactions, review threads, and thread comments. Do not treat missing comments, a pending reaction, truncated results, or elapsed time as review completion. + +## Merge requirements + +Do not merge while any of the following is true: + +- Codex review is still pending; +- an actionable review comment is unanswered; +- a review conversation is unresolved; +- a required check is pending or failing; +- the branch is out of date when the repository requires an up-to-date branch; +- required human approval or explicit owner authorization is missing. + +If a review arrives after a premature merge, treat that as a process failure: assess the feedback, reply to every thread, and ship valid corrections through a follow-up pull request. + +## Repository protection + +Prefer GitHub rulesets or branch protection for the default branch. At minimum: + +- require changes to arrive through a pull request; +- require conversations to be resolved before merging; +- require the repository's mandatory status checks; +- prevent bypass except for an intentional emergency path. + +A formal one-approval rule works only when someone other than the pull-request author can submit an approving review. In a solo repository where the owner account also authors pull requests, use a bot or service account for authored changes before requiring owner approval; GitHub does not count self-approval. Until that separation exists, require explicit owner authorization operationally and keep conversation resolution enforced technically. diff --git a/AgentGuidelines/Guidelines/Logging.md b/AgentGuidelines/Guidelines/Logging.md new file mode 100644 index 0000000..498c356 --- /dev/null +++ b/AgentGuidelines/Guidelines/Logging.md @@ -0,0 +1,74 @@ +# Logging + +Use this guide for Apple-platform applications and Swift packages that emit runtime diagnostics. Logging should improve observability without changing behavior, exposing sensitive data, or overwhelming the console. + +## Ownership + +- Each application or package owns the logs for operations it implements. +- A consuming application logs its own orchestration and lifecycle events. It must not reproduce or reformat a dependency's internal steps or outcomes. +- A reusable package describes events using its own domain language. Do not introduce concepts from one current client into package categories or messages. +- Ownership does not require every API or package to emit logs. Pure utilities and operations without a meaningful diagnostic event may emit nothing. +- Logging is a side effect. It must not affect returned values, state transitions, error handling, or control flow. +- Architectures that isolate side effects must call a logging package from an allowed side-effect boundary, such as middleware or a service, rather than from a pure reducer. + +## AppLogger and identity + +- Use the shared [AppLogger Swift package](https://github.com/thatfactory/applogger) rather than `print`, direct `Logger` instances, or project-specific logging backends that duplicate it. +- Add the package's `AppLogger` library product to each target that emits logs. Follow the package's current integration instructions for dependency configuration and version requirements. +- Give each artifact an explicit, stable, lowercase subsystem in reverse-DNS form: `com.thatfactory.`. +- A package always uses its own subsystem, even when its code runs inside a consuming application. This allows filtering all ThatFactory logs or one artifact independently. +- Choose stable categories from the artifact's reusable domain. Categories are not a global vocabulary: a language-evaluation package might use `evaluation`, a progression engine might use `progression`, and an application might use `session` or `lifecycle`. +- Keep the category set as small and generic as possible while still distinguishing meaningful operations within that artifact. +- Do not add a category solely because one current client uses that concept. + +## Package emoji + +- Every log message emitted by a ThatFactory package starts with that package's canonical emoji followed by one space. +- Use the emoji registered in the [ThatFactory Swift Package Collection](https://github.com/thatfactory/swift-package-collection). +- Declare the selected emoji in the package's local instructions or documentation. +- Route package logging through one package-local gateway that owns the subsystem, categories, and emoji prefix. Production call sites must not construct unprefixed package messages directly. + +## Message design + +- Keep each message short, direct, and on one line. +- Prefer one completion or outcome message over separate start, intermediate, and completion messages. +- Use a compact action followed by stable `key=value` metadata when context is useful: + + ```text + evaluate | type=classification, correct=true, score=10 + ``` + +- Do not log routine property access, initializers, collection iterations, or other high-frequency implementation details. +- Use `.debug` for routine diagnostics, `.info` or `.default` for meaningful lifecycle events, `.error` for failures, and `.fault` only for severe conditions that indicate a system-level problem. +- Do not prepend the current time or date. Apple unified logging already records the event timestamp. +- Use AppLogger's `Date.formattedLogTimestamp()` and `TimeInterval.formattedLogDuration()` only when a domain date or elapsed duration is part of the event itself. + +## Privacy + +- Never log credentials, tokens, secrets, personal data, prompts, submitted answers, or other user-generated content as public metadata. +- Prefer omitting sensitive values. If a diagnostic genuinely requires them, mark the entire AppLogger message private. +- Do not emit complete models, collections, or application-state snapshots in routine logs. +- Any temporary state snapshot must be debug-only, explicitly enabled, and private. + +## Testing + +- Keep message rendering independently testable through an internal formatter, injectable sink, or similarly narrow seam. +- Verify that every package message starts with its canonical emoji. +- Verify the stable category, meaningful fields, privacy choice, log level, and single-emission behavior for each logged operation. +- Do not make tests depend on querying the operating system's persisted log store. + +## Filtering + +Use the subsystem in Console or the macOS `/usr/bin/log` command. For example: + +```sh +/usr/bin/log stream --level debug \ + --predicate 'subsystem BEGINSWITH "com.thatfactory"' +``` + +Filter one artifact with an exact subsystem: + +```sh +/usr/bin/log stream --level debug \ + --predicate 'subsystem == "com.thatfactory.example"' +``` diff --git a/AgentGuidelines/Guidelines/Packages.md b/AgentGuidelines/Guidelines/Packages.md new file mode 100644 index 0000000..da332c3 --- /dev/null +++ b/AgentGuidelines/Guidelines/Packages.md @@ -0,0 +1,104 @@ +# Swift Packages + +## README badges + +Start a new ThatFactory project or package README with a centered HTML badge block: + +```html +

+ +

+``` + +Use only badges that describe the repository, in this order: + +1. Swift version. +2. Xcode version. +3. Supported platforms. +4. Relevant package manager, runtime, or ecosystem badges, such as SPM or NPM. +5. Relevant agent or tooling badges, such as Xcode MCP, Codex, or Claude. +6. Updated date. +7. Revision or latest release. +8. License. +9. CI. +10. Release, publishing, or documentation status when applicable. + +The common package baseline is Swift, Xcode, Platforms, License, and CI. Add optional badges only when they convey useful repository-specific information. Keep the order stable even when some positions are omitted. + +- Point CI, publishing, and documentation badges at workflows in the current repository; never copy another repository's badge URL unchanged. +- Use descriptive `alt` text. Preserve a repository's established Xcode badge convention when the label intentionally records the last verified Xcode version. +- Prefer dynamic Updated and Revision badges backed by repository history or releases so maintainers do not edit dates and versions by hand. +- Do not advertise a platform, integration, package manager, or agent that the repository does not support. +- Keep the repository's license in a root `LICENSE` file when reuse or redistribution is permitted. A README license heading is optional; the badge is a summary, not the license grant. +- Do not add a license to an existing repository without the owner's explicit choice of terms. + +## Package boundaries + +- Keep a reusable package focused on one coherent capability. +- Prefer UI-agnostic domain APIs unless UI is the package's explicit purpose. +- Do not add application Redux, navigation, persistence, or product policy to a generic package. +- Keep public APIs minimal and stable. Prefer composing focused types over introducing umbrella abstractions before multiple consumers need them. +- Declare platform and Swift toolchain requirements explicitly in `Package.swift`. +- New Swift packages must start on the latest supported Swift language and toolchain version. Before adding a major package capability to an older package, plan and complete the required Swift/toolchain modernization first. +- Put sources under `Sources//` and tests under `Tests/Tests/`. +- Keep resources in the target that owns them and use the package bundle for lookup. + +## Logging + +Packages own any diagnostics emitted by their implementation. Follow the shared [logging guide](Logging.md) for AppLogger usage, subsystem identity, package emoji prefixes, domain-owned categories, concise messages, privacy, and test coverage. A consuming application must not reproduce package-internal logs. + +## Development workflow + +1. Read the package's local `AGENTS.md`, README, DocC, and public API before changing behavior. +2. Add or update tests in the package itself. +3. Update DocC and README examples when public behavior changes. +4. Run the focused tests, then `swift test` or the package's declared Xcode test workflow. +5. Integrate the package into a consumer locally only when consumer behavior must also be verified. +6. Avoid committing consumer-specific workarounds into the package when the behavior belongs in the consumer. + +## DocC documentation + +DocC is the default documentation format for public Swift packages. Document public APIs with `///` DocC comments and keep package-level conceptual material in a DocC catalog when it needs more than declaration comments. + +Before adopting the DocC command, an existing package must be updated to the latest supported Swift toolchain and declare the Swift-DocC plugin dependency in `Package.swift` (for example, `.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "")`). New packages must declare this prerequisite from the beginning when they publish DocC. + +Packages that publish documentation must build and deploy their DocC site as part of the release workflow: + +1. Run tests before documentation generation. +2. Generate static-hosting documentation with `swift package generate-documentation --target --disable-indexing --output-path ./public --transform-for-static-hosting --hosting-base-path `. +3. Add a root redirect to `//documentation//`. +4. Upload `./public` with `actions/upload-pages-artifact` and deploy it with `actions/deploy-pages`. +5. Grant the workflow `pages: write` and `id-token: write` permissions and expose the deployed URL in the README through a DocC badge. + +The release job must publish documentation only after the release has been approved, merged, tagged, and published. Verify the generated site locally when practical and keep the README badge URL aligned with the repository's GitHub Pages site. + +## Local integration + +- Use Xcode's local-package workflow or an explicit temporary local dependency while developing package and consumer changes together. +- Do not commit machine-specific absolute package paths. +- Before release, restore the consumer to the tagged remote dependency unless its local instructions intentionally retain a monorepo relationship. +- Verify the final remote version resolves on a clean checkout. + +## Releases + +Never release a package directly from unreviewed changes. Every release change must first be submitted through a pull request, reviewed, and approved. This rule applies to `agent-guidelines` itself as well as every consumer package. Create and publish the release only after the PR has merged. + +For ThatFactory packages, “release a new version” means: + +1. Choose a semantic version appropriate to compatibility. +2. Update public documentation and release notes. +3. Run the declared CI/test workflow. +4. Open a pull request containing the release state and wait for approval. +5. Merge the approved pull request. +6. Create and push the matching Git tag. +7. Create a GitHub release for that tag. +8. Use real multiline release notes and backticks around technical names and versions. + +When using a CLI, pass multiline notes through a file so GitHub renders line breaks correctly. + +## Consumer updates + +- Review package release notes and API changes before updating. +- Update one dependency relationship intentionally; do not rewrite unrelated resolved versions. +- Build and test the affected consumer behavior. +- Update the consumer's package integration documentation when roles, mappings, or workflows change. diff --git a/AgentGuidelines/Guidelines/Swift/Localization.md b/AgentGuidelines/Guidelines/Swift/Localization.md new file mode 100644 index 0000000..02725c1 --- /dev/null +++ b/AgentGuidelines/Guidelines/Swift/Localization.md @@ -0,0 +1,43 @@ +# Localization + +Follow Apple's [Localizing your app using agents](https://developer.apple.com/documentation/xcode/localizing-your-app-using-agents) workflow and current Xcode localization tools. Consumer repositories declare their supported languages, catalog locations, key conventions, and generated-symbol policy locally. + +## Source artifacts + +- Use the consumer's existing String Catalogs (`.xcstrings`) as the source of truth. +- Do not create a parallel catalog or migrate an existing `.strings` setup unless the task includes that migration. +- An app target uses its main bundle by default. Swift packages and frameworks must resolve localized resources from their own bundle, using the current Apple-recommended bundle API. +- Keep one source of truth for translator context: either the source comment or the catalog comment. + +## User-facing values + +- Let SwiftUI's localized string initializers preserve localization context. +- Use `LocalizedStringResource` when a model, view state, notification, or other non-view value carries user-facing text that should resolve later. +- Use `String(localized:)` when a resolved localized `String` is genuinely required outside SwiftUI. +- Use `Text(verbatim:)` for intentional non-localized literals such as debug identifiers. +- Do not pass a runtime `String` to a localized initializer and expect Xcode to extract it as a catalog key. + +## Sentences and formatting + +- Interpolate values into one localizable sentence rather than concatenating translated fragments. +- Add translator comments for ambiguous language and describe interpolated placeholders by position and meaning. +- Use locale-aware `FormatStyle` APIs for dates, numbers, lists, measurements, and currencies. +- Avoid runtime case transformations for localized interface text; allow translations to choose appropriate casing. + +## Layout + +- Use leading and trailing instead of left and right for directional layout. +- Avoid fixed text frames that cannot accommodate translation length or script height. +- Prefer semantic text styles to fixed point sizes. +- Use the SwiftUI environment locale for view behavior that must respond to preview or subtree locale overrides. + +## Agent workflow + +1. Inspect the consumer's local localization instructions and catalogs. +2. Ask Xcode's current documentation or localization capability for the supported workflow. +3. Add or update source-language content and translator context. +4. Update only the languages in scope. +5. Build to validate catalog syntax, extraction, generated symbols, and bundle lookup. +6. Use previews or runtime visual verification for truncation, layout direction, and formatting when relevant. + +Do not invent translations from an unrelated project's conventions. Product vocabulary and tone remain consumer-specific. diff --git a/AgentGuidelines/Guidelines/Swift/Swift.md b/AgentGuidelines/Guidelines/Swift/Swift.md new file mode 100644 index 0000000..6ae523d --- /dev/null +++ b/AgentGuidelines/Guidelines/Swift/Swift.md @@ -0,0 +1,37 @@ +# Swift + +## Language and SDK guidance + +- Use the Swift language and platform versions declared by the consumer repository. +- Use current official Apple documentation through Xcode documentation search when API behavior or availability matters. +- Use current `swift-collections` documentation when working with its collection types. +- Import the module that owns an API. For example, APIs specific to `OrderedCollections` require `import OrderedCollections`. +- Maintain a zero-warning policy for warnings introduced by the change. + +## Implementation + +- Prefer concise, readable, maintainable code over clever abstractions. +- Prefer structured concurrency with `async`/`await`, task groups, actors, and `Task` where appropriate. +- Do not introduce `DispatchQueue.async` as a substitute for structured concurrency. +- Respect strict concurrency and the repository's default actor isolation. +- Prefer compiler-synthesized `Codable`, `Equatable`, `Hashable`, and `Sendable` conformances when their semantics are correct. +- Write manual serialization, equality, or hashing only when a documented requirement prevents synthesis. +- Import the narrowest framework the file requires. Models should not import SwiftUI merely to gain transitive access to Foundation types. +- A new Swift file must contain at least one required import; use `import Foundation` when it otherwise needs no module. + +## State and isolation + +- Treat actor isolation as part of an API's contract. +- When application and test targets use MainActor default isolation, infer isolated conformances, and `nonisolated(nonsending)` by default, omit annotations that merely restate those effective settings. Verify every affected target before removing annotations. +- `nonisolated(nonsending)` by default governs how nonisolated asynchronous functions run; it does not make synchronous types or conformances nonisolated. Keep explicit `nonisolated` where a value conformance must satisfy a `Sendable` generic contract, a synchronous API is called from a `@Sendable` closure, or another compiler-verified actor boundary requires it. +- Keep an explicit isolation annotation when a declaration intentionally differs from the target default, crosses an actor boundary, belongs to reusable code compiled under different defaults, or implements a documented compiler workaround. +- Use `Sendable` where values cross concurrency domains and their stored values support it. +- Avoid adding `@MainActor` to tests or domain types merely to silence a diagnostic. Resolve the actual isolation boundary. + +## C-family interoperability + +When a target exposes or consumes C, Objective-C, or C++ interfaces, use Xcode's current `adopt-c-bounds-safety` skill and official compiler documentation for that scoped work. Do not apply C bounds-safety rules to pure Swift targets. + +## Documentation + +Follow [Swift style](SwiftStyle.md) for source formatting and [Documentation](../Documentation.md) for DocC and project-level guidance. diff --git a/AgentGuidelines/Guidelines/Swift/SwiftFormat.md b/AgentGuidelines/Guidelines/Swift/SwiftFormat.md new file mode 100644 index 0000000..1ddd461 --- /dev/null +++ b/AgentGuidelines/Guidelines/Swift/SwiftFormat.md @@ -0,0 +1,54 @@ +# Swift Format + +## Workflow + +- Treat formatting and lint rules as readability and correctness tools, not as architecture. +- Use the shared configuration under `Configurations/Swift/`; consumers expose it through root `.swift-format` and `.editorconfig` symlinks so Xcode, local commands, and CI agree. Configuration discovery is hierarchical, while an explicit `--configuration` path is unconditional. +- In Xcode, use **Editor > Structure > Format File with 'swift-format'** (or the corresponding selection command) when you want to rewrite source. +- After changing Swift source, humans and agents run `AgentGuidelines/Scripts/swift_format.sh format-and-lint ` before handoff. Do this even when a later build would provide the same safety net. +- Run `AgentGuidelines/Scripts/swift_format.sh format ` when only rewriting source is required. +- Run `AgentGuidelines/Scripts/swift_format.sh lint ` for non-blocking local warnings and `lint-strict` for errors that block CI. +- Fix findings introduced by a change. Formatter-supported rules are corrected by `format`; linter-only rules require a source change. + +## Xcode build integration + +- Add a **Swift Format** run-script phase to every independently buildable app or test target that compiles Swift source. Place it before **Compile Sources** so compilation consumes the formatted files. +- Skip the phase when `CI=true`; CI must remain non-mutating and run `lint-strict` in one dedicated job. +- Invoke `AgentGuidelines/Scripts/swift_format.sh format-and-lint` only over source folders compiled by that target, including shared folders it consumes. Exclude unrelated app and test sources so an invalid file outside the selected build cannot block compilation. +- Run the phase on every build rather than using dependency analysis. A no-op formatting pass is intentionally cheaper than allowing locally generated formatting debt. +- Source mutation requires either declared source inputs and outputs or disabling Xcode's **User Script Sandboxing** for the affected configurations. Record and review that choice locally; never disable sandboxing without the formatting phase requiring it. +- Validate the integration in Xcode with an open, deliberately misformatted file. Confirm formatting happens before compilation and that editor saving, cursor state, and undo behavior remain acceptable. + +## Shared customizations + +The checked-in configuration starts from the exhaustive Xcode toolchain dump. These deliberate overrides are the shared policy and must be reapplied when the toolchain changes. + +### Xcode-aligned layout + +- `indentation`: 4 spaces +- `tabWidth`: 4 +- `lineLength`: 120 +- `indentSwitchCaseLabels`: `false` +- Swift-only EditorConfig settings mirror indentation, line length, LF newlines, final newlines, and trailing-whitespace cleanup. + +### Rules enabled beyond the dumped defaults + +- `AlwaysUseLiteralForEmptyCollectionInit`: keeps empty arrays concise and replaces the relevant SwiftLint array/empty-collection checks. +- `NeverUseForceTry`: retains a production safety check; swift-format exempts supported test code. +- `NoEmptyLinesOpeningClosingBraces`: replaces SwiftLint's opening- and closing-brace vertical-whitespace checks. +- `UseWhereClausesInForLoops`: preserves the former SwiftLint `for_where` behavior. +- `ValidateDocumentationComments`: validates documentation already present, including parameter coverage after signature changes, without requiring every declaration to be documented. +- `includeConditionalImports`: sorts imports inside conditional-compilation blocks together with ordinary imports. + +Rules not listed here retain the exhaustive Xcode dump values. In particular, universal public documentation, force-unwrap rejection, implicit-return rewriting, early-exit rewriting, leading-underscore rejection, and implicitly unwrapped optional rejection remain disabled until adopted deliberately. swift-format has no equivalent for repository-specific import bans or sorted enum cases. + +## Focused exceptions + +- Prefer a focused `// swift-format-ignore: RuleName` immediately before the affected declaration or statement when a rule conflicts with required semantics. Add a short preceding comment explaining why. +- Do not ignore a whole file or disable a shared rule to avoid fixing one occurrence. + +## Toolchain updates + +- When the supported Xcode toolchain changes, regenerate the exhaustive configuration with `xcrun swift-format dump-configuration`, reapply the documented Xcode-aligned values, review the resulting policy change, and release it centrally before consumer adoption. + +See swift-format's [configuration](https://github.com/swiftlang/swift-format/blob/main/Documentation/Configuration.md), [rule](https://github.com/swiftlang/swift-format/blob/main/Documentation/RuleDocumentation.md), and [focused suppression](https://github.com/swiftlang/swift-format/blob/main/Documentation/IgnoringSource.md) documentation for the underlying behavior. diff --git a/AgentGuidelines/Guidelines/Swift/SwiftStyle.md b/AgentGuidelines/Guidelines/Swift/SwiftStyle.md new file mode 100644 index 0000000..1a5d488 --- /dev/null +++ b/AgentGuidelines/Guidelines/Swift/SwiftStyle.md @@ -0,0 +1,44 @@ +# Swift Style + +- Keep conditional, loop, and closure bodies on separate lines. +- Keep `guard` exits on separate lines. +- Prefer seconds-based duration APIs such as `Task.sleep(for: .seconds(10))` over nanosecond literals. +- Use `///` for documentation comments and end documentation sentences with periods. +- Use meaningful names of at least three characters. Widely established type-level conventions are allowed only when the consumer explicitly uses them. +- Keep enum cases alphabetical unless ordering communicates behavior or a local lint suppression documents the exception. +- Use `// MARK: -` to separate meaningful sections. +- Use `// MARK: - Private` when separating private implementation from non-private declarations in the same file. +- Do not add Xcode boilerplate filename, author, or creation-date headers. +- Prefer one primary type or concern per file. +- Match a type file's name to its primary type. +- Put the declaration named by the file immediately after imports and file-level directives. Opening `EffectAssetLoader.swift`, for example, must reveal `EffectAssetLoader` before supporting declarations. A shared canonical template may retain type aliases that its documented layout deliberately places first. +- Put a supporting type used only by one primary type inside an extension of that primary type when the relationship forms a natural namespace. Put a supporting type used by other files in its own named file instead. +- Keep physical folders flat until one topic genuinely contains several files. When grouping becomes useful, organize related models, services, tools, views, and Redux components by a familiar domain, feature, or capability so readers can reason about them together. + +Example: + +```swift +guard isEnabled else { + return +} + +withAnimation { + isPresented = true +} +``` + +Namespaced supporting types keep their ownership visible: + +```swift +struct Measurement { + // ... +} + +// MARK: - Errors + +extension Measurement { + enum ValidationError: Error { + case invalidValue + } +} +``` diff --git a/AgentGuidelines/Guidelines/Swift/SwiftUI.md b/AgentGuidelines/Guidelines/Swift/SwiftUI.md new file mode 100644 index 0000000..faeffa1 --- /dev/null +++ b/AgentGuidelines/Guidelines/Swift/SwiftUI.md @@ -0,0 +1,62 @@ +# SwiftUI + +Use official Apple documentation and Xcode's current SwiftUI skills for API-specific behavior. This guide captures stable project policy rather than reproducing the current SDK's API catalog. + +## View structure + +- Keep a parent view focused on composition. +- Model meaningful sections such as headers, lists, metadata, sidebars, and footers as separate `View` types with narrow inputs. +- Keep each independently meaningful `View` in its own file, including private supporting views. Give every view its own deterministic preview when the required dependencies can be represented safely. +- Do not extract sections into computed `some View` properties merely to shorten `body`; computed properties remain in the parent's invalidation boundary. +- Tiny fragments reused within one body may use a small helper when they have no independent state, input, or invalidation story. +- Keep view initializers cheap. Do not decode data, access files, build large structures, or allocate formatters in `init`. +- Avoid a single-child `Group` that adds no structure or behavior. + +## Data flow + +- Pass a view only the value-type fields it reads or forwards. +- Use private `@State` for state genuinely owned by the view. +- Use `Binding` when a child edits state owned by its parent. +- In non-Redux designs, prefer `@Observable` to `ObservableObject` for new shared reference models when platform support allows it. +- In Redux applications, keep durable app and domain state in Redux. Do not introduce an observable view model as a parallel source of truth. +- Make observable stored-property types `Equatable` when equality matches their semantics, allowing redundant assignments to avoid unnecessary invalidation. +- Isolate side effects in `.task`, `.onChange`, actions, or explicit async functions rather than hiding them in rendering logic. +- Use the current `.onChange(of:)` form and read the updated captured value when the previous value is unnecessary. +- Avoid closure-based bindings when a writable key-path binding expresses the same relationship. + +With SDKs where `@State` is a macro, do not give a state property a declaration default and then attempt to replace that value in `init`. Choose one initialization source and verify current compiler guidance when migrating existing code. + +## Collections and identity + +- Give `ForEach`, `List`, `Table`, and similar data-driven views stable, unique element identity. +- Prefer meaningful `Identifiable` conformance when the model has natural identity. +- Do not use collection indices, offsets, or transient UUIDs as identity for mutable collections. +- Do not sort, filter, or map large collections inline inside a frequently evaluated view body. Prepare the collection before rendering. +- Use a dedicated row `View` for meaningful rows and pass it narrow inputs. +- Avoid `AnyView` in collection rows. + +## Modifiers and environment + +- Preserve stable view identity. Prefer modifiers whose values change over conditionally adding and removing modifier branches. +- Do not place high-frequency values in the environment when explicit narrow inputs work. +- Avoid unstable environment defaults and freshly created closures that invalidate large subtrees. +- Do not hide unstable values behind fake `Equatable` implementations. + +## Modern APIs and scope + +- Do not introduce APIs that current Xcode documentation identifies as deprecated or soft-deprecated. +- When fixing a feature, modernize only the code directly required by the task unless the user requests a broader migration. +- For SDK-sensitive features, search current Apple documentation through Xcode rather than relying on remembered signatures. +- Apply the current Xcode SwiftUI skill when a new SDK changes source behavior, builder resolution, state initialization, or modifier availability. + +## Previews + +- Put previews at the end of the file under `// MARK: - Preview`. +- Use `#Preview`. +- Use `@Previewable` for interactive preview state when appropriate. +- Keep preview fixtures deterministic and lightweight. +- After UI changes, follow [Xcode MCP and visual verification](../Xcode/MCP.md) when runtime verification adds meaningful confidence. + +## Localization + +Follow [Localization](Localization.md) for user-facing text, layout direction, formatting, and package bundles. diff --git a/AgentGuidelines/Guidelines/Testing/UnitTesting.md b/AgentGuidelines/Guidelines/Testing/UnitTesting.md new file mode 100644 index 0000000..a694331 --- /dev/null +++ b/AgentGuidelines/Guidelines/Testing/UnitTesting.md @@ -0,0 +1,52 @@ +# Unit and Integration Testing + +## Framework choice + +- Use Swift Testing (`import Testing`) for new unit and integration tests. +- Keep XCTest for UI automation based on XCUIAutomation and for `measure`-based performance tests. +- Do not migrate unrelated tests while implementing a focused feature or fix. +- After removing XCTest, add direct imports such as Foundation when the test relied on XCTest's transitive exports. + +## Structure + +- Organize test intent with `// Given`, `// When`, and `// Then` where the phases are meaningful. +- Prefer `struct` suites. Use a reference type only when lifecycle or identity requires it. +- Use suite initialization and `deinit` only for genuine shared setup and cleanup. +- Keep each test focused on one behavior and name it in domain language. +- Mirror production physical folders under the test target. +- Put reusable mocks and fixtures under the test target's `Mocks/` folder. +- Use shared test tags for recurring classification and keep their declarations alphabetical. Prefer small composable tags that can be combined to describe a test without repeating ad hoc metadata. +- Add a short `///` comment describing each mock's test purpose. + +## Assertions and control flow + +- Use `@Test` for tests and traits. +- Use `#expect` for assertions that allow the test to continue. +- Use `#require` for prerequisites whose failure must stop the current test. +- Prefer exact thrown-error expectations when the particular error is part of the contract. +- Use confirmation APIs for callback or delegate behavior rather than hand-built counters and arbitrary delays. +- Record known failures with Swift Testing's known-issue support instead of silently disabling coverage. + +## Concurrency + +- Assume Swift Testing may run tests concurrently and on arbitrary tasks. +- Make tests independent by default. +- Use `.serialized` only when a suite has a real shared-state dependency that cannot reasonably be removed. +- Add `@MainActor` only when the system under test requires main-actor isolation. +- Prefer async test functions and structured concurrency over expectations combined with arbitrary sleeps. +- Inject clocks, services, identifier generators, and providers to make asynchronous behavior deterministic. + +## Repetition + +- Prefer parameterized tests when the same behavior is exercised with multiple inputs and expected outputs. +- Keep argument cases readable and give complex cases a small named model. +- Do not loop manually inside one test when individual parameterized cases would produce better failure reporting. + +## Execution + +- Prefer Xcode MCP for discovering and running the relevant test plan or test target. +- Use XcodeBuildMCP only when Xcode MCP is unavailable or fails to complete the workflow. +- Start with the smallest relevant test selection, then run the broader affected suite when risk justifies it. +- Report which tests ran and whether any relevant tests could not be executed. + +Follow [Xcode MCP](../Xcode/MCP.md) for build and runtime verification. diff --git a/AgentGuidelines/Guidelines/Xcode/MCP.md b/AgentGuidelines/Guidelines/Xcode/MCP.md new file mode 100644 index 0000000..bed3e38 --- /dev/null +++ b/AgentGuidelines/Guidelines/Xcode/MCP.md @@ -0,0 +1,65 @@ +# Xcode MCP and Visual Verification + +Use Xcode as the primary source for Apple documentation, project knowledge, builds, tests, previews, simulator/device interaction, and diagnostics. + +Apple documents external agent access through [`xcrun mcpbridge`](https://developer.apple.com/documentation/xcode/giving-external-agents-access-to-xcode). Xcode must be open with the relevant project or package, and external-agent access must be enabled in Xcode settings. + +## Tool priority + +1. Use Xcode MCP for Apple documentation search and operations on an open Xcode project or package. +2. Use official Apple web documentation when Xcode documentation search is unavailable or an external reference is useful. +3. Use XcodeBuildMCP only when Xcode MCP is unavailable or cannot complete the required operation. + +Discover the tools exposed by the active Xcode server. Tool prefixes and exact names can vary by client; do not hardcode a server prefix when the active tool catalog can be inspected. + +## Documentation lookup + +- Search current Apple documentation before using an API whose signature, availability, behavior, or replacement may have changed. +- Prefer the documentation returned by the installed Xcode toolchain for SDK-sensitive work. +- Use Xcode-provided skills for specialized current workflows such as SwiftUI modernization, localization, security auditing, device interaction, and C bounds safety. +- Distill durable project policy into local documentation; do not copy an exported Apple skill into a repository. + +## Project operations + +- Prefer Xcode project-aware file, target, build-setting, issue, build, test, preview, and documentation tools when available. +- Build the smallest relevant target or scheme first. +- Read Xcode diagnostics and fix the first root failure before retrying broadly. +- Run focused tests before the full affected suite. +- Keep the project open and active throughout a multi-step Xcode MCP workflow. + +## SwiftUI previews + +After a meaningful SwiftUI change: + +1. Build the affected target. +2. Render or refresh the relevant preview when Xcode exposes preview tooling. +3. Inspect errors and warnings from the preview and build. +4. Verify representative states, localization, accessibility sizes, and appearances when relevant to the task. +5. If preview tooling is unavailable or insufficient, run the feature in a simulator or device session. + +## Build, run, and interaction + +Use runtime interaction when static compilation cannot establish that a user-visible workflow behaves correctly. + +1. Start or select an appropriate simulator/device session. +2. Build, install, and launch through Xcode tooling. +3. Prefer one-run launch arguments and environment variables to editing a shared scheme for temporary configuration. +4. Capture the accessibility or UI hierarchy before interaction. +5. Capture a screenshot when visual state matters. +6. Derive interaction targets from the hierarchy; do not guess coordinates when semantic information is available. +7. Perform the smallest interaction sequence that proves the behavior. +8. Capture the resulting hierarchy and screenshot. +9. Report both functional and visible defects. +10. End resource-heavy sessions when verification is complete. + +Retry a slow launch or interaction once when the application may still be settling. Do not add arbitrary waits as a default synchronization strategy. + +## Reporting + +State: + +- what target, scheme, preview, test, simulator, or device was used; +- what behavior was exercised; +- whether build and runtime diagnostics were clean; +- what visual evidence was inspected; +- what could not be verified and why. diff --git a/AgentGuidelines/Guidelines/Xcode/Security.md b/AgentGuidelines/Guidelines/Xcode/Security.md new file mode 100644 index 0000000..5817b01 --- /dev/null +++ b/AgentGuidelines/Guidelines/Xcode/Security.md @@ -0,0 +1,35 @@ +# Xcode Security Audits + +Use this guide only for an explicitly requested security audit, hardening task, entitlement review, or a change that materially affects application security. Do not expand ordinary feature work into a repository-wide audit. + +## Source of truth + +- Use Xcode's current `audit-xcode-security-settings` skill and official Apple documentation for the detailed baseline. +- Prefer Xcode MCP project-aware tools for targets, build settings, entitlements, capabilities, privacy manifests, source searches, and file updates. +- Discover active tool names rather than hardcoding MCP server prefixes. + +## Audit scope + +Confirm the requested targets and configurations, then inspect only relevant areas: + +- deployment and compiler security settings; +- code-signing and entitlements; +- application capabilities; +- privacy manifests and required-reason APIs; +- network security configuration; +- debug-only behavior and diagnostics; +- sensitive data storage and logging; +- unsafe interoperability boundaries. + +## Changes + +- Explain the concrete risk and affected target before changing a setting or entitlement. +- Prefer Xcode-aware entitlement and project-setting tools over textual project-file manipulation. +- Preserve required capabilities and configuration-specific differences. +- Make narrow changes that can be reviewed and reverted. +- Build the affected target after project-setting changes. +- Exercise relevant runtime behavior when a change affects signing, capabilities, networking, storage, or system integration. + +## Report + +Separate findings from changes. Record the target/configuration, evidence, severity, remediation, and verification for each changed item. Do not claim a comprehensive security guarantee from a settings audit alone. diff --git a/AgentGuidelines/LICENSE b/AgentGuidelines/LICENSE new file mode 100644 index 0000000..45f5b45 --- /dev/null +++ b/AgentGuidelines/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ThatFactory + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/AgentGuidelines/README.md b/AgentGuidelines/README.md new file mode 100644 index 0000000..8be771f --- /dev/null +++ b/AgentGuidelines/README.md @@ -0,0 +1,166 @@ +

+ Xcode + Codex + Updated + Revision + License + CI +

+ +# Agent Guidelines + +`agent-guidelines` is ThatFactory's public, versioned source of truth for reusable instructions and development configuration. It centralizes stable decisions about Swift development, Redux architecture, testing, documentation, logging, packages, CI/CD, localization, and Xcode tooling while leaving product context and exceptions in each consuming repository. + +The repository contains documentation and supporting configuration, not a Swift product. Consumers install a tagged release as a Git subtree at `AgentGuidelines/`, so every agent and supported tool sees ordinary version-controlled files at predictable paths. + +## How it fits together + +```text + thatfactory/agent-guidelines + versioned GitHub repository + | + tagged release + e.g. 0.0.3 + | + git subtree add/pull + | + v ++---------------- Consumer project or package -----------------+ +| | +| AGENTS.md | +| |-- local product/package context | +| |-- concrete project paths | +| |-- local exceptions | +| `-- pointers to shared guidelines -----------------+ | +| | | +| AgentGuidelines/ | | +| |-- VERSION | | +| |-- Configurations/ | | +| `-- Guidelines/ <----------------------------------+ | +| |-- Architecture/Redux.md | +| |-- Swift/SwiftUI.md | +| |-- Testing/UnitTesting.md | +| `-- Xcode/MCP.md | +| | +| Sources and project files | ++----------------------------+---------------------------------+ + | + reads instructions and project files + +----------+----------+ + v v + Codex Xcode agent + | + | Xcode MCP (`xcrun mcpbridge`) + v + Xcode +``` + +The subtree does not automatically import every guide into an agent's context. A consumer's root or folder-scoped `AGENTS.md` tells the agent which shared guides to read for the task. The nearest local `AGENTS.md` can specialize or override the shared baseline. + +## Guideline catalog + +- [Agent workflow and tool execution](Guidelines/AgentWorkflow.md) +- [CI/CD](Guidelines/CICD.md) +- [Development and reusability](Guidelines/Development.md) +- [Documentation](Guidelines/Documentation.md) +- [Git repositories and SSH-first cloning](Guidelines/Git/Repositories.md) +- [GitHub pull requests](Guidelines/GitHub/PullRequests.md) +- [Localization](Guidelines/Swift/Localization.md) +- [Logging](Guidelines/Logging.md) +- [Redux architecture and physical folder organization](Guidelines/Architecture/Redux.md) +- [Swift](Guidelines/Swift/Swift.md) +- [Swift format](Guidelines/Swift/SwiftFormat.md) +- [Swift packages](Guidelines/Packages.md) +- [Swift style](Guidelines/Swift/SwiftStyle.md) +- [SwiftUI](Guidelines/Swift/SwiftUI.md) +- [Unit and integration testing](Guidelines/Testing/UnitTesting.md) +- [Xcode MCP and visual verification](Guidelines/Xcode/MCP.md) +- [Xcode security audits](Guidelines/Xcode/Security.md) + +Only reference the guides that apply. Agent workflow normally applies to both applications and packages. A UI-agnostic package normally also uses Swift, style, testing, documentation, logging, packages, CI/CD, and Xcode guidance, but not Redux or SwiftUI guidance. + +## Add to a consumer + +From the consumer repository root, install a tagged release: + +```sh +git subtree add \ + --prefix=AgentGuidelines \ + https://github.com/thatfactory/agent-guidelines.git \ + 0.0.15 \ + --squash +``` + +Swift consumers that adopt the shared formatter expose its configuration at the repository root so Xcode and other tools discover it: + +```sh +ln -s AgentGuidelines/Configurations/Swift/.swift-format .swift-format +ln -s AgentGuidelines/Configurations/Swift/.editorconfig .editorconfig +``` + +Keep the subtree tracked, but add this to the consumer's tracked `.gitattributes` so GitHub collapses synchronized guideline files in pull-request diffs by default: + +```gitattributes +# Synced from thatfactory/agent-guidelines; keep tracked but collapse GitHub diffs. +AgentGuidelines/** linguist-generated +``` + +Copy and adapt [the consumer template](Templates/AGENTS.md). Keep the consumer file small: describe the product or package, map its concrete physical folders, point to the applicable shared guides, and state only genuine exceptions. + +### Configure global Codex instructions + +Copy the contents of [`Templates/GlobalCodexInstructions.md`](Templates/GlobalCodexInstructions.md) into the user's global Codex instructions. + +These instructions only bootstrap discovery of repository-local `AGENTS.md` files and shared guides. Repository engineering policy remains versioned in this repository rather than duplicated in each user's global configuration. + +Review this template when upgrading `agent-guidelines`, because the recommended global bootstrap instructions may change between releases. Installing or updating the Git subtree does not update a user's global Codex configuration. + +Redux applications also copy [the canonical Store](Templates/Store.swift) as is, following the composition and placement rules in [Redux architecture](Guidelines/Architecture/Redux.md). + +Expose the completion-audit skill at the consumer repository root so Codex can discover it: + +```sh +mkdir -p .agents/skills +ln -s ../../AgentGuidelines/.agents/skills/agent-guidelines-audit \ + .agents/skills/agent-guidelines-audit +``` + +## Update a consumer + +Review the target release's changelog, then pull it deliberately: + +```sh +git subtree pull \ + --prefix=AgentGuidelines \ + https://github.com/thatfactory/agent-guidelines.git \ + 0.0.15 \ + --squash +``` + +Confirm `AgentGuidelines/VERSION`, ensure the `.gitattributes` rule above is present, review the subtree diff, validate local `AGENTS.md` pointers, and run the consumer's relevant tests. Keep the subtree update in its own commit, and identify the old and new versions plus the central release or pull request in the consumer pull-request description. Updates are intentionally not automatic: one guideline release cannot silently change every project. + +## Maintain the source of truth + +1. Export current Xcode skills to a temporary review location when a new Xcode release materially changes agent behavior: + + ```sh + xcrun agent skills export --output-dir + ``` + +2. Compare relevant guidance with this repository and official Apple documentation. +3. Bring over durable policy, not the exported skill text or an SDK API catalog. +4. Remove obsolete or conflicting rules instead of accumulating historical alternatives. +5. Run `python3 Scripts/validate_guidelines.py`. +6. Update `VERSION` and `CHANGELOG.md`, open a pull request, and wait for approval before merging. +7. After the pull request has merged, create the matching tag and GitHub release. + +## Precedence + +For a consumer task, apply instructions in this order: + +1. The user's explicit request. +2. The nearest applicable consumer `AGENTS.md`. +3. The consumer root `AGENTS.md`. +4. The shared guides explicitly referenced by those files. + +Official Apple documentation remains authoritative for API behavior. A local convention can deliberately narrow a choice, but it must not rely on behavior contradicted by the current SDK documentation. diff --git a/AgentGuidelines/Scripts/swift_format.sh b/AgentGuidelines/Scripts/swift_format.sh new file mode 100755 index 0000000..7ed65b1 --- /dev/null +++ b/AgentGuidelines/Scripts/swift_format.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $0 ..." >&2 +} + +if [[ $# -lt 2 ]]; then + usage + exit 64 +fi + +mode="$1" +shift + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +configuration="$script_directory/../Configurations/Swift/.swift-format" + +if command -v xcrun >/dev/null 2>&1 && xcrun --find swift-format >/dev/null 2>&1; then + formatter=(xcrun swift-format) +elif command -v swift-format >/dev/null 2>&1; then + formatter=(swift-format) +elif command -v swift >/dev/null 2>&1; then + formatter=(swift format) +else + echo "error: swift-format is unavailable; install or select a Swift 6 toolchain." >&2 + exit 127 +fi + +common_arguments=( + --configuration "$configuration" + --recursive + --parallel +) + +format_sources() { + "${formatter[@]}" format --in-place "${common_arguments[@]}" "$@" +} + +lint_sources() { + "${formatter[@]}" lint "${common_arguments[@]}" "$@" +} + +case "$mode" in + format) + format_sources "$@" + ;; + format-and-lint) + format_sources "$@" + lint_sources "$@" + ;; + lint) + lint_sources "$@" + ;; + lint-strict) + "${formatter[@]}" lint --strict "${common_arguments[@]}" "$@" + ;; + *) + usage + exit 64 + ;; +esac diff --git a/AgentGuidelines/Scripts/validate_guidelines.py b/AgentGuidelines/Scripts/validate_guidelines.py new file mode 100644 index 0000000..23c935d --- /dev/null +++ b/AgentGuidelines/Scripts/validate_guidelines.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Validate the structure and public safety of the guideline repository.""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path, PurePosixPath + + +ROOT = Path(__file__).resolve().parents[1] +README = ROOT / "README.md" +VERSION = ROOT / "VERSION" +CHANGELOG = ROOT / "CHANGELOG.md" +SWIFT_FORMAT_CONFIGURATION = ROOT / "Configurations" / "Swift" / ".swift-format" +EDITOR_CONFIGURATION = ROOT / "Configurations" / "Swift" / ".editorconfig" +SWIFT_FORMAT_SCRIPT = ROOT / "Scripts" / "swift_format.sh" +AUDIT_SKILL = ROOT / ".agents" / "skills" / "agent-guidelines-audit" / "SKILL.md" +DEVELOPMENT_GUIDELINE = ROOT / "Guidelines" / "Development.md" +AGENTS_TEMPLATE = ROOT / "Templates" / "AGENTS.md" +EXPECTED_SWIFT_FORMAT_RULES = { + "AllPublicDeclarationsHaveDocumentation": False, + "AlwaysUseLiteralForEmptyCollectionInit": True, + "AlwaysUseLowerCamelCase": True, + "AmbiguousTrailingClosureOverload": True, + "AvoidRetroactiveConformances": True, + "BeginDocumentationCommentWithOneLineSummary": False, + "DoNotUseSemicolons": True, + "DontRepeatTypeInStaticProperties": True, + "FileScopedDeclarationPrivacy": True, + "FullyIndirectEnum": True, + "GroupNumericLiterals": True, + "IdentifiersMustBeASCII": True, + "NeverForceUnwrap": False, + "NeverUseForceTry": True, + "NeverUseImplicitlyUnwrappedOptionals": False, + "NoAccessLevelOnExtensionDeclaration": True, + "NoAssignmentInExpressions": True, + "NoBlockComments": True, + "NoCasesWithOnlyFallthrough": True, + "NoEmptyLinesOpeningClosingBraces": True, + "NoEmptyTrailingClosureParentheses": True, + "NoLabelsInCasePatterns": True, + "NoLeadingUnderscores": False, + "NoParensAroundConditions": True, + "NoPlaygroundLiterals": True, + "NoVoidReturnOnFunctionSignature": True, + "OmitExplicitReturns": False, + "OneCasePerLine": True, + "OneVariableDeclarationPerLine": True, + "OnlyOneTrailingClosureArgument": True, + "OrderedImports": True, + "ReplaceForEachWithForLoop": True, + "ReturnVoidInsteadOfEmptyTuple": True, + "TypeNamesShouldBeCapitalized": True, + "UseEarlyExits": False, + "UseExplicitNilCheckInConditions": True, + "UseLetInEveryBoundCaseVariable": True, + "UseShorthandTypeNames": True, + "UseSingleLinePropertyGetter": True, + "UseSynthesizedInitializer": True, + "UseTripleSlashForDocumentationComments": True, + "UseWhereClausesInForLoops": True, + "ValidateDocumentationComments": True, +} + +MARKDOWN_LINK = re.compile(r"\[[^\]]+\]\(([^)]+)\)") +SEMVER = re.compile( + r"^(0|[1-9][0-9]*)\." + r"(0|[1-9][0-9]*)\." + r"(0|[1-9][0-9]*)" + r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" +) +FORBIDDEN = { + "/" + "Users" + "/": "personal absolute path", + "file" + "://": "local file URL", + "mobile-ios-" + "chauffeur": "work-repository identifier", + "black" + "lane": "work-repository identifier", +} + + +def text_files() -> list[Path]: + suffixes = {".md", ".py", ".swift", ".yml", ".yaml", ".txt"} + files = [path for path in ROOT.rglob("*") if path.is_file() and path.suffix in suffixes] + files.extend(path for path in (ROOT / "VERSION", ROOT / "LICENSE") if path.is_file()) + files.extend( + path + for path in (SWIFT_FORMAT_CONFIGURATION, EDITOR_CONFIGURATION) + if path.is_file() + ) + return sorted(set(files)) + + +def resolve_link(source: Path, raw_target: str) -> Path | None: + target = raw_target.strip().strip("<>").split("#", maxsplit=1)[0] + if not target or target.startswith(("#", "http://", "https://", "mailto:")): + return None + + parts = PurePosixPath(target).parts + if "AgentGuidelines" in parts: + index = parts.index("AgentGuidelines") + return ROOT.joinpath(*parts[index + 1 :]).resolve() + + return (source.parent / target).resolve() + + +def validate_links(errors: list[str]) -> None: + for source in sorted(ROOT.rglob("*.md")): + for raw_target in MARKDOWN_LINK.findall(source.read_text(encoding="utf-8")): + resolved = resolve_link(source, raw_target) + if resolved is not None and not resolved.exists(): + relative_source = source.relative_to(ROOT) + errors.append(f"{relative_source}: missing link target {raw_target!r}") + + +def validate_catalog(errors: list[str]) -> None: + readme = README.read_text(encoding="utf-8") + for guide in sorted((ROOT / "Guidelines").rglob("*.md")): + relative = guide.relative_to(ROOT).as_posix() + if f"]({relative})" not in readme: + errors.append(f"README.md: guideline is not cataloged: {relative}") + + +def validate_version(errors: list[str]) -> None: + version = VERSION.read_text(encoding="utf-8").strip() + if not SEMVER.fullmatch(version): + errors.append(f"VERSION: invalid semantic version {version!r}") + + changelog = CHANGELOG.read_text(encoding="utf-8") + if f"## [{version}]" not in changelog: + errors.append(f"CHANGELOG.md: missing release heading for {version}") + + +def validate_readme_contract(errors: list[str]) -> None: + readme = README.read_text(encoding="utf-8") + required = { + 'alt="Xcode"': "Xcode badge alt text", + "thatfactory/agent-guidelines/actions/workflows/ci.yml": "CI badge repository", + "--prefix=AgentGuidelines": "subtree destination", + "https://github.com/thatfactory/agent-guidelines.git": "subtree remote", + "git subtree add": "subtree installation command", + "git subtree pull": "subtree update command", + "AgentGuidelines/** linguist-generated": "generated subtree attribute", + "AgentGuidelines/Configurations/Swift/.swift-format": "swift-format symlink command", + "AgentGuidelines/Configurations/Swift/.editorconfig": "EditorConfig symlink command", + ".agents/skills/agent-guidelines-audit": "completion-audit skill setup", + } + for value, description in required.items(): + if value not in readme: + errors.append(f"README.md: missing {description}: {value!r}") + + +def validate_public_content(errors: list[str]) -> None: + for path in text_files(): + contents = path.read_text(encoding="utf-8") + relative = path.relative_to(ROOT) + for forbidden, description in FORBIDDEN.items(): + if forbidden.lower() in contents.lower(): + errors.append(f"{relative}: contains {description}: {forbidden!r}") + + +def validate_swift_format_configuration(errors: list[str]) -> None: + try: + configuration = json.loads(SWIFT_FORMAT_CONFIGURATION.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + errors.append(f"{SWIFT_FORMAT_CONFIGURATION.relative_to(ROOT)}: invalid JSON: {error}") + return + + expected_values = { + "indentation": {"spaces": 4}, + "indentSwitchCaseLabels": False, + "lineLength": 120, + "tabWidth": 4, + "version": 1, + } + for key, expected in expected_values.items(): + actual = configuration.get(key) + if actual != expected: + errors.append( + f"{SWIFT_FORMAT_CONFIGURATION.relative_to(ROOT)}: " + f"{key} must be {expected!r}, found {actual!r}" + ) + + include_conditional_imports = configuration.get("orderedImports", {}).get( + "includeConditionalImports" + ) + if include_conditional_imports is not True: + errors.append( + f"{SWIFT_FORMAT_CONFIGURATION.relative_to(ROOT)}: " + "orderedImports.includeConditionalImports must be True, " + f"found {include_conditional_imports!r}" + ) + + rules = configuration.get("rules") + if not isinstance(rules, dict) or not rules: + errors.append( + f"{SWIFT_FORMAT_CONFIGURATION.relative_to(ROOT)}: " + "rules must be an exhaustive non-empty object" + ) + else: + missing = sorted(set(EXPECTED_SWIFT_FORMAT_RULES) - set(rules)) + unexpected = sorted(set(rules) - set(EXPECTED_SWIFT_FORMAT_RULES)) + if missing or unexpected: + errors.append( + f"{SWIFT_FORMAT_CONFIGURATION.relative_to(ROOT)}: " + f"rule map mismatch; missing={missing!r}, unexpected={unexpected!r}" + ) + for rule in sorted(set(rules) & set(EXPECTED_SWIFT_FORMAT_RULES)): + expected = EXPECTED_SWIFT_FORMAT_RULES[rule] + actual = rules[rule] + if actual != expected: + errors.append( + f"{SWIFT_FORMAT_CONFIGURATION.relative_to(ROOT)}: " + f"{rule} must be {expected!r}, found {actual!r}" + ) + + +def validate_editor_configuration(errors: list[str]) -> None: + try: + contents = EDITOR_CONFIGURATION.read_text(encoding="utf-8") + except OSError as error: + errors.append( + f"{EDITOR_CONFIGURATION.relative_to(ROOT)}: cannot read configuration: {error}" + ) + return + required = { + "root = true", + "[*.swift]", + "indent_style = space", + "indent_size = 4", + "tab_width = 4", + "max_line_length = 120", + "end_of_line = lf", + "insert_final_newline = true", + "trim_trailing_whitespace = true", + } + for value in sorted(required): + if value not in contents: + errors.append( + f"{EDITOR_CONFIGURATION.relative_to(ROOT)}: missing {value!r}" + ) + + +def validate_swift_format_script(errors: list[str]) -> None: + if not SWIFT_FORMAT_SCRIPT.is_file(): + errors.append(f"{SWIFT_FORMAT_SCRIPT.relative_to(ROOT)}: missing script") + elif not os.access(SWIFT_FORMAT_SCRIPT, os.X_OK): + errors.append(f"{SWIFT_FORMAT_SCRIPT.relative_to(ROOT)}: script is not executable") + + +def validate_audit_skill(errors: list[str]) -> None: + if not AUDIT_SKILL.is_file(): + errors.append(f"{AUDIT_SKILL.relative_to(ROOT)}: missing audit skill") + return + + skill = AUDIT_SKILL.read_text(encoding="utf-8") + required_skill_values = { + "name: agent-guidelines-audit": "skill name", + "before claiming completion": "completion trigger", + "git diff --check": "diff validation", + } + for value, description in required_skill_values.items(): + if value not in skill: + errors.append( + f"{AUDIT_SKILL.relative_to(ROOT)}: missing {description}: {value!r}" + ) + + development = DEVELOPMENT_GUIDELINE.read_text(encoding="utf-8") + if "$agent-guidelines-audit" not in development: + errors.append( + f"{DEVELOPMENT_GUIDELINE.relative_to(ROOT)}: " + "missing mandatory $agent-guidelines-audit invocation" + ) + + agents_template = AGENTS_TEMPLATE.read_text(encoding="utf-8") + if "AgentGuidelines/Guidelines/Development.md" not in agents_template: + errors.append( + f"{AGENTS_TEMPLATE.relative_to(ROOT)}: missing Development.md pointer" + ) + if "## Stack" not in agents_template: + errors.append(f"{AGENTS_TEMPLATE.relative_to(ROOT)}: missing Stack section") + + +def main() -> int: + errors: list[str] = [] + validate_links(errors) + validate_catalog(errors) + validate_version(errors) + validate_readme_contract(errors) + validate_public_content(errors) + validate_swift_format_configuration(errors) + validate_editor_configuration(errors) + validate_swift_format_script(errors) + validate_audit_skill(errors) + + if errors: + print("Guideline validation failed:") + for error in errors: + print(f"- {error}") + return 1 + + guide_count = len(list((ROOT / "Guidelines").rglob("*.md"))) + print(f"Validated {guide_count} guidelines for version {VERSION.read_text().strip()}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AgentGuidelines/Templates/AGENTS.md b/AgentGuidelines/Templates/AGENTS.md new file mode 100644 index 0000000..845abf7 --- /dev/null +++ b/AgentGuidelines/Templates/AGENTS.md @@ -0,0 +1,58 @@ +# Project Instructions + +## Context + +Describe the product or package, supported platforms, and durable constraints. Link to the project README or product documentation instead of duplicating it. + +## Shared guidelines + +Read only the guides relevant to the task: + +- [Agent workflow](AgentGuidelines/Guidelines/AgentWorkflow.md) +- [Swift](AgentGuidelines/Guidelines/Swift/Swift.md) +- [Swift style](AgentGuidelines/Guidelines/Swift/SwiftStyle.md) +- [SwiftUI](AgentGuidelines/Guidelines/Swift/SwiftUI.md) +- [Swift format](AgentGuidelines/Guidelines/Swift/SwiftFormat.md) +- [Localization](AgentGuidelines/Guidelines/Swift/Localization.md) +- [Unit and integration testing](AgentGuidelines/Guidelines/Testing/UnitTesting.md) +- [Documentation](AgentGuidelines/Guidelines/Documentation.md) +- [Logging](AgentGuidelines/Guidelines/Logging.md) +- [Packages](AgentGuidelines/Guidelines/Packages.md) +- [Development workflow](AgentGuidelines/Guidelines/Development.md) +- [CI/CD](AgentGuidelines/Guidelines/CICD.md) +- [Git repositories and SSH-first cloning](AgentGuidelines/Guidelines/Git/Repositories.md) +- [GitHub pull requests](AgentGuidelines/Guidelines/GitHub/PullRequests.md) +- [Xcode MCP and visual verification](AgentGuidelines/Guidelines/Xcode/MCP.md) +- [Xcode security audits](AgentGuidelines/Guidelines/Xcode/Security.md) + +For an application that uses Redux, also read [Redux architecture](AgentGuidelines/Guidelines/Architecture/Redux.md). + +Add the following section to the consumer repository's root `AGENTS.md` so it is loaded for root-level Codex and pull-request work: + +```md +## Codex review scope + +For consumer pull requests, do not substantively review `AgentGuidelines/**` after exact tagged-tree provenance has been verified. Verify its `VERSION`, compare its tree with the matching central tag, and verify the required `.gitattributes` rule. If provenance does not match exactly, review the subtree contents and stop the merge. Report substantive guideline feedback against the central `agent-guidelines` pull request. +``` + +This tracked, synchronized subtree is reviewed centrally in `thatfactory/agent-guidelines`; the root-level instruction ensures the review scope is loaded even when Codex starts from the repository root. + +## Physical folder map + +Replace these examples with exact repository paths: + +| Role | Physical folder | +|---|---| +| Application sources | `/` | +| Redux | `/Redux/` | +| Views | `/View/` | +| Services | `/Services/` | +| Unit tests | `Tests/` | + +## Stack + +Record the supported Xcode, Swift, and platform versions. State strict-concurrency mode, default actor isolation, infer-isolated-conformance behavior, and `nonisolated(nonsending)` defaults when they apply. Clarify whether application, package, and test targets share those settings. + +## Local specialization + +State only rules that specialize or override the shared baseline. Explain their scope and point to local source-of-truth documentation. diff --git a/AgentGuidelines/Templates/GlobalCodexInstructions.md b/AgentGuidelines/Templates/GlobalCodexInstructions.md new file mode 100644 index 0000000..4949810 --- /dev/null +++ b/AgentGuidelines/Templates/GlobalCodexInstructions.md @@ -0,0 +1,9 @@ +# Global Codex Instructions + +For repositories containing an `AGENTS.md`, read and follow the applicable repository instructions before starting substantive work. + +When a repository includes shared agent guidelines, read only the guides referenced by the applicable `AGENTS.md`. Treat those guides as the source of truth for language conventions, architecture, development workflow, testing, and agent execution. + +Repository and folder-level instructions may specialize the shared baseline within their scope. Do not replace deliberate repository conventions with generic global preferences. + +Do not duplicate repository guidance in global instructions. Global instructions should bootstrap discovery of the repository's own sources of truth. diff --git a/AgentGuidelines/Templates/Store.swift b/AgentGuidelines/Templates/Store.swift new file mode 100644 index 0000000..0120cf1 --- /dev/null +++ b/AgentGuidelines/Templates/Store.swift @@ -0,0 +1,112 @@ +import Foundation +import Observation + +typealias AppStore = Store +typealias StateType = Equatable & Sendable & Codable +typealias ActionType = Equatable & Sendable +typealias Reducer = (State, Action) -> State +typealias Middleware = (State, Action) async -> Action? + +/// A class representing the state management store for the app. +/// +/// The `Store` class is responsible for managing the state of the application and handling actions +/// through a reducer and optional middlewares. It's an `@Observable`, which allows SwiftUI views +/// to observe state changes. This template requires every application and test target that compiles +/// or exercises it to set `Default Actor Isolation` to `MainActor` and +/// `nonisolated(nonsending) By Default` to `Yes`. These settings keep middleware on the main actor +/// without redundant isolation annotations. +/// +/// - Parameters: +/// - State: The type representing the state of the application. +/// Must conform to `Equatable & Sendable & Codable`. +/// - Action: The type representing actions that can be dispatched to the store. +/// Must conform to `Equatable & Sendable`. +/// +/// Example usage: +/// ``` +/// let store = AppStore(initialState: AppState(), reducer: appReducer) +/// await store.dispatch(.someAction) +/// ``` +@Observable final class Store { + private(set) var state: State + + @ObservationIgnored + private let middlewares: [Middleware] + + @ObservationIgnored + private let reducer: Reducer + + init( + initialState: State, + middlewares: [Middleware] = [], + reducer: @escaping Reducer + ) { + self.state = initialState + self.middlewares = middlewares + self.reducer = reducer + } +} + +// MARK: - Dispatcher + +extension Store { + /// Dispatches an action, awaiting the entire middleware chain before returning. + /// + /// The reducer runs first, then every middleware executes sequentially against the same + /// post-reducer state snapshot; any follow-up actions they return are dispatched + /// recursively (depth-first) and awaited too. This guarantees: + /// - Middleware executes sequentially and completes before returning. + /// - Nested actions dispatched by middleware are also awaited. + /// - State updates are fully processed before subsequent operations. + /// - Network requests don't overlap or time out due to race conditions. + /// + /// Awaiting also keeps state mutation off the synchronous SwiftUI update/layout pass, + /// avoiding the re-entrant `@Observable` mutation that crashes on iOS 26 (recursive + /// layout / `SIGTRAP`). + /// + /// For fire-and-forget dispatching from a synchronous context (e.g. a `Button` action, + /// `onAppear` / `onChange`, app startup), wrap the call in a `Task`: + /// ```swift + /// Task { await store.dispatch(action) } + /// ``` + /// When several actions must keep their relative order, dispatch them from a single `Task` + /// so they can't interleave: + /// ```swift + /// Task { + /// await store.dispatch(firstAction) + /// await store.dispatch(secondAction) + /// } + /// ``` + /// Conversely, **independent** actions are intentionally left as one `Task` per call so they + /// run concurrently — don't merge them into a single `Task` just to save lines, as that + /// serializes them (the second waits for the first's full middleware chain): + /// ```swift + /// // Independent: keep separate so neither blocks the other. + /// Task { await store.dispatch(firstAction) } + /// Task { await store.dispatch(secondAction) } + /// ``` + /// + /// - Parameter action: The action to dispatch. + func dispatch(_ action: Action) async { + state = reducer(state, action) + + // Capture the post-reducer state snapshot so all middlewares in this action's + // chain see the same state, even if nested actions mutate state during execution. + let currentState = state + + // Execute all middlewares against the same state snapshot and collect their next + // actions. This ensures every middleware for this action sees the same state (Redux pattern). + var nextActions: [Action] = [] + for middleware in middlewares { + if let nextAction = await middleware(currentState, action) { + nextActions.append(nextAction) + } + } + + // Then dispatch the collected next actions sequentially, maintaining depth-first + // execution while preserving state-snapshot consistency. + for nextAction in nextActions { + await dispatch(nextAction) + } + } +} diff --git a/AgentGuidelines/Tests/test_validate_guidelines.py b/AgentGuidelines/Tests/test_validate_guidelines.py new file mode 100644 index 0000000..d250633 --- /dev/null +++ b/AgentGuidelines/Tests/test_validate_guidelines.py @@ -0,0 +1,128 @@ +"""Tests for the guideline repository validator.""" + +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +VALIDATOR_PATH = Path(__file__).resolve().parents[1] / "Scripts" / "validate_guidelines.py" +SPEC = importlib.util.spec_from_file_location("validate_guidelines", VALIDATOR_PATH) +assert SPEC is not None +assert SPEC.loader is not None +VALIDATOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VALIDATOR) + + +class SemanticVersionTests(unittest.TestCase): + """Verifies the supported Semantic Versioning grammar.""" + + def test_valid_versions(self) -> None: + """Accepts core, prerelease, and build metadata forms.""" + versions = ( + "0.0.2", + "1.2.3-rc.1+build.5", + "1.0.0-alpha-beta", + "1.0.0+001", + ) + + for version in versions: + with self.subTest(version=version): + self.assertIsNotNone(VALIDATOR.SEMVER.fullmatch(version)) + + def test_invalid_versions(self) -> None: + """Rejects leading zeroes and incomplete identifiers.""" + versions = ( + "01.2.3", + "1.02.3", + "1.2.03", + "1.2.3-01", + "1.2.3-rc.01", + "1.2.3+", + "1.2.3-", + ) + + for version in versions: + with self.subTest(version=version): + self.assertIsNone(VALIDATOR.SEMVER.fullmatch(version)) + + +class SwiftFormattingConfigurationTests(unittest.TestCase): + """Verifies the shared Swift formatting contract.""" + + def test_swift_format_configuration(self) -> None: + """Accepts the exhaustive Xcode-aligned swift-format configuration.""" + errors: list[str] = [] + + VALIDATOR.validate_swift_format_configuration(errors) + + self.assertEqual(errors, []) + + def test_swift_format_configuration_rejects_undocumented_rule_change(self) -> None: + """Rejects a changed rule value even when the exhaustive key set is unchanged.""" + configuration = json.loads( + VALIDATOR.SWIFT_FORMAT_CONFIGURATION.read_text(encoding="utf-8") + ) + configuration["rules"]["NeverForceUnwrap"] = True + + with tempfile.TemporaryDirectory(dir=VALIDATOR.ROOT) as directory: + path = Path(directory) / ".swift-format" + path.write_text(json.dumps(configuration), encoding="utf-8") + errors: list[str] = [] + + with mock.patch.object(VALIDATOR, "SWIFT_FORMAT_CONFIGURATION", path): + VALIDATOR.validate_swift_format_configuration(errors) + + self.assertTrue( + any("NeverForceUnwrap must be False, found True" in error for error in errors) + ) + + def test_swift_format_configuration_requires_conditional_import_sorting(self) -> None: + """Rejects disabling conditional import sorting.""" + configuration = json.loads( + VALIDATOR.SWIFT_FORMAT_CONFIGURATION.read_text(encoding="utf-8") + ) + configuration["orderedImports"]["includeConditionalImports"] = False + + with tempfile.TemporaryDirectory(dir=VALIDATOR.ROOT) as directory: + path = Path(directory) / ".swift-format" + path.write_text(json.dumps(configuration), encoding="utf-8") + errors: list[str] = [] + + with mock.patch.object(VALIDATOR, "SWIFT_FORMAT_CONFIGURATION", path): + VALIDATOR.validate_swift_format_configuration(errors) + + self.assertTrue( + any( + "orderedImports.includeConditionalImports must be True" in error + for error in errors + ) + ) + + def test_editor_configuration(self) -> None: + """Accepts the shared Swift EditorConfig values.""" + errors: list[str] = [] + + VALIDATOR.validate_editor_configuration(errors) + + self.assertEqual(errors, []) + + +class AgentGuidelinesAuditSkillTests(unittest.TestCase): + """Verifies the mandatory completion-audit skill contract.""" + + def test_audit_skill_contract(self) -> None: + """Accepts the skill, Development rule, and consumer template.""" + errors: list[str] = [] + + VALIDATOR.validate_audit_skill(errors) + + self.assertEqual(errors, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/AgentGuidelines/VERSION b/AgentGuidelines/VERSION new file mode 100644 index 0000000..ceddfb2 --- /dev/null +++ b/AgentGuidelines/VERSION @@ -0,0 +1 @@ +0.0.15 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..45f5b45 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ThatFactory + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..305890f --- /dev/null +++ b/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.4 + +import PackageDescription + +let package = Package( + name: "CloudSaveKit", + platforms: [ + .iOS(.v26), + .macOS(.v26), + .tvOS(.v26), + .watchOS(.v26), + .visionOS(.v26), + ], + products: [ + .library( + name: "CloudSaveKit", + targets: ["CloudSaveKit"] + ), + ], + dependencies: [ + .package(url: "https://github.com/thatfactory/applogger", from: "1.1.0"), + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.5.0"), + ], + targets: [ + .target( + name: "CloudSaveKit", + dependencies: [ + .product( + name: "AppLogger", + package: "applogger" + ), + ] + ), + .testTarget( + name: "CloudSaveKitTests", + dependencies: ["CloudSaveKit"] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d88c441 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +

+ Swift + Xcode + Platforms + SPM + License + CI + Release + DocC +

+ +# CloudSaveKit + +A reusable `CKSyncEngine` coordinator for synchronizing app-owned local data with a private CloudKit database. ☁️ + +CloudSaveKit owns CloudKit synchronization mechanics while the host application remains responsible for its local persistence, record schema, merge semantics, and user experience. It deliberately has no dependency on SwiftData, Core Data, Redux, or SwiftUI. + +## Responsibilities + +- Restore and persist opaque `CKSyncEngine` state. +- Create and recover a custom CloudKit record zone. +- Batch pending record saves and deletions within CloudKit limits. +- Schedule automatic synchronization and expose explicit fetch, send, and combined sync operations. +- Forward fetched changes, account events, saved system fields, and semantic conflicts to the host. +- Classify failures into privacy-safe values suitable for application state. + +## Quick start + +Add CloudSaveKit to your package dependencies after its first release: + +```swift +.package( + url: "https://github.com/thatfactory/cloudsavekit.git", + from: "0.1.0" +) +``` + +Implement `CloudSaveClient` in the actor that owns your local database, then configure a private CloudKit database and custom zone: + +```swift +let container = CKContainer(identifier: "iCloud.com.example.game") +let zone = CKRecordZone(zoneName: "GameSave") +let configuration = CloudSaveConfiguration( + database: container.privateCloudDatabase, + stateSerialization: restoredState, + zone: zone +) +let engine = CloudSaveEngine( + configuration: configuration, + client: localStore +) + +try await engine.start() +``` + +Persist every state value received by `CloudSaveClient.persist(stateSerialization:)` and restore it through `CloudSaveConfiguration.stateSerialization`. Also return every locally durable unsent change from `pendingChanges()`: this is what lets the engine recover correctly after termination, relaunch, zone recreation, and iCloud account changes. + +After committing local data, enqueue its durable CloudKit change: + +```swift +await engine.enqueue([.save(recordID)]) +``` + +Use `syncNow()` when the application must explicitly fetch, merge, and send before continuing: + +```swift +try await engine.syncNow() +``` + +Automatic synchronization should remain enabled in production. Explicit operations complement the system scheduler; they do not replace durable local saves or make offline networking possible. + +## Conflict handling + +CloudSaveKit forwards `serverRecordChanged` to `CloudSaveClient.resolve(conflict:)`. A retry record must be based on the supplied server record so it retains the current CloudKit change tag. The host may accept the server value, return a merged retry record, or preserve the conflict for a user decision. + +## Logging + +CloudSaveKit logs concise synchronization lifecycle information through [AppLogger](https://github.com/thatfactory/applogger), using subsystem `com.thatfactory.cloudsavekit`, category `sync`, and prefix `☁️`. It never logs record contents or identifiers. + +## Requirements + +- Swift 6.4 +- Xcode 27 +- iOS, macOS, tvOS, watchOS, or visionOS 26+ +- A private CloudKit container with CloudKit and Remote Notifications capabilities + +## License + +CloudSaveKit is available under the MIT license. diff --git a/Sources/CloudSaveKit/CloudSaveAccountChange.swift b/Sources/CloudSaveKit/CloudSaveAccountChange.swift new file mode 100644 index 0000000..8662d3b --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveAccountChange.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Describes a change to the iCloud account available to a cloud-save engine. +public enum CloudSaveAccountChange: Equatable, Sendable { + /// A person signed in to iCloud. + case signedIn(currentAccountID: String) + + /// The current person signed out of iCloud. + case signedOut(previousAccountID: String) + + /// The device switched directly between two iCloud accounts. + case switched(previousAccountID: String, currentAccountID: String) +} diff --git a/Sources/CloudSaveKit/CloudSaveClient.swift b/Sources/CloudSaveKit/CloudSaveClient.swift new file mode 100644 index 0000000..2c1c1ef --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveClient.swift @@ -0,0 +1,43 @@ +import CloudKit + +/// Connects ``CloudSaveEngine`` to an application's local persistence layer. +public protocol CloudSaveClient: Sendable { + /// Returns all locally durable changes that still need to reach CloudKit. + func pendingChanges() async throws -> [CloudSavePendingChange] + + /// Materializes the current local value for a pending record save. + func record(for recordID: CKRecord.ID) async -> CKRecord? + + /// Persists CKSyncEngine's opaque state after every state update. + func persist(stateSerialization: CKSyncEngine.State.Serialization) async throws + + /// Applies fetched records and deletions in one local transaction. + func applyFetchedChanges( + records: [CKRecord], + deletedRecordIDs: [CKRecord.ID] + ) async throws + + /// Applies fetched custom-zone deletions to the local store. + func applyDeletedZones(_ zoneIDs: [CKRecordZone.ID]) async throws + + /// Persists the server system fields returned for successfully saved records. + func didSave(records: [CKRecord]) async throws + + /// Marks record deletions as successfully synchronized. + func didDelete(recordIDs: [CKRecord.ID]) async throws + + /// Removes stale server system fields before recreating a missing remote record. + func clearServerRecord(for recordID: CKRecord.ID) async throws + + /// Reconciles an application-semantic record conflict. + func resolve(conflict: CloudSaveConflict) async throws -> CloudSaveConflictResolution + + /// Persists a merged conflict record before the engine retries it. + func persistResolvedRecord(_ record: CKRecord) async throws + + /// Updates local account-scoped persistence after an iCloud account change. + func handle(accountChange: CloudSaveAccountChange) async throws + + /// Records a failure that requires application attention. + func handle(failure: CloudSaveFailure, recordID: CKRecord.ID?) async +} diff --git a/Sources/CloudSaveKit/CloudSaveConfiguration.swift b/Sources/CloudSaveKit/CloudSaveConfiguration.swift new file mode 100644 index 0000000..47e1aba --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveConfiguration.swift @@ -0,0 +1,35 @@ +import CloudKit +import Foundation + +/// Configures one private-database cloud-save engine. +public struct CloudSaveConfiguration: Sendable { + /// The private CloudKit database used by the engine. + public let database: CKDatabase + + /// CKSyncEngine state restored from the host's local store. + public let stateSerialization: CKSyncEngine.State.Serialization? + + /// The custom record zone owned by this save domain. + public let zone: CKRecordZone + + /// Whether CKSyncEngine schedules background synchronization automatically. + public let automaticallySync: Bool + + /// An optional stable subscription identifier. + public let subscriptionID: CKSubscription.ID? + + /// Creates a cloud-save configuration. + public init( + database: CKDatabase, + stateSerialization: CKSyncEngine.State.Serialization? = nil, + zone: CKRecordZone, + automaticallySync: Bool = true, + subscriptionID: CKSubscription.ID? = nil + ) { + self.database = database + self.stateSerialization = stateSerialization + self.zone = zone + self.automaticallySync = automaticallySync + self.subscriptionID = subscriptionID + } +} diff --git a/Sources/CloudSaveKit/CloudSaveConflict.swift b/Sources/CloudSaveKit/CloudSaveConflict.swift new file mode 100644 index 0000000..a5b1222 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveConflict.swift @@ -0,0 +1,24 @@ +import CloudKit + +/// Contains the three record versions CloudKit provides for conflict resolution. +public struct CloudSaveConflict: Sendable { + /// The record the client attempted to save. + public let clientRecord: CKRecord + + /// The current record stored by CloudKit. + public let serverRecord: CKRecord + + /// The common record ancestor, when CloudKit supplies one. + public let ancestorRecord: CKRecord? + + /// Creates a semantic CloudKit conflict. + public init( + clientRecord: CKRecord, + serverRecord: CKRecord, + ancestorRecord: CKRecord? + ) { + self.clientRecord = clientRecord + self.serverRecord = serverRecord + self.ancestorRecord = ancestorRecord + } +} diff --git a/Sources/CloudSaveKit/CloudSaveConflictResolution.swift b/Sources/CloudSaveKit/CloudSaveConflictResolution.swift new file mode 100644 index 0000000..3bc22e7 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveConflictResolution.swift @@ -0,0 +1,13 @@ +import CloudKit + +/// Selects how the engine should finish handling a server-record conflict. +public enum CloudSaveConflictResolution: Sendable { + /// Accepts the server record and removes the local pending save. + case acceptServer + + /// Persists and retries a merged record based on the server record. + case retry(mergedRecord: CKRecord) + + /// Preserves the conflict for a user-facing application decision. + case requiresUserDecision +} diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift new file mode 100644 index 0000000..77884b0 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -0,0 +1,410 @@ +import CloudKit +import Foundation + +/// Synchronizes an application's durable local records with a private CloudKit database. +public final actor CloudSaveEngine { + /// A stream of privacy-safe synchronization status updates. + public nonisolated let statusUpdates: AsyncStream + + private let client: any CloudSaveClient + private let configuration: CloudSaveConfiguration + private let statusContinuation: AsyncStream.Continuation + private var storedSyncEngine: CKSyncEngine? + + /// Creates an engine without starting synchronization. + public init( + configuration: CloudSaveConfiguration, + client: any CloudSaveClient + ) { + let stream = AsyncStream.makeStream(of: CloudSaveStatus.self) + statusUpdates = stream.stream + statusContinuation = stream.continuation + self.client = client + self.configuration = configuration + } + + deinit { + statusContinuation.finish() + } + + /// Initializes CKSyncEngine and restores every locally durable pending change. + public func start() async throws { + let engine = syncEngine + + if configuration.stateSerialization == nil { + engine.state.add( + pendingDatabaseChanges: [.saveZone(configuration.zone)] + ) + } + + let pendingChanges = try await client.pendingChanges() + engine.state.add( + pendingRecordZoneChanges: pendingChanges.map(\.syncEngineChange) + ) + + publishReadyStatus(syncEngine: engine) + CloudSaveLogging.log("start | pending=\(pendingChanges.count)") + } + + /// Adds locally durable changes to CKSyncEngine's pending state. + public func enqueue(_ changes: [CloudSavePendingChange]) { + let engine = syncEngine + engine.state.add( + pendingRecordZoneChanges: changes.map(\.syncEngineChange) + ) + publishReadyStatus(syncEngine: engine) + CloudSaveLogging.log("enqueue | count=\(changes.count)") + } + + /// Immediately fetches changes for the configured save zone. + public func fetchNow() async throws { + do { + let options = CKSyncEngine.FetchChangesOptions( + scope: .zoneIDs([configuration.zone.zoneID]) + ) + try await syncEngine.fetchChanges(options) + } catch { + let failure = CloudSaveFailure(error: error) + statusContinuation.yield(.failed(failure)) + throw error + } + } + + /// Immediately sends pending changes for the configured save zone. + public func sendNow() async throws { + do { + let options = CKSyncEngine.SendChangesOptions( + scope: .zoneIDs([configuration.zone.zoneID]) + ) + try await syncEngine.sendChanges(options) + } catch { + let failure = CloudSaveFailure(error: error) + statusContinuation.yield(.failed(failure)) + throw error + } + } + + /// Fetches, merges, and then sends pending changes for the configured save zone. + public func syncNow() async throws { + try await fetchNow() + try await sendNow() + } + + /// Cancels in-flight CKSyncEngine operations. + public func cancel() async { + await syncEngine.cancelOperations() + } +} + +// MARK: - CKSyncEngineDelegate + +extension CloudSaveEngine: CKSyncEngineDelegate { + public func handleEvent( + _ event: CKSyncEngine.Event, + syncEngine: CKSyncEngine + ) async { + do { + switch event { + case .stateUpdate(let event): + try await client.persist( + stateSerialization: event.stateSerialization + ) + case .accountChange(let event): + try await client.handle( + accountChange: event.cloudSaveAccountChange + ) + try await restorePendingChangesAfterAccountChange( + event.cloudSaveAccountChange, + syncEngine: syncEngine + ) + case .fetchedDatabaseChanges(let event): + try await restoreDeletedZones( + event.deletions.map(\.zoneID), + syncEngine: syncEngine + ) + case .fetchedRecordZoneChanges(let event): + try await client.applyFetchedChanges( + records: event.modifications.map(\.record), + deletedRecordIDs: event.deletions.map(\.recordID) + ) + case .sentRecordZoneChanges(let event): + try await handleSentRecordZoneChanges( + event, + syncEngine: syncEngine + ) + case .sentDatabaseChanges(let event): + handleSentDatabaseChanges( + event, + syncEngine: syncEngine + ) + case .willFetchChanges: + statusContinuation.yield(.fetching) + case .willSendChanges: + statusContinuation.yield(.sending) + case .didFetchChanges, .didSendChanges: + publishReadyStatus(syncEngine: syncEngine) + case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges: + break + @unknown default: + CloudSaveLogging.log( + level: .info, + "event | unknown" + ) + } + } catch { + let failure = CloudSaveFailure(error: error) + statusContinuation.yield(.failed(failure)) + await client.handle( + failure: failure, + recordID: nil + ) + CloudSaveLogging.log( + level: .error, + "event | failure=\(failure)" + ) + } + } + + public func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + let pendingChanges = syncEngine.state.pendingRecordZoneChanges.filter { + context.options.scope.contains($0) + } + + return await CKSyncEngine.RecordZoneChangeBatch( + pendingChanges: pendingChanges + ) { [client] recordID in + let record = await client.record(for: recordID) + if record == nil { + syncEngine.state.remove( + pendingRecordZoneChanges: [.saveRecord(recordID)] + ) + } + return record + } + } +} + +// MARK: - Private + +extension CloudSaveEngine { + fileprivate func restorePendingChangesAfterAccountChange( + _ accountChange: CloudSaveAccountChange, + syncEngine: CKSyncEngine + ) async throws { + guard case .signedOut = accountChange else { + let pendingChanges = try await client.pendingChanges() + syncEngine.state.add( + pendingDatabaseChanges: [.saveZone(configuration.zone)] + ) + syncEngine.state.add( + pendingRecordZoneChanges: pendingChanges.map(\.syncEngineChange) + ) + publishReadyStatus(syncEngine: syncEngine) + return + } + } + + fileprivate func restoreDeletedZones( + _ zoneIDs: [CKRecordZone.ID], + syncEngine: CKSyncEngine + ) async throws { + try await client.applyDeletedZones(zoneIDs) + guard zoneIDs.contains(configuration.zone.zoneID) else { + return + } + + let pendingChanges = try await client.pendingChanges() + syncEngine.state.add( + pendingDatabaseChanges: [.saveZone(configuration.zone)] + ) + syncEngine.state.add( + pendingRecordZoneChanges: pendingChanges.map(\.syncEngineChange) + ) + } + + fileprivate var syncEngine: CKSyncEngine { + if let storedSyncEngine { + return storedSyncEngine + } + + var engineConfiguration = CKSyncEngine.Configuration( + database: configuration.database, + stateSerialization: configuration.stateSerialization, + delegate: self + ) + engineConfiguration.automaticallySync = configuration.automaticallySync + engineConfiguration.subscriptionID = configuration.subscriptionID + + let engine = CKSyncEngine(engineConfiguration) + storedSyncEngine = engine + return engine + } + + fileprivate func handleSentDatabaseChanges( + _ event: CKSyncEngine.Event.SentDatabaseChanges, + syncEngine: CKSyncEngine + ) { + var didFail = false + for failedSave in event.failedZoneSaves { + let failure = CloudSaveFailure(error: failedSave.error) + statusContinuation.yield(.failed(failure)) + didFail = true + } + + for (_, error) in event.failedZoneDeletes { + let failure = CloudSaveFailure(error: error) + statusContinuation.yield(.failed(failure)) + didFail = true + CloudSaveLogging.log( + level: .error, + "zone delete | failure=\(failure)" + ) + } + + if !didFail { + publishReadyStatus(syncEngine: syncEngine) + } + } + + fileprivate func handleSentRecordZoneChanges( + _ event: CKSyncEngine.Event.SentRecordZoneChanges, + syncEngine: CKSyncEngine + ) async throws { + try await client.didSave(records: event.savedRecords) + try await client.didDelete(recordIDs: event.deletedRecordIDs) + + var changesToRetry: [CKSyncEngine.PendingRecordZoneChange] = [] + var zonesToRetry: [CKSyncEngine.PendingDatabaseChange] = [] + var didRequireAttention = false + + for failedSave in event.failedRecordSaves { + let recordID = failedSave.record.recordID + switch failedSave.error.code { + case .serverRecordChanged: + didRequireAttention = + try await handleConflict( + failedSave, + changesToRetry: &changesToRetry + ) || didRequireAttention + case .zoneNotFound: + try await client.clearServerRecord(for: recordID) + zonesToRetry.append(.saveZone(configuration.zone)) + changesToRetry.append(.saveRecord(recordID)) + case .unknownItem: + try await client.clearServerRecord(for: recordID) + changesToRetry.append(.saveRecord(recordID)) + case .accountTemporarilyUnavailable, .networkFailure, .networkUnavailable, .notAuthenticated, + .operationCancelled, .requestRateLimited, .serviceUnavailable, .zoneBusy: + break + default: + let failure = CloudSaveFailure(error: failedSave.error) + await client.handle( + failure: failure, + recordID: recordID + ) + statusContinuation.yield(.failed(failure)) + didRequireAttention = true + } + } + + for (recordID, error) in event.failedRecordDeletes { + let failure = CloudSaveFailure(error: error) + await client.handle( + failure: failure, + recordID: recordID + ) + statusContinuation.yield(.failed(failure)) + didRequireAttention = true + } + + syncEngine.state.add(pendingDatabaseChanges: zonesToRetry) + syncEngine.state.add(pendingRecordZoneChanges: changesToRetry) + if !didRequireAttention { + publishReadyStatus(syncEngine: syncEngine) + } + } + + fileprivate func handleConflict( + _ failedSave: CKSyncEngine.Event.SentRecordZoneChanges.FailedRecordSave, + changesToRetry: inout [CKSyncEngine.PendingRecordZoneChange] + ) async throws -> Bool { + guard let serverRecord = failedSave.error.serverRecord else { + await client.handle( + failure: .recordConflict, + recordID: failedSave.record.recordID + ) + statusContinuation.yield(.failed(.recordConflict)) + return true + } + + let conflict = CloudSaveConflict( + clientRecord: failedSave.record, + serverRecord: serverRecord, + ancestorRecord: failedSave.error.ancestorRecord + ) + + switch try await client.resolve(conflict: conflict) { + case .acceptServer: + try await client.applyFetchedChanges( + records: [serverRecord], + deletedRecordIDs: [] + ) + return false + case .retry(let mergedRecord): + try await client.persistResolvedRecord(mergedRecord) + changesToRetry.append(.saveRecord(mergedRecord.recordID)) + return false + case .requiresUserDecision: + await client.handle( + failure: .recordConflict, + recordID: failedSave.record.recordID + ) + statusContinuation.yield(.failed(.recordConflict)) + return true + } + } + + fileprivate func publishReadyStatus(syncEngine: CKSyncEngine) { + statusContinuation.yield( + .ready( + hasPendingChanges: !syncEngine.state.pendingRecordZoneChanges.isEmpty + ) + ) + } +} + +extension CloudSavePendingChange { + fileprivate var syncEngineChange: CKSyncEngine.PendingRecordZoneChange { + switch self { + case .save(let recordID): + .saveRecord(recordID) + case .delete(let recordID): + .deleteRecord(recordID) + } + } +} + +extension CKSyncEngine.Event.AccountChange { + fileprivate var cloudSaveAccountChange: CloudSaveAccountChange { + switch changeType { + case .signIn(let currentUser): + .signedIn( + currentAccountID: currentUser.recordName + ) + case .signOut(let previousUser): + .signedOut( + previousAccountID: previousUser.recordName + ) + case .switchAccounts(let previousUser, let currentUser): + .switched( + previousAccountID: previousUser.recordName, + currentAccountID: currentUser.recordName + ) + @unknown default: + .signedOut(previousAccountID: "unknown") + } + } +} diff --git a/Sources/CloudSaveKit/CloudSaveFailure.swift b/Sources/CloudSaveKit/CloudSaveFailure.swift new file mode 100644 index 0000000..12d299b --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveFailure.swift @@ -0,0 +1,60 @@ +import CloudKit +import Foundation + +/// A stable, privacy-safe failure category suitable for application state. +public enum CloudSaveFailure: Equatable, Sendable { + /// No usable iCloud account is currently available. + case accountUnavailable + + /// CloudKit rejected the configured container or database. + case configuration + + /// Local save data or CKSyncEngine state couldn't be persisted. + case localPersistence + + /// A network connection is currently unavailable. + case networkUnavailable + + /// The person's iCloud storage quota is exhausted. + case quotaExceeded + + /// A record requires application or user conflict resolution. + case recordConflict + + /// CloudKit rejected the operation because of permissions or restrictions. + case restricted + + /// The custom record zone needs to be recreated. + case zoneUnavailable + + /// An unclassified CloudKit failure, retaining only its numeric code. + case unknown(code: Int) +} + +extension CloudSaveFailure { + init(error: any Error) { + guard let cloudError = error as? CKError else { + self = .unknown(code: (error as NSError).code) + return + } + + switch cloudError.code { + case .accountTemporarilyUnavailable, .notAuthenticated: + self = .accountUnavailable + case .badContainer, .badDatabase, .invalidArguments: + self = .configuration + case .networkFailure, .networkUnavailable, .requestRateLimited, .serviceUnavailable, .zoneBusy: + self = .networkUnavailable + case .quotaExceeded: + self = .quotaExceeded + case .serverRecordChanged: + self = .recordConflict + case .managedAccountRestricted, .missingEntitlement, .permissionFailure: + self = .restricted + case .zoneNotFound: + self = .zoneUnavailable + default: + self = .unknown(code: cloudError.errorCode) + } + } +} diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md new file mode 100644 index 0000000..92755b9 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -0,0 +1,31 @@ +# ``CloudSaveKit`` + +Coordinate an application-owned local store with a private CloudKit database. + +## Overview + +CloudSaveKit wraps Apple's `CKSyncEngine` lifecycle and delegate surface without choosing a local database or record schema. A host provides a ``CloudSaveClient`` that can materialize pending `CKRecord` values, apply fetched changes transactionally, preserve CKSyncEngine state, and resolve conflicts using application semantics. + +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. + +Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetchNow()``, ``CloudSaveEngine/sendNow()``, or ``CloudSaveEngine/syncNow()`` only at user-visible checkpoints where immediate work is useful. + +## Topics + +### Engine + +- ``CloudSaveEngine`` +- ``CloudSaveConfiguration`` +- ``CloudSaveStatus`` + +### Local-store boundary + +- ``CloudSaveClient`` +- ``CloudSavePendingChange`` + +### Recovery + +- ``CloudSaveFailure`` +- ``CloudSaveAccountChange`` +- ``CloudSaveConflict`` +- ``CloudSaveConflictResolution`` diff --git a/Sources/CloudSaveKit/CloudSaveLogging.swift b/Sources/CloudSaveKit/CloudSaveLogging.swift new file mode 100644 index 0000000..5a9786f --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveLogging.swift @@ -0,0 +1,20 @@ +import AppLogger + +enum CloudSaveLogging { + static let emoji = "☁️" + static let subsystem = "com.thatfactory.cloudsavekit" + + static func log( + level: AppLogLevel = .debug, + _ message: String + ) { + let logger = AppLogger( + subsystem: subsystem, + category: "sync" + ) + logger.log( + level: level, + "\(emoji) \(message)" + ) + } +} diff --git a/Sources/CloudSaveKit/CloudSavePendingChange.swift b/Sources/CloudSaveKit/CloudSavePendingChange.swift new file mode 100644 index 0000000..0a34d0b --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePendingChange.swift @@ -0,0 +1,10 @@ +import CloudKit + +/// Describes one locally durable CloudKit change waiting to be sent. +public enum CloudSavePendingChange: Hashable, Sendable { + /// Saves or replaces the record with the specified identifier. + case save(CKRecord.ID) + + /// Deletes the record with the specified identifier. + case delete(CKRecord.ID) +} diff --git a/Sources/CloudSaveKit/CloudSaveStatus.swift b/Sources/CloudSaveKit/CloudSaveStatus.swift new file mode 100644 index 0000000..aa74ad4 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveStatus.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Describes the current observable state of a cloud-save engine. +public enum CloudSaveStatus: Equatable, Sendable { + /// The engine has not started yet. + case idle + + /// The engine is fetching remote changes. + case fetching + + /// The engine is sending locally pending changes. + case sending + + /// The local store is ready and may still have pending uploads. + case ready(hasPendingChanges: Bool) + + /// Synchronization needs application attention. + case failed(CloudSaveFailure) +} diff --git a/Tests/CloudSaveKitTests/CloudSaveFailureTests.swift b/Tests/CloudSaveKitTests/CloudSaveFailureTests.swift new file mode 100644 index 0000000..315a267 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSaveFailureTests.swift @@ -0,0 +1,27 @@ +import CloudKit +import Testing + +@testable import CloudSaveKit + +@Suite("Cloud save failures") +struct CloudSaveFailureTests { + @Test( + "Classifies failures safe for application state", + arguments: [ + (CKError.Code.notAuthenticated, CloudSaveFailure.accountUnavailable), + (.badContainer, .configuration), + (.networkUnavailable, .networkUnavailable), + (.quotaExceeded, .quotaExceeded), + (.serverRecordChanged, .recordConflict), + (.permissionFailure, .restricted), + (.zoneNotFound, .zoneUnavailable), + ]) + func classifies( + code: CKError.Code, + expectedFailure: CloudSaveFailure + ) { + let error = CKError(code) + + #expect(CloudSaveFailure(error: error) == expectedFailure) + } +} From 74f47a185f291b401fa9e73f6239ddb271eb42dd Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 15:59:54 +0200 Subject: [PATCH 02/18] Update agent guidelines to 0.0.16 --- AgentGuidelines/CHANGELOG.md | 8 ++++++++ AgentGuidelines/Guidelines/Swift/SwiftFormat.md | 2 ++ AgentGuidelines/Guidelines/Swift/SwiftStyle.md | 17 +++++++++++++++-- AgentGuidelines/Guidelines/Swift/SwiftUI.md | 3 ++- AgentGuidelines/LICENSE | 1 + AgentGuidelines/README.md | 4 ++-- AgentGuidelines/VERSION | 2 +- 7 files changed, 31 insertions(+), 6 deletions(-) diff --git a/AgentGuidelines/CHANGELOG.md b/AgentGuidelines/CHANGELOG.md index ab64974..e622148 100644 --- a/AgentGuidelines/CHANGELOG.md +++ b/AgentGuidelines/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented in this file. +## [0.0.16] - 2026-08-13 + +### Changed + +- Required SwiftUI dynamic properties to precede ordinary stored properties and clarified deterministic preview expectations. +- Required one top-level type per file, focused function decomposition, logical enum grouping, and consistent declaration-modifier and multiline-signature layout. +- Documented which declaration layout conventions remain review-guided because swift-format cannot enforce them without broad source reflow. + ## [0.0.15] - 2026-07-27 ### Added diff --git a/AgentGuidelines/Guidelines/Swift/SwiftFormat.md b/AgentGuidelines/Guidelines/Swift/SwiftFormat.md index 1ddd461..b5060fb 100644 --- a/AgentGuidelines/Guidelines/Swift/SwiftFormat.md +++ b/AgentGuidelines/Guidelines/Swift/SwiftFormat.md @@ -42,6 +42,8 @@ The checked-in configuration starts from the exhaustive Xcode toolchain dump. Th Rules not listed here retain the exhaustive Xcode dump values. In particular, universal public documentation, force-unwrap rejection, implicit-return rewriting, early-exit rewriting, leading-underscore rejection, and implicitly unwrapped optional rejection remain disabled until adopted deliberately. swift-format has no equivalent for repository-specific import bans or sorted enum cases. +Declaration layout rules from [Swift style](SwiftStyle.md), including keeping modifiers on the declaration line and preserving an intentionally multiline signature, remain review-guided. The formatter preserves a correctly authored layout, but it has no focused rule that forces those shapes; disabling `respectsExistingLineBreaks` would broadly reflow otherwise intentional source formatting. + ## Focused exceptions - Prefer a focused `// swift-format-ignore: RuleName` immediately before the affected declaration or statement when a rule conflicts with required semantics. Add a short preceding comment explaining why. diff --git a/AgentGuidelines/Guidelines/Swift/SwiftStyle.md b/AgentGuidelines/Guidelines/Swift/SwiftStyle.md index 1a5d488..6277537 100644 --- a/AgentGuidelines/Guidelines/Swift/SwiftStyle.md +++ b/AgentGuidelines/Guidelines/Swift/SwiftStyle.md @@ -8,11 +8,13 @@ - Keep enum cases alphabetical unless ordering communicates behavior or a local lint suppression documents the exception. - Use `// MARK: -` to separate meaningful sections. - Use `// MARK: - Private` when separating private implementation from non-private declarations in the same file. +- Break branching or multi-step implementation into small, focused functions whose names make the caller read as a sequence of intentions. Keep orchestration concise, move implementation details below `// MARK: - Private`, and avoid extracting trivial expressions that are clearer inline. - Do not add Xcode boilerplate filename, author, or creation-date headers. -- Prefer one primary type or concern per file. +- Keep each top-level type in its own file, even when multiple types are closely related. Nest a supporting type only when it is private to one primary type and the relationship forms a natural namespace. - Match a type file's name to its primary type. - Put the declaration named by the file immediately after imports and file-level directives. Opening `EffectAssetLoader.swift`, for example, must reveal `EffectAssetLoader` before supporting declarations. A shared canonical template may retain type aliases that its documented layout deliberately places first. -- Put a supporting type used only by one primary type inside an extension of that primary type when the relationship forms a natural namespace. Put a supporting type used by other files in its own named file instead. +- Keep declaration modifiers such as `nonisolated` on the same line as the declaration they modify. For a multiline function signature, keep the opening brace on the return-type line. +- Separate groups of enum cases with blank lines when the groups represent distinct operations, phases, or workflows. Keep cases consistently ordered within each group; meaningful workflow order may override alphabetical order. - Keep physical folders flat until one topic genuinely contains several files. When grouping becomes useful, organize related models, services, tools, views, and Redux components by a familiar domain, feature, or capability so readers can reason about them together. Example: @@ -42,3 +44,14 @@ extension Measurement { } } ``` + +Multiline declarations keep their modifiers and braces attached to the declaration: + +```swift +nonisolated func reduce( + _ state: State, + _ action: Action +) -> State { + // ... +} +``` diff --git a/AgentGuidelines/Guidelines/Swift/SwiftUI.md b/AgentGuidelines/Guidelines/Swift/SwiftUI.md index faeffa1..cbc3185 100644 --- a/AgentGuidelines/Guidelines/Swift/SwiftUI.md +++ b/AgentGuidelines/Guidelines/Swift/SwiftUI.md @@ -4,9 +4,10 @@ Use official Apple documentation and Xcode's current SwiftUI skills for API-spec ## View structure +- Put SwiftUI dynamic properties such as `@Environment`, `@Query`, `@State`, and `@Binding` before ordinary stored `let` and `var` properties. Keep injected environment dependencies before locally owned state when both are present. - Keep a parent view focused on composition. - Model meaningful sections such as headers, lists, metadata, sidebars, and footers as separate `View` types with narrow inputs. -- Keep each independently meaningful `View` in its own file, including private supporting views. Give every view its own deterministic preview when the required dependencies can be represented safely. +- Keep each independently meaningful `View` in its own file, including private supporting views. Give every view its own deterministic preview when the required dependencies can be represented safely; when they cannot, document the concrete limitation in the handoff. - Do not extract sections into computed `some View` properties merely to shorten `body`; computed properties remain in the parent's invalidation boundary. - Tiny fragments reused within one body may use a small helper when they have no independent state, input, or invalidation story. - Keep view initializers cheap. Do not decode data, access files, build large structures, or allocate formatters in `init`. diff --git a/AgentGuidelines/LICENSE b/AgentGuidelines/LICENSE index 45f5b45..42d8021 100644 --- a/AgentGuidelines/LICENSE +++ b/AgentGuidelines/LICENSE @@ -19,3 +19,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/AgentGuidelines/README.md b/AgentGuidelines/README.md index 8be771f..e29c651 100644 --- a/AgentGuidelines/README.md +++ b/AgentGuidelines/README.md @@ -87,7 +87,7 @@ From the consumer repository root, install a tagged release: git subtree add \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.15 \ + 0.0.16 \ --squash ``` @@ -133,7 +133,7 @@ Review the target release's changelog, then pull it deliberately: git subtree pull \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.15 \ + 0.0.16 \ --squash ``` diff --git a/AgentGuidelines/VERSION b/AgentGuidelines/VERSION index ceddfb2..e3b86dd 100644 --- a/AgentGuidelines/VERSION +++ b/AgentGuidelines/VERSION @@ -1 +1 @@ -0.0.15 +0.0.16 From 649eae49ca54ede5dda3e36d0016a32b61189403 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 16:44:04 +0200 Subject: [PATCH 03/18] Harden CloudKit event recovery --- Sources/CloudSaveKit/CloudSaveEngine.swift | 203 +++++++++++++----- .../CloudSaveKit.docc/CloudSaveKit.md | 2 + 2 files changed, 156 insertions(+), 49 deletions(-) diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 77884b0..e8f55bd 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -9,7 +9,10 @@ public final actor CloudSaveEngine { private let client: any CloudSaveClient private let configuration: CloudSaveConfiguration private let statusContinuation: AsyncStream.Continuation + private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization? + private var pendingRecoveryOperation: RecoveryOperation? private var storedSyncEngine: CKSyncEngine? + private var unresolvedFailure: CloudSaveFailure? /// Creates an engine without starting synchronization. public init( @@ -21,6 +24,7 @@ public final actor CloudSaveEngine { statusContinuation = stream.continuation self.client = client self.configuration = configuration + lastPersistedStateSerialization = configuration.stateSerialization } deinit { @@ -65,7 +69,7 @@ public final actor CloudSaveEngine { try await syncEngine.fetchChanges(options) } catch { let failure = CloudSaveFailure(error: error) - statusContinuation.yield(.failed(failure)) + await reportAttentionRequiredFailure(failure) throw error } } @@ -79,7 +83,7 @@ public final actor CloudSaveEngine { try await syncEngine.sendChanges(options) } catch { let failure = CloudSaveFailure(error: error) - statusContinuation.yield(.failed(failure)) + await reportAttentionRequiredFailure(failure) throw error } } @@ -103,12 +107,18 @@ extension CloudSaveEngine: CKSyncEngineDelegate { _ event: CKSyncEngine.Event, syncEngine: CKSyncEngine ) async { + guard storedSyncEngine === syncEngine else { + CloudSaveLogging.log("event | ignored stale engine") + return + } + do { switch event { case .stateUpdate(let event): try await client.persist( stateSerialization: event.stateSerialization ) + lastPersistedStateSerialization = event.stateSerialization case .accountChange(let event): try await client.handle( accountChange: event.cloudSaveAccountChange @@ -124,8 +134,8 @@ extension CloudSaveEngine: CKSyncEngineDelegate { ) case .fetchedRecordZoneChanges(let event): try await client.applyFetchedChanges( - records: event.modifications.map(\.record), - deletedRecordIDs: event.deletions.map(\.recordID) + records: event.modifications.map(\.record).filter(isInConfiguredZone), + deletedRecordIDs: event.deletions.map(\.recordID).filter(isInConfiguredZone) ) case .sentRecordZoneChanges(let event): try await handleSentRecordZoneChanges( @@ -133,16 +143,15 @@ extension CloudSaveEngine: CKSyncEngineDelegate { syncEngine: syncEngine ) case .sentDatabaseChanges(let event): - handleSentDatabaseChanges( - event, - syncEngine: syncEngine - ) + await handleSentDatabaseChanges(event) case .willFetchChanges: - statusContinuation.yield(.fetching) + beginRecoveryOperation(.fetching) case .willSendChanges: - statusContinuation.yield(.sending) - case .didFetchChanges, .didSendChanges: - publishReadyStatus(syncEngine: syncEngine) + beginRecoveryOperation(.sending) + case .didFetchChanges: + completeRecoveryOperation(.fetching, syncEngine: syncEngine) + case .didSendChanges: + completeRecoveryOperation(.sending, syncEngine: syncEngine) case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges: break @unknown default: @@ -153,11 +162,8 @@ extension CloudSaveEngine: CKSyncEngineDelegate { } } catch { let failure = CloudSaveFailure(error: error) - statusContinuation.yield(.failed(failure)) - await client.handle( - failure: failure, - recordID: nil - ) + await reportAttentionRequiredFailure(failure) + await rebuildAfterClientFailure(syncEngine: syncEngine) CloudSaveLogging.log( level: .error, "event | failure=\(failure)" @@ -232,7 +238,7 @@ extension CloudSaveEngine { var engineConfiguration = CKSyncEngine.Configuration( database: configuration.database, - stateSerialization: configuration.stateSerialization, + stateSerialization: lastPersistedStateSerialization, delegate: self ) engineConfiguration.automaticallySync = configuration.automaticallySync @@ -244,28 +250,14 @@ extension CloudSaveEngine { } fileprivate func handleSentDatabaseChanges( - _ event: CKSyncEngine.Event.SentDatabaseChanges, - syncEngine: CKSyncEngine - ) { - var didFail = false + _ event: CKSyncEngine.Event.SentDatabaseChanges + ) async { for failedSave in event.failedZoneSaves { - let failure = CloudSaveFailure(error: failedSave.error) - statusContinuation.yield(.failed(failure)) - didFail = true + await handleFailedZoneChange(failedSave.error) } for (_, error) in event.failedZoneDeletes { - let failure = CloudSaveFailure(error: error) - statusContinuation.yield(.failed(failure)) - didFail = true - CloudSaveLogging.log( - level: .error, - "zone delete | failure=\(failure)" - ) - } - - if !didFail { - publishReadyStatus(syncEngine: syncEngine) + await handleFailedZoneChange(error) } } @@ -301,28 +293,30 @@ extension CloudSaveEngine { break default: let failure = CloudSaveFailure(error: failedSave.error) - await client.handle( - failure: failure, + await reportAttentionRequiredFailure( + failure, recordID: recordID ) - statusContinuation.yield(.failed(failure)) didRequireAttention = true } } for (recordID, error) in event.failedRecordDeletes { + if error.isTransientCloudSaveError { + continue + } + let failure = CloudSaveFailure(error: error) - await client.handle( - failure: failure, + await reportAttentionRequiredFailure( + failure, recordID: recordID ) - statusContinuation.yield(.failed(failure)) didRequireAttention = true } syncEngine.state.add(pendingDatabaseChanges: zonesToRetry) syncEngine.state.add(pendingRecordZoneChanges: changesToRetry) - if !didRequireAttention { + if !didRequireAttention, unresolvedFailure == nil { publishReadyStatus(syncEngine: syncEngine) } } @@ -332,11 +326,10 @@ extension CloudSaveEngine { changesToRetry: inout [CKSyncEngine.PendingRecordZoneChange] ) async throws -> Bool { guard let serverRecord = failedSave.error.serverRecord else { - await client.handle( - failure: .recordConflict, + await reportAttentionRequiredFailure( + .recordConflict, recordID: failedSave.record.recordID ) - statusContinuation.yield(.failed(.recordConflict)) return true } @@ -358,16 +351,95 @@ extension CloudSaveEngine { changesToRetry.append(.saveRecord(mergedRecord.recordID)) return false case .requiresUserDecision: - await client.handle( - failure: .recordConflict, + await reportAttentionRequiredFailure( + .recordConflict, recordID: failedSave.record.recordID ) - statusContinuation.yield(.failed(.recordConflict)) return true } } + /// Filters CloudKit fetches to the custom zone owned by this engine. + fileprivate func isInConfiguredZone(_ record: CKRecord) -> Bool { + isInConfiguredZone(record.recordID) + } + + /// Filters CloudKit fetches to the custom zone owned by this engine. + fileprivate func isInConfiguredZone(_ recordID: CKRecord.ID) -> Bool { + recordID.zoneID == configuration.zone.zoneID + } + + /// Starts a fetch or send that can clear an earlier attention-required failure. + fileprivate func beginRecoveryOperation(_ operation: RecoveryOperation) { + if unresolvedFailure != nil { + pendingRecoveryOperation = operation + return + } + statusContinuation.yield(operation.status) + } + + /// Clears an earlier failure only after its replacement operation completes successfully. + fileprivate func completeRecoveryOperation( + _ operation: RecoveryOperation, + syncEngine: CKSyncEngine + ) { + guard pendingRecoveryOperation == operation else { + if unresolvedFailure == nil { + publishReadyStatus(syncEngine: syncEngine) + } + return + } + + pendingRecoveryOperation = nil + unresolvedFailure = nil + publishReadyStatus(syncEngine: syncEngine) + } + + /// Reports a durable failure while preserving it across completion events. + fileprivate func reportAttentionRequiredFailure( + _ failure: CloudSaveFailure, + recordID: CKRecord.ID? = nil + ) async { + pendingRecoveryOperation = nil + unresolvedFailure = failure + statusContinuation.yield(.failed(failure)) + await client.handle(failure: failure, recordID: recordID) + } + + /// Rebuilds the engine from its last durable checkpoint after a host write fails. + fileprivate func rebuildAfterClientFailure(syncEngine: CKSyncEngine) async { + await syncEngine.cancelOperations() + storedSyncEngine = nil + + do { + try await start() + } catch { + CloudSaveLogging.log( + level: .error, + "rebuild | failure=\(CloudSaveFailure(error: error))" + ) + } + } + + /// Handles a zone change failure according to CKSyncEngine's retry policy. + fileprivate func handleFailedZoneChange(_ error: CKError) async { + guard !error.isTransientCloudSaveError else { + return + } + + let failure = CloudSaveFailure(error: error) + await reportAttentionRequiredFailure(failure) + CloudSaveLogging.log( + level: .error, + "zone change | failure=\(failure)" + ) + } + fileprivate func publishReadyStatus(syncEngine: CKSyncEngine) { + guard unresolvedFailure == nil else { + return + } + statusContinuation.yield( .ready( hasPendingChanges: !syncEngine.state.pendingRecordZoneChanges.isEmpty @@ -376,6 +448,25 @@ extension CloudSaveEngine { } } +/// Identifies the synchronization operation that may resolve a previous failure. +private enum RecoveryOperation: Equatable { + /// Fetches remote CloudKit changes. + case fetching + + /// Sends locally durable CloudKit changes. + case sending + + /// The public status reported while this operation is in progress. + var status: CloudSaveStatus { + switch self { + case .fetching: + .fetching + case .sending: + .sending + } + } +} + extension CloudSavePendingChange { fileprivate var syncEngineChange: CKSyncEngine.PendingRecordZoneChange { switch self { @@ -408,3 +499,17 @@ extension CKSyncEngine.Event.AccountChange { } } } + +extension CKError { + /// Whether CKSyncEngine can retry this CloudKit error without application attention. + fileprivate var isTransientCloudSaveError: Bool { + switch code { + case .accountTemporarilyUnavailable, .networkFailure, .networkUnavailable, + .notAuthenticated, .operationCancelled, .requestRateLimited, + .serviceUnavailable, .zoneBusy: + true + default: + false + } + } +} diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 92755b9..337ac18 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -10,6 +10,8 @@ Create the engine early in application launch, call ``CloudSaveEngine/start()``, Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetchNow()``, ``CloudSaveEngine/sendNow()``, or ``CloudSaveEngine/syncNow()`` only at user-visible checkpoints where immediate work is useful. +CloudSaveKit forwards only records and deletions from its configured custom zone. If the host cannot persist a sync-engine checkpoint or apply a CloudKit result, the engine cancels the current work and rebuilds from its last durable checkpoint. An attention-required failure remains observable until a later fetch or send cycle completes successfully. + ## Topics ### Engine From 95e1003f1d84cc2b82be5bb3b1ef5aac091a6858 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 16:44:13 +0200 Subject: [PATCH 04/18] Harden package automation and documentation --- .github/workflows/ci-pr.yml | 33 +++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 5 ++--- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 9 +++++---- README.md | 9 ++++++++- 5 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci-pr.yml diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 0000000..58f0bd6 --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,33 @@ +--- +name: Pull Request CI + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + name: Swift Format + runs-on: [self-hosted, macOS] + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Lint Swift Sources + run: AgentGuidelines/Scripts/swift_format.sh lint-strict Sources Tests + + test: + name: Test + runs-on: [self-hosted, macOS] + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + with: + clean: true + + - name: Run Tests + run: swift test -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dba404c..ced6e30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,6 @@ on: push: branches: - main - pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -17,7 +16,7 @@ jobs: runs-on: [self-hosted, macOS] steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Lint Swift Sources run: AgentGuidelines/Scripts/swift_format.sh lint-strict Sources Tests @@ -27,7 +26,7 @@ jobs: runs-on: [self-hosted, macOS] steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: clean: true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4fa795d..b09211c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -15,7 +15,7 @@ jobs: runs-on: [self-hosted, macOS] steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: clean: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa5bb7c..a2af249 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: - published permissions: + actions: read contents: read pages: write id-token: write @@ -22,7 +23,7 @@ jobs: runs-on: [self-hosted, macOS] steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: clean: true @@ -35,7 +36,7 @@ jobs: needs: test steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: clean: true @@ -71,7 +72,7 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@v4 notify_package_collection: name: Notify Package Collection @@ -94,7 +95,7 @@ jobs: exit 1 fi - curl -sS -X POST \ + curl --fail-with-body -sS -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer $GH_TOKEN" \ "https://api.github.com/repos/$COLLECTION_REPO/actions/workflows/$WORKFLOW_FILE/dispatches" \ diff --git a/README.md b/README.md index d88c441..030fab5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Platforms SPM License - CI + CI Release DocC

@@ -13,6 +13,13 @@ A reusable `CKSyncEngine` coordinator for synchronizing app-owned local data with a private CloudKit database. ☁️ +```mermaid +flowchart LR + LocalStore["Host local store"] --> Client["CloudSaveClient"] + Client <--> Engine["CloudSaveEngine"] + Engine <--> CloudKit["Private CloudKit database"] +``` + CloudSaveKit owns CloudKit synchronization mechanics while the host application remains responsible for its local persistence, record schema, merge semantics, and user experience. It deliberately has no dependency on SwiftData, Core Data, Redux, or SwiftUI. ## Responsibilities From d2971df20a98ac40f9986d45e1a99d10ba5cbb2d Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 17:01:32 +0200 Subject: [PATCH 05/18] Handle CloudKit deletion recovery --- Sources/CloudSaveKit/CloudSaveEngine.swift | 48 ++++++++++++++----- Sources/CloudSaveKit/CloudSaveFailure.swift | 10 ++++ .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- .../CloudSaveFailureTests.swift | 10 ++++ 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index e8f55bd..36c469c 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -67,6 +67,10 @@ public final actor CloudSaveEngine { scope: .zoneIDs([configuration.zone.zoneID]) ) try await syncEngine.fetchChanges(options) + } catch is CancellationError { + throw CancellationError() + } catch let error as CKError where error.code == .operationCancelled { + throw error } catch { let failure = CloudSaveFailure(error: error) await reportAttentionRequiredFailure(failure) @@ -81,6 +85,10 @@ public final actor CloudSaveEngine { scope: .zoneIDs([configuration.zone.zoneID]) ) try await syncEngine.sendChanges(options) + } catch is CancellationError { + throw CancellationError() + } catch let error as CKError where error.code == .operationCancelled { + throw error } catch { let failure = CloudSaveFailure(error: error) await reportAttentionRequiredFailure(failure) @@ -129,7 +137,7 @@ extension CloudSaveEngine: CKSyncEngineDelegate { ) case .fetchedDatabaseChanges(let event): try await restoreDeletedZones( - event.deletions.map(\.zoneID), + event.deletions.map(\.zoneID).filter(isInConfiguredZone), syncEngine: syncEngine ) case .fetchedRecordZoneChanges(let event): @@ -161,7 +169,7 @@ extension CloudSaveEngine: CKSyncEngineDelegate { ) } } catch { - let failure = CloudSaveFailure(error: error) + let failure = CloudSaveFailure(clientError: error) await reportAttentionRequiredFailure(failure) await rebuildAfterClientFailure(syncEngine: syncEngine) CloudSaveLogging.log( @@ -217,11 +225,12 @@ extension CloudSaveEngine { _ zoneIDs: [CKRecordZone.ID], syncEngine: CKSyncEngine ) async throws { - try await client.applyDeletedZones(zoneIDs) - guard zoneIDs.contains(configuration.zone.zoneID) else { + let configuredZoneIDs = zoneIDs.filter(isInConfiguredZone) + guard !configuredZoneIDs.isEmpty else { return } + try await client.applyDeletedZones(configuredZoneIDs) let pendingChanges = try await client.pendingChanges() syncEngine.state.add( pendingDatabaseChanges: [.saveZone(configuration.zone)] @@ -302,16 +311,24 @@ extension CloudSaveEngine { } for (recordID, error) in event.failedRecordDeletes { - if error.isTransientCloudSaveError { - continue - } + switch error.code { + case .unknownItem: + try await client.didDelete(recordIDs: [recordID]) + syncEngine.state.remove( + pendingRecordZoneChanges: [.deleteRecord(recordID)] + ) + default: + if error.isTransientCloudSaveError { + continue + } - let failure = CloudSaveFailure(error: error) - await reportAttentionRequiredFailure( - failure, - recordID: recordID - ) - didRequireAttention = true + let failure = CloudSaveFailure(error: error) + await reportAttentionRequiredFailure( + failure, + recordID: recordID + ) + didRequireAttention = true + } } syncEngine.state.add(pendingDatabaseChanges: zonesToRetry) @@ -369,6 +386,11 @@ extension CloudSaveEngine { recordID.zoneID == configuration.zone.zoneID } + /// Filters custom-zone events to the zone owned by this engine. + fileprivate func isInConfiguredZone(_ zoneID: CKRecordZone.ID) -> Bool { + zoneID == configuration.zone.zoneID + } + /// Starts a fetch or send that can clear an earlier attention-required failure. fileprivate func beginRecoveryOperation(_ operation: RecoveryOperation) { if unresolvedFailure != nil { diff --git a/Sources/CloudSaveKit/CloudSaveFailure.swift b/Sources/CloudSaveKit/CloudSaveFailure.swift index 12d299b..2f9e1a9 100644 --- a/Sources/CloudSaveKit/CloudSaveFailure.swift +++ b/Sources/CloudSaveKit/CloudSaveFailure.swift @@ -32,6 +32,16 @@ public enum CloudSaveFailure: Equatable, Sendable { } extension CloudSaveFailure { + /// Classifies a failure raised by the host persistence boundary. + init(clientError error: any Error) { + guard error is CKError else { + self = .localPersistence + return + } + + self.init(error: error) + } + init(error: any Error) { guard let cloudError = error as? CKError else { self = .unknown(code: (error as NSError).code) diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 337ac18..414ba7c 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -10,7 +10,7 @@ Create the engine early in application launch, call ``CloudSaveEngine/start()``, Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetchNow()``, ``CloudSaveEngine/sendNow()``, or ``CloudSaveEngine/syncNow()`` only at user-visible checkpoints where immediate work is useful. -CloudSaveKit forwards only records and deletions from its configured custom zone. If the host cannot persist a sync-engine checkpoint or apply a CloudKit result, the engine cancels the current work and rebuilds from its last durable checkpoint. An attention-required failure remains observable until a later fetch or send cycle completes successfully. +CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. If the host cannot persist a sync-engine checkpoint or apply a CloudKit result, the engine cancels the current work and rebuilds from its last durable checkpoint. Host callback failures are reported as ``CloudSaveFailure/localPersistence``. An attention-required failure remains observable until a later fetch or send cycle completes successfully. ## Topics diff --git a/Tests/CloudSaveKitTests/CloudSaveFailureTests.swift b/Tests/CloudSaveKitTests/CloudSaveFailureTests.swift index 315a267..12b587f 100644 --- a/Tests/CloudSaveKitTests/CloudSaveFailureTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveFailureTests.swift @@ -24,4 +24,14 @@ struct CloudSaveFailureTests { #expect(CloudSaveFailure(error: error) == expectedFailure) } + + @Test("Classifies a host persistence failure") + func classifiesHostPersistenceFailure() { + let error = NSError( + domain: "CloudSaveKitTests", + code: 1 + ) + + #expect(CloudSaveFailure(clientError: error) == .localPersistence) + } } From 6fc032277480fa6bcbcf728c4f8f9f81fca18e34 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 17:38:48 +0200 Subject: [PATCH 06/18] Refine CloudKit failure recovery --- Sources/CloudSaveKit/CloudSaveEngine.swift | 93 ++++++++++++++----- .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 36c469c..bee43ff 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -11,6 +11,8 @@ public final actor CloudSaveEngine { private let statusContinuation: AsyncStream.Continuation private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization? private var pendingRecoveryOperation: RecoveryOperation? + private var requiredRecoveryOperation: RecoveryOperation? + private var requiresHostRecovery = false private var storedSyncEngine: CKSyncEngine? private var unresolvedFailure: CloudSaveFailure? @@ -33,6 +35,10 @@ public final actor CloudSaveEngine { /// Initializes CKSyncEngine and restores every locally durable pending change. public func start() async throws { + guard !requiresHostRecovery || unresolvedFailure != nil else { + return + } + let engine = syncEngine if configuration.stateSerialization == nil { @@ -46,12 +52,21 @@ public final actor CloudSaveEngine { pendingRecordZoneChanges: pendingChanges.map(\.syncEngineChange) ) + clearFailureAfterHostRecovery() publishReadyStatus(syncEngine: engine) CloudSaveLogging.log("start | pending=\(pendingChanges.count)") } /// Adds locally durable changes to CKSyncEngine's pending state. public func enqueue(_ changes: [CloudSavePendingChange]) { + guard !requiresHostRecovery else { + CloudSaveLogging.log( + level: .error, + "enqueue | ignored while host recovery is required" + ) + return + } + let engine = syncEngine engine.state.add( pendingRecordZoneChanges: changes.map(\.syncEngineChange) @@ -73,7 +88,10 @@ public final actor CloudSaveEngine { throw error } catch { let failure = CloudSaveFailure(error: error) - await reportAttentionRequiredFailure(failure) + await reportAttentionRequiredFailure( + failure, + requiredRecoveryOperation: .fetching + ) throw error } } @@ -91,7 +109,10 @@ public final actor CloudSaveEngine { throw error } catch { let failure = CloudSaveFailure(error: error) - await reportAttentionRequiredFailure(failure) + await reportAttentionRequiredFailure( + failure, + requiredRecoveryOperation: .sending + ) throw error } } @@ -171,7 +192,7 @@ extension CloudSaveEngine: CKSyncEngineDelegate { } catch { let failure = CloudSaveFailure(clientError: error) await reportAttentionRequiredFailure(failure) - await rebuildAfterClientFailure(syncEngine: syncEngine) + await stopAfterClientFailure(syncEngine: syncEngine) CloudSaveLogging.log( level: .error, "event | failure=\(failure)" @@ -304,7 +325,8 @@ extension CloudSaveEngine { let failure = CloudSaveFailure(error: failedSave.error) await reportAttentionRequiredFailure( failure, - recordID: recordID + recordID: recordID, + requiredRecoveryOperation: .sending ) didRequireAttention = true } @@ -312,11 +334,14 @@ extension CloudSaveEngine { for (recordID, error) in event.failedRecordDeletes { switch error.code { - case .unknownItem: + case .unknownItem, .zoneNotFound: try await client.didDelete(recordIDs: [recordID]) syncEngine.state.remove( pendingRecordZoneChanges: [.deleteRecord(recordID)] ) + if error.code == .zoneNotFound { + zonesToRetry.append(.saveZone(configuration.zone)) + } default: if error.isTransientCloudSaveError { continue @@ -325,7 +350,8 @@ extension CloudSaveEngine { let failure = CloudSaveFailure(error: error) await reportAttentionRequiredFailure( failure, - recordID: recordID + recordID: recordID, + requiredRecoveryOperation: .sending ) didRequireAttention = true } @@ -345,7 +371,8 @@ extension CloudSaveEngine { guard let serverRecord = failedSave.error.serverRecord else { await reportAttentionRequiredFailure( .recordConflict, - recordID: failedSave.record.recordID + recordID: failedSave.record.recordID, + requiredRecoveryOperation: .sending ) return true } @@ -370,7 +397,8 @@ extension CloudSaveEngine { case .requiresUserDecision: await reportAttentionRequiredFailure( .recordConflict, - recordID: failedSave.record.recordID + recordID: failedSave.record.recordID, + requiredRecoveryOperation: .sending ) return true } @@ -393,11 +421,18 @@ extension CloudSaveEngine { /// Starts a fetch or send that can clear an earlier attention-required failure. fileprivate func beginRecoveryOperation(_ operation: RecoveryOperation) { - if unresolvedFailure != nil { - pendingRecoveryOperation = operation + guard let requiredRecoveryOperation else { + if unresolvedFailure == nil { + statusContinuation.yield(operation.status) + } return } - statusContinuation.yield(operation.status) + + guard requiredRecoveryOperation == operation else { + return + } + + pendingRecoveryOperation = operation } /// Clears an earlier failure only after its replacement operation completes successfully. @@ -413,6 +448,7 @@ extension CloudSaveEngine { } pendingRecoveryOperation = nil + requiredRecoveryOperation = nil unresolvedFailure = nil publishReadyStatus(syncEngine: syncEngine) } @@ -420,27 +456,21 @@ extension CloudSaveEngine { /// Reports a durable failure while preserving it across completion events. fileprivate func reportAttentionRequiredFailure( _ failure: CloudSaveFailure, - recordID: CKRecord.ID? = nil + recordID: CKRecord.ID? = nil, + requiredRecoveryOperation: RecoveryOperation? = nil ) async { pendingRecoveryOperation = nil + self.requiredRecoveryOperation = requiredRecoveryOperation unresolvedFailure = failure statusContinuation.yield(.failed(failure)) await client.handle(failure: failure, recordID: recordID) } - /// Rebuilds the engine from its last durable checkpoint after a host write fails. - fileprivate func rebuildAfterClientFailure(syncEngine: CKSyncEngine) async { + /// Stops automatic work after a host write fails until the host explicitly restarts it. + fileprivate func stopAfterClientFailure(syncEngine: CKSyncEngine) async { await syncEngine.cancelOperations() + requiresHostRecovery = true storedSyncEngine = nil - - do { - try await start() - } catch { - CloudSaveLogging.log( - level: .error, - "rebuild | failure=\(CloudSaveFailure(error: error))" - ) - } } /// Handles a zone change failure according to CKSyncEngine's retry policy. @@ -450,7 +480,10 @@ extension CloudSaveEngine { } let failure = CloudSaveFailure(error: error) - await reportAttentionRequiredFailure(failure) + await reportAttentionRequiredFailure( + failure, + requiredRecoveryOperation: .sending + ) CloudSaveLogging.log( level: .error, "zone change | failure=\(failure)" @@ -468,6 +501,18 @@ extension CloudSaveEngine { ) ) } + + /// Clears a host persistence failure after the host explicitly restarts the engine. + fileprivate func clearFailureAfterHostRecovery() { + guard requiresHostRecovery else { + return + } + + pendingRecoveryOperation = nil + requiredRecoveryOperation = nil + requiresHostRecovery = false + unresolvedFailure = nil + } } /// Identifies the synchronization operation that may resolve a previous failure. diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 414ba7c..05aabaa 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -10,7 +10,7 @@ Create the engine early in application launch, call ``CloudSaveEngine/start()``, Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetchNow()``, ``CloudSaveEngine/sendNow()``, or ``CloudSaveEngine/syncNow()`` only at user-visible checkpoints where immediate work is useful. -CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. If the host cannot persist a sync-engine checkpoint or apply a CloudKit result, the engine cancels the current work and rebuilds from its last durable checkpoint. Host callback failures are reported as ``CloudSaveFailure/localPersistence``. An attention-required failure remains observable until a later fetch or send cycle completes successfully. +CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. An attention-required failure remains observable until the matching later fetch or send cycle completes successfully. ## Topics From d1fff6d9db2adae3177cebf65726baa177d5c200 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 18:31:38 +0200 Subject: [PATCH 07/18] Model CloudKit recovery state explicitly --- README.md | 20 +- .../CKSyncEngineAccountChange+CloudSave.swift | 24 + ...inePendingRecordZoneChange+CloudSave.swift | 13 + Sources/CloudSaveKit/CloudSaveClient.swift | 3 + Sources/CloudSaveKit/CloudSaveEngine.swift | 702 +++++++++++------- .../CloudSaveKit/CloudSaveEngineError.swift | 10 + .../CloudSaveFailureContext.swift | 27 + .../CloudSaveFailureRequirement.swift | 13 + .../CloudSaveKit.docc/CloudSaveKit.md | 7 +- Sources/CloudSaveKit/CloudSaveOperation.swift | 20 + .../CloudSaveKit/CloudSavePendingChange.swift | 20 + .../CloudSaveKit/CloudSaveRetryPolicy.swift | 16 + .../CloudSaveKit/CloudSaveStateMachine.swift | 147 ++++ .../CloudSaveRetryPolicyTests.swift | 47 ++ .../CloudSaveStateMachineTests.swift | 236 ++++++ 15 files changed, 1048 insertions(+), 257 deletions(-) create mode 100644 Sources/CloudSaveKit/CKSyncEngineAccountChange+CloudSave.swift create mode 100644 Sources/CloudSaveKit/CKSyncEnginePendingRecordZoneChange+CloudSave.swift create mode 100644 Sources/CloudSaveKit/CloudSaveEngineError.swift create mode 100644 Sources/CloudSaveKit/CloudSaveFailureContext.swift create mode 100644 Sources/CloudSaveKit/CloudSaveFailureRequirement.swift create mode 100644 Sources/CloudSaveKit/CloudSaveOperation.swift create mode 100644 Sources/CloudSaveKit/CloudSaveRetryPolicy.swift create mode 100644 Sources/CloudSaveKit/CloudSaveStateMachine.swift create mode 100644 Tests/CloudSaveKitTests/CloudSaveRetryPolicyTests.swift create mode 100644 Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift diff --git a/README.md b/README.md index 030fab5..0c0085e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ CloudSaveKit owns CloudKit synchronization mechanics while the host application - Schedule automatic synchronization and expose explicit fetch, send, and combined sync operations. - Forward fetched changes, account events, saved system fields, and semantic conflicts to the host. - Classify failures into privacy-safe values suitable for application state. +- Preserve independent record, zone, operation, and host-persistence failures until their exact recovery conditions succeed. ## Quick start @@ -76,9 +77,26 @@ try await engine.syncNow() Automatic synchronization should remain enabled in production. Explicit operations complement the system scheduler; they do not replace durable local saves or make offline networking possible. +Call `start()` successfully before any explicit synchronization. `fetchNow()` and `sendNow()` throw `CloudSaveEngineError.notStarted` before startup and `CloudSaveEngineError.hostRecoveryRequired` after a host persistence callback fails. Once the local store is healthy again, call `start()` to rebuild from the last successfully persisted CKSyncEngine checkpoint and the host's current durable pending-change ledger. + +`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. + +## Failure and retry policy + +CloudSaveKit leaves temporary transport, service, authentication, throttling, and cancellation failures to CKSyncEngine's scheduler. Explicit methods still throw their underlying error so the caller can finish its immediate workflow, but routine retryable errors do not become durable attention-required state. + +Semantic and permanent failures are tracked independently: + +- A record failure clears only when that record is saved, its deletion is acknowledged, or the host removes it from the durable pending ledger. +- A zone failure clears only after that zone succeeds. +- An explicit operation failure clears only after a later matching operation completes. +- A host persistence failure stops the engine and blocks all synchronization until a successful `start()`. + +This follows [Apple's CKSyncEngine contract](https://developer.apple.com/documentation/cloudkit/cksyncengine-5sie5): the framework schedules and retries recoverable transport work, while the application persists engine state and resolves semantic record failures. + ## Conflict handling -CloudSaveKit forwards `serverRecordChanged` to `CloudSaveClient.resolve(conflict:)`. A retry record must be based on the supplied server record so it retains the current CloudKit change tag. The host may accept the server value, return a merged retry record, or preserve the conflict for a user decision. +CloudSaveKit forwards `serverRecordChanged` to `CloudSaveClient.resolve(conflict:)`. A retry record must be based on the supplied server record so it retains the current CloudKit change tag. The host may accept the server value, return a merged retry record, or preserve the conflict for a user decision. Accepting the server must clear the corresponding item from the host's durable pending ledger when applying the server record; CloudSaveKit removes the same pending save from CKSyncEngine. ## Logging diff --git a/Sources/CloudSaveKit/CKSyncEngineAccountChange+CloudSave.swift b/Sources/CloudSaveKit/CKSyncEngineAccountChange+CloudSave.swift new file mode 100644 index 0000000..d7ec047 --- /dev/null +++ b/Sources/CloudSaveKit/CKSyncEngineAccountChange+CloudSave.swift @@ -0,0 +1,24 @@ +import CloudKit + +extension CKSyncEngine.Event.AccountChange { + /// A known, non-destructive account transition suitable for the host boundary. + var cloudSaveAccountChange: CloudSaveAccountChange? { + switch changeType { + case .signIn(let currentUser): + .signedIn( + currentAccountID: currentUser.recordName + ) + case .signOut(let previousUser): + .signedOut( + previousAccountID: previousUser.recordName + ) + case .switchAccounts(let previousUser, let currentUser): + .switched( + previousAccountID: previousUser.recordName, + currentAccountID: currentUser.recordName + ) + @unknown default: + nil + } + } +} diff --git a/Sources/CloudSaveKit/CKSyncEnginePendingRecordZoneChange+CloudSave.swift b/Sources/CloudSaveKit/CKSyncEnginePendingRecordZoneChange+CloudSave.swift new file mode 100644 index 0000000..19f7935 --- /dev/null +++ b/Sources/CloudSaveKit/CKSyncEnginePendingRecordZoneChange+CloudSave.swift @@ -0,0 +1,13 @@ +import CloudKit + +extension CKSyncEngine.PendingRecordZoneChange { + /// The record identifier represented by this CKSyncEngine change. + var recordID: CKRecord.ID? { + switch self { + case .saveRecord(let recordID), .deleteRecord(let recordID): + recordID + @unknown default: + nil + } + } +} diff --git a/Sources/CloudSaveKit/CloudSaveClient.swift b/Sources/CloudSaveKit/CloudSaveClient.swift index 2c1c1ef..95b7239 100644 --- a/Sources/CloudSaveKit/CloudSaveClient.swift +++ b/Sources/CloudSaveKit/CloudSaveClient.swift @@ -12,6 +12,9 @@ public protocol CloudSaveClient: Sendable { func persist(stateSerialization: CKSyncEngine.State.Serialization) async throws /// Applies fetched records and deletions in one local transaction. + /// + /// Applying a record accepted during conflict resolution must also remove that record from the + /// host's durable pending-change ledger. func applyFetchedChanges( records: [CKRecord], deletedRecordIDs: [CKRecord.ID] diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index bee43ff..acfcc74 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -10,11 +10,9 @@ public final actor CloudSaveEngine { private let configuration: CloudSaveConfiguration private let statusContinuation: AsyncStream.Continuation private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization? - private var pendingRecoveryOperation: RecoveryOperation? - private var requiredRecoveryOperation: RecoveryOperation? - private var requiresHostRecovery = false + private var lifecycleGeneration = 0 + private var stateMachine = CloudSaveStateMachine() private var storedSyncEngine: CKSyncEngine? - private var unresolvedFailure: CloudSaveFailure? /// Creates an engine without starting synchronization. public init( @@ -35,31 +33,43 @@ public final actor CloudSaveEngine { /// Initializes CKSyncEngine and restores every locally durable pending change. public func start() async throws { - guard !requiresHostRecovery || unresolvedFailure != nil else { - return + let startingLifecycleGeneration = lifecycleGeneration + let pendingChanges: [CloudSavePendingChange] + do { + pendingChanges = try await client.pendingChanges() + } catch { + await handleHostFailure( + error, + syncEngine: storedSyncEngine + ) + throw error + } + + guard lifecycleGeneration == startingLifecycleGeneration else { + throw CloudSaveEngineError.hostRecoveryRequired } - let engine = syncEngine + let engine = storedSyncEngine ?? makeSyncEngine() + storedSyncEngine = engine + stateMachine.resolve(.hostPersistence) - if configuration.stateSerialization == nil { + if lastPersistedStateSerialization == nil { engine.state.add( pendingDatabaseChanges: [.saveZone(configuration.zone)] ) } - let pendingChanges = try await client.pendingChanges() - engine.state.add( - pendingRecordZoneChanges: pendingChanges.map(\.syncEngineChange) + restoreDurablePendingChanges( + pendingChanges, + syncEngine: engine ) - - clearFailureAfterHostRecovery() - publishReadyStatus(syncEngine: engine) + publishStatus(syncEngine: engine) CloudSaveLogging.log("start | pending=\(pendingChanges.count)") } /// Adds locally durable changes to CKSyncEngine's pending state. public func enqueue(_ changes: [CloudSavePendingChange]) { - guard !requiresHostRecovery else { + guard !stateMachine.requiresHostRecovery else { CloudSaveLogging.log( level: .error, "enqueue | ignored while host recovery is required" @@ -67,51 +77,98 @@ public final actor CloudSaveEngine { return } - let engine = syncEngine - engine.state.add( - pendingRecordZoneChanges: changes.map(\.syncEngineChange) + guard let storedSyncEngine else { + CloudSaveLogging.log( + level: .error, + "enqueue | ignored before start" + ) + return + } + + let configuredChanges = changes.filter { + isInConfiguredZone($0.recordID) + } + storedSyncEngine.state.add( + pendingRecordZoneChanges: configuredChanges.map(\.syncEngineChange) ) - publishReadyStatus(syncEngine: engine) - CloudSaveLogging.log("enqueue | count=\(changes.count)") + publishStatus(syncEngine: storedSyncEngine) + CloudSaveLogging.log("enqueue | count=\(configuredChanges.count)") } /// Immediately fetches changes for the configured save zone. public func fetchNow() async throws { + let session = try operationalSyncEngine() + do { let options = CKSyncEngine.FetchChangesOptions( scope: .zoneIDs([configuration.zone.zoneID]) ) - try await syncEngine.fetchChanges(options) + try await session.syncEngine.fetchChanges(options) + try validate(session) } catch is CancellationError { + try throwRecoveryErrorIfNeeded(for: session) throw CancellationError() } catch let error as CKError where error.code == .operationCancelled { + try throwRecoveryErrorIfNeeded(for: session) + throw error + } catch let error as CKError where !CloudSaveRetryPolicy.requiresApplicationAttention(for: error) { + try throwRecoveryErrorIfNeeded(for: session) throw error } catch { - let failure = CloudSaveFailure(error: error) - await reportAttentionRequiredFailure( - failure, - requiredRecoveryOperation: .fetching + try throwRecoveryErrorIfNeeded(for: session) + await reportOperationFailure( + CloudSaveFailure(error: error), + operation: .fetching, + syncEngine: session.syncEngine ) throw error } } - /// Immediately sends pending changes for the configured save zone. + /// Immediately sends every locally durable pending change for the configured save zone. public func sendNow() async throws { + let session = try operationalSyncEngine() + let pendingChanges: [CloudSavePendingChange] + + do { + pendingChanges = try await client.pendingChanges() + } catch { + try throwRecoveryErrorIfNeeded(for: session) + await handleHostFailure( + error, + syncEngine: session.syncEngine + ) + throw error + } + + try validate(session) + restoreDurablePendingChanges( + pendingChanges, + syncEngine: session.syncEngine + ) + restoreFailedZoneChangeIfNeeded(syncEngine: session.syncEngine) + do { let options = CKSyncEngine.SendChangesOptions( scope: .zoneIDs([configuration.zone.zoneID]) ) - try await syncEngine.sendChanges(options) + try await session.syncEngine.sendChanges(options) + try validate(session) } catch is CancellationError { + try throwRecoveryErrorIfNeeded(for: session) throw CancellationError() } catch let error as CKError where error.code == .operationCancelled { + try throwRecoveryErrorIfNeeded(for: session) + throw error + } catch let error as CKError where !CloudSaveRetryPolicy.requiresApplicationAttention(for: error) { + try throwRecoveryErrorIfNeeded(for: session) throw error } catch { - let failure = CloudSaveFailure(error: error) - await reportAttentionRequiredFailure( - failure, - requiredRecoveryOperation: .sending + try throwRecoveryErrorIfNeeded(for: session) + await reportOperationFailure( + CloudSaveFailure(error: error), + operation: .sending, + syncEngine: session.syncEngine ) throw error } @@ -123,9 +180,19 @@ public final actor CloudSaveEngine { try await sendNow() } - /// Cancels in-flight CKSyncEngine operations. + /// Cancels in-flight CKSyncEngine operations without starting or rebuilding an engine. public func cancel() async { - await syncEngine.cancelOperations() + guard let storedSyncEngine else { + return + } + + await storedSyncEngine.cancelOperations() + guard self.storedSyncEngine === storedSyncEngine else { + return + } + + stateMachine.resetActiveOperations() + publishStatus(syncEngine: storedSyncEngine) } } @@ -144,16 +211,10 @@ extension CloudSaveEngine: CKSyncEngineDelegate { do { switch event { case .stateUpdate(let event): - try await client.persist( - stateSerialization: event.stateSerialization - ) - lastPersistedStateSerialization = event.stateSerialization + try await persistStateUpdate(event) case .accountChange(let event): - try await client.handle( - accountChange: event.cloudSaveAccountChange - ) - try await restorePendingChangesAfterAccountChange( - event.cloudSaveAccountChange, + try await handleAccountChange( + event, syncEngine: syncEngine ) case .fetchedDatabaseChanges(let event): @@ -172,15 +233,18 @@ extension CloudSaveEngine: CKSyncEngineDelegate { syncEngine: syncEngine ) case .sentDatabaseChanges(let event): - await handleSentDatabaseChanges(event) + await handleSentDatabaseChanges( + event, + syncEngine: syncEngine + ) case .willFetchChanges: - beginRecoveryOperation(.fetching) + begin(.fetching, syncEngine: syncEngine) case .willSendChanges: - beginRecoveryOperation(.sending) + begin(.sending, syncEngine: syncEngine) case .didFetchChanges: - completeRecoveryOperation(.fetching, syncEngine: syncEngine) + complete(.fetching, syncEngine: syncEngine) case .didSendChanges: - completeRecoveryOperation(.sending, syncEngine: syncEngine) + complete(.sending, syncEngine: syncEngine) case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges: break @unknown default: @@ -190,12 +254,13 @@ extension CloudSaveEngine: CKSyncEngineDelegate { ) } } catch { - let failure = CloudSaveFailure(clientError: error) - await reportAttentionRequiredFailure(failure) - await stopAfterClientFailure(syncEngine: syncEngine) + await handleHostFailure( + error, + syncEngine: syncEngine + ) CloudSaveLogging.log( level: .error, - "event | failure=\(failure)" + "event | failure=\(CloudSaveFailure(clientError: error))" ) } } @@ -204,8 +269,18 @@ extension CloudSaveEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { + guard storedSyncEngine === syncEngine, + !stateMachine.requiresHostRecovery + else { + return nil + } + let pendingChanges = syncEngine.state.pendingRecordZoneChanges.filter { - context.options.scope.contains($0) + guard let recordID = $0.recordID else { + return false + } + + return context.options.scope.contains($0) && isInConfiguredZone(recordID) } return await CKSyncEngine.RecordZoneChangeBatch( @@ -222,9 +297,141 @@ extension CloudSaveEngine: CKSyncEngineDelegate { } } -// MARK: - Private +// MARK: - Private Lifecycle + +extension CloudSaveEngine { + /// Creates a CKSyncEngine from the last state successfully persisted by the host. + fileprivate func makeSyncEngine() -> CKSyncEngine { + var engineConfiguration = CKSyncEngine.Configuration( + database: configuration.database, + stateSerialization: lastPersistedStateSerialization, + delegate: self + ) + engineConfiguration.automaticallySync = configuration.automaticallySync + engineConfiguration.subscriptionID = configuration.subscriptionID + return CKSyncEngine(engineConfiguration) + } + + /// Returns the active engine or rejects work until its lifecycle is recovered. + fileprivate func operationalSyncEngine() throws -> ( + syncEngine: CKSyncEngine, + lifecycleGeneration: Int + ) { + guard !stateMachine.requiresHostRecovery else { + throw CloudSaveEngineError.hostRecoveryRequired + } + + guard let storedSyncEngine else { + throw CloudSaveEngineError.notStarted + } + + return ( + syncEngine: storedSyncEngine, + lifecycleGeneration: lifecycleGeneration + ) + } + + /// Verifies that an actor-reentrant operation still belongs to the active engine. + fileprivate func validate( + _ session: ( + syncEngine: CKSyncEngine, + lifecycleGeneration: Int + ) + ) throws { + guard session.lifecycleGeneration == lifecycleGeneration, + storedSyncEngine === session.syncEngine, + !stateMachine.requiresHostRecovery + else { + throw CloudSaveEngineError.hostRecoveryRequired + } + } + + /// Converts cancellation from a stopped engine into the host-recovery lifecycle error. + fileprivate func throwRecoveryErrorIfNeeded( + for session: ( + syncEngine: CKSyncEngine, + lifecycleGeneration: Int + ) + ) throws { + guard + session.lifecycleGeneration != lifecycleGeneration + || storedSyncEngine !== session.syncEngine + || stateMachine.requiresHostRecovery + else { + return + } + + throw CloudSaveEngineError.hostRecoveryRequired + } + + /// Stops all work after a host persistence failure and preserves the last good checkpoint. + fileprivate func stopAfterHostFailure(syncEngine: CKSyncEngine?) async { + guard syncEngine == nil || storedSyncEngine === syncEngine else { + return + } + + let engineToCancel = syncEngine ?? storedSyncEngine + storedSyncEngine = nil + stateMachine.resetActiveOperations() + await engineToCancel?.cancelOperations() + } +} + +// MARK: - Private Host Persistence extension CloudSaveEngine { + /// Persists an opaque state update before accepting it as the next recovery checkpoint. + fileprivate func persistStateUpdate( + _ event: CKSyncEngine.Event.StateUpdate + ) async throws { + try await client.persist( + stateSerialization: event.stateSerialization + ) + lastPersistedStateSerialization = event.stateSerialization + } + + /// Reconciles CKSyncEngine's tracked changes with the host's authoritative durable ledger. + fileprivate func restoreDurablePendingChanges( + _ pendingChanges: [CloudSavePendingChange], + syncEngine: CKSyncEngine + ) { + let configuredPendingChanges = pendingChanges.filter { + isInConfiguredZone($0.recordID) + } + let durableChanges = configuredPendingChanges.map(\.syncEngineChange) + let durableChangeSet = Set(durableChanges) + let staleChanges = syncEngine.state.pendingRecordZoneChanges.filter { + guard let recordID = $0.recordID else { + return false + } + + return isInConfiguredZone(recordID) && !durableChangeSet.contains($0) + } + + syncEngine.state.remove( + pendingRecordZoneChanges: staleChanges + ) + syncEngine.state.add( + pendingRecordZoneChanges: durableChanges + ) + stateMachine.reconcilePendingRecordIDs( + Set(configuredPendingChanges.map(\.recordID)), + in: configuration.zone.zoneID + ) + } + + /// Restores a failed configured-zone save only for a host-requested explicit send. + fileprivate func restoreFailedZoneChangeIfNeeded(syncEngine: CKSyncEngine) { + guard stateMachine.requiresRecovery(for: configuration.zone.zoneID) else { + return + } + + syncEngine.state.add( + pendingDatabaseChanges: [.saveZone(configuration.zone)] + ) + } + + /// Restores durable host changes after CKSyncEngine clears state for an account transition. fileprivate func restorePendingChangesAfterAccountChange( _ accountChange: CloudSaveAccountChange, syncEngine: CKSyncEngine @@ -234,14 +441,16 @@ extension CloudSaveEngine { syncEngine.state.add( pendingDatabaseChanges: [.saveZone(configuration.zone)] ) - syncEngine.state.add( - pendingRecordZoneChanges: pendingChanges.map(\.syncEngineChange) + restoreDurablePendingChanges( + pendingChanges, + syncEngine: syncEngine ) - publishReadyStatus(syncEngine: syncEngine) + publishStatus(syncEngine: syncEngine) return } } + /// Recreates the configured zone and restores the host's durable changes after deletion. fileprivate func restoreDeletedZones( _ zoneIDs: [CKRecordZone.ID], syncEngine: CKSyncEngine @@ -256,61 +465,91 @@ extension CloudSaveEngine { syncEngine.state.add( pendingDatabaseChanges: [.saveZone(configuration.zone)] ) - syncEngine.state.add( - pendingRecordZoneChanges: pendingChanges.map(\.syncEngineChange) + restoreDurablePendingChanges( + pendingChanges, + syncEngine: syncEngine ) } +} - fileprivate var syncEngine: CKSyncEngine { - if let storedSyncEngine { - return storedSyncEngine +// MARK: - Private Events + +extension CloudSaveEngine { + /// Forwards a known account transition without inventing destructive future cases. + fileprivate func handleAccountChange( + _ event: CKSyncEngine.Event.AccountChange, + syncEngine: CKSyncEngine + ) async throws { + guard let accountChange = event.cloudSaveAccountChange else { + CloudSaveLogging.log( + level: .info, + "account change | ignored unknown type" + ) + return } - var engineConfiguration = CKSyncEngine.Configuration( - database: configuration.database, - stateSerialization: lastPersistedStateSerialization, - delegate: self + try await client.handle(accountChange: accountChange) + try await restorePendingChangesAfterAccountChange( + accountChange, + syncEngine: syncEngine ) - engineConfiguration.automaticallySync = configuration.automaticallySync - engineConfiguration.subscriptionID = configuration.subscriptionID - - let engine = CKSyncEngine(engineConfiguration) - storedSyncEngine = engine - return engine } + /// Handles successful and failed configured-zone changes independently. fileprivate func handleSentDatabaseChanges( - _ event: CKSyncEngine.Event.SentDatabaseChanges + _ event: CKSyncEngine.Event.SentDatabaseChanges, + syncEngine: CKSyncEngine ) async { - for failedSave in event.failedZoneSaves { - await handleFailedZoneChange(failedSave.error) + let successfulZoneIDs = + event.savedZones.map(\.zoneID).filter(isInConfiguredZone) + + event.deletedZoneIDs.filter(isInConfiguredZone) + stateMachine.resolve(zoneIDs: successfulZoneIDs) + + for failedSave in event.failedZoneSaves where isInConfiguredZone(failedSave.zone.zoneID) { + await handleFailedZoneChange( + failedSave.error, + zoneID: failedSave.zone.zoneID, + syncEngine: syncEngine + ) } - for (_, error) in event.failedZoneDeletes { - await handleFailedZoneChange(error) + for (zoneID, error) in event.failedZoneDeletes where isInConfiguredZone(zoneID) { + await handleFailedZoneChange( + error, + zoneID: zoneID, + syncEngine: syncEngine + ) } + + publishStatus(syncEngine: syncEngine) } + /// Applies acknowledgements and resolves every record failure according to its error. fileprivate func handleSentRecordZoneChanges( _ event: CKSyncEngine.Event.SentRecordZoneChanges, syncEngine: CKSyncEngine ) async throws { - try await client.didSave(records: event.savedRecords) - try await client.didDelete(recordIDs: event.deletedRecordIDs) + let savedRecords = event.savedRecords.filter(isInConfiguredZone) + let deletedRecordIDs = event.deletedRecordIDs.filter(isInConfiguredZone) + + try await client.didSave(records: savedRecords) + try await client.didDelete(recordIDs: deletedRecordIDs) + stateMachine.resolve( + recordIDs: savedRecords.map(\.recordID) + deletedRecordIDs + ) var changesToRetry: [CKSyncEngine.PendingRecordZoneChange] = [] var zonesToRetry: [CKSyncEngine.PendingDatabaseChange] = [] - var didRequireAttention = false - for failedSave in event.failedRecordSaves { + for failedSave in event.failedRecordSaves where isInConfiguredZone(failedSave.record) { let recordID = failedSave.record.recordID switch failedSave.error.code { case .serverRecordChanged: - didRequireAttention = - try await handleConflict( - failedSave, - changesToRetry: &changesToRetry - ) || didRequireAttention + try await handleConflict( + failedSave, + changesToRetry: &changesToRetry, + syncEngine: syncEngine + ) case .zoneNotFound: try await client.clearServerRecord(for: recordID) zonesToRetry.append(.saveZone(configuration.zone)) @@ -318,63 +557,62 @@ extension CloudSaveEngine { case .unknownItem: try await client.clearServerRecord(for: recordID) changesToRetry.append(.saveRecord(recordID)) - case .accountTemporarilyUnavailable, .networkFailure, .networkUnavailable, .notAuthenticated, - .operationCancelled, .requestRateLimited, .serviceUnavailable, .zoneBusy: - break default: - let failure = CloudSaveFailure(error: failedSave.error) - await reportAttentionRequiredFailure( - failure, - recordID: recordID, - requiredRecoveryOperation: .sending + guard CloudSaveRetryPolicy.requiresApplicationAttention(for: failedSave.error) else { + continue + } + + await reportFailure( + CloudSaveFailure(error: failedSave.error), + context: .record(recordID), + syncEngine: syncEngine ) - didRequireAttention = true } } - for (recordID, error) in event.failedRecordDeletes { + for (recordID, error) in event.failedRecordDeletes where isInConfiguredZone(recordID) { switch error.code { case .unknownItem, .zoneNotFound: try await client.didDelete(recordIDs: [recordID]) syncEngine.state.remove( pendingRecordZoneChanges: [.deleteRecord(recordID)] ) + stateMachine.resolve(.record(recordID)) if error.code == .zoneNotFound { zonesToRetry.append(.saveZone(configuration.zone)) } default: - if error.isTransientCloudSaveError { + guard CloudSaveRetryPolicy.requiresApplicationAttention(for: error) else { continue } - let failure = CloudSaveFailure(error: error) - await reportAttentionRequiredFailure( - failure, - recordID: recordID, - requiredRecoveryOperation: .sending + await reportFailure( + CloudSaveFailure(error: error), + context: .record(recordID), + syncEngine: syncEngine ) - didRequireAttention = true } } syncEngine.state.add(pendingDatabaseChanges: zonesToRetry) syncEngine.state.add(pendingRecordZoneChanges: changesToRetry) - if !didRequireAttention, unresolvedFailure == nil { - publishReadyStatus(syncEngine: syncEngine) - } + publishStatus(syncEngine: syncEngine) } + /// Resolves one semantic record conflict without retaining an unwanted pending save. fileprivate func handleConflict( _ failedSave: CKSyncEngine.Event.SentRecordZoneChanges.FailedRecordSave, - changesToRetry: inout [CKSyncEngine.PendingRecordZoneChange] - ) async throws -> Bool { + changesToRetry: inout [CKSyncEngine.PendingRecordZoneChange], + syncEngine: CKSyncEngine + ) async throws { + let recordID = failedSave.record.recordID guard let serverRecord = failedSave.error.serverRecord else { - await reportAttentionRequiredFailure( + await reportFailure( .recordConflict, - recordID: failedSave.record.recordID, - requiredRecoveryOperation: .sending + context: .record(recordID), + syncEngine: syncEngine ) - return true + return } let conflict = CloudSaveConflict( @@ -389,100 +627,112 @@ extension CloudSaveEngine { records: [serverRecord], deletedRecordIDs: [] ) - return false + syncEngine.state.remove( + pendingRecordZoneChanges: [.saveRecord(recordID)] + ) + stateMachine.resolve(.record(recordID)) case .retry(let mergedRecord): try await client.persistResolvedRecord(mergedRecord) changesToRetry.append(.saveRecord(mergedRecord.recordID)) - return false case .requiresUserDecision: - await reportAttentionRequiredFailure( + await reportFailure( .recordConflict, - recordID: failedSave.record.recordID, - requiredRecoveryOperation: .sending + context: .record(recordID), + syncEngine: syncEngine ) - return true } } +} - /// Filters CloudKit fetches to the custom zone owned by this engine. - fileprivate func isInConfiguredZone(_ record: CKRecord) -> Bool { - isInConfiguredZone(record.recordID) - } - - /// Filters CloudKit fetches to the custom zone owned by this engine. - fileprivate func isInConfiguredZone(_ recordID: CKRecord.ID) -> Bool { - recordID.zoneID == configuration.zone.zoneID - } - - /// Filters custom-zone events to the zone owned by this engine. - fileprivate func isInConfiguredZone(_ zoneID: CKRecordZone.ID) -> Bool { - zoneID == configuration.zone.zoneID - } - - /// Starts a fetch or send that can clear an earlier attention-required failure. - fileprivate func beginRecoveryOperation(_ operation: RecoveryOperation) { - guard let requiredRecoveryOperation else { - if unresolvedFailure == nil { - statusContinuation.yield(operation.status) - } - return - } - - guard requiredRecoveryOperation == operation else { - return - } +// MARK: - Private State - pendingRecoveryOperation = operation +extension CloudSaveEngine { + /// Begins one operation and publishes its in-progress state when no failure supersedes it. + fileprivate func begin( + _ operation: CloudSaveOperation, + syncEngine: CKSyncEngine + ) { + stateMachine.begin(operation) + publishStatus(syncEngine: syncEngine) } - /// Clears an earlier failure only after its replacement operation completes successfully. - fileprivate func completeRecoveryOperation( - _ operation: RecoveryOperation, + /// Completes one operation and clears only an eligible matching operation failure. + fileprivate func complete( + _ operation: CloudSaveOperation, syncEngine: CKSyncEngine ) { - guard pendingRecoveryOperation == operation else { - if unresolvedFailure == nil { - publishReadyStatus(syncEngine: syncEngine) - } - return - } + stateMachine.complete(operation) + publishStatus(syncEngine: syncEngine) + } - pendingRecoveryOperation = nil - requiredRecoveryOperation = nil - unresolvedFailure = nil - publishReadyStatus(syncEngine: syncEngine) + /// Records an operation failure that requires a later matching generation to succeed. + fileprivate func reportOperationFailure( + _ failure: CloudSaveFailure, + operation: CloudSaveOperation, + syncEngine: CKSyncEngine + ) async { + stateMachine.fail( + failure, + operation: operation + ) + publishStatus(syncEngine: syncEngine) + await client.handle( + failure: failure, + recordID: nil + ) } - /// Reports a durable failure while preserving it across completion events. - fileprivate func reportAttentionRequiredFailure( + /// Records a durable failure without replacing unrelated recovery requirements. + fileprivate func reportFailure( _ failure: CloudSaveFailure, - recordID: CKRecord.ID? = nil, - requiredRecoveryOperation: RecoveryOperation? = nil + context: CloudSaveFailureContext, + syncEngine: CKSyncEngine? ) async { - pendingRecoveryOperation = nil - self.requiredRecoveryOperation = requiredRecoveryOperation - unresolvedFailure = failure - statusContinuation.yield(.failed(failure)) - await client.handle(failure: failure, recordID: recordID) + stateMachine.fail( + failure, + context: context + ) + publishStatus(syncEngine: syncEngine) + await client.handle( + failure: failure, + recordID: context.recordID + ) } - /// Stops automatic work after a host write fails until the host explicitly restarts it. - fileprivate func stopAfterClientFailure(syncEngine: CKSyncEngine) async { - await syncEngine.cancelOperations() - requiresHostRecovery = true - storedSyncEngine = nil + /// Reports and stops after a host callback failure. + fileprivate func handleHostFailure( + _ error: any Error, + syncEngine: CKSyncEngine? + ) async { + let failure = CloudSaveFailure(clientError: error) + lifecycleGeneration &+= 1 + stateMachine.fail( + failure, + context: .hostPersistence + ) + publishStatus(syncEngine: syncEngine) + await stopAfterHostFailure(syncEngine: syncEngine) + await client.handle( + failure: failure, + recordID: nil + ) } - /// Handles a zone change failure according to CKSyncEngine's retry policy. - fileprivate func handleFailedZoneChange(_ error: CKError) async { - guard !error.isTransientCloudSaveError else { + /// Handles a zone failure according to CKSyncEngine's retry ownership. + fileprivate func handleFailedZoneChange( + _ error: CKError, + zoneID: CKRecordZone.ID, + syncEngine: CKSyncEngine + ) async { + guard CloudSaveRetryPolicy.requiresApplicationAttention(for: error) else { return } let failure = CloudSaveFailure(error: error) - await reportAttentionRequiredFailure( + await reportFailure( failure, - requiredRecoveryOperation: .sending + context: .zone(zoneID), + syncEngine: syncEngine ) CloudSaveLogging.log( level: .error, @@ -490,93 +740,37 @@ extension CloudSaveEngine { ) } - fileprivate func publishReadyStatus(syncEngine: CKSyncEngine) { - guard unresolvedFailure == nil else { - return - } + /// Publishes the state-machine projection without exposing record identifiers. + fileprivate func publishStatus(syncEngine: CKSyncEngine?) { + let hasPendingChanges = + syncEngine?.state.pendingRecordZoneChanges.contains { + guard let recordID = $0.recordID else { + return false + } + return isInConfiguredZone(recordID) + } ?? false statusContinuation.yield( - .ready( - hasPendingChanges: !syncEngine.state.pendingRecordZoneChanges.isEmpty - ) + stateMachine.status(hasPendingChanges: hasPendingChanges) ) } - - /// Clears a host persistence failure after the host explicitly restarts the engine. - fileprivate func clearFailureAfterHostRecovery() { - guard requiresHostRecovery else { - return - } - - pendingRecoveryOperation = nil - requiredRecoveryOperation = nil - requiresHostRecovery = false - unresolvedFailure = nil - } } -/// Identifies the synchronization operation that may resolve a previous failure. -private enum RecoveryOperation: Equatable { - /// Fetches remote CloudKit changes. - case fetching - - /// Sends locally durable CloudKit changes. - case sending - - /// The public status reported while this operation is in progress. - var status: CloudSaveStatus { - switch self { - case .fetching: - .fetching - case .sending: - .sending - } - } -} +// MARK: - Private Zone Filtering -extension CloudSavePendingChange { - fileprivate var syncEngineChange: CKSyncEngine.PendingRecordZoneChange { - switch self { - case .save(let recordID): - .saveRecord(recordID) - case .delete(let recordID): - .deleteRecord(recordID) - } +extension CloudSaveEngine { + /// Filters CloudKit fetches to the custom zone owned by this engine. + fileprivate func isInConfiguredZone(_ record: CKRecord) -> Bool { + isInConfiguredZone(record.recordID) } -} -extension CKSyncEngine.Event.AccountChange { - fileprivate var cloudSaveAccountChange: CloudSaveAccountChange { - switch changeType { - case .signIn(let currentUser): - .signedIn( - currentAccountID: currentUser.recordName - ) - case .signOut(let previousUser): - .signedOut( - previousAccountID: previousUser.recordName - ) - case .switchAccounts(let previousUser, let currentUser): - .switched( - previousAccountID: previousUser.recordName, - currentAccountID: currentUser.recordName - ) - @unknown default: - .signedOut(previousAccountID: "unknown") - } + /// Filters CloudKit changes to the custom zone owned by this engine. + fileprivate func isInConfiguredZone(_ recordID: CKRecord.ID) -> Bool { + recordID.zoneID == configuration.zone.zoneID } -} -extension CKError { - /// Whether CKSyncEngine can retry this CloudKit error without application attention. - fileprivate var isTransientCloudSaveError: Bool { - switch code { - case .accountTemporarilyUnavailable, .networkFailure, .networkUnavailable, - .notAuthenticated, .operationCancelled, .requestRateLimited, - .serviceUnavailable, .zoneBusy: - true - default: - false - } + /// Filters custom-zone events to the zone owned by this engine. + fileprivate func isInConfiguredZone(_ zoneID: CKRecordZone.ID) -> Bool { + zoneID == configuration.zone.zoneID } } diff --git a/Sources/CloudSaveKit/CloudSaveEngineError.swift b/Sources/CloudSaveKit/CloudSaveEngineError.swift new file mode 100644 index 0000000..499c8af --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveEngineError.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Describes a lifecycle error raised before CloudKit synchronization can begin. +public enum CloudSaveEngineError: Error, Equatable, Sendable { + /// The engine has not been started successfully. + case notStarted + + /// A host persistence callback failed and the host must recover before restarting the engine. + case hostRecoveryRequired +} diff --git a/Sources/CloudSaveKit/CloudSaveFailureContext.swift b/Sources/CloudSaveKit/CloudSaveFailureContext.swift new file mode 100644 index 0000000..b2b87ca --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveFailureContext.swift @@ -0,0 +1,27 @@ +import CloudKit + +/// Identifies the independent work item that must recover from a failure. +enum CloudSaveFailureContext: Equatable, Sendable { + /// A host persistence callback must recover before the engine can restart. + case hostPersistence + + /// A fetch or send operation must complete in a later generation. + case operation(CloudSaveOperation) + + /// A specific record change must succeed or be explicitly discarded. + case record(CKRecord.ID) + + /// A specific record-zone change must succeed. + case zone(CKRecordZone.ID) +} + +extension CloudSaveFailureContext { + /// The record identifier supplied to the host failure callback, when relevant. + var recordID: CKRecord.ID? { + guard case .record(let recordID) = self else { + return nil + } + + return recordID + } +} diff --git a/Sources/CloudSaveKit/CloudSaveFailureRequirement.swift b/Sources/CloudSaveKit/CloudSaveFailureRequirement.swift new file mode 100644 index 0000000..ed5aef4 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveFailureRequirement.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Retains one attention-required failure until its exact recovery condition is satisfied. +struct CloudSaveFailureRequirement: Equatable, Sendable { + /// The work item that failed. + let context: CloudSaveFailureContext + + /// The privacy-safe failure reported to the host. + let failure: CloudSaveFailure + + /// The first operation generation allowed to clear an operation failure. + let minimumRecoveryGeneration: Int? +} diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 05aabaa..148b4a2 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -8,15 +8,18 @@ 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. -Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetchNow()``, ``CloudSaveEngine/sendNow()``, or ``CloudSaveEngine/syncNow()`` only at user-visible checkpoints where immediate work is useful. +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 synchronization requires a successful ``CloudSaveEngine/start()`` and raises ``CloudSaveEngineError`` when the engine has not started or host recovery is required. -CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. An attention-required failure remains observable until the matching later fetch or send cycle completes successfully. +CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. + +CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. ## Topics ### Engine - ``CloudSaveEngine`` +- ``CloudSaveEngineError`` - ``CloudSaveConfiguration`` - ``CloudSaveStatus`` diff --git a/Sources/CloudSaveKit/CloudSaveOperation.swift b/Sources/CloudSaveKit/CloudSaveOperation.swift new file mode 100644 index 0000000..838a32a --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveOperation.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Identifies a synchronization operation tracked by the state machine. +enum CloudSaveOperation: Hashable, Sendable { + /// Fetches remote CloudKit changes. + case fetching + + /// Sends locally durable CloudKit changes. + case sending + + /// The public status reported while this operation is in progress. + var status: CloudSaveStatus { + switch self { + case .fetching: + .fetching + case .sending: + .sending + } + } +} diff --git a/Sources/CloudSaveKit/CloudSavePendingChange.swift b/Sources/CloudSaveKit/CloudSavePendingChange.swift index 0a34d0b..de13f41 100644 --- a/Sources/CloudSaveKit/CloudSavePendingChange.swift +++ b/Sources/CloudSaveKit/CloudSavePendingChange.swift @@ -8,3 +8,23 @@ public enum CloudSavePendingChange: Hashable, Sendable { /// Deletes the record with the specified identifier. case delete(CKRecord.ID) } + +extension CloudSavePendingChange { + /// The record identifier represented by this durable host change. + var recordID: CKRecord.ID { + switch self { + case .save(let recordID), .delete(let recordID): + recordID + } + } + + /// The CKSyncEngine change represented by this durable host change. + var syncEngineChange: CKSyncEngine.PendingRecordZoneChange { + switch self { + case .save(let recordID): + .saveRecord(recordID) + case .delete(let recordID): + .deleteRecord(recordID) + } + } +} diff --git a/Sources/CloudSaveKit/CloudSaveRetryPolicy.swift b/Sources/CloudSaveKit/CloudSaveRetryPolicy.swift new file mode 100644 index 0000000..44a19ac --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveRetryPolicy.swift @@ -0,0 +1,16 @@ +import CloudKit + +/// Centralizes which CloudKit errors remain under CKSyncEngine's retry ownership. +enum CloudSaveRetryPolicy { + /// Returns whether a failure requires application attention. + static func requiresApplicationAttention(for error: CKError) -> Bool { + switch error.code { + case .accountTemporarilyUnavailable, .networkFailure, .networkUnavailable, + .notAuthenticated, .operationCancelled, .requestRateLimited, + .serviceUnavailable, .zoneBusy: + false + default: + true + } + } +} diff --git a/Sources/CloudSaveKit/CloudSaveStateMachine.swift b/Sources/CloudSaveKit/CloudSaveStateMachine.swift new file mode 100644 index 0000000..5afe255 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveStateMachine.swift @@ -0,0 +1,147 @@ +import CloudKit +import Foundation + +/// Tracks active operations and independent attention-required recovery conditions. +struct CloudSaveStateMachine: Sendable { + private var activeOperationCounts: [CloudSaveOperation: Int] = [:] + private var failures: [CloudSaveFailureRequirement] = [] + private var operationGenerations: [CloudSaveOperation: Int] = [:] + + /// Whether a host callback failure currently blocks all synchronization work. + var requiresHostRecovery: Bool { + containsFailure(for: .hostPersistence) + } + + /// Whether a zone failure still requires a later successful zone change. + func requiresRecovery(for zoneID: CKRecordZone.ID) -> Bool { + containsFailure(for: .zone(zoneID)) + } + + /// Begins a synchronization operation and advances its recovery generation. + mutating func begin(_ operation: CloudSaveOperation) { + activeOperationCounts[operation, default: 0] += 1 + operationGenerations[operation, default: 0] += 1 + } + + /// Completes one synchronization operation and clears only an eligible operation failure. + mutating func complete(_ operation: CloudSaveOperation) { + let activeCount = activeOperationCounts[operation, default: 0] + if activeCount <= 1 { + activeOperationCounts[operation] = nil + } else { + activeOperationCounts[operation] = activeCount - 1 + } + + let generation = operationGenerations[operation, default: 0] + failures.removeAll { requirement in + guard requirement.context == .operation(operation) else { + return false + } + + guard let minimumRecoveryGeneration = requirement.minimumRecoveryGeneration else { + return false + } + + return generation >= minimumRecoveryGeneration + } + } + + /// Discards operation activity belonging to an engine that has been stopped. + mutating func resetActiveOperations() { + activeOperationCounts.removeAll() + } + + /// Records a failure for a host, record, or zone work item. + mutating func fail( + _ failure: CloudSaveFailure, + context: CloudSaveFailureContext + ) { + record( + CloudSaveFailureRequirement( + context: context, + failure: failure, + minimumRecoveryGeneration: nil + ) + ) + } + + /// Records an operation failure that only a later operation generation may clear. + mutating func fail( + _ failure: CloudSaveFailure, + operation: CloudSaveOperation + ) { + let recoveryGeneration = operationGenerations[operation, default: 0] + 1 + record( + CloudSaveFailureRequirement( + context: .operation(operation), + failure: failure, + minimumRecoveryGeneration: recoveryGeneration + ) + ) + } + + /// Resolves one exact failure context without affecting unrelated failures. + mutating func resolve(_ context: CloudSaveFailureContext) { + failures.removeAll { $0.context == context } + } + + /// Resolves successful or terminally acknowledged record changes. + mutating func resolve(recordIDs: [CKRecord.ID]) { + for recordID in recordIDs { + resolve(.record(recordID)) + } + } + + /// Clears record failures whose work is no longer present in the host's durable ledger. + mutating func reconcilePendingRecordIDs( + _ pendingRecordIDs: Set, + in zoneID: CKRecordZone.ID + ) { + failures.removeAll { requirement in + guard case .record(let recordID) = requirement.context else { + return false + } + + return recordID.zoneID == zoneID && !pendingRecordIDs.contains(recordID) + } + } + + /// Resolves successful record-zone changes. + mutating func resolve(zoneIDs: [CKRecordZone.ID]) { + for zoneID in zoneIDs { + resolve(.zone(zoneID)) + } + } + + /// Projects the most important current state into the public status model. + func status(hasPendingChanges: Bool) -> CloudSaveStatus { + if let failure = failures.last?.failure { + return .failed(failure) + } + + if activeOperationCounts[.sending, default: 0] > 0 { + return .sending + } + + if activeOperationCounts[.fetching, default: 0] > 0 { + return .fetching + } + + return .ready(hasPendingChanges: hasPendingChanges) + } +} + +// MARK: - Private + +extension CloudSaveStateMachine { + /// Returns whether the exact failure context remains unresolved. + private func containsFailure(for context: CloudSaveFailureContext) -> Bool { + failures.contains { $0.context == context } + } + + /// Inserts or replaces one context while preserving every independent requirement. + private mutating func record(_ requirement: CloudSaveFailureRequirement) { + failures.removeAll { $0.context == requirement.context } + failures.append(requirement) + } +} diff --git a/Tests/CloudSaveKitTests/CloudSaveRetryPolicyTests.swift b/Tests/CloudSaveKitTests/CloudSaveRetryPolicyTests.swift new file mode 100644 index 0000000..d6ed4e2 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSaveRetryPolicyTests.swift @@ -0,0 +1,47 @@ +import CloudKit +import Testing + +@testable import CloudSaveKit + +@Suite("Cloud save retry policy") +struct CloudSaveRetryPolicyTests { + @Test( + "Leaves transport and scheduling failures to CKSyncEngine", + arguments: [ + CKError.Code.accountTemporarilyUnavailable, + .networkFailure, + .networkUnavailable, + .notAuthenticated, + .operationCancelled, + .requestRateLimited, + .serviceUnavailable, + .zoneBusy, + ] + ) + func leavesRetryableFailureToCKSyncEngine(code: CKError.Code) { + #expect( + !CloudSaveRetryPolicy.requiresApplicationAttention( + for: CKError(code) + ) + ) + } + + @Test( + "Requires application attention for semantic and permanent failures", + arguments: [ + CKError.Code.badContainer, + .permissionFailure, + .quotaExceeded, + .serverRecordChanged, + .unknownItem, + .zoneNotFound, + ] + ) + func requiresApplicationAttention(code: CKError.Code) { + #expect( + CloudSaveRetryPolicy.requiresApplicationAttention( + for: CKError(code) + ) + ) + } +} diff --git a/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift b/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift new file mode 100644 index 0000000..280d4c9 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift @@ -0,0 +1,236 @@ +import CloudKit +import Testing + +@testable import CloudSaveKit + +@Suite("Cloud save state machine") +struct CloudSaveStateMachineTests { + @Test("Preserves independent operation failures until each operation recovers") + func preservesIndependentOperationFailures() { + var stateMachine = CloudSaveStateMachine() + + stateMachine.fail( + .quotaExceeded, + operation: .sending + ) + stateMachine.fail( + .configuration, + operation: .fetching + ) + #expect( + stateMachine.status(hasPendingChanges: true) == .failed(.configuration) + ) + + stateMachine.begin(.fetching) + stateMachine.complete(.fetching) + #expect( + stateMachine.status(hasPendingChanges: true) == .failed(.quotaExceeded) + ) + + stateMachine.begin(.sending) + stateMachine.complete(.sending) + #expect( + stateMachine.status(hasPendingChanges: true) == .ready(hasPendingChanges: true) + ) + } + + @Test("Does not let a failed operation clear itself with its own completion event") + func requiresLaterOperationGeneration() { + var stateMachine = CloudSaveStateMachine() + + stateMachine.begin(.sending) + stateMachine.fail( + .restricted, + operation: .sending + ) + stateMachine.complete(.sending) + #expect( + stateMachine.status(hasPendingChanges: false) == .failed(.restricted) + ) + + stateMachine.begin(.sending) + stateMachine.complete(.sending) + #expect( + stateMachine.status(hasPendingChanges: false) == .ready(hasPendingChanges: false) + ) + } + + @Test("Resolves record failures independently") + func resolvesRecordFailuresIndependently() { + let firstRecordID = Self.makeRecordID(named: "first") + let secondRecordID = Self.makeRecordID(named: "second") + var stateMachine = CloudSaveStateMachine() + + stateMachine.fail( + .quotaExceeded, + context: .record(firstRecordID) + ) + stateMachine.fail( + .recordConflict, + context: .record(secondRecordID) + ) + stateMachine.resolve(.record(secondRecordID)) + #expect( + stateMachine.status(hasPendingChanges: true) == .failed(.quotaExceeded) + ) + + stateMachine.resolve(.record(firstRecordID)) + #expect( + stateMachine.status(hasPendingChanges: true) == .ready(hasPendingChanges: true) + ) + } + + @Test("Keeps earlier failures after host recovery") + func keepsEarlierFailureAfterHostRecovery() { + let recordID = Self.makeRecordID(named: "save") + var stateMachine = CloudSaveStateMachine() + + stateMachine.fail( + .recordConflict, + context: .record(recordID) + ) + stateMachine.fail( + .localPersistence, + context: .hostPersistence + ) + #expect(stateMachine.requiresHostRecovery) + #expect( + stateMachine.status(hasPendingChanges: true) == .failed(.localPersistence) + ) + + stateMachine.resolve(.hostPersistence) + #expect(!stateMachine.requiresHostRecovery) + #expect( + stateMachine.status(hasPendingChanges: true) == .failed(.recordConflict) + ) + } + + @Test("Drops operation activity when a failed engine stops") + func resetsStoppedEngineActivity() { + var stateMachine = CloudSaveStateMachine() + + stateMachine.begin(.fetching) + stateMachine.begin(.sending) + stateMachine.fail( + .localPersistence, + context: .hostPersistence + ) + stateMachine.resetActiveOperations() + stateMachine.resolve(.hostPersistence) + + #expect( + stateMachine.status(hasPendingChanges: false) == .ready(hasPendingChanges: false) + ) + } + + @Test("Reconciles record failures against the host durable ledger") + func reconcilesRecordFailures() { + let retainedRecordID = Self.makeRecordID(named: "retained") + let discardedRecordID = Self.makeRecordID(named: "discarded") + var stateMachine = CloudSaveStateMachine() + + stateMachine.fail( + .quotaExceeded, + context: .record(retainedRecordID) + ) + stateMachine.fail( + .recordConflict, + context: .record(discardedRecordID) + ) + stateMachine.reconcilePendingRecordIDs( + [retainedRecordID], + in: Self.zoneID + ) + + #expect( + stateMachine.status(hasPendingChanges: true) == .failed(.quotaExceeded) + ) + stateMachine.resolve(.record(retainedRecordID)) + #expect( + stateMachine.status(hasPendingChanges: false) == .ready(hasPendingChanges: false) + ) + } + + @Test("Preserves operation progress until every overlapping operation completes") + func preservesOverlappingOperationProgress() { + var stateMachine = CloudSaveStateMachine() + + stateMachine.begin(.fetching) + stateMachine.begin(.fetching) + stateMachine.begin(.sending) + #expect(stateMachine.status(hasPendingChanges: false) == .sending) + + stateMachine.complete(.sending) + #expect(stateMachine.status(hasPendingChanges: false) == .fetching) + + stateMachine.complete(.fetching) + #expect(stateMachine.status(hasPendingChanges: false) == .fetching) + + stateMachine.complete(.fetching) + #expect( + stateMachine.status(hasPendingChanges: false) == .ready(hasPendingChanges: false) + ) + } + + @Test("Resolves a zone failure only when that zone succeeds") + func resolvesZoneFailureIndependently() { + let unrelatedZoneID = CKRecordZone.ID( + zoneName: "Unrelated", + ownerName: CKCurrentUserDefaultName + ) + var stateMachine = CloudSaveStateMachine() + + stateMachine.fail( + .zoneUnavailable, + context: .zone(Self.zoneID) + ) + stateMachine.resolve(zoneIDs: [unrelatedZoneID]) + #expect(stateMachine.requiresRecovery(for: Self.zoneID)) + + stateMachine.resolve(zoneIDs: [Self.zoneID]) + #expect(!stateMachine.requiresRecovery(for: Self.zoneID)) + } + + @Test("Replaces a repeated failure for the same work item") + func replacesRepeatedFailureForSameContext() { + let recordID = Self.makeRecordID(named: "save") + var stateMachine = CloudSaveStateMachine() + + stateMachine.fail( + .quotaExceeded, + context: .record(recordID) + ) + stateMachine.fail( + .restricted, + context: .record(recordID) + ) + #expect( + stateMachine.status(hasPendingChanges: true) == .failed(.restricted) + ) + + stateMachine.resolve(.record(recordID)) + #expect( + stateMachine.status(hasPendingChanges: false) == .ready(hasPendingChanges: false) + ) + } +} + +// MARK: - Private + +extension CloudSaveStateMachineTests { + /// The custom zone used by state-machine record identifiers. + private static var zoneID: CKRecordZone.ID { + CKRecordZone.ID( + zoneName: "CloudSaveKitTests", + ownerName: CKCurrentUserDefaultName + ) + } + + /// Creates a deterministic record identifier in the test zone. + private static func makeRecordID(named name: String) -> CKRecord.ID { + CKRecord.ID( + recordName: name, + zoneID: zoneID + ) + } +} From f2445117583f19d16a524760ef6d298dda7660bc Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 19:30:56 +0200 Subject: [PATCH 08/18] Protect concurrent ledger reconciliation --- README.md | 4 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 74 ++++++++---- .../CloudSaveKit.docc/CloudSaveKit.md | 4 +- .../CloudSaveLedgerSnapshotTracker.swift | 105 +++++++++++++++++ .../CloudSavePendingChangesSnapshot.swift | 10 ++ .../CloudSaveKit/CloudSaveStatusChannel.swift | 17 +++ .../CloudSaveLedgerSnapshotTrackerTests.swift | 110 ++++++++++++++++++ .../CloudSaveStatusChannelTests.swift | 21 ++++ 8 files changed, 320 insertions(+), 25 deletions(-) create mode 100644 Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift create mode 100644 Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift create mode 100644 Sources/CloudSaveKit/CloudSaveStatusChannel.swift create mode 100644 Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift create mode 100644 Tests/CloudSaveKitTests/CloudSaveStatusChannelTests.swift diff --git a/README.md b/README.md index 0c0085e..1d8386c 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,9 @@ Automatic synchronization should remain enabled in production. Explicit operatio Call `start()` successfully before any explicit synchronization. `fetchNow()` and `sendNow()` throw `CloudSaveEngineError.notStarted` before startup and `CloudSaveEngineError.hostRecoveryRequired` after a host persistence callback fails. Once the local store is healthy again, call `start()` to rebuild from the last successfully persisted CKSyncEngine checkpoint and the host's current durable pending-change ledger. -`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. +`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits and enqueues newer changes while a ledger read is suspended, CloudSaveKit replays those enqueues in their original order after reconciling the returned snapshot. + +`statusUpdates` is a current-state projection, not an event history. It retains only the latest unconsumed status so an absent or slow observer cannot accumulate an unbounded buffer. ## Failure and retry policy diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index acfcc74..80080b3 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -3,13 +3,14 @@ import Foundation /// Synchronizes an application's durable local records with a private CloudKit database. public final actor CloudSaveEngine { - /// A stream of privacy-safe synchronization status updates. + /// A stream that retains the latest unconsumed privacy-safe synchronization status. public nonisolated let statusUpdates: AsyncStream private let client: any CloudSaveClient private let configuration: CloudSaveConfiguration private let statusContinuation: AsyncStream.Continuation private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization? + private var ledgerSnapshotTracker = CloudSaveLedgerSnapshotTracker() private var lifecycleGeneration = 0 private var stateMachine = CloudSaveStateMachine() private var storedSyncEngine: CKSyncEngine? @@ -19,9 +20,9 @@ public final actor CloudSaveEngine { configuration: CloudSaveConfiguration, client: any CloudSaveClient ) { - let stream = AsyncStream.makeStream(of: CloudSaveStatus.self) - statusUpdates = stream.stream - statusContinuation = stream.continuation + let statusChannel = CloudSaveStatusChannel() + statusUpdates = statusChannel.stream + statusContinuation = statusChannel.continuation self.client = client self.configuration = configuration lastPersistedStateSerialization = configuration.stateSerialization @@ -34,9 +35,9 @@ public final actor CloudSaveEngine { /// Initializes CKSyncEngine and restores every locally durable pending change. public func start() async throws { let startingLifecycleGeneration = lifecycleGeneration - let pendingChanges: [CloudSavePendingChange] + let ledgerSnapshot: CloudSavePendingChangesSnapshot do { - pendingChanges = try await client.pendingChanges() + ledgerSnapshot = try await readPendingChangesSnapshot() } catch { await handleHostFailure( error, @@ -60,15 +61,22 @@ public final actor CloudSaveEngine { } restoreDurablePendingChanges( - pendingChanges, + ledgerSnapshot, syncEngine: engine ) publishStatus(syncEngine: engine) - CloudSaveLogging.log("start | pending=\(pendingChanges.count)") + CloudSaveLogging.log( + "start | pending=\(ledgerSnapshot.durableChanges.count)" + ) } /// Adds locally durable changes to CKSyncEngine's pending state. public func enqueue(_ changes: [CloudSavePendingChange]) { + let configuredChanges = changes.filter { + isInConfiguredZone($0.recordID) + } + ledgerSnapshotTracker.record(configuredChanges) + guard !stateMachine.requiresHostRecovery else { CloudSaveLogging.log( level: .error, @@ -85,9 +93,6 @@ public final actor CloudSaveEngine { return } - let configuredChanges = changes.filter { - isInConfiguredZone($0.recordID) - } storedSyncEngine.state.add( pendingRecordZoneChanges: configuredChanges.map(\.syncEngineChange) ) @@ -128,10 +133,10 @@ public final actor CloudSaveEngine { /// Immediately sends every locally durable pending change for the configured save zone. public func sendNow() async throws { let session = try operationalSyncEngine() - let pendingChanges: [CloudSavePendingChange] + let ledgerSnapshot: CloudSavePendingChangesSnapshot do { - pendingChanges = try await client.pendingChanges() + ledgerSnapshot = try await readPendingChangesSnapshot() } catch { try throwRecoveryErrorIfNeeded(for: session) await handleHostFailure( @@ -143,7 +148,7 @@ public final actor CloudSaveEngine { try validate(session) restoreDurablePendingChanges( - pendingChanges, + ledgerSnapshot, syncEngine: session.syncEngine ) restoreFailedZoneChangeIfNeeded(syncEngine: session.syncEngine) @@ -380,6 +385,23 @@ extension CloudSaveEngine { // MARK: - Private Host Persistence extension CloudSaveEngine { + /// Reads the host ledger while retaining every enqueue that can race with its snapshot. + fileprivate func readPendingChangesSnapshot() async throws -> CloudSavePendingChangesSnapshot { + let snapshot = ledgerSnapshotTracker.beginSnapshot() + + do { + let durableChanges = try await client.pendingChanges() + let subsequentlyEnqueuedChanges = ledgerSnapshotTracker.completeSnapshot(snapshot) + return CloudSavePendingChangesSnapshot( + durableChanges: durableChanges, + subsequentlyEnqueuedChanges: subsequentlyEnqueuedChanges + ) + } catch { + ledgerSnapshotTracker.cancelSnapshot(snapshot) + throw error + } + } + /// Persists an opaque state update before accepting it as the next recovery checkpoint. fileprivate func persistStateUpdate( _ event: CKSyncEngine.Event.StateUpdate @@ -392,13 +414,16 @@ extension CloudSaveEngine { /// Reconciles CKSyncEngine's tracked changes with the host's authoritative durable ledger. fileprivate func restoreDurablePendingChanges( - _ pendingChanges: [CloudSavePendingChange], + _ snapshot: CloudSavePendingChangesSnapshot, syncEngine: CKSyncEngine ) { - let configuredPendingChanges = pendingChanges.filter { + let configuredDurableChanges = snapshot.durableChanges.filter { + isInConfiguredZone($0.recordID) + } + let configuredSubsequentChanges = snapshot.subsequentlyEnqueuedChanges.filter { isInConfiguredZone($0.recordID) } - let durableChanges = configuredPendingChanges.map(\.syncEngineChange) + let durableChanges = configuredDurableChanges.map(\.syncEngineChange) let durableChangeSet = Set(durableChanges) let staleChanges = syncEngine.state.pendingRecordZoneChanges.filter { guard let recordID = $0.recordID else { @@ -414,8 +439,13 @@ extension CloudSaveEngine { syncEngine.state.add( pendingRecordZoneChanges: durableChanges ) + syncEngine.state.add( + pendingRecordZoneChanges: configuredSubsequentChanges.map(\.syncEngineChange) + ) stateMachine.reconcilePendingRecordIDs( - Set(configuredPendingChanges.map(\.recordID)), + Set( + (configuredDurableChanges + configuredSubsequentChanges).map(\.recordID) + ), in: configuration.zone.zoneID ) } @@ -437,12 +467,12 @@ extension CloudSaveEngine { syncEngine: CKSyncEngine ) async throws { guard case .signedOut = accountChange else { - let pendingChanges = try await client.pendingChanges() + let ledgerSnapshot = try await readPendingChangesSnapshot() syncEngine.state.add( pendingDatabaseChanges: [.saveZone(configuration.zone)] ) restoreDurablePendingChanges( - pendingChanges, + ledgerSnapshot, syncEngine: syncEngine ) publishStatus(syncEngine: syncEngine) @@ -461,12 +491,12 @@ extension CloudSaveEngine { } try await client.applyDeletedZones(configuredZoneIDs) - let pendingChanges = try await client.pendingChanges() + let ledgerSnapshot = try await readPendingChangesSnapshot() syncEngine.state.add( pendingDatabaseChanges: [.saveZone(configuration.zone)] ) restoreDurablePendingChanges( - pendingChanges, + ledgerSnapshot, syncEngine: syncEngine ) } diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 148b4a2..7f1e03f 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -6,13 +6,13 @@ Coordinate an application-owned local store with a private CloudKit database. CloudSaveKit wraps Apple's `CKSyncEngine` lifecycle and delegate surface without choosing a local database or record schema. A host provides a ``CloudSaveClient`` that can materialize pending `CKRecord` values, apply fetched changes transactionally, preserve CKSyncEngine state, and resolve conflicts using application semantics. -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. +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 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 synchronization requires a successful ``CloudSaveEngine/start()`` and raises ``CloudSaveEngineError`` when the engine has not started or host recovery is required. CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. +CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer save or deletion. ## Topics diff --git a/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift b/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift new file mode 100644 index 0000000..3c3f90b --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Retains ordered enqueues that occur while the host produces a durable-ledger snapshot. +struct CloudSaveLedgerSnapshotTracker: Sendable { + private var activeSnapshots: [Snapshot] = [] + private var enqueuedChanges: [EnqueuedChange] = [] + private var latestGeneration = 0 + private var nextIdentifier = 0 + + /// Begins tracking changes that can race with one host-ledger read. + mutating func beginSnapshot() -> Snapshot { + nextIdentifier &+= 1 + let snapshot = Snapshot( + generation: latestGeneration, + identifier: nextIdentifier + ) + activeSnapshots.append(snapshot) + return snapshot + } + + /// Records changes in the exact order in which the engine receives them. + mutating func record(_ changes: [CloudSavePendingChange]) { + guard !activeSnapshots.isEmpty else { + return + } + + for change in changes { + latestGeneration &+= 1 + enqueuedChanges.append( + EnqueuedChange( + change: change, + generation: latestGeneration + ) + ) + } + } + + /// Completes a snapshot and returns changes enqueued after its ledger read began. + mutating func completeSnapshot(_ snapshot: Snapshot) -> [CloudSavePendingChange] { + guard remove(snapshot) else { + return [] + } + + let changes: [CloudSavePendingChange] = enqueuedChanges.compactMap { enqueuedChange in + guard enqueuedChange.generation > snapshot.generation else { + return nil + } + + return enqueuedChange.change + } + pruneChangesNoLongerNeeded() + return changes + } + + /// Cancels a snapshot without replaying its concurrently enqueued changes. + mutating func cancelSnapshot(_ snapshot: Snapshot) { + guard remove(snapshot) else { + return + } + + pruneChangesNoLongerNeeded() + } +} + +// MARK: - Snapshot + +extension CloudSaveLedgerSnapshotTracker { + /// Identifies the enqueue generation visible when one host-ledger read begins. + struct Snapshot: Equatable, Sendable { + fileprivate let generation: Int + fileprivate let identifier: Int + } +} + +// MARK: - Private + +extension CloudSaveLedgerSnapshotTracker { + /// Associates one ordered pending change with its enqueue generation. + private struct EnqueuedChange: Sendable { + let change: CloudSavePendingChange + let generation: Int + } + + /// Removes one active snapshot if it is still tracked. + private mutating func remove(_ snapshot: Snapshot) -> Bool { + guard let index = activeSnapshots.firstIndex(of: snapshot) else { + return false + } + + activeSnapshots.remove(at: index) + return true + } + + /// Discards changes that every remaining host-ledger snapshot already includes. + private mutating func pruneChangesNoLongerNeeded() { + guard let oldestGeneration = activeSnapshots.map(\.generation).min() else { + enqueuedChanges.removeAll(keepingCapacity: true) + return + } + + enqueuedChanges.removeAll { enqueuedChange in + enqueuedChange.generation <= oldestGeneration + } + } +} diff --git a/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift new file mode 100644 index 0000000..1924eab --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Combines one durable host-ledger snapshot with enqueues that raced with its asynchronous read. +struct CloudSavePendingChangesSnapshot: Sendable { + /// The authoritative pending changes returned by the host. + let durableChanges: [CloudSavePendingChange] + + /// Changes enqueued after the host-ledger read began, in their original order. + let subsequentlyEnqueuedChanges: [CloudSavePendingChange] +} diff --git a/Sources/CloudSaveKit/CloudSaveStatusChannel.swift b/Sources/CloudSaveKit/CloudSaveStatusChannel.swift new file mode 100644 index 0000000..4b9539a --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveStatusChannel.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Owns the bounded asynchronous channel for current synchronization status. +struct CloudSaveStatusChannel: Sendable { + let continuation: AsyncStream.Continuation + let stream: AsyncStream + + /// Creates a channel that retains only its latest unconsumed status. + init() { + let channel = AsyncStream.makeStream( + of: CloudSaveStatus.self, + bufferingPolicy: .bufferingNewest(1) + ) + continuation = channel.continuation + stream = channel.stream + } +} diff --git a/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift b/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift new file mode 100644 index 0000000..d94c4d0 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift @@ -0,0 +1,110 @@ +import CloudKit +import Testing + +@testable import CloudSaveKit + +@Suite("Cloud save ledger snapshot tracker") +struct CloudSaveLedgerSnapshotTrackerTests { + @Test("Replays changes enqueued after a ledger read begins in order") + func replaysConcurrentEnqueuesInOrder() { + let firstRecordID = Self.makeRecordID(named: "first") + let secondRecordID = Self.makeRecordID(named: "second") + let expectedChanges: [CloudSavePendingChange] = [ + .delete(firstRecordID), + .save(firstRecordID), + .save(secondRecordID), + ] + var tracker = CloudSaveLedgerSnapshotTracker() + + let snapshot = tracker.beginSnapshot() + tracker.record(expectedChanges) + + #expect(tracker.completeSnapshot(snapshot) == expectedChanges) + } + + @Test("Preserves the suffix required by every overlapping ledger read") + func preservesChangesForOverlappingSnapshots() { + let firstChange = CloudSavePendingChange.delete( + Self.makeRecordID(named: "first") + ) + let secondChange = CloudSavePendingChange.save( + Self.makeRecordID(named: "second") + ) + var tracker = CloudSaveLedgerSnapshotTracker() + + let earlierSnapshot = tracker.beginSnapshot() + tracker.record([firstChange]) + let laterSnapshot = tracker.beginSnapshot() + tracker.record([secondChange]) + + #expect(tracker.completeSnapshot(laterSnapshot) == [secondChange]) + #expect(tracker.completeSnapshot(earlierSnapshot) == [firstChange, secondChange]) + } + + @Test("Completing an older ledger read retains the suffix needed by a newer read") + func completesOverlappingSnapshotsInEitherOrder() { + let firstChange = CloudSavePendingChange.delete( + Self.makeRecordID(named: "first") + ) + let secondChange = CloudSavePendingChange.save( + Self.makeRecordID(named: "second") + ) + var tracker = CloudSaveLedgerSnapshotTracker() + + let earlierSnapshot = tracker.beginSnapshot() + tracker.record([firstChange]) + let laterSnapshot = tracker.beginSnapshot() + tracker.record([secondChange]) + + #expect(tracker.completeSnapshot(earlierSnapshot) == [firstChange, secondChange]) + #expect(tracker.completeSnapshot(laterSnapshot) == [secondChange]) + } + + @Test("Does not retain enqueues when no ledger read is suspended") + func ignoresEnqueuesOutsideSnapshot() { + let change = CloudSavePendingChange.save( + Self.makeRecordID(named: "save") + ) + var tracker = CloudSaveLedgerSnapshotTracker() + + tracker.record([change]) + let snapshot = tracker.beginSnapshot() + + #expect(tracker.completeSnapshot(snapshot).isEmpty) + } + + @Test("Cancelling one ledger read keeps changes needed by an older read") + func cancelsSnapshotsIndependently() { + let change = CloudSavePendingChange.save( + Self.makeRecordID(named: "save") + ) + var tracker = CloudSaveLedgerSnapshotTracker() + + let earlierSnapshot = tracker.beginSnapshot() + let cancelledSnapshot = tracker.beginSnapshot() + tracker.record([change]) + tracker.cancelSnapshot(cancelledSnapshot) + + #expect(tracker.completeSnapshot(earlierSnapshot) == [change]) + } +} + +// MARK: - Private + +extension CloudSaveLedgerSnapshotTrackerTests { + /// The custom zone used by ledger-snapshot record identifiers. + private static var zoneID: CKRecordZone.ID { + CKRecordZone.ID( + zoneName: "CloudSaveKitTests", + ownerName: CKCurrentUserDefaultName + ) + } + + /// Creates a deterministic record identifier in the test zone. + private static func makeRecordID(named name: String) -> CKRecord.ID { + CKRecord.ID( + recordName: name, + zoneID: zoneID + ) + } +} diff --git a/Tests/CloudSaveKitTests/CloudSaveStatusChannelTests.swift b/Tests/CloudSaveKitTests/CloudSaveStatusChannelTests.swift new file mode 100644 index 0000000..d183eb4 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSaveStatusChannelTests.swift @@ -0,0 +1,21 @@ +import Testing + +@testable import CloudSaveKit + +@Suite("Cloud save status channel") +struct CloudSaveStatusChannelTests { + @Test("Retains only the latest unconsumed status") + func retainsLatestStatus() async { + let channel = CloudSaveStatusChannel() + channel.continuation.yield(.fetching) + channel.continuation.yield(.sending) + channel.continuation.finish() + var iterator = channel.stream.makeAsyncIterator() + + let status = await iterator.next() + let completion = await iterator.next() + + #expect(status == .sending) + #expect(completion == nil) + } +} From 38b6c9d103485b133ea4ac75882047e87bb75d05 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 19:45:48 +0200 Subject: [PATCH 09/18] Publish the initial idle status --- README.md | 2 +- .../CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md | 2 +- Sources/CloudSaveKit/CloudSaveStatusChannel.swift | 1 + .../CloudSaveStatusChannelTests.swift | 11 +++++++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1d8386c..6118cce 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Call `start()` successfully before any explicit synchronization. `fetchNow()` an `sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits and enqueues newer changes while a ledger read is suspended, CloudSaveKit replays those enqueues in their original order after reconciling the returned snapshot. -`statusUpdates` is a current-state projection, not an event history. It retains only the latest unconsumed status so an absent or slow observer cannot accumulate an unbounded buffer. +`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. ## Failure and retry policy diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 7f1e03f..5f8e811 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -6,7 +6,7 @@ Coordinate an application-owned local store with a private CloudKit database. CloudSaveKit wraps Apple's `CKSyncEngine` lifecycle and delegate surface without choosing a local database or record schema. A host provides a ``CloudSaveClient`` that can materialize pending `CKRecord` values, apply fetched changes transactionally, preserve CKSyncEngine state, and resolve conflicts using application semantics. -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 retains only its latest unconsumed value rather than preserving an event history. +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 synchronization requires a successful ``CloudSaveEngine/start()`` and raises ``CloudSaveEngineError`` when the engine has not started or host recovery is required. diff --git a/Sources/CloudSaveKit/CloudSaveStatusChannel.swift b/Sources/CloudSaveKit/CloudSaveStatusChannel.swift index 4b9539a..f53c76d 100644 --- a/Sources/CloudSaveKit/CloudSaveStatusChannel.swift +++ b/Sources/CloudSaveKit/CloudSaveStatusChannel.swift @@ -11,6 +11,7 @@ struct CloudSaveStatusChannel: Sendable { of: CloudSaveStatus.self, bufferingPolicy: .bufferingNewest(1) ) + channel.continuation.yield(.idle) continuation = channel.continuation stream = channel.stream } diff --git a/Tests/CloudSaveKitTests/CloudSaveStatusChannelTests.swift b/Tests/CloudSaveKitTests/CloudSaveStatusChannelTests.swift index d183eb4..82e4f73 100644 --- a/Tests/CloudSaveKitTests/CloudSaveStatusChannelTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveStatusChannelTests.swift @@ -4,6 +4,17 @@ import Testing @Suite("Cloud save status channel") struct CloudSaveStatusChannelTests { + @Test("Starts with the idle status") + func startsIdle() async { + let channel = CloudSaveStatusChannel() + channel.continuation.finish() + var iterator = channel.stream.makeAsyncIterator() + + let status = await iterator.next() + + #expect(status == .idle) + } + @Test("Retains only the latest unconsumed status") func retainsLatestStatus() async { let channel = CloudSaveStatusChannel() From aed85757dbc49b9d0e098cc215faac98cc730b13 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 20:10:22 +0200 Subject: [PATCH 10/18] Order checkpoint and ledger acknowledgements --- README.md | 4 +- Sources/CloudSaveKit/CloudSaveAsyncLock.swift | 49 ++++ Sources/CloudSaveKit/CloudSaveEngine.swift | 249 +++++++++++++----- .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- .../CloudSaveLedgerMutation.swift | 10 + .../CloudSaveLedgerSnapshotTracker.swift | 77 +++--- .../CloudSavePendingChangesSnapshot.swift | 6 +- .../CloudSaveAsyncLockTests.swift | 66 +++++ .../CloudSaveLedgerSnapshotTrackerTests.swift | 62 ++++- 9 files changed, 409 insertions(+), 116 deletions(-) create mode 100644 Sources/CloudSaveKit/CloudSaveAsyncLock.swift create mode 100644 Sources/CloudSaveKit/CloudSaveLedgerMutation.swift create mode 100644 Tests/CloudSaveKitTests/CloudSaveAsyncLockTests.swift diff --git a/README.md b/README.md index 6118cce..18d7683 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,9 @@ Automatic synchronization should remain enabled in production. Explicit operatio Call `start()` successfully before any explicit synchronization. `fetchNow()` and `sendNow()` throw `CloudSaveEngineError.notStarted` before startup and `CloudSaveEngineError.hostRecoveryRequired` after a host persistence callback fails. Once the local store is healthy again, call `start()` to rebuild from the last successfully persisted CKSyncEngine checkpoint and the host's current durable pending-change ledger. -`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits and enqueues newer changes while a ledger read is suspended, CloudSaveKit replays those enqueues in their original order after reconciling the returned snapshot. +`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits enqueues or acknowledgements while a ledger read is suspended, CloudSaveKit replays those mutations in their original order after reconciling the returned snapshot. + +Opaque CKSyncEngine checkpoint writes are serialized with host-failure lifecycle invalidation. A replacement engine therefore cannot start from a checkpoint that an older engine later regresses through actor reentrancy. `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/CloudSaveAsyncLock.swift b/Sources/CloudSaveKit/CloudSaveAsyncLock.swift new file mode 100644 index 0000000..dce0a31 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveAsyncLock.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Serializes asynchronous operations without blocking their executor. +actor CloudSaveAsyncLock { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + /// Runs one operation after every previously submitted operation completes. + func withLock( + _ operation: @Sendable () async throws -> Result + ) async rethrows -> Result { + await acquire() + + do { + let result = try await operation() + release() + return result + } catch { + release() + throw error + } + } +} + +// MARK: - Private + +extension CloudSaveAsyncLock { + /// Acquires the lock immediately or suspends behind earlier callers. + private func acquire() async { + guard isLocked else { + isLocked = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + /// Transfers ownership to the oldest waiter or makes the lock available. + private func release() { + guard !waiters.isEmpty else { + isLocked = false + return + } + + waiters.removeFirst().resume() + } +} diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 80080b3..2a0cad6 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -8,6 +8,8 @@ public final actor CloudSaveEngine { private let client: any CloudSaveClient private let configuration: CloudSaveConfiguration + private let eventHandlingLock = CloudSaveAsyncLock() + private let statePersistenceLock = CloudSaveAsyncLock() private let statusContinuation: AsyncStream.Continuation private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization? private var ledgerSnapshotTracker = CloudSaveLedgerSnapshotTracker() @@ -75,7 +77,7 @@ public final actor CloudSaveEngine { let configuredChanges = changes.filter { isInConfiguredZone($0.recordID) } - ledgerSnapshotTracker.record(configuredChanges) + ledgerSnapshotTracker.recordEnqueues(configuredChanges) guard !stateMachine.requiresHostRecovery else { CloudSaveLogging.log( @@ -207,6 +209,55 @@ extension CloudSaveEngine: CKSyncEngineDelegate { public func handleEvent( _ event: CKSyncEngine.Event, syncEngine: CKSyncEngine + ) async { + await eventHandlingLock.withLock { [self] in + await handleEventInOrder( + event, + syncEngine: syncEngine + ) + } + } + + public func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, + syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + guard storedSyncEngine === syncEngine, + !stateMachine.requiresHostRecovery + else { + return nil + } + + let pendingChanges = syncEngine.state.pendingRecordZoneChanges.filter { + guard let recordID = $0.recordID else { + return false + } + + return context.options.scope.contains($0) && isInConfiguredZone(recordID) + } + + return await CKSyncEngine.RecordZoneChangeBatch( + pendingChanges: pendingChanges + ) { [client, weak self] recordID in + let record = await client.record(for: recordID) + if record == nil { + await self?.removePendingChanges( + [.save(recordID)], + syncEngine: syncEngine + ) + } + return record + } + } +} + +// MARK: - Private Ordered Events + +extension CloudSaveEngine { + /// Processes one CKSyncEngine event without allowing later events to overtake its host writes. + fileprivate func handleEventInOrder( + _ event: CKSyncEngine.Event, + syncEngine: CKSyncEngine ) async { guard storedSyncEngine === syncEngine else { CloudSaveLogging.log("event | ignored stale engine") @@ -216,7 +267,10 @@ extension CloudSaveEngine: CKSyncEngineDelegate { do { switch event { case .stateUpdate(let event): - try await persistStateUpdate(event) + try await persistStateUpdate( + event, + syncEngine: syncEngine + ) case .accountChange(let event): try await handleAccountChange( event, @@ -269,37 +323,6 @@ extension CloudSaveEngine: CKSyncEngineDelegate { ) } } - - public func nextRecordZoneChangeBatch( - _ context: CKSyncEngine.SendChangesContext, - syncEngine: CKSyncEngine - ) async -> CKSyncEngine.RecordZoneChangeBatch? { - guard storedSyncEngine === syncEngine, - !stateMachine.requiresHostRecovery - else { - return nil - } - - let pendingChanges = syncEngine.state.pendingRecordZoneChanges.filter { - guard let recordID = $0.recordID else { - return false - } - - return context.options.scope.contains($0) && isInConfiguredZone(recordID) - } - - return await CKSyncEngine.RecordZoneChangeBatch( - pendingChanges: pendingChanges - ) { [client] recordID in - let record = await client.record(for: recordID) - if record == nil { - syncEngine.state.remove( - pendingRecordZoneChanges: [.saveRecord(recordID)] - ) - } - return record - } - } } // MARK: - Private Lifecycle @@ -369,32 +392,50 @@ extension CloudSaveEngine { throw CloudSaveEngineError.hostRecoveryRequired } - /// Stops all work after a host persistence failure and preserves the last good checkpoint. - fileprivate func stopAfterHostFailure(syncEngine: CKSyncEngine?) async { + /// Invalidates one active engine only after every earlier checkpoint write completes. + fileprivate func invalidateAfterHostFailure( + _ failure: CloudSaveFailure, + syncEngine: CKSyncEngine? + ) -> ( + shouldHandle: Bool, + engineToCancel: CKSyncEngine? + ) { guard syncEngine == nil || storedSyncEngine === syncEngine else { - return + return ( + shouldHandle: false, + engineToCancel: nil + ) } let engineToCancel = syncEngine ?? storedSyncEngine + lifecycleGeneration &+= 1 + stateMachine.fail( + failure, + context: .hostPersistence + ) storedSyncEngine = nil stateMachine.resetActiveOperations() - await engineToCancel?.cancelOperations() + publishStatus(syncEngine: engineToCancel) + return ( + shouldHandle: true, + engineToCancel: engineToCancel + ) } } // MARK: - Private Host Persistence extension CloudSaveEngine { - /// Reads the host ledger while retaining every enqueue that can race with its snapshot. + /// Reads the host ledger while retaining every mutation that can race with its snapshot. fileprivate func readPendingChangesSnapshot() async throws -> CloudSavePendingChangesSnapshot { let snapshot = ledgerSnapshotTracker.beginSnapshot() do { let durableChanges = try await client.pendingChanges() - let subsequentlyEnqueuedChanges = ledgerSnapshotTracker.completeSnapshot(snapshot) + let subsequentMutations = ledgerSnapshotTracker.completeSnapshot(snapshot) return CloudSavePendingChangesSnapshot( durableChanges: durableChanges, - subsequentlyEnqueuedChanges: subsequentlyEnqueuedChanges + subsequentMutations: subsequentMutations ) } catch { ledgerSnapshotTracker.cancelSnapshot(snapshot) @@ -404,11 +445,34 @@ extension CloudSaveEngine { /// Persists an opaque state update before accepting it as the next recovery checkpoint. fileprivate func persistStateUpdate( - _ event: CKSyncEngine.Event.StateUpdate + _ event: CKSyncEngine.Event.StateUpdate, + syncEngine: CKSyncEngine ) async throws { + try await statePersistenceLock.withLock { [self] in + try await persistStateUpdateInOrder( + event, + syncEngine: syncEngine + ) + } + } + + /// Writes a checkpoint only while its originating engine remains active. + fileprivate func persistStateUpdateInOrder( + _ event: CKSyncEngine.Event.StateUpdate, + syncEngine: CKSyncEngine + ) async throws { + guard storedSyncEngine === syncEngine else { + return + } + try await client.persist( stateSerialization: event.stateSerialization ) + + guard storedSyncEngine === syncEngine else { + return + } + lastPersistedStateSerialization = event.stateSerialization } @@ -420,9 +484,9 @@ extension CloudSaveEngine { let configuredDurableChanges = snapshot.durableChanges.filter { isInConfiguredZone($0.recordID) } - let configuredSubsequentChanges = snapshot.subsequentlyEnqueuedChanges.filter { - isInConfiguredZone($0.recordID) - } + let configuredSubsequentMutations = snapshot.subsequentMutations.filter( + isInConfiguredZone + ) let durableChanges = configuredDurableChanges.map(\.syncEngineChange) let durableChangeSet = Set(durableChanges) let staleChanges = syncEngine.state.pendingRecordZoneChanges.filter { @@ -439,17 +503,53 @@ extension CloudSaveEngine { syncEngine.state.add( pendingRecordZoneChanges: durableChanges ) - syncEngine.state.add( - pendingRecordZoneChanges: configuredSubsequentChanges.map(\.syncEngineChange) - ) + var effectiveChanges: [CKRecord.ID: CloudSavePendingChange] = [:] + for change in configuredDurableChanges { + effectiveChanges[change.recordID] = change + } + for mutation in configuredSubsequentMutations { + switch mutation { + case .enqueue(let change): + syncEngine.state.add( + pendingRecordZoneChanges: [change.syncEngineChange] + ) + effectiveChanges[change.recordID] = change + case .remove(let change): + syncEngine.state.remove( + pendingRecordZoneChanges: [change.syncEngineChange] + ) + if effectiveChanges[change.recordID] == change { + effectiveChanges[change.recordID] = nil + } + } + } stateMachine.reconcilePendingRecordIDs( - Set( - (configuredDurableChanges + configuredSubsequentChanges).map(\.recordID) - ), + Set(effectiveChanges.keys), in: configuration.zone.zoneID ) } + /// Removes acknowledged host-ledger entries from snapshots and the active engine. + fileprivate func removePendingChanges( + _ changes: [CloudSavePendingChange], + syncEngine: CKSyncEngine + ) { + let configuredChanges = changes.filter { + isInConfiguredZone($0.recordID) + } + ledgerSnapshotTracker.recordRemovals(configuredChanges) + + guard storedSyncEngine === syncEngine else { + return + } + + syncEngine.state.remove( + pendingRecordZoneChanges: configuredChanges.map(\.syncEngineChange) + ) + stateMachine.resolve(recordIDs: configuredChanges.map(\.recordID)) + publishStatus(syncEngine: syncEngine) + } + /// Restores a failed configured-zone save only for a host-requested explicit send. fileprivate func restoreFailedZoneChangeIfNeeded(syncEngine: CKSyncEngine) { guard stateMachine.requiresRecovery(for: configuration.zone.zoneID) else { @@ -563,9 +663,14 @@ extension CloudSaveEngine { let deletedRecordIDs = event.deletedRecordIDs.filter(isInConfiguredZone) try await client.didSave(records: savedRecords) + removePendingChanges( + savedRecords.map { .save($0.recordID) }, + syncEngine: syncEngine + ) try await client.didDelete(recordIDs: deletedRecordIDs) - stateMachine.resolve( - recordIDs: savedRecords.map(\.recordID) + deletedRecordIDs + removePendingChanges( + deletedRecordIDs.map(CloudSavePendingChange.delete), + syncEngine: syncEngine ) var changesToRetry: [CKSyncEngine.PendingRecordZoneChange] = [] @@ -604,10 +709,10 @@ extension CloudSaveEngine { switch error.code { case .unknownItem, .zoneNotFound: try await client.didDelete(recordIDs: [recordID]) - syncEngine.state.remove( - pendingRecordZoneChanges: [.deleteRecord(recordID)] + removePendingChanges( + [.delete(recordID)], + syncEngine: syncEngine ) - stateMachine.resolve(.record(recordID)) if error.code == .zoneNotFound { zonesToRetry.append(.saveZone(configuration.zone)) } @@ -657,10 +762,10 @@ extension CloudSaveEngine { records: [serverRecord], deletedRecordIDs: [] ) - syncEngine.state.remove( - pendingRecordZoneChanges: [.saveRecord(recordID)] + removePendingChanges( + [.save(recordID)], + syncEngine: syncEngine ) - stateMachine.resolve(.record(recordID)) case .retry(let mergedRecord): try await client.persistResolvedRecord(mergedRecord) changesToRetry.append(.saveRecord(mergedRecord.recordID)) @@ -735,13 +840,17 @@ extension CloudSaveEngine { syncEngine: CKSyncEngine? ) async { let failure = CloudSaveFailure(clientError: error) - lifecycleGeneration &+= 1 - stateMachine.fail( - failure, - context: .hostPersistence - ) - publishStatus(syncEngine: syncEngine) - await stopAfterHostFailure(syncEngine: syncEngine) + let invalidation = await statePersistenceLock.withLock { [self] in + await invalidateAfterHostFailure( + failure, + syncEngine: syncEngine + ) + } + guard invalidation.shouldHandle else { + return + } + + await invalidation.engineToCancel?.cancelOperations() await client.handle( failure: failure, recordID: nil @@ -794,6 +903,16 @@ extension CloudSaveEngine { isInConfiguredZone(record.recordID) } + /// Filters host-ledger mutations to the custom zone owned by this engine. + fileprivate func isInConfiguredZone(_ mutation: CloudSaveLedgerMutation) -> Bool { + switch mutation { + case .enqueue(let change): + isInConfiguredZone(change.recordID) + case .remove(let change): + isInConfiguredZone(change.recordID) + } + } + /// Filters CloudKit changes to the custom zone owned by this engine. fileprivate func isInConfiguredZone(_ recordID: CKRecord.ID) -> Bool { recordID.zoneID == configuration.zone.zoneID diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 5f8e811..5c8a0bf 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -12,7 +12,7 @@ Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetc CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer save or deletion. +CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Checkpoint writes are serialized with lifecycle invalidation so an older engine cannot regress the recovery state after its replacement starts. ## Topics diff --git a/Sources/CloudSaveKit/CloudSaveLedgerMutation.swift b/Sources/CloudSaveKit/CloudSaveLedgerMutation.swift new file mode 100644 index 0000000..3c9325f --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveLedgerMutation.swift @@ -0,0 +1,10 @@ +import CloudKit + +/// Describes an ordered host-ledger mutation that can race with an asynchronous snapshot. +enum CloudSaveLedgerMutation: Equatable, Sendable { + /// Adds or replaces one durable pending change. + case enqueue(CloudSavePendingChange) + + /// Removes one exact durable pending change after acknowledgement or local disappearance. + case remove(CloudSavePendingChange) +} diff --git a/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift b/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift index 3c3f90b..41dbc28 100644 --- a/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift +++ b/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift @@ -1,9 +1,10 @@ +import CloudKit import Foundation /// Retains ordered enqueues that occur while the host produces a durable-ledger snapshot. struct CloudSaveLedgerSnapshotTracker: Sendable { private var activeSnapshots: [Snapshot] = [] - private var enqueuedChanges: [EnqueuedChange] = [] + private var ledgerMutations: [TrackedMutation] = [] private var latestGeneration = 0 private var nextIdentifier = 0 @@ -18,54 +19,47 @@ struct CloudSaveLedgerSnapshotTracker: Sendable { return snapshot } - /// Records changes in the exact order in which the engine receives them. - mutating func record(_ changes: [CloudSavePendingChange]) { - guard !activeSnapshots.isEmpty else { - return - } + /// Records pending changes in the exact order in which the engine receives them. + mutating func recordEnqueues(_ changes: [CloudSavePendingChange]) { + record(changes.map(CloudSaveLedgerMutation.enqueue)) + } - for change in changes { - latestGeneration &+= 1 - enqueuedChanges.append( - EnqueuedChange( - change: change, - generation: latestGeneration - ) - ) - } + /// Records removed pending changes in the exact order in which the host commits them. + mutating func recordRemovals(_ changes: [CloudSavePendingChange]) { + record(changes.map(CloudSaveLedgerMutation.remove)) } - /// Completes a snapshot and returns changes enqueued after its ledger read began. - mutating func completeSnapshot(_ snapshot: Snapshot) -> [CloudSavePendingChange] { + /// Completes a snapshot and returns ledger mutations committed after its read began. + mutating func completeSnapshot(_ snapshot: Snapshot) -> [CloudSaveLedgerMutation] { guard remove(snapshot) else { return [] } - let changes: [CloudSavePendingChange] = enqueuedChanges.compactMap { enqueuedChange in - guard enqueuedChange.generation > snapshot.generation else { + let mutations: [CloudSaveLedgerMutation] = ledgerMutations.compactMap { trackedMutation in + guard trackedMutation.generation > snapshot.generation else { return nil } - return enqueuedChange.change + return trackedMutation.mutation } - pruneChangesNoLongerNeeded() - return changes + pruneMutationsNoLongerNeeded() + return mutations } - /// Cancels a snapshot without replaying its concurrently enqueued changes. + /// Cancels a snapshot without replaying its concurrent ledger mutations. mutating func cancelSnapshot(_ snapshot: Snapshot) { guard remove(snapshot) else { return } - pruneChangesNoLongerNeeded() + pruneMutationsNoLongerNeeded() } } // MARK: - Snapshot extension CloudSaveLedgerSnapshotTracker { - /// Identifies the enqueue generation visible when one host-ledger read begins. + /// Identifies the ledger generation visible when one host-ledger read begins. struct Snapshot: Equatable, Sendable { fileprivate let generation: Int fileprivate let identifier: Int @@ -75,10 +69,27 @@ extension CloudSaveLedgerSnapshotTracker { // MARK: - Private extension CloudSaveLedgerSnapshotTracker { - /// Associates one ordered pending change with its enqueue generation. - private struct EnqueuedChange: Sendable { - let change: CloudSavePendingChange + /// Associates one ordered ledger mutation with its generation. + private struct TrackedMutation: Sendable { let generation: Int + let mutation: CloudSaveLedgerMutation + } + + /// Records ledger mutations only while at least one asynchronous snapshot is suspended. + private mutating func record(_ mutations: [CloudSaveLedgerMutation]) { + guard !activeSnapshots.isEmpty else { + return + } + + for mutation in mutations { + latestGeneration &+= 1 + ledgerMutations.append( + TrackedMutation( + generation: latestGeneration, + mutation: mutation + ) + ) + } } /// Removes one active snapshot if it is still tracked. @@ -91,15 +102,15 @@ extension CloudSaveLedgerSnapshotTracker { return true } - /// Discards changes that every remaining host-ledger snapshot already includes. - private mutating func pruneChangesNoLongerNeeded() { + /// Discards mutations that every remaining host-ledger snapshot already includes. + private mutating func pruneMutationsNoLongerNeeded() { guard let oldestGeneration = activeSnapshots.map(\.generation).min() else { - enqueuedChanges.removeAll(keepingCapacity: true) + ledgerMutations.removeAll(keepingCapacity: true) return } - enqueuedChanges.removeAll { enqueuedChange in - enqueuedChange.generation <= oldestGeneration + ledgerMutations.removeAll { trackedMutation in + trackedMutation.generation <= oldestGeneration } } } diff --git a/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift index 1924eab..62c0888 100644 --- a/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift +++ b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift @@ -1,10 +1,10 @@ import Foundation -/// Combines one durable host-ledger snapshot with enqueues that raced with its asynchronous read. +/// Combines one durable host-ledger snapshot with mutations that raced with its asynchronous read. struct CloudSavePendingChangesSnapshot: Sendable { /// The authoritative pending changes returned by the host. let durableChanges: [CloudSavePendingChange] - /// Changes enqueued after the host-ledger read began, in their original order. - let subsequentlyEnqueuedChanges: [CloudSavePendingChange] + /// Ledger mutations committed after the host-ledger read began, in their original order. + let subsequentMutations: [CloudSaveLedgerMutation] } diff --git a/Tests/CloudSaveKitTests/CloudSaveAsyncLockTests.swift b/Tests/CloudSaveKitTests/CloudSaveAsyncLockTests.swift new file mode 100644 index 0000000..5c99242 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSaveAsyncLockTests.swift @@ -0,0 +1,66 @@ +import Foundation +import Testing + +@testable import CloudSaveKit + +@Suite("Cloud save asynchronous lock") +struct CloudSaveAsyncLockTests { + @Test("Serializes overlapping asynchronous operations") + func serializesOperations() async { + let lock = CloudSaveAsyncLock() + let probe = CriticalSectionProbe() + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<50 { + group.addTask { + await lock.withLock { + await probe.enter() + await Task.yield() + await probe.leave() + } + } + } + } + + let result = await probe.result() + #expect(result.entryCount == 50) + #expect(result.maximumConcurrentCount == 1) + } +} + +// MARK: - CriticalSectionProbe + +extension CloudSaveAsyncLockTests { + /// Records how many test operations overlap inside one critical section. + private actor CriticalSectionProbe { + private var activeCount = 0 + private var entryCount = 0 + private var maximumConcurrentCount = 0 + + /// Records one operation entering the critical section. + func enter() { + activeCount += 1 + entryCount += 1 + maximumConcurrentCount = max( + maximumConcurrentCount, + activeCount + ) + } + + /// Records one operation leaving the critical section. + func leave() { + activeCount -= 1 + } + + /// Returns the completed concurrency measurements. + func result() -> ( + entryCount: Int, + maximumConcurrentCount: Int + ) { + ( + entryCount: entryCount, + maximumConcurrentCount: maximumConcurrentCount + ) + } + } +} diff --git a/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift b/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift index d94c4d0..982fef4 100644 --- a/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift @@ -17,9 +17,12 @@ struct CloudSaveLedgerSnapshotTrackerTests { var tracker = CloudSaveLedgerSnapshotTracker() let snapshot = tracker.beginSnapshot() - tracker.record(expectedChanges) + tracker.recordEnqueues(expectedChanges) - #expect(tracker.completeSnapshot(snapshot) == expectedChanges) + #expect( + tracker.completeSnapshot(snapshot) + == expectedChanges.map(CloudSaveLedgerMutation.enqueue) + ) } @Test("Preserves the suffix required by every overlapping ledger read") @@ -33,12 +36,15 @@ struct CloudSaveLedgerSnapshotTrackerTests { var tracker = CloudSaveLedgerSnapshotTracker() let earlierSnapshot = tracker.beginSnapshot() - tracker.record([firstChange]) + tracker.recordEnqueues([firstChange]) let laterSnapshot = tracker.beginSnapshot() - tracker.record([secondChange]) + tracker.recordEnqueues([secondChange]) - #expect(tracker.completeSnapshot(laterSnapshot) == [secondChange]) - #expect(tracker.completeSnapshot(earlierSnapshot) == [firstChange, secondChange]) + #expect(tracker.completeSnapshot(laterSnapshot) == [.enqueue(secondChange)]) + #expect( + tracker.completeSnapshot(earlierSnapshot) + == [.enqueue(firstChange), .enqueue(secondChange)] + ) } @Test("Completing an older ledger read retains the suffix needed by a newer read") @@ -52,12 +58,15 @@ struct CloudSaveLedgerSnapshotTrackerTests { var tracker = CloudSaveLedgerSnapshotTracker() let earlierSnapshot = tracker.beginSnapshot() - tracker.record([firstChange]) + tracker.recordEnqueues([firstChange]) let laterSnapshot = tracker.beginSnapshot() - tracker.record([secondChange]) + tracker.recordEnqueues([secondChange]) - #expect(tracker.completeSnapshot(earlierSnapshot) == [firstChange, secondChange]) - #expect(tracker.completeSnapshot(laterSnapshot) == [secondChange]) + #expect( + tracker.completeSnapshot(earlierSnapshot) + == [.enqueue(firstChange), .enqueue(secondChange)] + ) + #expect(tracker.completeSnapshot(laterSnapshot) == [.enqueue(secondChange)]) } @Test("Does not retain enqueues when no ledger read is suspended") @@ -67,7 +76,7 @@ struct CloudSaveLedgerSnapshotTrackerTests { ) var tracker = CloudSaveLedgerSnapshotTracker() - tracker.record([change]) + tracker.recordEnqueues([change]) let snapshot = tracker.beginSnapshot() #expect(tracker.completeSnapshot(snapshot).isEmpty) @@ -82,10 +91,37 @@ struct CloudSaveLedgerSnapshotTrackerTests { let earlierSnapshot = tracker.beginSnapshot() let cancelledSnapshot = tracker.beginSnapshot() - tracker.record([change]) + tracker.recordEnqueues([change]) tracker.cancelSnapshot(cancelledSnapshot) - #expect(tracker.completeSnapshot(earlierSnapshot) == [change]) + #expect(tracker.completeSnapshot(earlierSnapshot) == [.enqueue(change)]) + } + + @Test("Preserves acknowledgements that race with a ledger read") + func preservesConcurrentAcknowledgements() { + let recordID = Self.makeRecordID(named: "acknowledged") + var tracker = CloudSaveLedgerSnapshotTracker() + + let snapshot = tracker.beginSnapshot() + tracker.recordRemovals([.save(recordID)]) + + #expect(tracker.completeSnapshot(snapshot) == [.remove(.save(recordID))]) + } + + @Test("Preserves enqueue and acknowledgement order for one record") + func preservesMutationOrder() { + let recordID = Self.makeRecordID(named: "ordered") + let change = CloudSavePendingChange.save(recordID) + var tracker = CloudSaveLedgerSnapshotTracker() + + let snapshot = tracker.beginSnapshot() + tracker.recordEnqueues([change]) + tracker.recordRemovals([change]) + + #expect( + tracker.completeSnapshot(snapshot) + == [.enqueue(change), .remove(change)] + ) } } From d0511b9fe9a6cc58be0afeff420514c1ccec0697 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 20:30:25 +0200 Subject: [PATCH 11/18] Preserve newer work across acknowledgements --- README.md | 4 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 33 +++++++-- .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- .../CloudSavePendingChangesSnapshot.swift | 24 +++++++ .../CloudSaveKit/CloudSaveStateMachine.swift | 7 ++ .../CloudSaveLedgerSnapshotTrackerTests.swift | 2 +- ...CloudSavePendingChangesSnapshotTests.swift | 70 +++++++++++++++++++ .../CloudSaveStateMachineTests.swift | 28 ++++++++ 8 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift diff --git a/README.md b/README.md index 18d7683..ed50483 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,12 @@ Automatic synchronization should remain enabled in production. Explicit operatio Call `start()` successfully before any explicit synchronization. `fetchNow()` and `sendNow()` throw `CloudSaveEngineError.notStarted` before startup and `CloudSaveEngineError.hostRecoveryRequired` after a host persistence callback fails. Once the local store is healthy again, call `start()` to rebuild from the last successfully persisted CKSyncEngine checkpoint and the host's current durable pending-change ledger. -`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits enqueues or acknowledgements while a ledger read is suspended, CloudSaveKit replays those mutations in their original order after reconciling the returned snapshot. +`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits enqueues or acknowledgements while a ledger read is suspended, CloudSaveKit replays those mutations in their original order after reconciling the returned snapshot. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. Opaque CKSyncEngine checkpoint writes are serialized with host-failure lifecycle invalidation. A replacement engine therefore cannot start from a checkpoint that an older engine later regresses through actor reentrancy. +Known iCloud account 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. ## Failure and retry policy diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 2a0cad6..1b6b415 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -529,7 +529,26 @@ extension CloudSaveEngine { ) } - /// Removes acknowledged host-ledger entries from snapshots and the active engine. + /// Reconciles successful work against the host ledger without discarding a newer mutation. + fileprivate func reconcileAcknowledgedPendingChanges( + _ acknowledgedChanges: [CloudSavePendingChange], + syncEngine: CKSyncEngine + ) async throws { + let snapshot = try await readPendingChangesSnapshot() + let completedChanges = acknowledgedChanges.filter { + !snapshot.containsEffectiveChange($0) + } + + ledgerSnapshotTracker.recordRemovals(completedChanges) + restoreDurablePendingChanges( + snapshot, + syncEngine: syncEngine + ) + stateMachine.resolve(recordIDs: acknowledgedChanges.map(\.recordID)) + publishStatus(syncEngine: syncEngine) + } + + /// Discards a pending change that the host can no longer materialize. fileprivate func removePendingChanges( _ changes: [CloudSavePendingChange], syncEngine: CKSyncEngine @@ -566,6 +585,8 @@ extension CloudSaveEngine { _ accountChange: CloudSaveAccountChange, syncEngine: CKSyncEngine ) async throws { + stateMachine.resetForAccountChange() + guard case .signedOut = accountChange else { let ledgerSnapshot = try await readPendingChangesSnapshot() syncEngine.state.add( @@ -578,6 +599,8 @@ extension CloudSaveEngine { publishStatus(syncEngine: syncEngine) return } + + publishStatus(syncEngine: nil) } /// Recreates the configured zone and restores the host's durable changes after deletion. @@ -663,12 +686,12 @@ extension CloudSaveEngine { let deletedRecordIDs = event.deletedRecordIDs.filter(isInConfiguredZone) try await client.didSave(records: savedRecords) - removePendingChanges( + try await reconcileAcknowledgedPendingChanges( savedRecords.map { .save($0.recordID) }, syncEngine: syncEngine ) try await client.didDelete(recordIDs: deletedRecordIDs) - removePendingChanges( + try await reconcileAcknowledgedPendingChanges( deletedRecordIDs.map(CloudSavePendingChange.delete), syncEngine: syncEngine ) @@ -709,7 +732,7 @@ extension CloudSaveEngine { switch error.code { case .unknownItem, .zoneNotFound: try await client.didDelete(recordIDs: [recordID]) - removePendingChanges( + try await reconcileAcknowledgedPendingChanges( [.delete(recordID)], syncEngine: syncEngine ) @@ -762,7 +785,7 @@ extension CloudSaveEngine { records: [serverRecord], deletedRecordIDs: [] ) - removePendingChanges( + try await reconcileAcknowledgedPendingChanges( [.save(recordID)], syncEngine: syncEngine ) diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 5c8a0bf..30ef453 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -12,7 +12,7 @@ Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetc CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Checkpoint writes are serialized with lifecycle invalidation so an older engine cannot regress the recovery state after its replacement starts. +CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends reread the post-commit host ledger before discarding pending state, preserving a newer same-record mutation that superseded the in-flight value. Checkpoint writes are serialized with lifecycle invalidation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions clear recovery state from the previous account, and sign-out immediately publishes the cleared status. ## Topics diff --git a/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift index 62c0888..a90fe65 100644 --- a/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift +++ b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift @@ -8,3 +8,27 @@ struct CloudSavePendingChangesSnapshot: Sendable { /// Ledger mutations committed after the host-ledger read began, in their original order. let subsequentMutations: [CloudSaveLedgerMutation] } + +// MARK: - Effective Changes + +extension CloudSavePendingChangesSnapshot { + /// Returns whether reconciliation leaves one exact change pending. + func containsEffectiveChange(_ expectedChange: CloudSavePendingChange) -> Bool { + var effectiveChange = durableChanges.last { + $0.recordID == expectedChange.recordID + } + + for mutation in subsequentMutations { + switch mutation { + case .enqueue(let change) where change.recordID == expectedChange.recordID: + effectiveChange = change + case .remove(let change) where effectiveChange == change: + effectiveChange = nil + case .enqueue, .remove: + break + } + } + + return effectiveChange == expectedChange + } +} diff --git a/Sources/CloudSaveKit/CloudSaveStateMachine.swift b/Sources/CloudSaveKit/CloudSaveStateMachine.swift index 5afe255..8768590 100644 --- a/Sources/CloudSaveKit/CloudSaveStateMachine.swift +++ b/Sources/CloudSaveKit/CloudSaveStateMachine.swift @@ -51,6 +51,13 @@ struct CloudSaveStateMachine: Sendable { activeOperationCounts.removeAll() } + /// Discards state scoped to the previous iCloud account after a known account transition. + mutating func resetForAccountChange() { + activeOperationCounts.removeAll() + failures.removeAll() + operationGenerations.removeAll() + } + /// Records a failure for a host, record, or zone work item. mutating func fail( _ failure: CloudSaveFailure, diff --git a/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift b/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift index 982fef4..6e6b583 100644 --- a/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift @@ -108,7 +108,7 @@ struct CloudSaveLedgerSnapshotTrackerTests { #expect(tracker.completeSnapshot(snapshot) == [.remove(.save(recordID))]) } - @Test("Preserves enqueue and acknowledgement order for one record") + @Test("Preserves enqueue and explicit removal order for one record") func preservesMutationOrder() { let recordID = Self.makeRecordID(named: "ordered") let change = CloudSavePendingChange.save(recordID) diff --git a/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift b/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift new file mode 100644 index 0000000..4e9f4d9 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift @@ -0,0 +1,70 @@ +import CloudKit +import Testing + +@testable import CloudSaveKit + +@Suite("Cloud save pending-change snapshot") +struct CloudSavePendingChangesSnapshotTests { + @Test("Retains a newer save with the same record identity") + func retainsNewerSave() { + let save = CloudSavePendingChange.save( + Self.makeRecordID(named: "edited-during-upload") + ) + let snapshot = CloudSavePendingChangesSnapshot( + durableChanges: [], + subsequentMutations: [.enqueue(save)] + ) + + #expect(snapshot.containsEffectiveChange(save)) + } + + @Test("Recognizes a completed save absent from the current ledger") + func recognizesCompletedSave() { + let save = CloudSavePendingChange.save( + Self.makeRecordID(named: "completed") + ) + let snapshot = CloudSavePendingChangesSnapshot( + durableChanges: [save], + subsequentMutations: [.remove(save)] + ) + + #expect(!snapshot.containsEffectiveChange(save)) + } + + @Test("Does not let an old save acknowledgement erase a newer deletion") + func preservesNewerDeletion() { + let recordID = Self.makeRecordID(named: "deleted-during-upload") + let save = CloudSavePendingChange.save(recordID) + let delete = CloudSavePendingChange.delete(recordID) + let snapshot = CloudSavePendingChangesSnapshot( + durableChanges: [save], + subsequentMutations: [ + .enqueue(delete), + .remove(save), + ] + ) + + #expect(!snapshot.containsEffectiveChange(save)) + #expect(snapshot.containsEffectiveChange(delete)) + } +} + +// MARK: - Private + +extension CloudSavePendingChangesSnapshotTests { + /// The custom zone used by pending-change snapshot record identifiers. + private static var zoneID: CKRecordZone.ID { + CKRecordZone.ID( + zoneName: "CloudSaveKitTests", + ownerName: CKCurrentUserDefaultName + ) + } + + /// Creates a deterministic record identifier in the test zone. + private static func makeRecordID(named name: String) -> CKRecord.ID { + CKRecord.ID( + recordName: name, + zoneID: zoneID + ) + } +} diff --git a/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift b/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift index 280d4c9..24b06b9 100644 --- a/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift @@ -123,6 +123,34 @@ struct CloudSaveStateMachineTests { ) } + @Test("Clears previous-account activity and recovery state") + func resetsPreviousAccountState() { + let recordID = Self.makeRecordID(named: "previous-account") + var stateMachine = CloudSaveStateMachine() + + stateMachine.begin(.fetching) + stateMachine.fail( + .recordConflict, + context: .record(recordID) + ) + stateMachine.fail( + .zoneUnavailable, + context: .zone(Self.zoneID) + ) + stateMachine.fail( + .localPersistence, + context: .hostPersistence + ) + + stateMachine.resetForAccountChange() + + #expect(!stateMachine.requiresHostRecovery) + #expect(!stateMachine.requiresRecovery(for: Self.zoneID)) + #expect( + stateMachine.status(hasPendingChanges: false) == .ready(hasPendingChanges: false) + ) + } + @Test("Reconciles record failures against the host durable ledger") func reconcilesRecordFailures() { let retainedRecordID = Self.makeRecordID(named: "retained") From b56e86ab23dcb9292912688c576c86dd5a20d916 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 21:06:48 +0200 Subject: [PATCH 12/18] Invalidate stale ledger work --- README.md | 4 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 143 +++++++++++++----- .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- .../CloudSavePendingChangesSnapshot.swift | 8 + ...CloudSavePendingChangesSnapshotTests.swift | 15 ++ 5 files changed, 128 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index ed50483..a9577ce 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,11 @@ Automatic synchronization should remain enabled in production. Explicit operatio Call `start()` successfully before any explicit synchronization. `fetchNow()` and `sendNow()` throw `CloudSaveEngineError.notStarted` before startup and `CloudSaveEngineError.hostRecoveryRequired` after a host persistence callback fails. Once the local store is healthy again, call `start()` to rebuild from the last successfully persisted CKSyncEngine checkpoint and the host's current durable pending-change ledger. -`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits enqueues or acknowledgements while a ledger read is suspended, CloudSaveKit replays those mutations in their original order after reconciling the returned snapshot. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. +`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits enqueues or acknowledgements while a ledger read is suspended, CloudSaveKit replays those mutations in their original order after reconciling the returned snapshot. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. A nil `record(for:)` result uses the same reconciliation, so an older suspended materialization cannot discard a newer durable save. Opaque CKSyncEngine checkpoint writes are serialized with host-failure lifecycle invalidation. A replacement engine therefore cannot start from a checkpoint that an older engine later regresses through actor reentrancy. -Known iCloud account 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 before the host switches accounts, invalidating every pre-transition ledger snapshot and explicit operation. They also 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/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 1b6b415..4f73349 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -62,10 +62,14 @@ public final actor CloudSaveEngine { ) } - restoreDurablePendingChanges( - ledgerSnapshot, - syncEngine: engine - ) + guard + restoreDurablePendingChanges( + ledgerSnapshot, + syncEngine: engine + ) + else { + throw CloudSaveEngineError.hostRecoveryRequired + } publishStatus(syncEngine: engine) CloudSaveLogging.log( "start | pending=\(ledgerSnapshot.durableChanges.count)" @@ -149,10 +153,14 @@ public final actor CloudSaveEngine { } try validate(session) - restoreDurablePendingChanges( - ledgerSnapshot, - syncEngine: session.syncEngine - ) + guard + restoreDurablePendingChanges( + ledgerSnapshot, + syncEngine: session.syncEngine + ) + else { + throw CloudSaveEngineError.hostRecoveryRequired + } restoreFailedZoneChangeIfNeeded(syncEngine: session.syncEngine) do { @@ -241,8 +249,8 @@ extension CloudSaveEngine: CKSyncEngineDelegate { ) { [client, weak self] recordID in let record = await client.record(for: recordID) if record == nil { - await self?.removePendingChanges( - [.save(recordID)], + await self?.reconcileUnavailablePendingSave( + recordID, syncEngine: syncEngine ) } @@ -429,11 +437,13 @@ extension CloudSaveEngine { /// Reads the host ledger while retaining every mutation that can race with its snapshot. fileprivate func readPendingChangesSnapshot() async throws -> CloudSavePendingChangesSnapshot { let snapshot = ledgerSnapshotTracker.beginSnapshot() + let snapshotLifecycleGeneration = lifecycleGeneration do { let durableChanges = try await client.pendingChanges() let subsequentMutations = ledgerSnapshotTracker.completeSnapshot(snapshot) return CloudSavePendingChangesSnapshot( + lifecycleGeneration: snapshotLifecycleGeneration, durableChanges: durableChanges, subsequentMutations: subsequentMutations ) @@ -480,7 +490,12 @@ extension CloudSaveEngine { fileprivate func restoreDurablePendingChanges( _ snapshot: CloudSavePendingChangesSnapshot, syncEngine: CKSyncEngine - ) { + ) -> Bool { + guard isCurrent(snapshot, syncEngine: syncEngine) else { + CloudSaveLogging.log("ledger snapshot | ignored stale lifecycle") + return false + } + let configuredDurableChanges = snapshot.durableChanges.filter { isInConfiguredZone($0.recordID) } @@ -527,6 +542,7 @@ extension CloudSaveEngine { Set(effectiveChanges.keys), in: configuration.zone.zoneID ) + return true } /// Reconciles successful work against the host ledger without discarding a newer mutation. @@ -534,39 +550,75 @@ extension CloudSaveEngine { _ acknowledgedChanges: [CloudSavePendingChange], syncEngine: CKSyncEngine ) async throws { - let snapshot = try await readPendingChangesSnapshot() - let completedChanges = acknowledgedChanges.filter { - !snapshot.containsEffectiveChange($0) + guard + try await reconcilePendingChangesAgainstHostLedger( + acknowledgedChanges, + syncEngine: syncEngine + ) != nil + else { + return } - ledgerSnapshotTracker.recordRemovals(completedChanges) - restoreDurablePendingChanges( - snapshot, - syncEngine: syncEngine - ) stateMachine.resolve(recordIDs: acknowledgedChanges.map(\.recordID)) publishStatus(syncEngine: syncEngine) } - /// Discards a pending change that the host can no longer materialize. - fileprivate func removePendingChanges( - _ changes: [CloudSavePendingChange], + /// Reconciles candidate removals against the current durable ledger and lifecycle. + fileprivate func reconcilePendingChangesAgainstHostLedger( + _ candidateChanges: [CloudSavePendingChange], syncEngine: CKSyncEngine - ) { - let configuredChanges = changes.filter { - isInConfiguredZone($0.recordID) + ) async throws -> [CloudSavePendingChange]? { + let snapshot = try await readPendingChangesSnapshot() + guard isCurrent(snapshot, syncEngine: syncEngine) else { + CloudSaveLogging.log("ledger reconciliation | ignored stale lifecycle") + return nil } - ledgerSnapshotTracker.recordRemovals(configuredChanges) - guard storedSyncEngine === syncEngine else { - return + let completedChanges = candidateChanges.filter { + !snapshot.containsEffectiveChange($0) } - syncEngine.state.remove( - pendingRecordZoneChanges: configuredChanges.map(\.syncEngineChange) - ) - stateMachine.resolve(recordIDs: configuredChanges.map(\.recordID)) - publishStatus(syncEngine: syncEngine) + ledgerSnapshotTracker.recordRemovals(completedChanges) + guard restoreDurablePendingChanges(snapshot, syncEngine: syncEngine) else { + return nil + } + + return completedChanges + } + + /// Reconciles a nil record-provider result without discarding a newer durable save. + fileprivate func reconcileUnavailablePendingSave( + _ recordID: CKRecord.ID, + syncEngine: CKSyncEngine + ) async { + do { + guard + let completedChanges = try await reconcilePendingChangesAgainstHostLedger( + [.save(recordID)], + syncEngine: syncEngine + ) + else { + return + } + + stateMachine.resolve(recordIDs: completedChanges.map(\.recordID)) + publishStatus(syncEngine: syncEngine) + } catch { + await handleHostFailure( + error, + syncEngine: syncEngine + ) + } + } + + /// Returns whether a host-ledger snapshot still belongs to the active engine lifecycle. + fileprivate func isCurrent( + _ snapshot: CloudSavePendingChangesSnapshot, + syncEngine: CKSyncEngine + ) -> Bool { + snapshot.belongs(to: lifecycleGeneration) + && storedSyncEngine === syncEngine + && !stateMachine.requiresHostRecovery } /// Restores a failed configured-zone save only for a host-requested explicit send. @@ -592,10 +644,14 @@ extension CloudSaveEngine { syncEngine.state.add( pendingDatabaseChanges: [.saveZone(configuration.zone)] ) - restoreDurablePendingChanges( - ledgerSnapshot, - syncEngine: syncEngine - ) + guard + restoreDurablePendingChanges( + ledgerSnapshot, + syncEngine: syncEngine + ) + else { + throw CloudSaveEngineError.hostRecoveryRequired + } publishStatus(syncEngine: syncEngine) return } @@ -618,10 +674,14 @@ extension CloudSaveEngine { syncEngine.state.add( pendingDatabaseChanges: [.saveZone(configuration.zone)] ) - restoreDurablePendingChanges( - ledgerSnapshot, - syncEngine: syncEngine - ) + guard + restoreDurablePendingChanges( + ledgerSnapshot, + syncEngine: syncEngine + ) + else { + throw CloudSaveEngineError.hostRecoveryRequired + } } } @@ -641,6 +701,7 @@ extension CloudSaveEngine { return } + lifecycleGeneration &+= 1 try await client.handle(accountChange: accountChange) try await restorePendingChangesAfterAccountChange( accountChange, diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 30ef453..25d9c6a 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -12,7 +12,7 @@ Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetc CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends reread the post-commit host ledger before discarding pending state, preserving a newer same-record mutation that superseded the in-flight value. Checkpoint writes are serialized with lifecycle invalidation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions clear recovery state from the previous account, and sign-out immediately publishes the cleared status. +CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. Checkpoint writes are serialized with lifecycle invalidation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions advance that lifecycle before the host switches accounts, preventing pre-transition ledger snapshots from entering the new account; they clear previous-account recovery state, and sign-out immediately publishes the cleared status. ## Topics diff --git a/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift index a90fe65..1a80dfa 100644 --- a/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift +++ b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift @@ -2,6 +2,9 @@ import Foundation /// Combines one durable host-ledger snapshot with mutations that raced with its asynchronous read. struct CloudSavePendingChangesSnapshot: Sendable { + /// The engine lifecycle generation in which the host-ledger read began. + let lifecycleGeneration: Int + /// The authoritative pending changes returned by the host. let durableChanges: [CloudSavePendingChange] @@ -12,6 +15,11 @@ struct CloudSavePendingChangesSnapshot: Sendable { // MARK: - Effective Changes extension CloudSavePendingChangesSnapshot { + /// Returns whether the snapshot was read during the specified engine lifecycle. + func belongs(to expectedLifecycleGeneration: Int) -> Bool { + lifecycleGeneration == expectedLifecycleGeneration + } + /// Returns whether reconciliation leaves one exact change pending. func containsEffectiveChange(_ expectedChange: CloudSavePendingChange) -> Bool { var effectiveChange = durableChanges.last { diff --git a/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift b/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift index 4e9f4d9..0618d32 100644 --- a/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift +++ b/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift @@ -11,6 +11,7 @@ struct CloudSavePendingChangesSnapshotTests { Self.makeRecordID(named: "edited-during-upload") ) let snapshot = CloudSavePendingChangesSnapshot( + lifecycleGeneration: 1, durableChanges: [], subsequentMutations: [.enqueue(save)] ) @@ -24,6 +25,7 @@ struct CloudSavePendingChangesSnapshotTests { Self.makeRecordID(named: "completed") ) let snapshot = CloudSavePendingChangesSnapshot( + lifecycleGeneration: 1, durableChanges: [save], subsequentMutations: [.remove(save)] ) @@ -37,6 +39,7 @@ struct CloudSavePendingChangesSnapshotTests { let save = CloudSavePendingChange.save(recordID) let delete = CloudSavePendingChange.delete(recordID) let snapshot = CloudSavePendingChangesSnapshot( + lifecycleGeneration: 1, durableChanges: [save], subsequentMutations: [ .enqueue(delete), @@ -47,6 +50,18 @@ struct CloudSavePendingChangesSnapshotTests { #expect(!snapshot.containsEffectiveChange(save)) #expect(snapshot.containsEffectiveChange(delete)) } + + @Test("Rejects a snapshot from a previous engine lifecycle") + func rejectsPreviousLifecycle() { + let snapshot = CloudSavePendingChangesSnapshot( + lifecycleGeneration: 7, + durableChanges: [], + subsequentMutations: [] + ) + + #expect(snapshot.belongs(to: 7)) + #expect(!snapshot.belongs(to: 8)) + } } // MARK: - Private From c38992fbb8da53b4f73c6c92bd38330bbb9e0823 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 21:25:40 +0200 Subject: [PATCH 13/18] Block work during host invalidation --- README.md | 2 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 106 ++++++++++++++---- .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a9577ce..2644d50 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Call `start()` successfully before any explicit synchronization. `fetchNow()` an `sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits enqueues or acknowledgements while a ledger read is suspended, CloudSaveKit replays those mutations in their original order after reconciling the returned snapshot. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. A nil `record(for:)` result uses the same reconciliation, so an older suspended materialization cannot discard a newer durable save. -Opaque CKSyncEngine checkpoint writes are serialized with host-failure lifecycle invalidation. A replacement engine therefore cannot start from a checkpoint that an older engine later regresses through actor reentrancy. +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 before the host switches accounts, invalidating every pre-transition ledger snapshot and explicit operation. They also 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. diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 4f73349..6cedbb3 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -11,6 +11,7 @@ public final actor CloudSaveEngine { private let eventHandlingLock = CloudSaveAsyncLock() private let statePersistenceLock = CloudSaveAsyncLock() private let statusContinuation: AsyncStream.Continuation + private var isHostFailureInvalidationPending = false private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization? private var ledgerSnapshotTracker = CloudSaveLedgerSnapshotTracker() private var lifecycleGeneration = 0 @@ -36,6 +37,8 @@ public final actor CloudSaveEngine { /// Initializes CKSyncEngine and restores every locally durable pending change. public func start() async throws { + await waitForPendingHostFailureInvalidation() + let startingLifecycleGeneration = lifecycleGeneration let ledgerSnapshot: CloudSavePendingChangesSnapshot do { @@ -83,7 +86,9 @@ public final actor CloudSaveEngine { } ledgerSnapshotTracker.recordEnqueues(configuredChanges) - guard !stateMachine.requiresHostRecovery else { + guard !isHostFailureInvalidationPending, + !stateMachine.requiresHostRecovery + else { CloudSaveLogging.log( level: .error, "enqueue | ignored while host recovery is required" @@ -230,7 +235,8 @@ extension CloudSaveEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - guard storedSyncEngine === syncEngine, + guard !isHostFailureInvalidationPending, + storedSyncEngine === syncEngine, !stateMachine.requiresHostRecovery else { return nil @@ -267,7 +273,9 @@ extension CloudSaveEngine { _ event: CKSyncEngine.Event, syncEngine: CKSyncEngine ) async { - guard storedSyncEngine === syncEngine else { + guard !isHostFailureInvalidationPending, + storedSyncEngine === syncEngine + else { CloudSaveLogging.log("event | ignored stale engine") return } @@ -353,7 +361,9 @@ extension CloudSaveEngine { syncEngine: CKSyncEngine, lifecycleGeneration: Int ) { - guard !stateMachine.requiresHostRecovery else { + guard !isHostFailureInvalidationPending, + !stateMachine.requiresHostRecovery + else { throw CloudSaveEngineError.hostRecoveryRequired } @@ -374,7 +384,8 @@ extension CloudSaveEngine { lifecycleGeneration: Int ) ) throws { - guard session.lifecycleGeneration == lifecycleGeneration, + guard !isHostFailureInvalidationPending, + session.lifecycleGeneration == lifecycleGeneration, storedSyncEngine === session.syncEngine, !stateMachine.requiresHostRecovery else { @@ -390,7 +401,8 @@ extension CloudSaveEngine { ) ) throws { guard - session.lifecycleGeneration != lifecycleGeneration + isHostFailureInvalidationPending + || session.lifecycleGeneration != lifecycleGeneration || storedSyncEngine !== session.syncEngine || stateMachine.requiresHostRecovery else { @@ -400,35 +412,70 @@ extension CloudSaveEngine { throw CloudSaveEngineError.hostRecoveryRequired } - /// Invalidates one active engine only after every earlier checkpoint write completes. - fileprivate func invalidateAfterHostFailure( + /// Blocks new work as soon as a host failure is observed. + fileprivate func beginHostFailureInvalidation( _ failure: CloudSaveFailure, syncEngine: CKSyncEngine? ) -> ( - shouldHandle: Bool, + lifecycleGeneration: Int, engineToCancel: CKSyncEngine? - ) { + )? { guard syncEngine == nil || storedSyncEngine === syncEngine else { - return ( - shouldHandle: false, - engineToCancel: nil - ) + return nil + } + + guard !isHostFailureInvalidationPending else { + return nil } let engineToCancel = syncEngine ?? storedSyncEngine + isHostFailureInvalidationPending = true lifecycleGeneration &+= 1 stateMachine.fail( failure, context: .hostPersistence ) - storedSyncEngine = nil stateMachine.resetActiveOperations() publishStatus(syncEngine: engineToCancel) return ( - shouldHandle: true, + lifecycleGeneration: lifecycleGeneration, engineToCancel: engineToCancel ) } + + /// Clears the failed engine after every earlier checkpoint write completes. + fileprivate func finishHostFailureInvalidation( + _ failure: CloudSaveFailure, + lifecycleGeneration: Int, + syncEngine: CKSyncEngine? + ) -> Bool { + guard isHostFailureInvalidationPending else { + return false + } + + isHostFailureInvalidationPending = false + guard self.lifecycleGeneration == lifecycleGeneration, + storedSyncEngine === syncEngine + else { + CloudSaveLogging.log("host failure | ignored superseded invalidation") + return false + } + + stateMachine.fail( + failure, + context: .hostPersistence + ) + storedSyncEngine = nil + publishStatus(syncEngine: syncEngine) + return true + } + + /// Prevents explicit recovery from overtaking an invalidation waiting on checkpoint writes. + fileprivate func waitForPendingHostFailureInvalidation() async { + while isHostFailureInvalidationPending { + await statePersistenceLock.withLock {} + } + } } // MARK: - Private Host Persistence @@ -616,7 +663,8 @@ extension CloudSaveEngine { _ snapshot: CloudSavePendingChangesSnapshot, syncEngine: CKSyncEngine ) -> Bool { - snapshot.belongs(to: lifecycleGeneration) + !isHostFailureInvalidationPending + && snapshot.belongs(to: lifecycleGeneration) && storedSyncEngine === syncEngine && !stateMachine.requiresHostRecovery } @@ -702,7 +750,15 @@ extension CloudSaveEngine { } lifecycleGeneration &+= 1 + let accountLifecycleGeneration = lifecycleGeneration try await client.handle(accountChange: accountChange) + guard lifecycleGeneration == accountLifecycleGeneration, + !isHostFailureInvalidationPending, + !stateMachine.requiresHostRecovery, + storedSyncEngine === syncEngine + else { + throw CloudSaveEngineError.hostRecoveryRequired + } try await restorePendingChangesAfterAccountChange( accountChange, syncEngine: syncEngine @@ -924,13 +980,23 @@ extension CloudSaveEngine { syncEngine: CKSyncEngine? ) async { let failure = CloudSaveFailure(clientError: error) - let invalidation = await statePersistenceLock.withLock { [self] in - await invalidateAfterHostFailure( + guard + let invalidation = beginHostFailureInvalidation( failure, syncEngine: syncEngine ) + else { + return + } + + let didFinish = await statePersistenceLock.withLock { [self] in + await finishHostFailureInvalidation( + failure, + lifecycleGeneration: invalidation.lifecycleGeneration, + syncEngine: invalidation.engineToCancel + ) } - guard invalidation.shouldHandle else { + guard didFinish else { return } diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 25d9c6a..ba8a984 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -12,7 +12,7 @@ Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetc CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. Checkpoint writes are serialized with lifecycle invalidation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions advance that lifecycle before the host switches accounts, preventing pre-transition ledger snapshots from entering the new account; they clear previous-account recovery state, and sign-out immediately publishes the cleared status. +CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine teardown so an older engine cannot regress the recovery state after its replacement starts. Known account transitions advance that lifecycle before the host switches accounts, preventing pre-transition ledger snapshots from entering the new account; they clear previous-account recovery state, and sign-out immediately publishes the cleared status. ## Topics From e6af0415dbe75d582e4af217df9cbc0583295821 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 21:37:11 +0200 Subject: [PATCH 14/18] Complete recovery barriers safely --- README.md | 2 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 30 ++++++++++++++++--- .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- .../CloudSaveKit/CloudSaveStateMachine.swift | 3 +- .../CloudSaveStateMachineTests.swift | 24 +++++++++++++++ 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2644d50..b13639d 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Semantic and permanent failures are tracked independently: - A record failure clears only when that record is saved, its deletion is acknowledged, or the host removes it from the durable pending ledger. - A zone failure clears only after that zone succeeds. -- An explicit operation failure clears only after a later matching operation completes. +- An explicit operation failure clears only after a later matching operation completes and every older overlapping operation of that kind has drained. - A host persistence failure stops the engine and blocks all synchronization until a successful `start()`. This follows [Apple's CKSyncEngine contract](https://developer.apple.com/documentation/cloudkit/cksyncengine-5sie5): the framework schedules and retries recoverable transport work, while the application persists engine state and resolves semantic record failures. diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 6cedbb3..ff7c18c 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -12,6 +12,7 @@ public final actor CloudSaveEngine { private let statePersistenceLock = CloudSaveAsyncLock() private let statusContinuation: AsyncStream.Continuation private var isHostFailureInvalidationPending = false + private var hostFailureInvalidationWaiters: [CheckedContinuation] = [] private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization? private var ledgerSnapshotTracker = CloudSaveLedgerSnapshotTracker() private var lifecycleGeneration = 0 @@ -443,7 +444,7 @@ extension CloudSaveEngine { ) } - /// Clears the failed engine after every earlier checkpoint write completes. + /// Detaches the failed engine after every earlier checkpoint write completes. fileprivate func finishHostFailureInvalidation( _ failure: CloudSaveFailure, lifecycleGeneration: Int, @@ -458,6 +459,7 @@ extension CloudSaveEngine { storedSyncEngine === syncEngine else { CloudSaveLogging.log("host failure | ignored superseded invalidation") + endHostFailureInvalidation() return false } @@ -470,10 +472,29 @@ extension CloudSaveEngine { return true } - /// Prevents explicit recovery from overtaking an invalidation waiting on checkpoint writes. + /// Prevents explicit recovery from overtaking checkpoint ordering or failed-engine shutdown. fileprivate func waitForPendingHostFailureInvalidation() async { - while isHostFailureInvalidationPending { - await statePersistenceLock.withLock {} + guard isHostFailureInvalidationPending else { + return + } + + await withCheckedContinuation { continuation in + guard isHostFailureInvalidationPending else { + continuation.resume() + return + } + + hostFailureInvalidationWaiters.append(continuation) + } + } + + /// Releases explicit recovery only after the failed engine has stopped. + fileprivate func endHostFailureInvalidation() { + isHostFailureInvalidationPending = false + let waiters = hostFailureInvalidationWaiters + hostFailureInvalidationWaiters.removeAll() + for waiter in waiters { + waiter.resume() } } } @@ -1001,6 +1022,7 @@ extension CloudSaveEngine { } await invalidation.engineToCancel?.cancelOperations() + endHostFailureInvalidation() await client.handle( failure: failure, recordID: nil diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index ba8a984..391e523 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -12,7 +12,7 @@ Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetc CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine teardown so an older engine cannot regress the recovery state after its replacement starts. Known account transitions advance that lifecycle before the host switches accounts, preventing pre-transition ledger snapshots from entering the new account; they clear previous-account recovery state, and sign-out immediately publishes the cleared status. +CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. A matching recovery operation also waits for every older overlapping operation of that kind to drain before clearing the failure. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine cancellation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions advance that lifecycle before the host switches accounts, preventing pre-transition ledger snapshots from entering the new account; they clear previous-account recovery state, and sign-out immediately publishes the cleared status. ## Topics diff --git a/Sources/CloudSaveKit/CloudSaveStateMachine.swift b/Sources/CloudSaveKit/CloudSaveStateMachine.swift index 8768590..374f4c4 100644 --- a/Sources/CloudSaveKit/CloudSaveStateMachine.swift +++ b/Sources/CloudSaveKit/CloudSaveStateMachine.swift @@ -33,6 +33,7 @@ struct CloudSaveStateMachine: Sendable { } let generation = operationGenerations[operation, default: 0] + let hasActiveOperations = activeOperationCounts[operation] != nil failures.removeAll { requirement in guard requirement.context == .operation(operation) else { return false @@ -42,7 +43,7 @@ struct CloudSaveStateMachine: Sendable { return false } - return generation >= minimumRecoveryGeneration + return !hasActiveOperations && generation >= minimumRecoveryGeneration } } diff --git a/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift b/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift index 24b06b9..a088b8d 100644 --- a/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveStateMachineTests.swift @@ -55,6 +55,30 @@ struct CloudSaveStateMachineTests { ) } + @Test("Does not let an older overlapping completion satisfy recovery") + func requiresRecoveryOperationAndOverlappingOperationsToComplete() { + var stateMachine = CloudSaveStateMachine() + + stateMachine.begin(.fetching) + stateMachine.begin(.fetching) + stateMachine.fail( + .restricted, + operation: .fetching + ) + stateMachine.complete(.fetching) + + stateMachine.begin(.fetching) + stateMachine.complete(.fetching) + #expect( + stateMachine.status(hasPendingChanges: false) == .failed(.restricted) + ) + + stateMachine.complete(.fetching) + #expect( + stateMachine.status(hasPendingChanges: false) == .ready(hasPendingChanges: false) + ) + } + @Test("Resolves record failures independently") func resolvesRecordFailuresIndependently() { let firstRecordID = Self.makeRecordID(named: "first") From 467e70f46f58c05b6bcfb4651229201215a82c46 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 21:53:08 +0200 Subject: [PATCH 15/18] Gate account transitions and cancellation --- README.md | 4 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 162 +++++++++++++++--- .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- 3 files changed, 141 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index b13639d..fb17e7b 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,13 @@ 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 before the host switches accounts, invalidating every pre-transition ledger snapshot and explicit operation. They also 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. Transitions also 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. ## Failure and retry policy -CloudSaveKit leaves temporary transport, service, authentication, throttling, and cancellation failures to CKSyncEngine's scheduler. Explicit methods still throw their underlying error so the caller can finish its immediate workflow, but routine retryable errors do not become durable attention-required state. +CloudSaveKit leaves temporary transport, service, authentication, throttling, and cancellation failures to CKSyncEngine's scheduler. Explicit methods and their host-ledger preflight reads still throw their underlying cancellation so the caller can finish its immediate workflow, but routine retryable errors do not become durable attention-required state. Semantic and permanent failures are tracked independently: diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index ff7c18c..0217015 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -11,11 +11,13 @@ public final actor CloudSaveEngine { private let eventHandlingLock = CloudSaveAsyncLock() private let statePersistenceLock = CloudSaveAsyncLock() private let statusContinuation: AsyncStream.Continuation + private var isAccountTransitionPending = false private var isHostFailureInvalidationPending = false - private var hostFailureInvalidationWaiters: [CheckedContinuation] = [] private var lastPersistedStateSerialization: CKSyncEngine.State.Serialization? private var ledgerSnapshotTracker = CloudSaveLedgerSnapshotTracker() + private var lifecycleTransitionWaiters: [UUID: CheckedContinuation] = [:] private var lifecycleGeneration = 0 + private var needsAccountTransitionLedgerRefresh = false private var stateMachine = CloudSaveStateMachine() private var storedSyncEngine: CKSyncEngine? @@ -38,12 +40,16 @@ public final actor CloudSaveEngine { /// Initializes CKSyncEngine and restores every locally durable pending change. public func start() async throws { - await waitForPendingHostFailureInvalidation() + try await waitForPendingLifecycleTransition() let startingLifecycleGeneration = lifecycleGeneration let ledgerSnapshot: CloudSavePendingChangesSnapshot do { ledgerSnapshot = try await readPendingChangesSnapshot() + } catch is CancellationError { + throw CancellationError() + } catch let error as CKError where error.code == .operationCancelled { + throw error } catch { await handleHostFailure( error, @@ -85,9 +91,22 @@ public final actor CloudSaveEngine { let configuredChanges = changes.filter { isInConfiguredZone($0.recordID) } + guard !configuredChanges.isEmpty else { + return + } + + guard !isAccountTransitionPending else { + needsAccountTransitionLedgerRefresh = true + CloudSaveLogging.log( + level: .error, + "enqueue | ignored during account transition" + ) + return + } + ledgerSnapshotTracker.recordEnqueues(configuredChanges) - guard !isHostFailureInvalidationPending, + guard !isLifecycleTransitionPending, !stateMachine.requiresHostRecovery else { CloudSaveLogging.log( @@ -114,6 +133,8 @@ public final actor CloudSaveEngine { /// Immediately fetches changes for the configured save zone. public func fetchNow() async throws { + try await waitForPendingLifecycleTransition() + let session = try operationalSyncEngine() do { @@ -144,11 +165,19 @@ public final actor CloudSaveEngine { /// Immediately sends every locally durable pending change for the configured save zone. public func sendNow() async throws { + try await waitForPendingLifecycleTransition() + let session = try operationalSyncEngine() let ledgerSnapshot: CloudSavePendingChangesSnapshot do { ledgerSnapshot = try await readPendingChangesSnapshot() + } catch is CancellationError { + try throwRecoveryErrorIfNeeded(for: session) + throw CancellationError() + } catch let error as CKError where error.code == .operationCancelled { + try throwRecoveryErrorIfNeeded(for: session) + throw error } catch { try throwRecoveryErrorIfNeeded(for: session) await handleHostFailure( @@ -236,7 +265,7 @@ extension CloudSaveEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - guard !isHostFailureInvalidationPending, + guard !isLifecycleTransitionPending, storedSyncEngine === syncEngine, !stateMachine.requiresHostRecovery else { @@ -274,7 +303,7 @@ extension CloudSaveEngine { _ event: CKSyncEngine.Event, syncEngine: CKSyncEngine ) async { - guard !isHostFailureInvalidationPending, + guard !isLifecycleTransitionPending, storedSyncEngine === syncEngine else { CloudSaveLogging.log("event | ignored stale engine") @@ -345,6 +374,11 @@ extension CloudSaveEngine { // MARK: - Private Lifecycle extension CloudSaveEngine { + /// Whether account-scoped persistence or failed-engine shutdown blocks new work. + fileprivate var isLifecycleTransitionPending: Bool { + isAccountTransitionPending || isHostFailureInvalidationPending + } + /// Creates a CKSyncEngine from the last state successfully persisted by the host. fileprivate func makeSyncEngine() -> CKSyncEngine { var engineConfiguration = CKSyncEngine.Configuration( @@ -362,7 +396,7 @@ extension CloudSaveEngine { syncEngine: CKSyncEngine, lifecycleGeneration: Int ) { - guard !isHostFailureInvalidationPending, + guard !isLifecycleTransitionPending, !stateMachine.requiresHostRecovery else { throw CloudSaveEngineError.hostRecoveryRequired @@ -385,7 +419,7 @@ extension CloudSaveEngine { lifecycleGeneration: Int ) ) throws { - guard !isHostFailureInvalidationPending, + guard !isLifecycleTransitionPending, session.lifecycleGeneration == lifecycleGeneration, storedSyncEngine === session.syncEngine, !stateMachine.requiresHostRecovery @@ -402,7 +436,7 @@ extension CloudSaveEngine { ) ) throws { guard - isHostFailureInvalidationPending + isLifecycleTransitionPending || session.lifecycleGeneration != lifecycleGeneration || storedSyncEngine !== session.syncEngine || stateMachine.requiresHostRecovery @@ -431,6 +465,8 @@ extension CloudSaveEngine { let engineToCancel = syncEngine ?? storedSyncEngine isHostFailureInvalidationPending = true + isAccountTransitionPending = false + needsAccountTransitionLedgerRefresh = false lifecycleGeneration &+= 1 stateMachine.fail( failure, @@ -454,7 +490,6 @@ extension CloudSaveEngine { return false } - isHostFailureInvalidationPending = false guard self.lifecycleGeneration == lifecycleGeneration, storedSyncEngine === syncEngine else { @@ -472,27 +507,63 @@ extension CloudSaveEngine { return true } - /// Prevents explicit recovery from overtaking checkpoint ordering or failed-engine shutdown. - fileprivate func waitForPendingHostFailureInvalidation() async { - guard isHostFailureInvalidationPending else { + /// Prevents explicit recovery from overtaking account changes or failed-engine shutdown. + fileprivate func waitForPendingLifecycleTransition() async throws { + try Task.checkCancellation() + guard isLifecycleTransitionPending else { return } - await withCheckedContinuation { continuation in - guard isHostFailureInvalidationPending else { - continuation.resume() - return - } + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + + guard isLifecycleTransitionPending else { + continuation.resume() + return + } - hostFailureInvalidationWaiters.append(continuation) + lifecycleTransitionWaiters[waiterID] = continuation + } + } onCancel: { + Task { [weak self] in + await self?.cancelLifecycleTransitionWaiter(waiterID) + } } } + /// Removes and cancels one operation waiting for a lifecycle transition. + fileprivate func cancelLifecycleTransitionWaiter(_ waiterID: UUID) { + lifecycleTransitionWaiters.removeValue(forKey: waiterID)?.resume( + throwing: CancellationError() + ) + } + /// Releases explicit recovery only after the failed engine has stopped. fileprivate func endHostFailureInvalidation() { isHostFailureInvalidationPending = false - let waiters = hostFailureInvalidationWaiters - hostFailureInvalidationWaiters.removeAll() + resumeLifecycleTransitionWaitersIfReady() + } + + /// Releases blocked work after the new account's durable ledger has been restored. + fileprivate func endAccountTransition() { + isAccountTransitionPending = false + resumeLifecycleTransitionWaitersIfReady() + } + + /// Resumes lifecycle waiters only when no transition can expose scoped data. + fileprivate func resumeLifecycleTransitionWaitersIfReady() { + guard !isLifecycleTransitionPending else { + return + } + + let waiters = Array(lifecycleTransitionWaiters.values) + lifecycleTransitionWaiters.removeAll() for waiter in waiters { waiter.resume() } @@ -557,9 +628,16 @@ extension CloudSaveEngine { /// Reconciles CKSyncEngine's tracked changes with the host's authoritative durable ledger. fileprivate func restoreDurablePendingChanges( _ snapshot: CloudSavePendingChangesSnapshot, - syncEngine: CKSyncEngine + syncEngine: CKSyncEngine, + allowsAccountTransition: Bool = false ) -> Bool { - guard isCurrent(snapshot, syncEngine: syncEngine) else { + guard + isCurrent( + snapshot, + syncEngine: syncEngine, + allowsAccountTransition: allowsAccountTransition + ) + else { CloudSaveLogging.log("ledger snapshot | ignored stale lifecycle") return false } @@ -682,9 +760,11 @@ extension CloudSaveEngine { /// Returns whether a host-ledger snapshot still belongs to the active engine lifecycle. fileprivate func isCurrent( _ snapshot: CloudSavePendingChangesSnapshot, - syncEngine: CKSyncEngine + syncEngine: CKSyncEngine, + allowsAccountTransition: Bool = false ) -> Bool { !isHostFailureInvalidationPending + && (allowsAccountTransition || !isAccountTransitionPending) && snapshot.belongs(to: lifecycleGeneration) && storedSyncEngine === syncEngine && !stateMachine.requiresHostRecovery @@ -716,7 +796,8 @@ extension CloudSaveEngine { guard restoreDurablePendingChanges( ledgerSnapshot, - syncEngine: syncEngine + syncEngine: syncEngine, + allowsAccountTransition: true ) else { throw CloudSaveEngineError.hostRecoveryRequired @@ -728,6 +809,31 @@ extension CloudSaveEngine { publishStatus(syncEngine: nil) } + /// Reloads the new account's durable ledger when enqueues were blocked during its transition. + fileprivate func refreshPendingChangesAfterAccountTransitionIfNeeded( + _ accountChange: CloudSaveAccountChange, + syncEngine: CKSyncEngine + ) async throws { + if case .signedOut = accountChange { + needsAccountTransitionLedgerRefresh = false + return + } + + while needsAccountTransitionLedgerRefresh { + needsAccountTransitionLedgerRefresh = false + let ledgerSnapshot = try await readPendingChangesSnapshot() + guard + restoreDurablePendingChanges( + ledgerSnapshot, + syncEngine: syncEngine, + allowsAccountTransition: true + ) + else { + throw CloudSaveEngineError.hostRecoveryRequired + } + } + } + /// Recreates the configured zone and restores the host's durable changes after deletion. fileprivate func restoreDeletedZones( _ zoneIDs: [CKRecordZone.ID], @@ -770,10 +876,13 @@ extension CloudSaveEngine { return } + isAccountTransitionPending = true + needsAccountTransitionLedgerRefresh = false lifecycleGeneration &+= 1 let accountLifecycleGeneration = lifecycleGeneration try await client.handle(accountChange: accountChange) guard lifecycleGeneration == accountLifecycleGeneration, + isAccountTransitionPending, !isHostFailureInvalidationPending, !stateMachine.requiresHostRecovery, storedSyncEngine === syncEngine @@ -784,6 +893,11 @@ extension CloudSaveEngine { accountChange, syncEngine: syncEngine ) + try await refreshPendingChangesAfterAccountTransitionIfNeeded( + accountChange, + syncEngine: syncEngine + ) + endAccountTransition() } /// Handles successful and failed configured-zone changes independently. diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 391e523..d09fc17 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -12,7 +12,7 @@ Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetc CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. A matching recovery operation also waits for every older overlapping operation of that kind to drain before clearing the failure. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine cancellation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions advance that lifecycle before the host switches accounts, preventing pre-transition ledger snapshots from entering the new account; they clear previous-account recovery state, and sign-out immediately publishes the cleared status. +CKSyncEngine retains recoverable transport failures and schedules their retries; cancellation from an explicit operation or its host-ledger preflight propagates without becoming a local-persistence failure. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. A matching recovery operation also waits for every older overlapping operation of that kind to drain before clearing the failure. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine cancellation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions block synchronization from the moment the lifecycle advances until the host has switched account-scoped persistence and restored the new durable ledger. This prevents pre-transition work from entering the new account; transitions also clear previous-account recovery state, and sign-out immediately publishes the cleared status. ## Topics From b314b25aed2316770cb4c3acae93a7fc03fd0a58 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 22:10:38 +0200 Subject: [PATCH 16/18] Reject stale host ledger work --- README.md | 2 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 131 ++++++++++++++---- .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- .../CloudSaveLedgerSnapshotTracker.swift | 26 +++- .../CloudSavePendingChangesSnapshot.swift | 11 +- .../CloudSaveLedgerSnapshotTrackerTests.swift | 15 +- ...CloudSavePendingChangesSnapshotTests.swift | 9 +- 7 files changed, 156 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index fb17e7b..492c1fb 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Automatic synchronization should remain enabled in production. Explicit operatio Call `start()` successfully before any explicit synchronization. `fetchNow()` and `sendNow()` throw `CloudSaveEngineError.notStarted` before startup and `CloudSaveEngineError.hostRecoveryRequired` after a host persistence callback fails. Once the local store is healthy again, call `start()` to rebuild from the last successfully persisted CKSyncEngine checkpoint and the host's current durable pending-change ledger. -`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. If the host commits enqueues or acknowledgements while a ledger read is suspended, CloudSaveKit replays those mutations in their original order after reconciling the returned snapshot. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. A nil `record(for:)` result uses the same reconciliation, so an older suspended materialization cannot discard a newer durable save. +`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. Host-ledger reads and mutations are serialized and versioned; a snapshot from before a host commit is rejected, while app enqueues that race with a suspended read are replayed in their original order. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. A nil `record(for:)` result uses the same reconciliation, so an older suspended materialization cannot discard a newer durable save. Record batches are also revalidated after asynchronous materialization and rejected as a whole if an account or engine lifecycle changed while they were being built. 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. diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 0217015..84db2f4 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -9,6 +9,7 @@ public final actor CloudSaveEngine { private let client: any CloudSaveClient private let configuration: CloudSaveConfiguration private let eventHandlingLock = CloudSaveAsyncLock() + private let ledgerPersistenceLock = CloudSaveAsyncLock() private let statePersistenceLock = CloudSaveAsyncLock() private let statusContinuation: AsyncStream.Continuation private var isAccountTransitionPending = false @@ -279,11 +280,21 @@ extension CloudSaveEngine: CKSyncEngineDelegate { return context.options.scope.contains($0) && isInConfiguredZone(recordID) } + let batchLifecycleGeneration = lifecycleGeneration - return await CKSyncEngine.RecordZoneChangeBatch( + let batch = await CKSyncEngine.RecordZoneChangeBatch( pendingChanges: pendingChanges ) { [client, weak self] recordID in let record = await client.record(for: recordID) + guard + await self?.isActive( + syncEngine: syncEngine, + lifecycleGeneration: batchLifecycleGeneration + ) == true + else { + return nil + } + if record == nil { await self?.reconcileUnavailablePendingSave( recordID, @@ -292,6 +303,16 @@ extension CloudSaveEngine: CKSyncEngineDelegate { } return record } + guard + isActive( + syncEngine: syncEngine, + lifecycleGeneration: batchLifecycleGeneration + ) + else { + return nil + } + + return batch } } @@ -328,10 +349,14 @@ extension CloudSaveEngine { syncEngine: syncEngine ) case .fetchedRecordZoneChanges(let event): - try await client.applyFetchedChanges( - records: event.modifications.map(\.record).filter(isInConfiguredZone), - deletedRecordIDs: event.deletions.map(\.recordID).filter(isInConfiguredZone) - ) + let fetchedRecords = event.modifications.map(\.record).filter(isInConfiguredZone) + let deletedRecordIDs = event.deletions.map(\.recordID).filter(isInConfiguredZone) + try await commitPendingChangesMutation { [client] in + try await client.applyFetchedChanges( + records: fetchedRecords, + deletedRecordIDs: deletedRecordIDs + ) + } case .sentRecordZoneChanges(let event): try await handleSentRecordZoneChanges( event, @@ -379,6 +404,17 @@ extension CloudSaveEngine { isAccountTransitionPending || isHostFailureInvalidationPending } + /// Returns whether asynchronous work still belongs to the active engine lifecycle. + fileprivate func isActive( + syncEngine: CKSyncEngine, + lifecycleGeneration: Int + ) -> Bool { + !isLifecycleTransitionPending + && self.lifecycleGeneration == lifecycleGeneration + && storedSyncEngine === syncEngine + && !stateMachine.requiresHostRecovery + } + /// Creates a CKSyncEngine from the last state successfully persisted by the host. fileprivate func makeSyncEngine() -> CKSyncEngine { var engineConfiguration = CKSyncEngine.Configuration( @@ -575,23 +611,42 @@ extension CloudSaveEngine { extension CloudSaveEngine { /// Reads the host ledger while retaining every mutation that can race with its snapshot. fileprivate func readPendingChangesSnapshot() async throws -> CloudSavePendingChangesSnapshot { - let snapshot = ledgerSnapshotTracker.beginSnapshot() - let snapshotLifecycleGeneration = lifecycleGeneration + while true { + try Task.checkCancellation() + let snapshot = ledgerSnapshotTracker.beginSnapshot() + let snapshotLifecycleGeneration = lifecycleGeneration + + do { + let durableChanges = try await ledgerPersistenceLock.withLock { [client] in + try await client.pendingChanges() + } + guard + let subsequentMutations = ledgerSnapshotTracker.completeSnapshot(snapshot) + else { + continue + } - do { - let durableChanges = try await client.pendingChanges() - let subsequentMutations = ledgerSnapshotTracker.completeSnapshot(snapshot) - return CloudSavePendingChangesSnapshot( - lifecycleGeneration: snapshotLifecycleGeneration, - durableChanges: durableChanges, - subsequentMutations: subsequentMutations - ) - } catch { - ledgerSnapshotTracker.cancelSnapshot(snapshot) - throw error + return CloudSavePendingChangesSnapshot( + lifecycleGeneration: snapshotLifecycleGeneration, + ledgerGeneration: ledgerSnapshotTracker.currentGeneration, + durableChanges: durableChanges, + subsequentMutations: subsequentMutations + ) + } catch { + ledgerSnapshotTracker.cancelSnapshot(snapshot) + throw error + } } } + /// Serializes a host ledger mutation and invalidates snapshots from before its commit. + fileprivate func commitPendingChangesMutation( + _ operation: @Sendable () async throws -> Result + ) async rethrows -> Result { + ledgerSnapshotTracker.invalidateSnapshotsForHostMutation() + return try await ledgerPersistenceLock.withLock(operation) + } + /// Persists an opaque state update before accepting it as the next recovery checkpoint. fileprivate func persistStateUpdate( _ event: CKSyncEngine.Event.StateUpdate, @@ -765,7 +820,10 @@ extension CloudSaveEngine { ) -> Bool { !isHostFailureInvalidationPending && (allowsAccountTransition || !isAccountTransitionPending) - && snapshot.belongs(to: lifecycleGeneration) + && snapshot.belongs( + to: lifecycleGeneration, + ledgerGeneration: ledgerSnapshotTracker.currentGeneration + ) && storedSyncEngine === syncEngine && !stateMachine.requiresHostRecovery } @@ -844,7 +902,9 @@ extension CloudSaveEngine { return } - try await client.applyDeletedZones(configuredZoneIDs) + try await commitPendingChangesMutation { [client] in + try await client.applyDeletedZones(configuredZoneIDs) + } let ledgerSnapshot = try await readPendingChangesSnapshot() syncEngine.state.add( pendingDatabaseChanges: [.saveZone(configuration.zone)] @@ -880,7 +940,9 @@ extension CloudSaveEngine { needsAccountTransitionLedgerRefresh = false lifecycleGeneration &+= 1 let accountLifecycleGeneration = lifecycleGeneration - try await client.handle(accountChange: accountChange) + try await commitPendingChangesMutation { [client] in + try await client.handle(accountChange: accountChange) + } guard lifecycleGeneration == accountLifecycleGeneration, isAccountTransitionPending, !isHostFailureInvalidationPending, @@ -937,12 +999,16 @@ extension CloudSaveEngine { let savedRecords = event.savedRecords.filter(isInConfiguredZone) let deletedRecordIDs = event.deletedRecordIDs.filter(isInConfiguredZone) - try await client.didSave(records: savedRecords) + try await commitPendingChangesMutation { [client] in + try await client.didSave(records: savedRecords) + } try await reconcileAcknowledgedPendingChanges( savedRecords.map { .save($0.recordID) }, syncEngine: syncEngine ) - try await client.didDelete(recordIDs: deletedRecordIDs) + try await commitPendingChangesMutation { [client] in + try await client.didDelete(recordIDs: deletedRecordIDs) + } try await reconcileAcknowledgedPendingChanges( deletedRecordIDs.map(CloudSavePendingChange.delete), syncEngine: syncEngine @@ -983,7 +1049,9 @@ extension CloudSaveEngine { for (recordID, error) in event.failedRecordDeletes where isInConfiguredZone(recordID) { switch error.code { case .unknownItem, .zoneNotFound: - try await client.didDelete(recordIDs: [recordID]) + try await commitPendingChangesMutation { [client] in + try await client.didDelete(recordIDs: [recordID]) + } try await reconcileAcknowledgedPendingChanges( [.delete(recordID)], syncEngine: syncEngine @@ -1033,16 +1101,21 @@ extension CloudSaveEngine { switch try await client.resolve(conflict: conflict) { case .acceptServer: - try await client.applyFetchedChanges( - records: [serverRecord], - deletedRecordIDs: [] - ) + try await commitPendingChangesMutation { [client] in + try await client.applyFetchedChanges( + records: [serverRecord], + deletedRecordIDs: [] + ) + } try await reconcileAcknowledgedPendingChanges( [.save(recordID)], syncEngine: syncEngine ) case .retry(let mergedRecord): - try await client.persistResolvedRecord(mergedRecord) + try await commitPendingChangesMutation { [client] in + try await client.persistResolvedRecord(mergedRecord) + } + ledgerSnapshotTracker.recordEnqueues([.save(mergedRecord.recordID)]) changesToRetry.append(.saveRecord(mergedRecord.recordID)) case .requiresUserDecision: await reportFailure( diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index d09fc17..13d7c03 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -12,7 +12,7 @@ Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetc CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries; cancellation from an explicit operation or its host-ledger preflight propagates without becoming a local-persistence failure. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. A matching recovery operation also waits for every older overlapping operation of that kind to drain before clearing the failure. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Enqueues and acknowledgements that race with any suspended ledger read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine cancellation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions block synchronization from the moment the lifecycle advances until the host has switched account-scoped persistence and restored the new durable ledger. This prevents pre-transition work from entering the new account; transitions also clear previous-account recovery state, and sign-out immediately publishes the cleared status. +CKSyncEngine retains recoverable transport failures and schedules their retries; cancellation from an explicit operation or its host-ledger preflight propagates without becoming a local-persistence failure. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. A matching recovery operation also waits for every older overlapping operation of that kind to drain before clearing the failure. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Host-ledger reads and mutations are serialized and versioned, and app enqueues that race with a suspended read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. Record batches are revalidated after asynchronous materialization and rejected as a whole when their engine lifecycle becomes stale. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine cancellation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions block synchronization from the moment the lifecycle advances until the host has switched account-scoped persistence and restored the new durable ledger. This prevents pre-transition work from entering the new account; transitions also clear previous-account recovery state, and sign-out immediately publishes the cleared status. ## Topics diff --git a/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift b/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift index 41dbc28..621da39 100644 --- a/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift +++ b/Sources/CloudSaveKit/CloudSaveLedgerSnapshotTracker.swift @@ -1,19 +1,26 @@ import CloudKit import Foundation -/// Retains ordered enqueues that occur while the host produces a durable-ledger snapshot. +/// Versions host-ledger snapshots and retains ordered mutations that race with their reads. struct CloudSaveLedgerSnapshotTracker: Sendable { private var activeSnapshots: [Snapshot] = [] private var ledgerMutations: [TrackedMutation] = [] private var latestGeneration = 0 + private var latestInvalidationGeneration = 0 private var nextIdentifier = 0 + /// The latest boundary established before an authoritative host-ledger mutation. + var currentGeneration: Int { + latestInvalidationGeneration + } + /// Begins tracking changes that can race with one host-ledger read. mutating func beginSnapshot() -> Snapshot { nextIdentifier &+= 1 let snapshot = Snapshot( generation: latestGeneration, - identifier: nextIdentifier + identifier: nextIdentifier, + invalidationGeneration: latestInvalidationGeneration ) activeSnapshots.append(snapshot) return snapshot @@ -29,10 +36,20 @@ struct CloudSaveLedgerSnapshotTracker: Sendable { record(changes.map(CloudSaveLedgerMutation.remove)) } + /// Invalidates snapshots that could predate an authoritative host-ledger mutation. + mutating func invalidateSnapshotsForHostMutation() { + latestInvalidationGeneration &+= 1 + } + /// Completes a snapshot and returns ledger mutations committed after its read began. - mutating func completeSnapshot(_ snapshot: Snapshot) -> [CloudSaveLedgerMutation] { + mutating func completeSnapshot(_ snapshot: Snapshot) -> [CloudSaveLedgerMutation]? { guard remove(snapshot) else { - return [] + return nil + } + + guard snapshot.invalidationGeneration == latestInvalidationGeneration else { + pruneMutationsNoLongerNeeded() + return nil } let mutations: [CloudSaveLedgerMutation] = ledgerMutations.compactMap { trackedMutation in @@ -63,6 +80,7 @@ extension CloudSaveLedgerSnapshotTracker { struct Snapshot: Equatable, Sendable { fileprivate let generation: Int fileprivate let identifier: Int + fileprivate let invalidationGeneration: Int } } diff --git a/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift index 1a80dfa..952a5f7 100644 --- a/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift +++ b/Sources/CloudSaveKit/CloudSavePendingChangesSnapshot.swift @@ -5,6 +5,9 @@ struct CloudSavePendingChangesSnapshot: Sendable { /// The engine lifecycle generation in which the host-ledger read began. let lifecycleGeneration: Int + /// The authoritative host-ledger generation in which the read completed. + let ledgerGeneration: Int + /// The authoritative pending changes returned by the host. let durableChanges: [CloudSavePendingChange] @@ -15,9 +18,13 @@ struct CloudSavePendingChangesSnapshot: Sendable { // MARK: - Effective Changes extension CloudSavePendingChangesSnapshot { - /// Returns whether the snapshot was read during the specified engine lifecycle. - func belongs(to expectedLifecycleGeneration: Int) -> Bool { + /// Returns whether the snapshot belongs to the specified engine and host-ledger generations. + func belongs( + to expectedLifecycleGeneration: Int, + ledgerGeneration expectedLedgerGeneration: Int + ) -> Bool { lifecycleGeneration == expectedLifecycleGeneration + && ledgerGeneration == expectedLedgerGeneration } /// Returns whether reconciliation leaves one exact change pending. diff --git a/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift b/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift index 6e6b583..c146f8b 100644 --- a/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveLedgerSnapshotTrackerTests.swift @@ -79,7 +79,7 @@ struct CloudSaveLedgerSnapshotTrackerTests { tracker.recordEnqueues([change]) let snapshot = tracker.beginSnapshot() - #expect(tracker.completeSnapshot(snapshot).isEmpty) + #expect(tracker.completeSnapshot(snapshot) == []) } @Test("Cancelling one ledger read keeps changes needed by an older read") @@ -123,6 +123,19 @@ struct CloudSaveLedgerSnapshotTrackerTests { == [.enqueue(change), .remove(change)] ) } + + @Test("Invalidates a ledger read that predates an authoritative host commit") + func invalidatesSnapshotAcrossHostCommit() { + var tracker = CloudSaveLedgerSnapshotTracker() + + let staleSnapshot = tracker.beginSnapshot() + tracker.invalidateSnapshotsForHostMutation() + let currentSnapshot = tracker.beginSnapshot() + + #expect(tracker.completeSnapshot(staleSnapshot) == nil) + #expect(tracker.completeSnapshot(currentSnapshot) == []) + #expect(tracker.currentGeneration == 1) + } } // MARK: - Private diff --git a/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift b/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift index 0618d32..893d13a 100644 --- a/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift +++ b/Tests/CloudSaveKitTests/CloudSavePendingChangesSnapshotTests.swift @@ -12,6 +12,7 @@ struct CloudSavePendingChangesSnapshotTests { ) let snapshot = CloudSavePendingChangesSnapshot( lifecycleGeneration: 1, + ledgerGeneration: 1, durableChanges: [], subsequentMutations: [.enqueue(save)] ) @@ -26,6 +27,7 @@ struct CloudSavePendingChangesSnapshotTests { ) let snapshot = CloudSavePendingChangesSnapshot( lifecycleGeneration: 1, + ledgerGeneration: 1, durableChanges: [save], subsequentMutations: [.remove(save)] ) @@ -40,6 +42,7 @@ struct CloudSavePendingChangesSnapshotTests { let delete = CloudSavePendingChange.delete(recordID) let snapshot = CloudSavePendingChangesSnapshot( lifecycleGeneration: 1, + ledgerGeneration: 1, durableChanges: [save], subsequentMutations: [ .enqueue(delete), @@ -55,12 +58,14 @@ struct CloudSavePendingChangesSnapshotTests { func rejectsPreviousLifecycle() { let snapshot = CloudSavePendingChangesSnapshot( lifecycleGeneration: 7, + ledgerGeneration: 11, durableChanges: [], subsequentMutations: [] ) - #expect(snapshot.belongs(to: 7)) - #expect(!snapshot.belongs(to: 8)) + #expect(snapshot.belongs(to: 7, ledgerGeneration: 11)) + #expect(!snapshot.belongs(to: 8, ledgerGeneration: 11)) + #expect(!snapshot.belongs(to: 7, ledgerGeneration: 12)) } } From 8b406277ebd17e02021f50015ba9c549e0e1adcc Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 22:23:15 +0200 Subject: [PATCH 17/18] Propagate record materialization failures --- README.md | 2 +- Sources/CloudSaveKit/CloudSaveClient.swift | 5 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 88 +++++++++++++++---- .../CloudSaveKit.docc/CloudSaveKit.md | 2 +- 4 files changed, 78 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 492c1fb..cf99097 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Automatic synchronization should remain enabled in production. Explicit operatio Call `start()` successfully before any explicit synchronization. `fetchNow()` and `sendNow()` throw `CloudSaveEngineError.notStarted` before startup and `CloudSaveEngineError.hostRecoveryRequired` after a host persistence callback fails. Once the local store is healthy again, call `start()` to rebuild from the last successfully persisted CKSyncEngine checkpoint and the host's current durable pending-change ledger. -`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. Host-ledger reads and mutations are serialized and versioned; a snapshot from before a host commit is rejected, while app enqueues that race with a suspended read are replayed in their original order. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. A nil `record(for:)` result uses the same reconciliation, so an older suspended materialization cannot discard a newer durable save. Record batches are also revalidated after asynchronous materialization and rejected as a whole if an account or engine lifecycle changed while they were being built. +`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. Host-ledger reads and mutations are serialized and versioned; a snapshot from before a host commit is rejected, while app enqueues that race with a suspended read are replayed in their original order. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. A nil `record(for:)` result means the record no longer exists and uses the same reconciliation, while a thrown materialization error stops synchronization and requires host recovery. Record batches are also revalidated after asynchronous materialization and rejected as a whole if an account or engine lifecycle changed while they were being built. 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. diff --git a/Sources/CloudSaveKit/CloudSaveClient.swift b/Sources/CloudSaveKit/CloudSaveClient.swift index 95b7239..93cb224 100644 --- a/Sources/CloudSaveKit/CloudSaveClient.swift +++ b/Sources/CloudSaveKit/CloudSaveClient.swift @@ -6,7 +6,10 @@ public protocol CloudSaveClient: Sendable { func pendingChanges() async throws -> [CloudSavePendingChange] /// Materializes the current local value for a pending record save. - func record(for recordID: CKRecord.ID) async -> CKRecord? + /// + /// Return `nil` only when the record no longer exists. Throw when local persistence cannot read + /// or decode a record so ``CloudSaveEngine`` can enter host recovery. + func record(for recordID: CKRecord.ID) async throws -> CKRecord? /// Persists CKSyncEngine's opaque state after every state update. func persist(stateSerialization: CKSyncEngine.State.Serialization) async throws diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 84db2f4..5c758b4 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -285,23 +285,32 @@ extension CloudSaveEngine: CKSyncEngineDelegate { let batch = await CKSyncEngine.RecordZoneChangeBatch( pendingChanges: pendingChanges ) { [client, weak self] recordID in - let record = await client.record(for: recordID) - guard - await self?.isActive( + do { + let record = try await client.record(for: recordID) + guard + await self?.isActive( + syncEngine: syncEngine, + lifecycleGeneration: batchLifecycleGeneration + ) == true + else { + return nil + } + + if record == nil { + await self?.reconcileUnavailablePendingSave( + recordID, + syncEngine: syncEngine + ) + } + return record + } catch { + await self?.handleRecordMaterializationFailure( + error, syncEngine: syncEngine, lifecycleGeneration: batchLifecycleGeneration - ) == true - else { - return nil - } - - if record == nil { - await self?.reconcileUnavailablePendingSave( - recordID, - syncEngine: syncEngine ) + return nil } - return record } guard isActive( @@ -812,6 +821,40 @@ extension CloudSaveEngine { } } + /// Stops the active engine when its host cannot materialize a pending record. + fileprivate func handleRecordMaterializationFailure( + _ error: any Error, + syncEngine: CKSyncEngine, + lifecycleGeneration: Int + ) { + guard + isActive( + syncEngine: syncEngine, + lifecycleGeneration: lifecycleGeneration + ) + else { + return + } + + let failure = CloudSaveFailure(clientError: error) + guard + let invalidation = beginHostFailureInvalidation( + failure, + syncEngine: syncEngine + ) + else { + return + } + + Task { [weak self] in + await self?.completeHostFailureInvalidation( + failure, + lifecycleGeneration: invalidation.lifecycleGeneration, + engineToCancel: invalidation.engineToCancel + ) + } + } + /// Returns whether a host-ledger snapshot still belongs to the active engine lifecycle. fileprivate func isCurrent( _ snapshot: CloudSavePendingChangesSnapshot, @@ -1197,18 +1240,31 @@ extension CloudSaveEngine { return } + await completeHostFailureInvalidation( + failure, + lifecycleGeneration: invalidation.lifecycleGeneration, + engineToCancel: invalidation.engineToCancel + ) + } + + /// Completes failed-engine checkpoint ordering, cancellation, and host notification. + fileprivate func completeHostFailureInvalidation( + _ failure: CloudSaveFailure, + lifecycleGeneration: Int, + engineToCancel: CKSyncEngine? + ) async { let didFinish = await statePersistenceLock.withLock { [self] in await finishHostFailureInvalidation( failure, - lifecycleGeneration: invalidation.lifecycleGeneration, - syncEngine: invalidation.engineToCancel + lifecycleGeneration: lifecycleGeneration, + syncEngine: engineToCancel ) } guard didFinish else { return } - await invalidation.engineToCancel?.cancelOperations() + await engineToCancel?.cancelOperations() endHostFailureInvalidation() await client.handle( failure: failure, diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 13d7c03..b00d824 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -12,7 +12,7 @@ Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetc CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries; cancellation from an explicit operation or its host-ledger preflight propagates without becoming a local-persistence failure. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. A matching recovery operation also waits for every older overlapping operation of that kind to drain before clearing the failure. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Host-ledger reads and mutations are serialized and versioned, and app enqueues that race with a suspended read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and nil record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. Record batches are revalidated after asynchronous materialization and rejected as a whole when their engine lifecycle becomes stale. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine cancellation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions block synchronization from the moment the lifecycle advances until the host has switched account-scoped persistence and restored the new durable ledger. This prevents pre-transition work from entering the new account; transitions also clear previous-account recovery state, and sign-out immediately publishes the cleared status. +CKSyncEngine retains recoverable transport failures and schedules their retries; cancellation from an explicit operation or its host-ledger preflight propagates without becoming a local-persistence failure. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. A matching recovery operation also waits for every older overlapping operation of that kind to drain before clearing the failure. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Host-ledger reads and mutations are serialized and versioned, and app enqueues that race with a suspended read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and absent record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. A thrown materialization error enters host recovery instead of silently retrying an unreadable record. Record batches are revalidated after asynchronous materialization and rejected as a whole when their engine lifecycle becomes stale. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine cancellation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions block synchronization from the moment the lifecycle advances until the host has switched account-scoped persistence and restored the new durable ledger. This prevents pre-transition work from entering the new account; transitions also clear previous-account recovery state, and sign-out immediately publishes the cleared status. ## Topics From a4f279c055a49079d2050cb82d831d49f9ffffa0 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Fri, 14 Aug 2026 22:34:02 +0200 Subject: [PATCH 18/18] Preserve record materialization cancellation --- README.md | 2 +- Sources/CloudSaveKit/CloudSaveEngine.swift | 4 ++++ Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md | 2 +- Sources/CloudSaveKit/CloudSaveRetryPolicy.swift | 6 ++++++ Tests/CloudSaveKitTests/CloudSaveRetryPolicyTests.swift | 7 +++++++ 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cf99097..e0c8819 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Automatic synchronization should remain enabled in production. Explicit operatio Call `start()` successfully before any explicit synchronization. `fetchNow()` and `sendNow()` throw `CloudSaveEngineError.notStarted` before startup and `CloudSaveEngineError.hostRecoveryRequired` after a host persistence callback fails. Once the local store is healthy again, call `start()` to rebuild from the last successfully persisted CKSyncEngine checkpoint and the host's current durable pending-change ledger. -`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. Host-ledger reads and mutations are serialized and versioned; a snapshot from before a host commit is rejected, while app enqueues that race with a suspended read are replayed in their original order. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. A nil `record(for:)` result means the record no longer exists and uses the same reconciliation, while a thrown materialization error stops synchronization and requires host recovery. Record batches are also revalidated after asynchronous materialization and rejected as a whole if an account or engine lifecycle changed while they were being built. +`sendNow()` reloads that ledger before sending. This makes the host the source of truth if CKSyncEngine discarded a semantic failure or if a previously persisted checkpoint still contains a change the host has since acknowledged. Host-ledger reads and mutations are serialized and versioned; a snapshot from before a host commit is rejected, while app enqueues that race with a suspended read are replayed in their original order. After a successful send, CloudSaveKit rereads the host ledger before removing completed work; a newer save of the same record therefore remains pending instead of being erased by the older acknowledgement. A nil `record(for:)` result means the record no longer exists and uses the same reconciliation, while a thrown materialization error stops synchronization and requires host recovery. Materialization cancellation remains under CKSyncEngine's cancellation lifecycle and does not invalidate the host. Record batches are also revalidated after asynchronous materialization and rejected as a whole if an account or engine lifecycle changed while they were being built. 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. diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index 5c758b4..bdf7513 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -304,6 +304,10 @@ extension CloudSaveEngine: CKSyncEngineDelegate { } return record } catch { + guard !CloudSaveRetryPolicy.isCancellation(error) else { + return nil + } + await self?.handleRecordMaterializationFailure( error, syncEngine: syncEngine, diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index b00d824..e5626bd 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -12,7 +12,7 @@ Automatic synchronization remains enabled by default. Use ``CloudSaveEngine/fetc CloudSaveKit forwards only records, record deletions, and custom-zone deletions from its configured custom zone. 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``. -CKSyncEngine retains recoverable transport failures and schedules their retries; cancellation from an explicit operation or its host-ledger preflight propagates without becoming a local-persistence failure. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. A matching recovery operation also waits for every older overlapping operation of that kind to drain before clearing the failure. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Host-ledger reads and mutations are serialized and versioned, and app enqueues that race with a suspended read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and absent record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. A thrown materialization error enters host recovery instead of silently retrying an unreadable record. Record batches are revalidated after asynchronous materialization and rejected as a whole when their engine lifecycle becomes stale. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine cancellation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions block synchronization from the moment the lifecycle advances until the host has switched account-scoped persistence and restored the new durable ledger. This prevents pre-transition work from entering the new account; transitions also clear previous-account recovery state, and sign-out immediately publishes the cleared status. +CKSyncEngine retains recoverable transport failures and schedules their retries; cancellation from an explicit operation, host-ledger preflight, or record materialization does not become a local-persistence failure. CloudSaveKit keeps permanent and semantic failures independently by record, zone, operation, and host-persistence context. A successful fetch cannot erase a record upload failure, one successful record cannot erase another conflict, and completion from the operation that failed cannot immediately clear its own error. A matching recovery operation also waits for every older overlapping operation of that kind to drain before clearing the failure. ``CloudSaveEngine/sendNow()`` reconciles CKSyncEngine state with the host's durable pending-change ledger before every explicit send. Host-ledger reads and mutations are serialized and versioned, and app enqueues that race with a suspended read are replayed afterward in their original order, so a stale snapshot cannot replace a newer change or resurrect completed work. Successful sends and absent record materializations reread the current host ledger before discarding pending state, preserving a newer same-record mutation that superseded suspended work. A thrown materialization error enters host recovery instead of silently retrying an unreadable record. Record batches are revalidated after asynchronous materialization and rejected as a whole when their engine lifecycle becomes stale. A host callback failure blocks new work and advances the lifecycle immediately. Checkpoint writes and explicit ``CloudSaveEngine/start()`` recovery remain ordered behind failed-engine cancellation so an older engine cannot regress the recovery state after its replacement starts. Known account transitions block synchronization from the moment the lifecycle advances until the host has switched account-scoped persistence and restored the new durable ledger. This prevents pre-transition work from entering the new account; transitions also clear previous-account recovery state, and sign-out immediately publishes the cleared status. ## Topics diff --git a/Sources/CloudSaveKit/CloudSaveRetryPolicy.swift b/Sources/CloudSaveKit/CloudSaveRetryPolicy.swift index 44a19ac..7b43413 100644 --- a/Sources/CloudSaveKit/CloudSaveRetryPolicy.swift +++ b/Sources/CloudSaveKit/CloudSaveRetryPolicy.swift @@ -2,6 +2,12 @@ import CloudKit /// Centralizes which CloudKit errors remain under CKSyncEngine's retry ownership. enum CloudSaveRetryPolicy { + /// Returns whether a failure represents intentional task or CloudKit cancellation. + static func isCancellation(_ error: any Error) -> Bool { + error is CancellationError + || (error as? CKError)?.code == .operationCancelled + } + /// Returns whether a failure requires application attention. static func requiresApplicationAttention(for error: CKError) -> Bool { switch error.code { diff --git a/Tests/CloudSaveKitTests/CloudSaveRetryPolicyTests.swift b/Tests/CloudSaveKitTests/CloudSaveRetryPolicyTests.swift index d6ed4e2..fc340b9 100644 --- a/Tests/CloudSaveKitTests/CloudSaveRetryPolicyTests.swift +++ b/Tests/CloudSaveKitTests/CloudSaveRetryPolicyTests.swift @@ -5,6 +5,13 @@ import Testing @Suite("Cloud save retry policy") struct CloudSaveRetryPolicyTests { + @Test("Recognizes task and CloudKit cancellation") + func recognizesCancellation() { + #expect(CloudSaveRetryPolicy.isCancellation(CancellationError())) + #expect(CloudSaveRetryPolicy.isCancellation(CKError(.operationCancelled))) + #expect(!CloudSaveRetryPolicy.isCancellation(CKError(.networkFailure))) + } + @Test( "Leaves transport and scheduling failures to CKSyncEngine", arguments: [