mycode/myApp/HaruDanim/Shared/DataStore.swift
songyc macbook c18e3c7c6f fix(review): 전체 코드 리뷰 1차 — 데이터 복구·통계 표시·기록 편집 결함 수정
'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(DayMath·
QuestProgress·Models)는 발견 0건.

[M] DataStore: 스토어 손상 백업 복구 직후 legacy 이관 가드(타깃 없음)가
    다시 열려 구 샌드박스 스토어(영구 잔존)가 부활 — 수개월 전 데이터로
    조용한 롤백. App Group 플래그(migration.legacyStoreImported)로 평생
    1회 보장 (§5.1 문서화)
[M] 통계 시리즈가 행동/꼬리표 이름 키 — 동명 2개면 차트가 한 선으로 합쳐
    지고 Identifiable id 충돌, 동명 꼬리표는 합산. Format.disambiguated
    (이름 (2) 형식)로 통계 탭·통계 내보내기·⑤ 위젯 3표면 통일, 꼬리표
    집계는 identity 키로 재작성 (수치는 기존과 동일, 표시·범례만 구분)
[L] SessionAlertManager: 시작→즉시 종료 연타 시 조회(await)~추가 사이
    경합으로 종료된 세션의 장시간 알림이 잔존 — 세대 카운터로 마지막
    호출만 확정
[L] 세션 편집기: 분 절사 값을 무조건 덮어써 1분 미만 세션이 메모만
    고쳐도 0초로 파괴, 시작=종료 0길이 기록은 저장돼도 어디에도 안 보임
    — 안 움직인 필드는 원본 시각(초) 보존 + 0길이 저장 차단(문구 갱신)
[L] 타임테이블 시간축: 하루 시작이 정시가 아니면(06:30) 라벨이 시만
    표기해 최대 59분 어긋남 — 분 성분 포함(HH:mm), 화면·내보내기 동일
[L] 기록·통계 필터: 제외했던 행동을 삭제하면 잔존 ID로 칩·내보내기
    필터 문구가 허위 활성 — 실재 행동 기준으로 판정
[L] 목표 편집: 시작일을 종료일 뒤로 옮기면 종료<시작 저장 가능(DatePicker
    in: 은 표시 제약만) — 저장 시 정규화

+ QuestEditor·TagViews·GoalViews·인텐트·WidgetSupport 정독 — 추가 발견 없음
+ §15-11 서브 에이전트 금지 명문화 (사용자 지시)

검증: Debug/Store 빌드, 카탈로그 missing/stale 0 (새 문구 2키 en/ja)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:05:16 +09:00

218 lines
10 KiB
Swift

