From ce5e9b743d9957ee64fffdc52c557ee8f9a62253 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Sat, 22 Aug 2026 23:37:31 +0900 Subject: [PATCH 1/4] feat: add SwiftData persistence models and CairnEnvironment container CachedRepository/CachedRelease/InstalledApp model the GitHub cache and installed-app records from the Phase 2 plan. CairnEnvironment wraps ModelContainer creation (on-disk by default, in-memory for tests) so the schema has a single source of truth. CachedRelease gains an explicit inverse (repository) not present in the original plan sketch, required for CachedRepository's cascade delete rule to actually take effect. Co-Authored-By: Claude Sonnet 5 --- Sources/Cairn/Cache/CachedRelease.swift | 25 ++++++++++ Sources/Cairn/Cache/CachedRepository.swift | 46 +++++++++++++++++++ Sources/Cairn/Cache/CairnEnvironment.swift | 31 +++++++++++++ Sources/Cairn/Cache/InstalledApp.swift | 17 +++++++ .../Cache/CairnEnvironmentTests.swift | 22 +++++++++ 5 files changed, 141 insertions(+) create mode 100644 Sources/Cairn/Cache/CachedRelease.swift create mode 100644 Sources/Cairn/Cache/CachedRepository.swift create mode 100644 Sources/Cairn/Cache/CairnEnvironment.swift create mode 100644 Sources/Cairn/Cache/InstalledApp.swift create mode 100644 Tests/CairnTests/Cache/CairnEnvironmentTests.swift diff --git a/Sources/Cairn/Cache/CachedRelease.swift b/Sources/Cairn/Cache/CachedRelease.swift new file mode 100644 index 0000000..5f1c9ac --- /dev/null +++ b/Sources/Cairn/Cache/CachedRelease.swift @@ -0,0 +1,25 @@ +import Foundation +import SwiftData + +@Model +final class CachedRelease { + var tagName: String + var assetNames: [String] + var assetURLs: [String] + + // 元プランのスニペットにはない逆参照。CachedRepository側のdeleteRule: .cascadeを + // 機能させるため双方向関係として明示的に追加している。 + var repository: CachedRepository? + + init( + tagName: String, + assetNames: [String] = [], + assetURLs: [String] = [], + repository: CachedRepository? = nil + ) { + self.tagName = tagName + self.assetNames = assetNames + self.assetURLs = assetURLs + self.repository = repository + } +} diff --git a/Sources/Cairn/Cache/CachedRepository.swift b/Sources/Cairn/Cache/CachedRepository.swift new file mode 100644 index 0000000..25385bd --- /dev/null +++ b/Sources/Cairn/Cache/CachedRepository.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftData + +@Model +final class CachedRepository { + @Attribute(.unique) var githubId: Int + var fullName: String + var topics: [String] + var starCount: Int + var primaryLanguage: String + var htmlURL: String + // Phase3のCategory enumのrawValue相当。Phase2ではCategory型に依存させず素のStringのまま持つ。 + var category: String + var subTags: [String] + var lastFetchedAt: Date + var lastReleaseCheckedAt: Date? + + @Relationship(deleteRule: .cascade, inverse: \CachedRelease.repository) + var releases: [CachedRelease] + + init( + githubId: Int, + fullName: String, + topics: [String] = [], + starCount: Int, + primaryLanguage: String, + htmlURL: String, + category: String, + subTags: [String] = [], + lastFetchedAt: Date, + lastReleaseCheckedAt: Date? = nil, + releases: [CachedRelease] = [] + ) { + self.githubId = githubId + self.fullName = fullName + self.topics = topics + self.starCount = starCount + self.primaryLanguage = primaryLanguage + self.htmlURL = htmlURL + self.category = category + self.subTags = subTags + self.lastFetchedAt = lastFetchedAt + self.lastReleaseCheckedAt = lastReleaseCheckedAt + self.releases = releases + } +} diff --git a/Sources/Cairn/Cache/CairnEnvironment.swift b/Sources/Cairn/Cache/CairnEnvironment.swift new file mode 100644 index 0000000..e43d281 --- /dev/null +++ b/Sources/Cairn/Cache/CairnEnvironment.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftData + +/// アプリ全体で共有する永続化コンテナを保持する軽量な環境容器。 +/// +/// Phase2時点ではModelContainerのみを保持する。GitHubClient/AuthenticationState等の +/// 実配線はUI機能フェーズで本容器を拡張して行う(その際はApp/への移動を検討する)。 +@MainActor +final class CairnEnvironment { + let modelContainer: ModelContainer + + /// - Parameter inMemory: `true`の場合、ディスクへ永続化しないインメモリコンテナを生成する(テスト用)。 + /// 本番の通常起動では`false`(デフォルト)を使う。 + init(inMemory: Bool = false) { + let schema = Schema([ + CachedRepository.self, + CachedRelease.self, + InstalledApp.self, + ]) + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: inMemory + ) + + do { + modelContainer = try ModelContainer(for: schema, configurations: [configuration]) + } catch { + fatalError("ModelContainerの初期化に失敗しました: \(error)") + } + } +} diff --git a/Sources/Cairn/Cache/InstalledApp.swift b/Sources/Cairn/Cache/InstalledApp.swift new file mode 100644 index 0000000..592b48f --- /dev/null +++ b/Sources/Cairn/Cache/InstalledApp.swift @@ -0,0 +1,17 @@ +import Foundation +import SwiftData + +@Model +final class InstalledApp { + @Attribute(.unique) var bundleIdentifier: String + var appName: String + var installedVersion: String + var installDate: Date + + init(bundleIdentifier: String, appName: String, installedVersion: String, installDate: Date) { + self.bundleIdentifier = bundleIdentifier + self.appName = appName + self.installedVersion = installedVersion + self.installDate = installDate + } +} diff --git a/Tests/CairnTests/Cache/CairnEnvironmentTests.swift b/Tests/CairnTests/Cache/CairnEnvironmentTests.swift new file mode 100644 index 0000000..73a268f --- /dev/null +++ b/Tests/CairnTests/Cache/CairnEnvironmentTests.swift @@ -0,0 +1,22 @@ +import Testing + +@testable import Cairn + +@MainActor +@Suite +struct CairnEnvironmentTests { + @Test + func inMemoryContainerInitializesWithoutError() { + let environment = CairnEnvironment(inMemory: true) + let entityNames = environment.modelContainer.schema.entities.map(\.name).sorted() + + #expect(entityNames == ["CachedRelease", "CachedRepository", "InstalledApp"]) + } + + @Test + func onDiskContainerInitializesWithoutError() { + // デフォルト引数(inMemory: false)でも生成が失敗しないことのみ確認する。 + // Application Support配下に実ファイルが作られるが、後片付けは行わない。 + _ = CairnEnvironment() + } +} From b75a36321a447322d30258a074bfe642f12c431c Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Sat, 22 Aug 2026 23:42:08 +0900 Subject: [PATCH 2/4] test: add CRUD/cascade/unique coverage for SwiftData models Covers insert/fetch round-trips, CachedRepository -> CachedRelease cascade delete, the inverse relationship reflecting on the repository side, and unique-constraint duplicate inserts (githubId/bundleIdentifier). SwiftData's .unique constraint does not throw on duplicate insert; it upserts the existing record instead. Confirmed by running the tests rather than assuming, since this diverges from a naive expectation. Suites are @Suite(.serialized) because parallel test execution against separately-constructed in-memory ModelContainers crashed the test process (signal 5) during verification. Co-Authored-By: Claude Sonnet 5 --- .../CairnTests/Cache/CachedReleaseTests.swift | 55 +++++++++ .../Cache/CachedRepositoryTests.swift | 107 ++++++++++++++++++ .../CairnTests/Cache/InstalledAppTests.swift | 93 +++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 Tests/CairnTests/Cache/CachedReleaseTests.swift create mode 100644 Tests/CairnTests/Cache/CachedRepositoryTests.swift create mode 100644 Tests/CairnTests/Cache/InstalledAppTests.swift diff --git a/Tests/CairnTests/Cache/CachedReleaseTests.swift b/Tests/CairnTests/Cache/CachedReleaseTests.swift new file mode 100644 index 0000000..7b80c95 --- /dev/null +++ b/Tests/CairnTests/Cache/CachedReleaseTests.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData +import Testing + +@testable import Cairn + +@MainActor +@Suite(.serialized) +struct CachedReleaseTests { + private func makeEnvironment() -> CairnEnvironment { + CairnEnvironment(inMemory: true) + } + + @Test + func standaloneReleaseWithoutRepositoryCanBeSaved() throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let release = CachedRelease( + tagName: "v0.1.0", + assetNames: ["App.zip"], + assetURLs: ["https://example.com/App.zip"] + ) + context.insert(release) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + + #expect(fetched.count == 1) + #expect(fetched.first?.repository == nil) + } + + @Test + func attachingMultipleReleasesReflectsOnRepositorySide() throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let repository = CachedRepository( + githubId: 3, + fullName: "owner/inverse-repo", + starCount: 0, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/inverse-repo", + category: "utilities", + lastFetchedAt: Date(timeIntervalSince1970: 3000) + ) + context.insert(repository) + + let releaseA = CachedRelease(tagName: "v1.0.0", repository: repository) + let releaseB = CachedRelease(tagName: "v2.0.0", repository: repository) + context.insert(releaseA) + context.insert(releaseB) + try context.save() + + #expect(repository.releases.count == 2) + } +} diff --git a/Tests/CairnTests/Cache/CachedRepositoryTests.swift b/Tests/CairnTests/Cache/CachedRepositoryTests.swift new file mode 100644 index 0000000..df98f32 --- /dev/null +++ b/Tests/CairnTests/Cache/CachedRepositoryTests.swift @@ -0,0 +1,107 @@ +import Foundation +import SwiftData +import Testing + +@testable import Cairn + +@MainActor +@Suite(.serialized) +struct CachedRepositoryTests { + // CairnEnvironmentを戻り値のModelContextだけでなくインスタンスごと保持し続けないと、 + // スコープを抜けた時点でModelContainerが解放されクラッシュするため、 + // 各テストで環境そのものをローカル変数に束縛する。 + private func makeEnvironment() -> CairnEnvironment { + CairnEnvironment(inMemory: true) + } + + @Test + func insertedRepositoryCanBeFetchedBack() throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let repository = CachedRepository( + githubId: 1, + fullName: "owner/repo", + topics: ["macos-app"], + starCount: 42, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/repo", + category: "utilities", + subTags: ["cli"], + lastFetchedAt: Date(timeIntervalSince1970: 1000) + ) + context.insert(repository) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + + #expect(fetched.count == 1) + #expect(fetched.first?.fullName == "owner/repo") + #expect(fetched.first?.starCount == 42) + #expect(fetched.first?.lastReleaseCheckedAt == nil) + } + + @Test + func duplicateGithubIdOverwritesExistingRecord() throws { + // SwiftDataの`.unique`制約は例外を投げず、既存レコードを上書き(upsert)する挙動を + // 実機で確認済み。よってエラーではなく1件に集約されることを期待値とする。 + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + + context.insert( + CachedRepository( + githubId: 10, + fullName: "owner/first", + starCount: 1, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/first", + category: "utilities", + lastFetchedAt: Date(timeIntervalSince1970: 1000) + ) + ) + try context.save() + + context.insert( + CachedRepository( + githubId: 10, + fullName: "owner/second", + starCount: 2, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/second", + category: "utilities", + lastFetchedAt: Date(timeIntervalSince1970: 2000) + ) + ) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + #expect(fetched.count == 1) + #expect(fetched.first?.fullName == "owner/second") + } + + @Test + func deletingRepositoryCascadesToItsReleases() throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let repository = CachedRepository( + githubId: 2, + fullName: "owner/cascade-repo", + starCount: 0, + primaryLanguage: "Swift", + htmlURL: "https://github.com/owner/cascade-repo", + category: "utilities", + lastFetchedAt: Date(timeIntervalSince1970: 2000), + releases: [ + CachedRelease(tagName: "v1.0.0"), + CachedRelease(tagName: "v1.1.0"), + ] + ) + context.insert(repository) + try context.save() + + context.delete(repository) + try context.save() + + let remainingReleases = try context.fetch(FetchDescriptor()) + #expect(remainingReleases.isEmpty) + } +} diff --git a/Tests/CairnTests/Cache/InstalledAppTests.swift b/Tests/CairnTests/Cache/InstalledAppTests.swift new file mode 100644 index 0000000..a16e8d9 --- /dev/null +++ b/Tests/CairnTests/Cache/InstalledAppTests.swift @@ -0,0 +1,93 @@ +import Foundation +import SwiftData +import Testing + +@testable import Cairn + +@MainActor +@Suite(.serialized) +struct InstalledAppTests { + private func makeEnvironment() -> CairnEnvironment { + CairnEnvironment(inMemory: true) + } + + @Test + func insertedAppCanBeFetchedBack() throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + let app = InstalledApp( + bundleIdentifier: "com.example.App", + appName: "Example", + installedVersion: "1.0.0", + installDate: Date(timeIntervalSince1970: 4000) + ) + context.insert(app) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + + #expect(fetched.count == 1) + #expect(fetched.first?.appName == "Example") + } + + @Test + func duplicateBundleIdentifierOverwritesExistingRecord() throws { + // CachedRepositoryTestsと同様、SwiftDataの`.unique`制約はエラーではなく + // 既存レコードの上書き(upsert)として実機で確認された挙動を期待値とする。 + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + + context.insert( + InstalledApp( + bundleIdentifier: "com.example.Duplicate", + appName: "First", + installedVersion: "1.0.0", + installDate: Date(timeIntervalSince1970: 1000) + ) + ) + try context.save() + + context.insert( + InstalledApp( + bundleIdentifier: "com.example.Duplicate", + appName: "Second", + installedVersion: "2.0.0", + installDate: Date(timeIntervalSince1970: 2000) + ) + ) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + #expect(fetched.count == 1) + #expect(fetched.first?.appName == "Second") + } + + @Test + func fetchDescriptorSortsByInstallDate() throws { + let environment = makeEnvironment() + let context = environment.modelContainer.mainContext + context.insert( + InstalledApp( + bundleIdentifier: "com.example.Newer", + appName: "Newer", + installedVersion: "1.0.0", + installDate: Date(timeIntervalSince1970: 2000) + ) + ) + context.insert( + InstalledApp( + bundleIdentifier: "com.example.Older", + appName: "Older", + installedVersion: "1.0.0", + installDate: Date(timeIntervalSince1970: 1000) + ) + ) + try context.save() + + var descriptor = FetchDescriptor() + descriptor.sortBy = [SortDescriptor(\.installDate, order: .forward)] + let fetched = try context.fetch(descriptor) + + #expect(fetched.map(\.appName) == ["Older", "Newer"]) + } +} From f7d1db53b4c219b9b3aff08a7afc04d7fb988986 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Sat, 22 Aug 2026 23:43:53 +0900 Subject: [PATCH 3/4] feat: wire CairnEnvironment's ModelContainer into CairnApp The app now boots with SwiftData persistence active via .modelContainer(_:) on the WindowGroup scene. Verified with 'just run': the process launches without fatalError-ing, and a persistent store file is created on disk. ContentView stays as the Phase 0 placeholder; Discovery UI comes in Phase 5. Co-Authored-By: Claude Sonnet 5 --- Sources/Cairn/App/CairnApp.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Cairn/App/CairnApp.swift b/Sources/Cairn/App/CairnApp.swift index 574f4df..cbc5a22 100644 --- a/Sources/Cairn/App/CairnApp.swift +++ b/Sources/Cairn/App/CairnApp.swift @@ -1,11 +1,15 @@ +import SwiftData import SwiftUI @main struct CairnApp: App { + private let environment = CairnEnvironment() + var body: some Scene { WindowGroup { ContentView() } + .modelContainer(environment.modelContainer) } } From 990f0866206978794def444a8794ad7d3e1b4898 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Sat, 22 Aug 2026 23:44:11 +0900 Subject: [PATCH 4/4] docs: mark Phase 2 (persistence layer) complete in progress checklist Co-Authored-By: Claude Sonnet 5 --- docs/progress.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 485395e..0bd1f1c 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -40,9 +40,22 @@ **既知の未実施事項**: `just run`での実機サインインフロー確認(ユーザー環境でのみ実施可能)。 -## Phase 2〜11 — 未着手 +## Phase 2: 永続化層(SwiftData) — 完了 -永続化層 / ノイズ除去・分類ロジック / 検索アーキテクチャ / Discovery UI / AppDetail UI / インストール機能 / アンインストール機能 / Sparkle統合 / 配布パッケージング / オンボーディング / 仕上げ。詳細は実装計画ファイル参照。 +- [x] `CachedRepository`/`CachedRelease`/`InstalledApp` の `@Model` 定義(`Cache/`配下) + - `category`はPhase3の`Category` enumへの依存を避け、Phase2では`String`のまま保持 + - `CachedRelease`に`repository`逆参照を追加(元プランのスニペットにはないが、`deleteRule: .cascade`を機能させるために必須) +- [x] `CairnEnvironment`実装(`ModelContainer`保持の軽量環境容器、on-disk/in-memory切り替え、初期化失敗時は`fatalError`) +- [x] `CairnApp.swift`への`.modelContainer(_:)`配線(`just run`で実機起動・永続化ストア生成を確認済み) +- [x] SwiftTestingによるCRUD/cascade削除/unique制約のテスト + +**実装時に判明した挙動**: SwiftDataの`@Attribute(.unique)`制約は違反時に例外を投げず、既存レコードを上書き(upsert)する。テストはこの実挙動に合わせて期待値を確定した。また、in-memory `ModelContainer`を返す際は`CairnEnvironment`インスタンス自体を保持し続ける必要がある(`ModelContext`だけを返す一時関数だとコンテナが解放されテストプロセスがクラッシュする)。 + +**今回のスコープ外(次フェーズへ)**: `GitHubClient`/`AuthenticationState`を含む本格的な`AppEnvironment`へのDI配線は、引き続き「UI機能フェーズ」で行う。 + +## Phase 3〜11 — 未着手 + +ノイズ除去・分類ロジック / 検索アーキテクチャ / Discovery UI / AppDetail UI / インストール機能 / アンインストール機能 / Sparkle統合 / 配布パッケージング / オンボーディング / 仕上げ。詳細は実装計画ファイル参照。 ## 既知の未解決事項