@horka/app-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +99 -0
- package/bin/cli.js +371 -0
- package/bin/cli.test.js +91 -0
- package/package.json +43 -0
- package/templates/core/CLAUDE.md +36 -0
- package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
- package/templates/core/claude/memory/COMMANDS.md +13 -0
- package/templates/core/claude/memory/DECISIONS.md +5 -0
- package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
- package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
- package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
- package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
- package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
- package/templates/core/claude/skills/save-context/SKILL.md +35 -0
- package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
- package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
- package/templates/core/docs-architecture/DELIVERY.md +68 -0
- package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
- package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
- package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
- package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
- package/templates/core/gitignore +15 -0
- package/templates/core/mcp.json +8 -0
- package/templates/packs/nuxt-web/CLAUDE.md +74 -0
- package/templates/packs/nuxt-web/app/app.vue +5 -0
- package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
- package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
- package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
- package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
- package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
- package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
- package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
- package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
- package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
- package/templates/packs/nuxt-web/gitignore +18 -0
- package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
- package/templates/packs/nuxt-web/pack.json +11 -0
- package/templates/packs/nuxt-web/package.json +31 -0
- package/templates/packs/nuxt-web/playwright.config.ts +39 -0
- package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
- package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
- package/templates/packs/nuxt-web/tsconfig.json +4 -0
- package/templates/packs/nuxt-web/vitest.config.ts +23 -0
- package/templates/packs/swift-ios/CLAUDE.md +64 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
- package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
- package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
- package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
- package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
- package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
- package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
- package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
- package/templates/packs/swift-ios/gitignore +5 -0
- package/templates/packs/swift-ios/mcp.json +8 -0
- package/templates/packs/swift-ios/pack.json +11 -0
- package/templates/packs/swift-ios/project.yml +33 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
- package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
- package/templates/packs/ts-sdk/CLAUDE.md +72 -0
- package/templates/packs/ts-sdk/MIGRATION.md +28 -0
- package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
- package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
- package/templates/packs/ts-sdk/gitignore +6 -0
- package/templates/packs/ts-sdk/pack.json +11 -0
- package/templates/packs/ts-sdk/package.json +55 -0
- package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
- package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
- package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
- package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
- package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
- package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
- package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
- package/templates/packs/ts-sdk/src/index.ts +62 -0
- package/templates/packs/ts-sdk/src/types/index.ts +33 -0
- package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
- package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
- package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
- package/templates/packs/ts-sdk/tsconfig.json +15 -0
- package/templates/packs/ts-sdk/tsup.config.ts +22 -0
- package/templates/packs/ts-sdk/vitest.config.ts +8 -0
- package/templates/packs/vapor-api/CLAUDE.md +73 -0
- package/templates/packs/vapor-api/Dockerfile +80 -0
- package/templates/packs/vapor-api/Package.swift +68 -0
- package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
- package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
- package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
- package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
- package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
- package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
- package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
- package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
- package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
- package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
- package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
- package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
- package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
- package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
- package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
- package/templates/packs/vapor-api/env_dist +29 -0
- package/templates/packs/vapor-api/gitignore +7 -0
- package/templates/packs/vapor-api/pack.json +11 -0
- package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
- package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
public extension DS {
|
|
4
|
+
/// Typography scale. Use through `.designSystem(font:)` — never `.font(.system(...))` in app code.
|
|
5
|
+
enum Font {
|
|
6
|
+
case largeTitle, title, h2, h3, body, callout, caption, button, mono
|
|
7
|
+
|
|
8
|
+
var font: SwiftUI.Font {
|
|
9
|
+
switch self {
|
|
10
|
+
case .largeTitle: .system(size: 34, weight: .bold)
|
|
11
|
+
case .title: .system(size: 28, weight: .bold)
|
|
12
|
+
case .h2: .system(size: 22, weight: .semibold)
|
|
13
|
+
case .h3: .system(size: 17, weight: .semibold)
|
|
14
|
+
case .body: .system(size: 17)
|
|
15
|
+
case .callout: .system(size: 15)
|
|
16
|
+
case .caption: .system(size: 12)
|
|
17
|
+
case .button: .system(size: 17, weight: .semibold)
|
|
18
|
+
case .mono: .system(size: 15, design: .monospaced)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public extension View {
|
|
25
|
+
func designSystem(font: DS.Font) -> some View { self.font(font.font) }
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} — Commands
|
|
2
|
+
|
|
3
|
+
> Only commands proven to work in THIS project, with exact flags.
|
|
4
|
+
|
|
5
|
+
## Packages (fast loop — always first)
|
|
6
|
+
swift build --package-path Packages/{{PROJECT_NAME}}Core
|
|
7
|
+
swift test --package-path Packages/{{PROJECT_NAME}}Core
|
|
8
|
+
swift build --package-path Packages/DataLayer
|
|
9
|
+
swift build --package-path Packages/{{PROJECT_NAME}}DS
|
|
10
|
+
|
|
11
|
+
## App target
|
|
12
|
+
xcodegen generate # regenerate .xcodeproj after adding files (run at project root)
|
|
13
|
+
xcodebuild -project {{PROJECT_NAME}}.xcodeproj -scheme {{PROJECT_NAME}} \
|
|
14
|
+
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
|
|
15
|
+
-derivedDataPath /tmp/{{PROJECT_NAME}}_dd build 2>&1 | grep -E "error:|BUILD"
|
|
16
|
+
|
|
17
|
+
## Simulator (via ios-simulator MCP)
|
|
18
|
+
# install_app → launch_app({{BUNDLE_ID}}) → screenshot → inspect
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# ARCHITECTURE — Layered "Lego Brick" iOS App
|
|
2
|
+
|
|
3
|
+
Pattern for SwiftUI apps (Swift 6.2, strict concurrency, iOS 26 everywhere — app target AND packages (one deployment story; bump together)). The app
|
|
4
|
+
is assembled from
|
|
5
|
+
independently buildable bricks: 3 local SPM packages + 3 app-target folders. Every layer below the
|
|
6
|
+
app target compiles and tests with plain `swift build` / `swift test` — no Xcode, no simulator.
|
|
7
|
+
|
|
8
|
+
## 1. Layer Model — Swift instantiation of the universal L0–L5 contract
|
|
9
|
+
|
|
10
|
+
This maps `ARCHITECTURE_PRINCIPLES.md` (read it first) onto SPM packages + app folders:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
L5 COMPLETE FEATURES App/ user-facing screens assembling bricks (VVM-I)
|
|
14
|
+
Store/ @Observable app state, composition root (picks real vs InMemory)
|
|
15
|
+
Tools/ cross-cutting: router, schedulers, formatters, location
|
|
16
|
+
L4 SHARED FEATURES Module/ reusable domain-aware UI bricks Module/Item, Module/ItemGroup, Module/Map
|
|
17
|
+
L3 CORE LOGIC Packages/{{PROJECT_NAME}}Core (pure Swift) domain models + engines + services
|
|
18
|
+
+ repository CONTRACTS + InMemory impls
|
|
19
|
+
L3 CORE UI Packages/{{PROJECT_NAME}}DS — Components/ domain-blind components (buttons, cards…)
|
|
20
|
+
L2 DATA Packages/DataLayer (IO) repository IMPLEMENTATIONS (CloudKit/network)
|
|
21
|
+
+ CKRecord↔domain mapping — implements L3 contracts
|
|
22
|
+
L1 OPS no dedicated package until needed — logging conventions live in CONVENTIONS.md;
|
|
23
|
+
create Packages/Ops (remote config, analytics, feature flags) the day a slice needs it
|
|
24
|
+
L0 FOUNDATION Packages/{{PROJECT_NAME}}DS — tokens DS.Padding/Radius, Color.DS.*, DS.Font
|
|
25
|
+
+ base extensions/formatters
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Notes on the mapping:
|
|
29
|
+
- **The DS package physically hosts two layers**: L0 (tokens) and L3 Core UI (Components/).
|
|
30
|
+
Internal rule: Components import tokens, never the reverse.
|
|
31
|
+
- **DataLayer implements Core's contracts** — the one sanctioned upward arrow (ports & adapters):
|
|
32
|
+
it may import L3 protocols + models, never services/engines.
|
|
33
|
+
- **Imports point downward only** everywhere else; a feature never imports a sibling feature.
|
|
34
|
+
|
|
35
|
+
Brick rule: **Module/ components must work in any app screen; App/ screens are throwaway
|
|
36
|
+
assemblies.** When in doubt, start in App/ and promote to Module/ on second use.
|
|
37
|
+
|
|
38
|
+
## 2. Dependency Direction Rules
|
|
39
|
+
|
|
40
|
+
| Layer (L#) | May import | Must NEVER import | Why |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| L3 Core Logic ({{PROJECT_NAME}}Core) | Foundation (the Swift module) only | SwiftUI, UIKit, CloudKit, DataLayer | Tests run on the macOS host in seconds; logic stays platform-portable |
|
|
43
|
+
| L2 DataLayer | L3 contracts/models + IO frameworks (CloudKit, URLSession) | SwiftUI, the app target | It only implements Core protocols |
|
|
44
|
+
| L0+L3-UI {{PROJECT_NAME}}DS | SwiftUI | Core, DataLayer | Tokens/components are domain-blind, reusable across apps |
|
|
45
|
+
| L4 Module/ | L3 Core, DS | DataLayer, Store types in signatures | Bricks take domain values + closures, not the store |
|
|
46
|
+
| L5 App/ | everything | — | Final assembly point |
|
|
47
|
+
| L5 Store/ | L3 Core, L2 DataLayer | DS (it has no UI) | Sole place that knows which repository implementation runs |
|
|
48
|
+
|
|
49
|
+
> ⚠️ **Gotcha:** Symptom — `swift test` on Core suddenly needs a simulator destination and takes
|
|
50
|
+
> minutes. Cause — someone added `import SwiftUI` (often for `Color` or `@Observable` view helpers)
|
|
51
|
+
> to a domain file. Fix — Core stays UI-free; presentation mapping (colors, labels) lives in
|
|
52
|
+
> Module/ extensions, e.g. `extension Item.Status { var dsColor: Color }` in the app target.
|
|
53
|
+
|
|
54
|
+
> ⚠️ **Gotcha:** Symptom — testing a Service or ViewModel forces importing CloudKit and an iCloud
|
|
55
|
+
> account. Cause — repository protocol was declared in DataLayer next to its implementation.
|
|
56
|
+
> Fix — `protocol ItemRepository: Sendable` lives in **Core**, alongside an
|
|
57
|
+
> `actor InMemoryItemRepository: ItemRepository` for tests/previews/offline. DataLayer only ships
|
|
58
|
+
> `CloudKitItemRepository` (or `URLSessionItemRepository`, etc.) conforming to it.
|
|
59
|
+
|
|
60
|
+
## 3. SPM Local Packages — one Package.swift per layer
|
|
61
|
+
|
|
62
|
+
```swift
|
|
63
|
+
// Packages/{{PROJECT_NAME}}Core/Package.swift
|
|
64
|
+
// swift-tools-version: 6.2
|
|
65
|
+
import PackageDescription
|
|
66
|
+
|
|
67
|
+
let package = Package(
|
|
68
|
+
name: "{{PROJECT_NAME}}Core",
|
|
69
|
+
platforms: [.iOS(.v18), .macOS(.v14)], // macOS = host-runnable tests
|
|
70
|
+
products: [.library(name: "{{PROJECT_NAME}}Core", targets: ["{{PROJECT_NAME}}Core"])],
|
|
71
|
+
targets: [
|
|
72
|
+
.target(name: "{{PROJECT_NAME}}Core", swiftSettings: [.swiftLanguageMode(.v6)]),
|
|
73
|
+
.testTarget(name: "{{PROJECT_NAME}}CoreTests", dependencies: ["{{PROJECT_NAME}}Core"],
|
|
74
|
+
swiftSettings: [.swiftLanguageMode(.v6)]),
|
|
75
|
+
]
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- DataLayer adds `dependencies: [.package(path: "../{{PROJECT_NAME}}Core")]` and its own test target.
|
|
80
|
+
- The DS package adds `.defaultIsolation(MainActor.self)` to its swiftSettings.
|
|
81
|
+
- Xcode: add the three local packages to the app target once; they resolve by path.
|
|
82
|
+
|
|
83
|
+
> ⚠️ **Gotcha:** Symptom — a SwiftUI-only design-system package drowns in Swift 6 errors
|
|
84
|
+
> ("call to main actor-isolated…") on every component. Cause — SwiftUI types are MainActor-bound
|
|
85
|
+
> but package code defaults to nonisolated. Fix — set `.defaultIsolation(MainActor.self)` on the
|
|
86
|
+
> DS target instead of annotating every struct. Do NOT set it on Core (its actors/engines must
|
|
87
|
+
> stay nonisolated) or DataLayer.
|
|
88
|
+
|
|
89
|
+
## 4. Layer Contracts
|
|
90
|
+
|
|
91
|
+
### Core — pure business logic
|
|
92
|
+
- **Domain**: `Sendable` value types (`Item`, `ItemDraft`, `User`, `ItemGroup`, `Coordinate`, `Stats`).
|
|
93
|
+
Never name a domain type `Group` — it collides with `SwiftUI.Group` in every view file.
|
|
94
|
+
- **Engine**: pure static funcs on caseless enums — `ScoreEngine`, `StatsEngine.compute(...)`,
|
|
95
|
+
`AchievementEngine`. Deterministic in/out, no IO, no clock access (pass `now: Date` as parameter).
|
|
96
|
+
- **Catalog**: static data tables (achievement definitions, levels) — rules stay in engines, data
|
|
97
|
+
in catalogs; never encode rules as stored JSON conditions.
|
|
98
|
+
- **Repository**: protocols + in-memory actor implementations.
|
|
99
|
+
- **Service**: `actor ItemService` orchestrating one transaction across engines + repository,
|
|
100
|
+
e.g. `addItem(_:now:) -> AddItemResult` (persist + score + unlocks in one testable unit).
|
|
101
|
+
|
|
102
|
+
### DataLayer — IO implementations
|
|
103
|
+
- One repository class/actor per Core protocol, plus mapping files (`CKRecord+Mapping.swift`,
|
|
104
|
+
`DTO+Mapping.swift`) and config (`CloudKitConfig` holding the single container ID constant
|
|
105
|
+
`iCloud.{{BUNDLE_ID}}` — one constant, referenced everywhere, equal to the entitlement).
|
|
106
|
+
|
|
107
|
+
> ⚠️ **Gotcha:** Symptom — Swift 6 data-race errors (or runtime corruption) around `CKRecord`.
|
|
108
|
+
> Cause — `CKRecord` is non-`Sendable`; it must not cross actor boundaries. Fix — fetch and map
|
|
109
|
+
> to `Sendable` domain values **inside** the repository actor; only domain types ever leave it.
|
|
110
|
+
|
|
111
|
+
> ⚠️ **Gotcha:** Symptom — right after accepting a share / receiving a push, an immediate refresh
|
|
112
|
+
> fails with "Record not found" though the data exists. Cause — server-side propagation delay on
|
|
113
|
+
> freshly shared zones/records. Fix — on remote-triggered refreshes, add a short delay (~1.5 s)
|
|
114
|
+
> or one retry before surfacing an error.
|
|
115
|
+
|
|
116
|
+
### {{PROJECT_NAME}}DS — design system
|
|
117
|
+
Tokens as namespaces (`DS.Padding.m`, `DS.Gradients.brand`, `Color.DS.accent`, `DSFont`), plus
|
|
118
|
+
generic components (`DSBackground`, button styles, cards). Nothing here knows the domain.
|
|
119
|
+
|
|
120
|
+
### Module/ — reusable UI bricks (app target)
|
|
121
|
+
One folder per domain concept (`Module/Item/`, `Module/Map/`). Bricks receive domain values and
|
|
122
|
+
closures — never the store. Prefer generics for shared mechanics, e.g. one
|
|
123
|
+
`ItemMap<Item, Marker: View, Detail: View>` used by every map screen (selection, clustering,
|
|
124
|
+
anchored popover live here; screens inject `marker:` and `detail:` builders).
|
|
125
|
+
A brick becomes navigable through closures injected by the parent screen — no router dependency:
|
|
126
|
+
|
|
127
|
+
```swift
|
|
128
|
+
// child brick: var onAccept: () -> Void // plain stored closure(s); domain value captured by value
|
|
129
|
+
// parent screen: Button { interactor.openDetail(group) } label: { ItemGroup.Row(group: group) }
|
|
130
|
+
// ItemGroup.InviteCard(group: group, onAccept: { Task { await interactor.accept(group) } }, …)
|
|
131
|
+
```
|
|
132
|
+
The parent's Interactor owns the routing (`viewModel.path.append`) — no environment plumbing,
|
|
133
|
+
no third-party DI/navigation package.
|
|
134
|
+
|
|
135
|
+
### App/ — screens, VVM-I pattern
|
|
136
|
+
Per screen, one folder with up to 4 files:
|
|
137
|
+
- `App<Screen>.swift` — View, namespaced via extension: `extension App { struct Detail: View }`,
|
|
138
|
+
`body` in `extension App.Detail`. Deps via `@Environment`; `@State private var viewModel = ViewModel()`.
|
|
139
|
+
- `App<Screen>ViewModel.swift` — `@Observable final class ViewModel`: local UI state ONLY. No business
|
|
140
|
+
logic, no navigation, no store reference.
|
|
141
|
+
- `App<Screen>Interactor.swift` — `struct Interactor` with deps injected by init (store, router path);
|
|
142
|
+
business calls + routing. Recreated on demand:
|
|
143
|
+
`private var interactor: Interactor { Interactor(viewModel: viewModel, store: store) }`.
|
|
144
|
+
Trivial screens (no routing/orchestration) may omit it.
|
|
145
|
+
- `App<Screen>+Translate.swift` — `nonisolated enum Translate` of user-facing string constants
|
|
146
|
+
(+ pure formatting helpers). No state, no logic.
|
|
147
|
+
|
|
148
|
+
Navigation: a `TabView` shell where **each tab owns its `NavigationStack`** with value-based
|
|
149
|
+
`.navigationDestination(for: ItemGroup.self)`. Add a global `@Observable` Coordinator (path array +
|
|
150
|
+
`goTo(_:)`) only for single-stack, deep-link-driven apps — most tab apps don't need one.
|
|
151
|
+
|
|
152
|
+
### Store/ — observable state + composition root
|
|
153
|
+
`@MainActor @Observable final class AppStore`: wraps Core services, exposes `private(set)` state
|
|
154
|
+
and intent methods. It is the ONLY place deciding which backend runs:
|
|
155
|
+
|
|
156
|
+
```swift
|
|
157
|
+
func bootstrap() async {
|
|
158
|
+
guard service == nil else { return }
|
|
159
|
+
if ProcessInfo.processInfo.arguments.contains("-uitest-mock") {
|
|
160
|
+
activateMemoryBackend(); await refresh(); return
|
|
161
|
+
}
|
|
162
|
+
let cloud = CloudKitItemRepository()
|
|
163
|
+
if await cloud.isAccountAvailable() { service = ItemService(repository: cloud); backend = .cloud }
|
|
164
|
+
else { activateMemoryBackend() } // seeded with SampleData → app fully demoable offline
|
|
165
|
+
await refresh()
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Injected once at the root: `@State private var store = AppStore()` → `.environment(store)` on the
|
|
170
|
+
root view. No DI container. `SampleData` lives next to the store and feeds previews, the in-memory
|
|
171
|
+
backend, and UI tests. On `scenePhase == .active`, upgrade a memory backend to cloud if an account
|
|
172
|
+
appeared, then refresh. Intents that gate UI return success:
|
|
173
|
+
`@discardableResult func addItem(_ draft: ItemDraft) async -> Bool` — the sheet dismisses only
|
|
174
|
+
when the write landed.
|
|
175
|
+
|
|
176
|
+
> ⚠️ **Gotcha:** Symptom — UI tests and cold simulators hang on first launch. Cause — the cloud
|
|
177
|
+
> account probe (`CKContainer.accountStatus`) can stall with no account configured. Fix — a launch
|
|
178
|
+
> argument (`-uitest-mock`) short-circuits straight to the in-memory backend; the simulator
|
|
179
|
+
> fallback path keeps the app demoable with sample data.
|
|
180
|
+
|
|
181
|
+
> ⚠️ **Gotcha:** Symptom — infinite splash spinner when the first load fails. Cause — `isReady`
|
|
182
|
+
> was only set on the success path. Fix — set `isReady = true` in the catch too, and surface
|
|
183
|
+
> `lastError`; an empty state beats a dead spinner.
|
|
184
|
+
|
|
185
|
+
> ⚠️ **Gotcha:** Symptom — a stale error alert pops during an unrelated, successful action.
|
|
186
|
+
> Cause — `lastError` from a previous failure was never cleared. Fix — first line of every intent:
|
|
187
|
+
> `lastError = nil`.
|
|
188
|
+
|
|
189
|
+
> ⚠️ **Gotcha:** Symptom — error alert never appears while a sheet is presented. Cause — an alert
|
|
190
|
+
> attached to the presenting view cannot cover a presented sheet. Fix — the app-wide alert lives on
|
|
191
|
+
> the root view, AND any sheet that can fail (e.g. the add form) declares its own local alert.
|
|
192
|
+
|
|
193
|
+
### Tools/ — cross-cutting
|
|
194
|
+
Router/Coordinator (if used), notification scheduler, push routing, formatters, location provider.
|
|
195
|
+
Anything used by multiple screens that is neither domain logic nor a visual component.
|
|
196
|
+
|
|
197
|
+
## 5. Where Does a New File Go?
|
|
198
|
+
|
|
199
|
+
| You are adding… | It lives in… |
|
|
200
|
+
|---|---|
|
|
201
|
+
| A domain value type | `Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/` |
|
|
202
|
+
| A pure rule (scoring, validation, stats) | `Core/Engine/` — static func, `now`/inputs as params |
|
|
203
|
+
| Static data tables (levels, definitions) | `Core/Catalog/` |
|
|
204
|
+
| A persistence/network contract | `Core/Repository/` (protocol + `InMemory…` actor) |
|
|
205
|
+
| Multi-step domain transaction | `Core/Service/` (actor) |
|
|
206
|
+
| The real backend implementation + DTO mapping | `Packages/DataLayer/` |
|
|
207
|
+
| Color/spacing/font token, domain-blind component | `Packages/{{PROJECT_NAME}}DS/` |
|
|
208
|
+
| Reusable UI for a domain concept (ItemCard, ItemMap) | app `Module/<Concept>/` |
|
|
209
|
+
| A user-facing screen | app `App/<Screen>/` — View + ViewModel (+ Interactor + Translate) |
|
|
210
|
+
| App-wide state or a new user intent | `Store/AppStore.swift` |
|
|
211
|
+
| Preview/demo/test fixture data | `Store/SampleData.swift` |
|
|
212
|
+
| Scheduler, router, formatter, location | `Tools/` |
|
|
213
|
+
|
|
214
|
+
## 6. Why This Works Exceptionally Well With AI Agents
|
|
215
|
+
|
|
216
|
+
- **Fast ground truth without Xcode.** `cd Packages/{{PROJECT_NAME}}Core && swift test` runs the whole business
|
|
217
|
+
logic suite on the host Mac in seconds — no simulator boot, no signing. Agents iterate on
|
|
218
|
+
logic with a compile+test loop per edit.
|
|
219
|
+
- **Compiler-enforced boundaries.** An agent that violates layering (imports SwiftUI in Core,
|
|
220
|
+
references the store in a Module brick) gets an immediate build error instead of silently
|
|
221
|
+
degrading the architecture.
|
|
222
|
+
- **Deterministic UI without a backend.** The in-memory repository + `SampleData` + `-uitest-mock`
|
|
223
|
+
launch arg let agents build screens, run UI tests, and take simulator screenshots with zero
|
|
224
|
+
accounts or network.
|
|
225
|
+
- **Predictable file placement.** The VVM-I naming convention and the table above mean an agent
|
|
226
|
+
knows exactly which 3–4 files to create for a feature — diffs stay small and reviewable.
|
|
227
|
+
- **Engines are pure functions.** Time, randomness, and IO are parameters, so agents can write
|
|
228
|
+
exhaustive table-driven tests (`swift test`) without mocks or fakes beyond `InMemory…` actors.
|
|
229
|
+
|
|
230
|
+
Build matrix an agent should run before claiming "done":
|
|
231
|
+
```
|
|
232
|
+
cd Packages/{{PROJECT_NAME}}Core && swift test # logic — seconds
|
|
233
|
+
cd Packages/DataLayer && swift build # IO compiles against Core contracts
|
|
234
|
+
cd Packages/{{PROJECT_NAME}}DS && swift build
|
|
235
|
+
xcodebuild -project {{PROJECT_NAME}}.xcodeproj -scheme {{PROJECT_NAME}} \
|
|
236
|
+
-destination 'generic/platform=iOS Simulator' build # app assembly only
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
## Acceptable variations
|
|
241
|
+
The Store-internal `bootstrap()` shown above is the default, not dogma. Known-good variants:
|
|
242
|
+
- **Protocol-based init injection** (`Store(repository: some ItemRepository)`) — preferred by
|
|
243
|
+
teams that want the composition root outside the Store. Fine: record it in
|
|
244
|
+
`.claude/memory/DECISIONS.md` and keep ONE pattern per project.
|
|
245
|
+
Deviating consciously is healthy; deviating silently is how two conventions end up in one repo
|
|
246
|
+
(see ANTI_PATTERNS.md).
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# CloudKit Guide — Private Sync + CKShare Groups (no server)
|
|
2
|
+
|
|
3
|
+
Battle-tested patterns from two production apps. CloudKit IS the backend: private DB for the user's
|
|
4
|
+
own data, shared DB for groups via `CKShare`. Read the Gotchas section before touching any of this.
|
|
5
|
+
|
|
6
|
+
## 1. Architecture
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
Private DB (per user) Shared DB (per user)
|
|
10
|
+
├── ItemsZone (custom zone) └── GroupZone-<UUID> ← zones OTHER owners shared with me
|
|
11
|
+
│ ├── Item records (zoneID.ownerName = the REAL owner id)
|
|
12
|
+
│ └── Profile record (singleton)
|
|
13
|
+
└── GroupZone-<UUID> (one per group I own)
|
|
14
|
+
├── Group root record ──── CKShare
|
|
15
|
+
└── SharedItem records (parent → Group root)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **Repository protocol lives in the pure Core package** (no CloudKit import) → services/ViewModels
|
|
19
|
+
testable with `swift test`. `CloudKitRepository` / `CloudKitGroupRepository` (actors) live in
|
|
20
|
+
DataLayer. An `InMemory*Repository` backs tests, previews, and the simulator.
|
|
21
|
+
- **Custom zones are mandatory** — change tokens (`recordZoneChanges`) and `CKShare` don't work in
|
|
22
|
+
the default zone. One zone per shared group (`GroupZone-<UUID>`), found later by name prefix.
|
|
23
|
+
- **One container constant, used everywhere** (both repositories AND share acceptance), identical to
|
|
24
|
+
the entitlement: `iCloud.{{BUNDLE_ID}}`. A second hardcoded id = silent data split.
|
|
25
|
+
- Centralize every record type / field / subscription id in one `CloudKitConfig` enum. No string
|
|
26
|
+
literals at call sites.
|
|
27
|
+
|
|
28
|
+
## 2. Record Mapping
|
|
29
|
+
|
|
30
|
+
Two-way mapping as extensions on the domain model. `init?(record:)` validates required fields
|
|
31
|
+
(guard-let, return nil on malformed); optional fields get defaults on read.
|
|
32
|
+
|
|
33
|
+
```swift
|
|
34
|
+
extension Item {
|
|
35
|
+
init?(record: CKRecord) {
|
|
36
|
+
guard record.recordType == CloudKitConfig.RecordType.item,
|
|
37
|
+
let id = UUID(uuidString: record.recordID.recordName),
|
|
38
|
+
let createdAt = record[Field.createdAt] as? Date else { return nil }
|
|
39
|
+
self.init(id: id, title: (record[Field.title] as? String) ?? "", createdAt: createdAt)
|
|
40
|
+
}
|
|
41
|
+
/// New records only — recordName IS the model's UUID (deterministic IDs, no lookup table).
|
|
42
|
+
func makeRecord(in zoneID: CKRecordZone.ID) -> CKRecord { ... }
|
|
43
|
+
/// Updates — write fields into the FETCHED record to preserve its server change tag.
|
|
44
|
+
func apply(to record: CKRecord) { ... }
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Rules:
|
|
49
|
+
- **Never build a fresh `CKRecord` to update an existing one** — you lose the change tag and get
|
|
50
|
+
`serverRecordChanged`. Fetch, `apply(to:)`, save.
|
|
51
|
+
- Loading: prefer `database.recordZoneChanges(inZoneWith:since:)` loops (handles `moreComing`,
|
|
52
|
+
surfaces deletions) over `CKQuery`. Persist tokens per (scope, zoneID) via a small
|
|
53
|
+
`ChangeTokenStore` actor (NSKeyedArchiver → UserDefaults). On a cold launch with an empty
|
|
54
|
+
in-memory cache, ignore the persisted token once and do a full scan — a stale token + empty cache
|
|
55
|
+
returns an incomplete list.
|
|
56
|
+
- Conflict handling (`serverRecordChanged`, also nested inside `partialFailure`): take
|
|
57
|
+
`error.serverRecord`, merge local state into it, re-save **once**. Never retry in a loop.
|
|
58
|
+
|
|
59
|
+
## 3. CKShare Lifecycle
|
|
60
|
+
|
|
61
|
+
**Create** — zone first, then root record + share **in the same atomic write**:
|
|
62
|
+
|
|
63
|
+
```swift
|
|
64
|
+
let zone = CKRecordZone(zoneName: "GroupZone-\(group.id.uuidString)")
|
|
65
|
+
_ = try await privateDB.modifyRecordZones(saving: [zone], deleting: [])
|
|
66
|
+
|
|
67
|
+
let rootRecord = group.makeRecord(in: zone.zoneID)
|
|
68
|
+
let share = CKShare(rootRecord: rootRecord)
|
|
69
|
+
share[CKShare.SystemFieldKey.title] = group.name as CKRecordValue
|
|
70
|
+
share.publicPermission = .readWrite // link sharing: anyone with the URL joins & can write
|
|
71
|
+
_ = try await privateDB.modifyRecords(saving: [rootRecord, share], deleting: [],
|
|
72
|
+
savePolicy: .ifServerRecordUnchanged, atomically: true)
|
|
73
|
+
return share.url // hand to ShareLink / UIActivityViewController
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Permission matrix: `.readOnly` participants can read but **cannot create even with a correct
|
|
77
|
+
parent**; creating requires `.readWrite`. For invite-only apps use `publicPermission = .none` +
|
|
78
|
+
explicit participants at `.readWrite` (via `UICloudSharingController`, or
|
|
79
|
+
`CKFetchShareParticipantsOperation` + `CKShare.addParticipant(_:)`) — a leaked link then grants nothing.
|
|
80
|
+
|
|
81
|
+
**Child records** — every record a participant must be able to create needs the hierarchy parent:
|
|
82
|
+
|
|
83
|
+
```swift
|
|
84
|
+
record.parent = CKRecord.Reference(recordID: rootRecordID, action: .none) // system sharing prop
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Accept** — fires in the app/scene delegate (see Gotcha 2). `CKShare.Metadata` is NOT `Sendable`:
|
|
88
|
+
consume it synchronously on `@MainActor`, extract the Sendable group UUID from
|
|
89
|
+
`metadata.hierarchicalRootRecordID.recordName` *before* any `await`, then run
|
|
90
|
+
`CKAcceptSharesOperation`. Only the UUID escapes. After accept, the zone appears in the
|
|
91
|
+
participant's `sharedCloudDatabase` (with delay — Gotcha 4).
|
|
92
|
+
|
|
93
|
+
**Locate** — load groups from BOTH databases by listing `allRecordZones()` and filtering by zone
|
|
94
|
+
name prefix; cache the **exact** `(scope, zoneID)` per group. All later operations route through
|
|
95
|
+
that cache (owner → private DB, participant → shared DB).
|
|
96
|
+
|
|
97
|
+
**Members** — do NOT persist a member-list field; it drifts. The authoritative list is
|
|
98
|
+
`share.participants` (fetch root record → `record.share` reference → fetch the `CKShare`). Map to a
|
|
99
|
+
Sendable struct inside the actor; never let `CKShare` escape it.
|
|
100
|
+
|
|
101
|
+
**Remove a participant** (owner): find the non-owner participant by
|
|
102
|
+
`userIdentity.userRecordID?.recordName`, `share.removeParticipant(...)`, save the share with
|
|
103
|
+
`savePolicy: .changedKeys`. With a public link this is best-effort — they can re-accept the
|
|
104
|
+
still-valid URL until you rotate it.
|
|
105
|
+
|
|
106
|
+
**Rotate the invite link**: delete the old `CKShare` record, re-fetch the root record (its `share`
|
|
107
|
+
reference is now cleared), create a new `CKShare(rootRecord:)`, save root + share atomically →
|
|
108
|
+
brand-new URL, old one dead.
|
|
109
|
+
|
|
110
|
+
**Delete / leave**: owner deletes the zone in the private DB (removes the group for everyone);
|
|
111
|
+
participant deletes the zone from their shared DB (= leave). Clear cached change tokens.
|
|
112
|
+
|
|
113
|
+
## 4. Push, Refresh, Notifications
|
|
114
|
+
|
|
115
|
+
- Register `CKDatabaseSubscription` with `notificationInfo.shouldSendContentAvailable = true`
|
|
116
|
+
(silent push) on **both** the private DB (owner sees participants' writes) and the shared DB
|
|
117
|
+
(participants see everyone else's writes). Idempotent, best-effort (`try?`). A
|
|
118
|
+
`CKRecordZoneSubscription` on the private items zone keeps the user's own devices in sync.
|
|
119
|
+
- AppDelegate: `application.registerForRemoteNotifications()` at launch (no user permission needed
|
|
120
|
+
for silent push), Info.plist `UIBackgroundModes: [remote-notification]`.
|
|
121
|
+
- `didReceiveRemoteNotification` → `CKNotification(fromRemoteNotificationDictionary:)` → route:
|
|
122
|
+
`.database` → refresh groups; `.recordZone` → refresh items; unknown → refresh both. Return
|
|
123
|
+
`.newData`. CloudKit pushes carry no payload beyond "something changed" — always re-fetch.
|
|
124
|
+
- Visible alerts: silent push + local notification on diff (you computed what changed during the
|
|
125
|
+
refresh), or implement `userNotificationCenter(_:willPresent:) async -> [.banner, .sound]` to show
|
|
126
|
+
banners in the foreground.
|
|
127
|
+
- Cold-launch race: a push/share-accept can arrive before SwiftUI wires your store. Use a small
|
|
128
|
+
`@MainActor PushRouter` holding `weak var store`; if nil, set a `pending` flag and flush it in
|
|
129
|
+
`store`'s `didSet`.
|
|
130
|
+
|
|
131
|
+
## 5. Gotchas (production bugs — each cost hours/days)
|
|
132
|
+
|
|
133
|
+
> ⚠️ **Gotcha 1 — Empty array on a List field.** Symptom: first save fails with
|
|
134
|
+
> `cannot use an empty list to initialize a new field`. Cause: CloudKit infers a List field's
|
|
135
|
+
> element type from the first non-empty save and rejects `[]` on a not-yet-existing field. Fix:
|
|
136
|
+
> when the collection is empty, set the field to `nil` (omit it); read side defaults to `[]`. The
|
|
137
|
+
> schema field is created the first time a non-empty value is saved.
|
|
138
|
+
|
|
139
|
+
> ⚠️ **Gotcha 2 — Share acceptance never fires.** Symptom: tapping an invite link opens the app,
|
|
140
|
+
> nothing happens; `userDidAcceptCloudKitShareWith` on the AppDelegate is never called. Cause: in a
|
|
141
|
+
> SwiftUI-lifecycle app the callback is delivered to the **scene** delegate. Fix: implement
|
|
142
|
+
> `application(_:configurationForConnecting:options:)` returning a config with
|
|
143
|
+
> `delegateClass = SceneDelegate.self`, and implement
|
|
144
|
+
> `windowScene(_:userDidAcceptCloudKitShareWith:)` there. Keep the AppDelegate variant too as
|
|
145
|
+
> belt-and-braces. Don't implement `scene(_:willConnectTo:)` — SwiftUI still owns the window.
|
|
146
|
+
|
|
147
|
+
> ⚠️ **Gotcha 3 — Invite link says "you need a newer version of the app".** Cause: missing
|
|
148
|
+
> `CKSharingSupported = true` (Boolean) in Info.plist. Fix: add it. No code change.
|
|
149
|
+
|
|
150
|
+
> ⚠️ **Gotcha 4 — "Record not found" right after accepting a share.** Cause: server-side
|
|
151
|
+
> propagation delay — the shared zone isn't visible in the participant's shared DB immediately
|
|
152
|
+
> after `CKAcceptSharesOperation` succeeds. Fix: refresh immediately, then refresh again after
|
|
153
|
+
> ~2 s (`try? await Task.sleep(for: .seconds(2))`). Never treat the first miss as an error.
|
|
154
|
+
|
|
155
|
+
> ⚠️ **Gotcha 5 — Works in Debug/Dev, broken on TestFlight.** Symptom: TestFlight build sees zero
|
|
156
|
+
> data or errors on every record type. Cause: the schema only exists in the **Development**
|
|
157
|
+
> environment; TestFlight/App Store use **Production**. Fix: exercise every record type AND field
|
|
158
|
+
> in code against Dev (fields are created lazily on first save — including `parent`! see Gotcha 1),
|
|
159
|
+
> then CloudKit Dashboard → "Deploy Schema Changes" to Production **before** the TestFlight build.
|
|
160
|
+
|
|
161
|
+
> ⚠️ **Gotcha 6 — Silent pushes never arrive in production.** Cause: `aps-environment` entitlement
|
|
162
|
+
> is `development` for Xcode builds; TestFlight/App Store need `production`. Xcode rewrites it at
|
|
163
|
+
> archive time for App Store distribution — but verify in the built `.ipa` if pushes are dead, and
|
|
164
|
+
> never test push on a device while the entitlement/profile mismatch.
|
|
165
|
+
|
|
166
|
+
> ⚠️ **Gotcha 7 — Participant gets "CREATE operation not permitted".** Cause: hierarchical sharing
|
|
167
|
+
> only lets participants create records whose `record.parent` chain reaches the shared root.
|
|
168
|
+
> Orphan records save fine for the owner and fail for everyone else. Fix: always set
|
|
169
|
+
> `record.parent` → root record on shared child records (keep a separate custom Reference field if
|
|
170
|
+
> you also need queries — `parent` is invisible in the Dashboard).
|
|
171
|
+
|
|
172
|
+
> ⚠️ **Gotcha 8 — Wrong zone owner (`__defaultOwner__`).** Symptom: participant operations hit
|
|
173
|
+
> `zoneNotFound` though the zone exists. Cause: `CKCurrentUserDefaultName` resolves to the current
|
|
174
|
+
> user — reconstructing a shared zoneID with it points at the participant's own (nonexistent) zone.
|
|
175
|
+
> Fix: never reconstruct zone IDs. Keep the exact `CKRecordZone.ID` from `allRecordZones()` /
|
|
176
|
+
> `record.recordID.zoneID`; resolve the real owner via `metadata.ownerIdentity.userRecordID`.
|
|
177
|
+
|
|
178
|
+
> ⚠️ **Gotcha 9 — Share created but URL is nil / root not shared.** Cause: root record and its
|
|
179
|
+
> `CKShare` were saved in separate operations. Fix: first save must include **both** in one
|
|
180
|
+
> `modifyRecords(atomically: true)`.
|
|
181
|
+
|
|
182
|
+
> ⚠️ **Gotcha 10 — `CKShare.Metadata` across actors.** Symptom: Swift 6 sendability errors, or
|
|
183
|
+
> crashes when stashing metadata for later. Fix: consume it synchronously on `@MainActor`, extract
|
|
184
|
+
> Sendable values (root record UUID) before the first `await`, never store it.
|
|
185
|
+
|
|
186
|
+
> ⚠️ **Gotcha 11 — `CKQuery` fails in custom zones.** Symptom: "recordName is not marked
|
|
187
|
+
> queryable". Fix: don't query; iterate `recordZoneChanges(inZoneWith:since:)` until
|
|
188
|
+
> `!moreComing` — it's also the only way to observe deletions.
|
|
189
|
+
|
|
190
|
+
> ⚠️ **Gotcha 12 — Participants show as "Anonymous"/stale members.** Cause: member list persisted
|
|
191
|
+
> as a record field drifts from reality. Fix: derive members exclusively from
|
|
192
|
+
> `share.participants` (name via `PersonNameComponentsFormatter` on `userIdentity.nameComponents`,
|
|
193
|
+
> fallback `lookupInfo?.emailAddress`).
|
|
194
|
+
|
|
195
|
+
Minor but real: treat `CKError.unknownItem` on delete as success (idempotent); coalesce concurrent
|
|
196
|
+
bootstrap (zone + subscription creation) onto a single in-flight `Task` inside the actor.
|
|
197
|
+
|
|
198
|
+
## 6. Dev / Test Workflow
|
|
199
|
+
|
|
200
|
+
- **Simulator**: CRUD against the private DB works (signed-in iCloud account required). Share
|
|
201
|
+
acceptance and push do NOT work → fall back to `InMemoryGroupRepository` on simulator.
|
|
202
|
+
- **Device required** for: silent push, share accept, multi-account flows. Full validation needs
|
|
203
|
+
**two real iCloud accounts**: A creates + invites → B accepts → B writes → A receives push.
|
|
204
|
+
- Debugging on device: `os.Logger(subsystem:category:)` with `privacy: .public` on interpolations
|
|
205
|
+
(otherwise values show as `<private>` in Console.app). Log every share-accept and push entry
|
|
206
|
+
point — these paths are unreproducible in a debugger session that started after the tap.
|
|
207
|
+
- CloudKit Dashboard: inspect records per zone (Dev env), check "Deploy Schema Changes" diff, and
|
|
208
|
+
use Logs to confirm pushes were emitted.
|
|
209
|
+
|
|
210
|
+
## 7. Checklist — adding a shared record type
|
|
211
|
+
|
|
212
|
+
1. Add type + fields to `CloudKitConfig`. 2. Mapping extension (`init?(record:)` /
|
|
213
|
+
`makeRecord(in:parent:)` with `record.parent` → root). 3. Route through the `(scope, zoneID)` cache
|
|
214
|
+
— never hardcode a DB. 4. Exercise every field in Dev (non-empty lists!), then deploy schema to
|
|
215
|
+
Production. 5. Two-account device test before shipping.
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
## Atomic multi-record persistence — encode it in the CONTRACT
|
|
219
|
+
> ⚠️ **Gotcha:** Symptom — a pin saves but the player profile write fails (or vice-versa):
|
|
220
|
+
> XP/achievements drift from the pin set forever. Cause — two separate `save` calls where the
|
|
221
|
+
> domain requires all-or-nothing. Fix — the repository CONTRACT exposes the atomic operation
|
|
222
|
+
> (`savePinAndPlayer(_:_:)` — one method, one `modifyRecords(atomically: true)` underneath),
|
|
223
|
+
> so no caller CAN write them separately. If two records must stay consistent, their atomicity
|
|
224
|
+
> belongs in the protocol, not in caller discipline.
|