//
// DataStore.swift
// Haru_Danim
//
// SwiftData (CLAUDE.md §2.2)
// - : App Group (·App Intents DB )
// - default.store 1 App Group
// - iCloud (): CloudKit ,
// - CloudKit . (.appex)
// CloudKit ·
// / . CloudKit .
//
import Foundation
import SwiftData
nonisolated enum DataStore {
/// .
/// App Intents .
static let shared: ModelContainer = makeContainer()
static let schema = Schema([
Tag.self,
Action.self,
TimeSession.self,
CountEntry.self,
Goal.self,
Quest.self,
DiaryEntry.self,
DiaryTodo.self,
DiaryPage.self,
DiaryPageItem.self,
DiaryTemplate.self,
])
/// iCloud on/off (, App Group defaults )
static let cloudSyncKey = "settings.cloudSync"
static let cloudContainerID = "iCloud.com.yechan.HaruDanim"
/// App Group
static var storeURL: URL {
guard let base = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppGroup.identifier
) else {
// App Group ( )
return URL.applicationSupportDirectory.appending(path: "default.store")
}
return base.appending(path: "HaruDanim.store")
}
/// (.appex)
static var isExtensionProcess: Bool {
Bundle.main.bundleURL.pathExtension == "appex"
}
/// iCloud ( + on)
static var wantsCloudSync: Bool {
#if DEBUG
if UserDefaults.standard.object(forKey: "cloudSync") != nil {
return UserDefaults.standard.bool(forKey: "cloudSync")
}
#endif
return PremiumGate.isUnlocked(.cloudSync) && AppGroup.defaults.bool(forKey: cloudSyncKey)
}
/// ·· .
/// CloudKit .
///
/// :
/// 1) (
/// ) ,
/// 2) ( / )
/// fatalError
/// . (HaruDanim.store.backup-*) .
static func makeContainer() -> ModelContainer {
do {
return try makeContainerThrowing()
} catch {
print("[DataStore] 컨테이너 생성 실패, 재시도: \(error)")
for attempt in 1...3 {
Thread.sleep(forTimeInterval: 0.4 * Double(attempt))
if let container = try? makeContainerThrowing() {
return container
}
}
print("[DataStore] 재시도 실패 — 스토어를 백업하고 새로 시작: \(error)")
backupBrokenStore()
do {
return try makeContainerThrowing()
} catch {
fatalError("[DataStore] 컨테이너 생성 실패(백업 후 재생성도 실패): \(error)")
}
}
}
/// (-wal/-shm ) .
/// .
private static func backupBrokenStore() {
let fileManager = FileManager.default
let stamp = Int(Date.now.timeIntervalSince1970)
for suffix in ["", "-wal", "-shm"] {
let source = URL(fileURLWithPath: storeURL.path + suffix)
guard fileManager.fileExists(atPath: source.path) else { continue }
let destination = URL(fileURLWithPath: storeURL.path + ".backup-\(stamp)" + suffix)
try? fileManager.moveItem(at: source, to: destination)
}
}
/// makeContainer throwing .
/// try? ,
/// (= ''
/// ) .
static func makeContainerThrowing() throws -> ModelContainer {
migrateLegacyStoreIfNeeded()
if wantsCloudSync && !isExtensionProcess {
do {
let config = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .private(cloudContainerID)
)
return try ModelContainer(for: schema, configurations: [config])
} catch {
// iCloud /
print("[DataStore] CloudKit 컨테이너 생성 실패, 로컬로 폴백: \(error)")
}
}
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
return try ModelContainer(for: schema, configurations: [config])
}
/// uuid : UUID
/// ( · , 1 )
@MainActor
static func ensureUniqueEntityIDs(context: ModelContext) {
var changed = false
var tagIDs = Set<UUID>()
for tag in (try? context.fetch(FetchDescriptor<Tag>())) ?? [] where !tagIDs.insert(tag.uuid).inserted {
tag.uuid = UUID()
changed = true
}
var actionIDs = Set<UUID>()
for action in (try? context.fetch(FetchDescriptor<Action>())) ?? [] where !actionIDs.insert(action.uuid).inserted {
action.uuid = UUID()
changed = true
}
var goalIDs = Set<UUID>()
for goal in (try? context.fetch(FetchDescriptor<Goal>())) ?? [] where !goalIDs.insert(goal.uuid).inserted {
goal.uuid = UUID()
changed = true
}
var questIDs = Set<UUID>()
for quest in (try? context.fetch(FetchDescriptor<Quest>())) ?? [] where !questIDs.insert(quest.uuid).inserted {
quest.uuid = UUID()
changed = true
}
if changed { try? context.save() }
}
/// (App Group · )
private static let legacyMigrationDoneKey = "migration.legacyStoreImported"
/// (Application Support/default.store) DB App Group 1 .
/// "1" copyItem ,
/// (backupBrokenStore)
/// (" "
/// ).
private static func migrateLegacyStoreIfNeeded() {
let fm = FileManager.default
let target = storeURL
guard target.path != URL.applicationSupportDirectory.appending(path: "default.store").path else { return }
let defaults = AppGroup.defaults
guard !defaults.bool(forKey: legacyMigrationDoneKey) else { return }
if fm.fileExists(atPath: target.path) {
// ( )
defaults.set(true, forKey: legacyMigrationDoneKey)
return
}
let legacy = URL.applicationSupportDirectory.appending(path: "default.store")
guard fm.fileExists(atPath: legacy.path) else {
// ( )
defaults.set(true, forKey: legacyMigrationDoneKey)
return
}
// -wal/-shm
for suffix in ["", "-shm", "-wal"] {
let from = URL(fileURLWithPath: legacy.path + suffix)
let to = URL(fileURLWithPath: target.path + suffix)
if fm.fileExists(atPath: from.path) {
try? fm.copyItem(at: from, to: to)
}
}
defaults.set(true, forKey: legacyMigrationDoneKey)
}
}
// MARK: - App Group
/// · ( , , )
/// UserDefaults.standard App Group defaults 1
nonisolated enum SettingsMigration {
private static let doneKey = "migration.settingsToAppGroup"
static func runIfNeeded() {
let group = AppGroup.defaults
guard !group.bool(forKey: doneKey) else { return }
let standard = UserDefaults.standard
for key in [SettingsKeys.weekStartWeekday, SettingsKeys.dayStartMinutes,
SettingsKeys.liveActivityMode, SettingsKeys.minSessionSeconds,
SettingsKeys.theme] {
if group.object(forKey: key) == nil, let value = standard.object(forKey: key) {
group.set(value, forKey: key)
}
}
group.set(true, forKey: doneKey)
}
}