SwiftData で @Attribute(.unique) を後から追加する

AI 主体で執筆

SwiftData のモデルに @Attribute(.unique) を付け忘れていた。リリース後に重複データの問題が起きてから付け直したくなった ── このときに一番怖いのは、既存ユーザー端末でマイグレーションがクラッシュすることです。60d Memo v1.0 リリース後に同じ状況に直面し、クラウド同期前提の設計を活かして 3 段階で安全に切り替えた記録をまとめます。

前提:なぜ最初から付けなかったのか

60d Memo はメモをユーザーの Google ドライブに保存するアプリで、ローカルには SwiftData で同じデータのコピーを持ちます。メモの主キーは UUID。つまり仕様上、衝突はほぼありえない値です。

それでも実装初期は、Drive との同期バグで同じ UUID のメモが 2 件ローカルに入るケースが稀に発生していました。@Attribute(.unique) を最初から付けていれば、そもそも重複が入らなかった。しかし当時は「後で付ければいいや」と判断し、そのまま v1.0 をリリース。リリース直前にも重複を検知する同期ロジックを組んだので、実害はほとんど起きていない ── という状態でした。

そして v1.0 配布後、根本対策としてモデル側に制約を付けることにしました。ところが、この後付けが一筋縄ではいかないのです。

一つ目の罠:@Attribute(.unique) は CloudKit と併用できない

Apple の公式ドキュメント Syncing model data across a person’s devices に、こう書かれています。

The framework synchronizes changes concurrently and at opportune times, which means CloudKit is unable to enforce the unique property option.

要するに、SwiftData + CloudKit の組み合わせで .unique 制約はサポートされない。もし CloudKit 同期を有効にしたモデルに @Attribute(.unique) を付けてしまうと、ModelContainer のロード自体が失敗します(=アプリ起動時クラッシュ)。

CloudKit で同様の一意性を担保したいときの定番パターンは次の 3 つです。

60d Memo の同期先は CloudKit ではなく Google Drive。CloudKit 同期は無効なので、@Attribute(.unique) は問題なく使えます。自分のアプリが CloudKit を使っているかを先に確認するのが、この機能を使う上での大前提です。

二つ目の罠:既存データに重複があるとマイグレーションが死ぬ

CloudKit を使わないなら @Attribute(.unique) は使える。でもリリース済みのアプリに後付けするときに残るのがこの罠です。

既存のローカル SQLite ストアに id が重複したレコードが 1 件でもあると、新スキーマへのマイグレーション時に一意制約違反で失敗します。SwiftData は ModelContainer の初期化時にこれを検知し、ModelContainer(for:configurations:) が throw します。普通に書いていれば、そのままアプリがクラッシュします。

ここで取れる選択肢は三つ。

60d Memo では B + C の合わせ技を採りました。B 単独ではデータ浸透の完了を 100% 確定できないので、念のため C の安全装置を加える、という発想です。

60d Memo の 3 段階ロールアウト

段階 1: 重複を自動修復するビルド(v1.0)を配布 既存の同期ロジックに、起動時/フォアグラウンド復帰時に同一 UUID のメモを統合する処理を組み込み、まず v1.0 として広く配布。リリースから 1〜2 週間、TestFlight の更新率と App Store の自動アップデート普及を待つ。
段階 2: @Attribute(.unique) を追加(v1.0.x) モデル定義に一行追加するだけ。段階 1 のビルドに更新済みの端末では、ローカル重複は既に解消されているはずなので、マイグレーションはシームレスに通る。
段階 3: 万一の失敗時のフォールバック ModelContainer の初期化時に catch し、ローカルストアのファイルを削除してから再作成。Drive から再同期することで、ユーザーのメモは Drive に保存されている分だけ戻ってくる。

実装

モデル側:たった一行

段階 2 の変更は、これだけです。

@Model
final class Memo {
    @Attribute(.unique) var id: UUID
    var title: String
    var body: String
    // ...
}

コンテナ側:マイグレーション失敗時のフォールバック

段階 3 の安全装置は、ModelContainer を生成するヘルパーに実装します。

import Foundation
import os
import SwiftData

private let logger = Logger(subsystem: "com.example.app", category: "persistence")

enum SharedModelContainer {
    static let shared: ModelContainer = {
        let schema = Schema([Memo.self])
        let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        do {
            return try ModelContainer(for: schema, configurations: [config])
        } catch {
            // マイグレーション失敗(スキーマ変更や @Attribute(.unique) 違反など)。
            // クラウドに正本があるのでローカル DB 消失は許容できる前提。
            logger.error("Migration failed, recreating store: \(error)")
            let url = config.url
            let paths = [
                url,
                url.deletingPathExtension().appendingPathExtension("store-shm"),
                url.deletingPathExtension().appendingPathExtension("store-wal"),
            ]
            for path in paths {
                try? FileManager.default.removeItem(at: path)
            }
            do {
                return try ModelContainer(for: schema, configurations: [config])
            } catch {
                fatalError("Could not create ModelContainer after reset: \(error)")
            }
        }
    }()
}

SwiftData のデフォルトストアは SQLite の default.store と、WAL モード用の default.store-shm / default.store-wal の 3 ファイルで構成されるので、3 つとも消します。消した後に再度 ModelContainer を作ると、空のストアでアプリが起動し、その後のクラウド同期で本来の状態に戻る、という動作になります。

注意: この手順はローカルだけにしか存在しないデータ(=まだクラウドに一度も同期していないメモ)を失う可能性があります。採用する前に、クラウドに正本があることを自分のアプリの設計で必ず確認してください。

なぜローカル消失を許容できるのか

60d Memo のコアコンセプトは「データの所有権をユーザーの Google ドライブに返す」こと。SwiftData のローカルストアはビューア兼キャッシュという位置づけで、正本は Drive 側にあります。

この設計だからこそ、「マイグレーションが死んだらローカルを作り直せばよい」という大胆な安全装置が取れる。もしローカル SwiftData が唯一の保存先だったら、この手は絶対に使えません。必ず VersionedSchema による正攻法のマイグレーションを書くべきです。

言い換えると、クラウド同期前提の設計は、スキーマ変更の自由度を一段上げる。これは今回の実装で得られた一番大きな学びでした。

ロールアウト戦略のコツ

他のアプリで真似する前のチェックリスト

この「マイグレーション失敗時に DB を作り直す」方針は、合う合わないが明確です。採用する前に以下を確認してください。

どれか一つでも外れているなら、素直に VersionedSchema + SchemaMigrationPlan でカスタムマイグレーションを書く方が安全です。

まとめ

参考