@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.
Files changed (141) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +99 -0
  3. package/bin/cli.js +371 -0
  4. package/bin/cli.test.js +91 -0
  5. package/package.json +43 -0
  6. package/templates/core/CLAUDE.md +36 -0
  7. package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
  8. package/templates/core/claude/memory/COMMANDS.md +13 -0
  9. package/templates/core/claude/memory/DECISIONS.md +5 -0
  10. package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
  11. package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
  12. package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
  13. package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
  14. package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
  15. package/templates/core/claude/skills/save-context/SKILL.md +35 -0
  16. package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
  17. package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
  18. package/templates/core/docs-architecture/DELIVERY.md +68 -0
  19. package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
  20. package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
  21. package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
  22. package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
  23. package/templates/core/gitignore +15 -0
  24. package/templates/core/mcp.json +8 -0
  25. package/templates/packs/nuxt-web/CLAUDE.md +74 -0
  26. package/templates/packs/nuxt-web/app/app.vue +5 -0
  27. package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
  28. package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
  29. package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
  30. package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
  31. package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
  32. package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
  33. package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
  34. package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
  35. package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
  36. package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
  37. package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
  38. package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
  39. package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
  40. package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
  41. package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
  42. package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
  43. package/templates/packs/nuxt-web/gitignore +18 -0
  44. package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
  45. package/templates/packs/nuxt-web/pack.json +11 -0
  46. package/templates/packs/nuxt-web/package.json +31 -0
  47. package/templates/packs/nuxt-web/playwright.config.ts +39 -0
  48. package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
  49. package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
  50. package/templates/packs/nuxt-web/tsconfig.json +4 -0
  51. package/templates/packs/nuxt-web/vitest.config.ts +23 -0
  52. package/templates/packs/swift-ios/CLAUDE.md +64 -0
  53. package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
  54. package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
  55. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
  56. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
  57. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
  58. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
  59. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
  60. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
  61. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
  62. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
  63. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
  64. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
  65. package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
  66. package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
  67. package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
  68. package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
  69. package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
  70. package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
  71. package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
  72. package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
  73. package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
  74. package/templates/packs/swift-ios/gitignore +5 -0
  75. package/templates/packs/swift-ios/mcp.json +8 -0
  76. package/templates/packs/swift-ios/pack.json +11 -0
  77. package/templates/packs/swift-ios/project.yml +33 -0
  78. package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
  79. package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
  80. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
  81. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
  82. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
  83. package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
  84. package/templates/packs/ts-sdk/CLAUDE.md +72 -0
  85. package/templates/packs/ts-sdk/MIGRATION.md +28 -0
  86. package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
  87. package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
  88. package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
  89. package/templates/packs/ts-sdk/gitignore +6 -0
  90. package/templates/packs/ts-sdk/pack.json +11 -0
  91. package/templates/packs/ts-sdk/package.json +55 -0
  92. package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
  93. package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
  94. package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
  95. package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
  96. package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
  97. package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
  98. package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
  99. package/templates/packs/ts-sdk/src/index.ts +62 -0
  100. package/templates/packs/ts-sdk/src/types/index.ts +33 -0
  101. package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
  102. package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
  103. package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
  104. package/templates/packs/ts-sdk/tsconfig.json +15 -0
  105. package/templates/packs/ts-sdk/tsup.config.ts +22 -0
  106. package/templates/packs/ts-sdk/vitest.config.ts +8 -0
  107. package/templates/packs/vapor-api/CLAUDE.md +73 -0
  108. package/templates/packs/vapor-api/Dockerfile +80 -0
  109. package/templates/packs/vapor-api/Package.swift +68 -0
  110. package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
  111. package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
  112. package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
  113. package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
  114. package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
  115. package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
  116. package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
  117. package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
  118. package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
  119. package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
  120. package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
  121. package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
  122. package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
  123. package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
  124. package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
  125. package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
  126. package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
  127. package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
  128. package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
  129. package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
  130. package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
  131. package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
  132. package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
  133. package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
  134. package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
  135. package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
  136. package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
  137. package/templates/packs/vapor-api/env_dist +29 -0
  138. package/templates/packs/vapor-api/gitignore +7 -0
  139. package/templates/packs/vapor-api/pack.json +11 -0
  140. package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
  141. 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.